From 38633ee7058dd3e0b7f395c4de4dbe64e524a004 Mon Sep 17 00:00:00 2001 From: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:12:24 +0100 Subject: [PATCH 1/9] fix: refresh provider health status checks --- apps/server/src/provider/Layers/ProviderHealth.ts | 2 +- .../src/provider/Services/ProviderHealth.ts | 6 +++--- .../src/components/chat/ProviderHealthBanner.tsx | 15 ++++++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807..03732d845 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -14,7 +14,7 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { Array, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 318d7e18d..ec3b2d318 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -1,8 +1,8 @@ /** * ProviderHealth - Provider readiness snapshot service. * - * Owns startup-time provider health checks (install/auth reachability) and - * exposes the cached results to transport layers. + * Owns provider health checks (install/auth reachability) and exposes the + * latest results to transport layers. * * @module ProviderHealth */ @@ -12,7 +12,7 @@ import type { Effect } from "effect"; export interface ProviderHealthShape { /** - * Read provider health statuses computed at server startup. + * Read the latest provider health statuses. */ readonly getStatuses: Effect.Effect>; } diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx index 12c7f6054..08d71606e 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -12,18 +12,23 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ return null; } + const providerLabel = + status.provider === "codex" + ? "Codex" + : status.provider === "claudeCode" + ? "Claude Code" + : status.provider; const defaultMessage = status.status === "error" - ? `${status.provider} provider is unavailable.` - : `${status.provider} provider has limited availability.`; + ? `${providerLabel} provider is unavailable.` + : `${providerLabel} provider has limited availability.`; + const title = providerLabel === "Codex" ? "Codex provider status" : `${providerLabel} status`; return (
- - {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} - + {title} {status.message ?? defaultMessage} From 43a58565fe1bf95ae200107b7b6d8d5b7ccd68ec Mon Sep 17 00:00:00 2001 From: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:37:21 +0100 Subject: [PATCH 2/9] feat: add claude effort controls --- .../Layers/ProviderCommandReactor.test.ts | 117 +++ .../Layers/ProviderCommandReactor.ts | 42 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 81 ++ .../src/provider/Layers/ClaudeAdapter.ts | 10 +- .../provider/Layers/ProviderService.test.ts | 52 + .../src/provider/Layers/ProviderService.ts | 19 +- apps/web/src/components/ChatView.tsx | 966 ++++++++++-------- .../src/components/chat/CodexTraitsPicker.tsx | 80 +- .../chat/CompactComposerControlsMenu.tsx | 56 +- apps/web/src/composerDraftStore.ts | 26 +- apps/web/src/index.css | 19 + packages/contracts/src/model.ts | 12 +- packages/contracts/src/provider.test.ts | 22 + packages/shared/src/model.test.ts | 52 +- packages/shared/src/model.ts | 72 +- 15 files changed, 1102 insertions(+), 524 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 52f1db0dc..685d3b4b9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -351,6 +351,56 @@ describe("ProviderCommandReactor", () => { }); }); + it("forwards claude effort options through session start and turn send", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort"), + role: "user", + text: "hello with effort", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + }); + it("forwards plan interaction mode to the provider turn request", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -531,6 +581,73 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("restarts claude sessions when claude effort changes", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort-1"), + role: "user", + text: "first claude turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "medium", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-effort-2"), + role: "user", + text: "second claude turn", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: "claudeCode", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 38d0b7bbd..db7d3ebc1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -75,6 +75,11 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +const sameModelOptions = ( + left: ProviderModelOptions | undefined, + right: ProviderModelOptions | undefined, +): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); + function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { @@ -137,6 +142,7 @@ const make = Effect.gen(function* () { ); const threadProviderOptions = new Map(); + const threadModelOptions = new Map(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -289,13 +295,23 @@ const make = Effect.gen(function* () { : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; const modelChanged = options?.model !== undefined && options.model !== activeSession?.model; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; + const previousModelOptions = threadModelOptions.get(threadId); + const shouldRestartForModelOptionsChange = + currentProvider === "claudeCode" && + options?.modelOptions !== undefined && + !sameModelOptions(previousModelOptions, options.modelOptions); - if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { + if ( + !runtimeModeChanged && + !providerChanged && + !shouldRestartForModelChange && + !shouldRestartForModelOptionsChange + ) { return existingSessionThreadId; } const resumeCursor = - providerChanged || shouldRestartForModelChange + providerChanged || shouldRestartForModelChange || shouldRestartForModelOptionsChange ? undefined : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { @@ -309,6 +325,7 @@ const make = Effect.gen(function* () { providerChanged, modelChanged, shouldRestartForModelChange, + shouldRestartForModelOptionsChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession({ @@ -348,15 +365,18 @@ const make = Effect.gen(function* () { if (!thread) { return; } - if (input.providerOptions !== undefined) { - threadProviderOptions.set(input.threadId, input.providerOptions); - } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); + if (input.providerOptions !== undefined) { + threadProviderOptions.set(input.threadId, input.providerOptions); + } + if (input.modelOptions !== undefined) { + threadModelOptions.set(input.threadId, input.modelOptions); + } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService @@ -657,13 +677,13 @@ const make = Effect.gen(function* () { return; } const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); - yield* ensureSessionForThread( - event.payload.threadId, - event.occurredAt, - cachedProviderOptions !== undefined + const cachedModelOptions = threadModelOptions.get(event.payload.threadId); + yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { + ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } - : undefined, - ); + : {}), + ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}), + }); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5792c5307..4c770f72e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -156,6 +156,28 @@ function makeDeterministicRandomService(seed = 0x1234_5678): { }; } +async function readFirstPromptText( + input: + | { + readonly prompt: AsyncIterable; + } + | undefined, +): Promise { + const iterator = input?.prompt[Symbol.asyncIterator](); + if (!iterator) { + return undefined; + } + const next = await iterator.next(); + if (next.done) { + return undefined; + } + const content = next.value.message.content[0]; + if (!content || content.type !== "text") { + return undefined; + } + return content.text; +} + const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); @@ -229,6 +251,65 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("forwards claude effort levels into query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps ultrathink to max effort and prefixes the prompt", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + modelOptions: { + claudeCode: { + effort: "ultrathink", + }, + }, + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Investigate the edge cases", + attachments: [], + modelOptions: { + claudeCode: { + effort: "ultrathink", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + const promptText = yield* Effect.promise(() => readFirstPromptText(createInput)); + assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 887a2f2de..166aee26b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -37,6 +37,7 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; +import { applyClaudePromptEffortPrefix, getEffectiveClaudeCodeEffort } from "@t3tools/shared/model"; import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; import { @@ -338,7 +339,10 @@ function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { } } - const text = fragments.join("\n\n"); + const text = applyClaudePromptEffortPrefix( + fragments.join("\n\n"), + input.modelOptions?.claudeCode?.effort ?? null, + ); return { type: "user", @@ -2138,6 +2142,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ); const providerOptions = input.providerOptions?.claudeAgent; + const effort = input.modelOptions?.claudeAgent?.effort; + const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = toPermissionMode(providerOptions?.permissionMode) ?? (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); @@ -2148,6 +2154,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(providerOptions?.binaryPath ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } @@ -2239,6 +2246,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { config: { ...(input.model ? { model: input.model } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(providerOptions?.maxThinkingTokens !== undefined ? { maxThinkingTokens: providerOptions.maxThinkingTokens } diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 08c532062..889cf7779 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -600,6 +600,58 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("recovers stale claudeCode sessions for sendTurn using persisted cwd", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude-send-turn"), + cwd: "/tmp/project-claude-send-turn", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + runtimeMode: "full-access", + }); + + yield* routing.claude.stopAll(); + routing.claude.startSession.mockClear(); + routing.claude.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: initial.threadId, + input: "resume with claude", + attachments: [], + }); + + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const resumedStartInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + modelOptions?: unknown; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); + assert.deepEqual(startPayload.modelOptions, { + claudeCode: { + effort: "max", + }, + }); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + assert.equal(routing.claude.sendTurn.mock.calls.length, 1); + }), + ); + + it.effect("lists no sessions after adapter runtime clears", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e3bc7204..0a250f965 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -88,17 +88,29 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st function toRuntimePayloadFromSession( session: ProviderSession, - extra?: { readonly providerOptions?: unknown }, + extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown }, ): Record { return { cwd: session.cwd ?? null, model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, + ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}), ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), }; } +function readPersistedModelOptions( + runtimePayload: ProviderRuntimeBinding["runtimePayload"], +): Record | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + return raw as Record; +} + function readPersistedProviderOptions( runtimePayload: ProviderRuntimeBinding["runtimePayload"], ): Record | undefined { @@ -150,7 +162,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, - extra?: { readonly providerOptions?: unknown }, + extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown }, ) => directory.upsert({ threadId, @@ -213,12 +225,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload); const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}), ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", @@ -292,6 +306,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(session, threadId, { + modelOptions: input.modelOptions, providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f71a2f3f0..1c771641a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,9 +1,9 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, + type ClaudeCodeEffort, type EditorId, type KeybindingCommand, - type CodexReasoningEffort, type MessageId, type ProjectId, type ProjectEntry, @@ -15,6 +15,7 @@ import { type ProviderApprovalDecision, type ServerProviderStatus, type ProviderKind, + type ProviderReasoningEffort, type ThreadId, type TurnId, OrchestrationThreadActivity, @@ -22,10 +23,12 @@ import { ProviderInteractionMode, } from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, normalizeModelSlug, + resolveReasoningEffortForProvider, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -137,7 +140,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; +import { ProviderTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; @@ -170,6 +173,17 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; + +function formatOutgoingPrompt(params: { + provider: ProviderKind; + effort: ProviderReasoningEffort | null; + text: string; +}): string { + if (params.provider !== "claudeCode") { + return params.text; + } + return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); +} const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -521,19 +535,45 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const selectedCodexEffort = + selectedProvider === "codex" + ? (resolveReasoningEffortForProvider("codex", composerDraft.effort) ?? + getDefaultReasoningEffort("codex")) + : null; + const selectedClaudeEffort = + selectedProvider === "claudeCode" + ? (resolveReasoningEffortForProvider("claudeCode", composerDraft.effort) ?? + getDefaultReasoningEffort("claudeCode")) + : null; + const selectedEffort = selectedCodexEffort ?? selectedClaudeEffort; const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const isClaudeUltrathink = selectedClaudeEffort === "ultrathink"; const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider !== "codex") { - return undefined; + if (selectedProvider === "codex") { + const codexOptions = { + ...(supportsReasoningEffort && selectedCodexEffort + ? { reasoningEffort: selectedCodexEffort } + : {}), + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; } - const codexOptions = { - ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), - ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; - }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + if (selectedProvider === "claudeCode" && selectedClaudeEffort) { + return { + claudeCode: { + effort: selectedClaudeEffort, + }, + }; + } + return undefined; + }, [ + selectedClaudeEffort, + selectedCodexEffort, + selectedCodexFastModeEnabled, + selectedProvider, + supportsReasoningEffort, + ]); const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; @@ -2266,6 +2306,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -2288,7 +2333,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2426,7 +2471,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: outgoingMessageText, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2667,6 +2712,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const threadIdForSend = activeThread.id; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: trimmed, + }); sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -2676,7 +2726,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, @@ -2704,7 +2754,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, attachments: [], }, provider: selectedProvider, @@ -2748,6 +2798,7 @@ export default function ChatView({ threadId }: ChatViewProps) { persistThreadSettingsForNextTurn, resetSendPhase, runtimeMode, + selectedEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2777,6 +2828,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); + const outgoingImplementationPrompt = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedEffort, + text: implementationPrompt, + }); const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModel: ModelSlug = selectedModel || @@ -2813,7 +2869,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: implementationPrompt, + text: outgoingImplementationPrompt, attachments: [], }, provider: selectedProvider, @@ -2871,6 +2927,7 @@ export default function ChatView({ threadId }: ChatViewProps) { navigate, resetSendPhase, runtimeMode, + selectedEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2903,7 +2960,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); const onEffortSelect = useCallback( - (effort: CodexReasoningEffort) => { + (effort: ProviderReasoningEffort) => { setComposerDraftEffort(threadId, effort); scheduleComposerFocus(); }, @@ -3335,474 +3392,505 @@ export default function ChatView({ threadId }: ChatViewProps) { data-chat-composer-form="true" >
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} - - {/* Textarea area */}
- {composerMenuOpen && !isComposerApprovalState && ( -
- +
- )} + ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + {/* Textarea area */} +
+ {isClaudeUltrathink ? ( +
+ + Ultrathink + +
+ ) : null} + {composerMenuOpen && !isComposerApprovalState && ( +
+
)} - -
- {/* Bottom toolbar */} - {activePendingApproval ? ( -
- 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} +
- ) : ( -
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : (
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - + {/* Provider/model picker */} + - ) : ( - <> - {selectedProvider === "codex" && selectedEffort != null ? ( - <> - - - - ) : null} - - - - - + ) : ( + <> + {selectedEffort != null ? ( + <> + + + + ) : null} - - - {activePlan || activeProposedPlan || planSidebarOpen ? ( - <> - - - - ) : null} - - )} -
+ - {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - ) : null} - -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( + + + + + + {activePlan || activeProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )} +
+ + {/* Right side: send / stop button */} +
+ {isPreparingWorktree ? ( + + Preparing worktree... + + ) : null} + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} - ) : ( -
+
+ ) : phase === "running" ? ( + + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( - - - } + ) : ( +
+ + + + } > - Implement in new thread - - - -
+ +
+ + void onImplementPlanInNewThread()} + > + Implement in new thread + + +
+
+ ) + ) : ( + ) - ) : ( - - ) - ) : null} + ) : null} +
-
- )} + )} +
diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 6c72f497b..529f2769e 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -1,4 +1,4 @@ -import { type CodexReasoningEffort } from "@t3tools/contracts"; +import { type ProviderKind, type ProviderReasoningEffort } from "@t3tools/contracts"; import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; @@ -13,24 +13,40 @@ import { MenuTrigger, } from "../ui/menu"; -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { - effort: CodexReasoningEffort; - fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { +function effortLabel(provider: ProviderKind, effort: ProviderReasoningEffort): string { + if (provider === "codex") { + const codexLabels: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + return codexLabels[effort] ?? effort; + } + + const claudeLabels: Record = { low: "Low", medium: "Medium", high: "High", - xhigh: "Extra High", + max: "Max", + ultrathink: "Ultrathink", }; + return claudeLabels[effort] ?? effort; +} + +export const ProviderTraitsPicker = memo(function ProviderTraitsPicker(props: { + provider: ProviderKind; + effort: ProviderReasoningEffort; + fastModeEnabled?: boolean; + options: ReadonlyArray; + onEffortChange: (effort: ProviderReasoningEffort) => void; + onFastModeChange?: (enabled: boolean) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultReasoningEffort = getDefaultReasoningEffort(props.provider); const triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), + effortLabel(props.provider, props.effort), + ...(props.provider === "codex" && props.fastModeEnabled ? ["Fast"] : []), ] .filter(Boolean) .join(" · "); @@ -56,7 +72,9 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { -
Reasoning
+
+ {props.provider === "codex" ? "Reasoning" : "Effort"} +
{ @@ -68,25 +86,29 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { > {props.options.map((effort) => ( - {reasoningLabelByOption[effort]} + {effortLabel(props.provider, effort)} {effort === defaultReasoningEffort ? " (default)" : ""} ))}
- - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
+ {props.provider === "codex" && props.onFastModeChange ? ( + <> + + +
Fast Mode
+ { + props.onFastModeChange?.(value === "on"); + }} + > + off + on + +
+ + ) : null}
); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 0af50ff01..6bca85efe 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,8 +1,8 @@ import { - type CodexReasoningEffort, type ProviderKind, RuntimeMode, ProviderInteractionMode, + type ProviderReasoningEffort, } from "@t3tools/contracts"; import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo } from "react"; @@ -24,22 +24,24 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls interactionMode: ProviderInteractionMode; planSidebarOpen: boolean; runtimeMode: RuntimeMode; - selectedEffort: CodexReasoningEffort | null; + selectedEffort: ProviderReasoningEffort | null; selectedProvider: ProviderKind; selectedCodexFastModeEnabled: boolean; - reasoningOptions: ReadonlyArray; - onEffortSelect: (effort: CodexReasoningEffort) => void; + reasoningOptions: ReadonlyArray; + onEffortSelect: (effort: ProviderReasoningEffort) => void; onCodexFastModeChange: (enabled: boolean) => void; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onToggleRuntimeMode: () => void; }) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { + const defaultReasoningEffort = getDefaultReasoningEffort(props.selectedProvider); + const reasoningLabelByOption: Record = { low: "Low", medium: "Medium", high: "High", xhigh: "Extra High", + max: "Max", + ultrathink: "Ultrathink", }; return ( @@ -57,10 +59,12 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls