diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 494e14be752db00e9166413f7a7b0dcc27542334..f583ff69a71562e40ac9a94d9b2a9c59d6b69822 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6,6 +6,8 @@ import { type EditorId, type KeybindingCommand, type MessageId, + getDefaultModel, + getModelOptions, type ProjectId, type ProjectEntry, type ProjectScript, @@ -16,10 +18,11 @@ import { type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ServerProviderStatus, + type ProviderKind, type ThreadId, type TurnId, normalizeModelSlug, - resolveModelSlug, + resolveModelSlugForProvider, OrchestrationThreadActivity, } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -49,6 +52,7 @@ import { derivePhase, deriveTimelineEntries, type PendingApproval, + PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, isLatestTurnSettled, @@ -509,6 +513,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [selectedProviderByThread, setSelectedProviderByThread] = useState< + Partial> + >({}); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); @@ -632,20 +639,45 @@ export default function ChatView({ threadId }: ChatViewProps) { markThreadVisited, ]); - const baseThreadModel = resolveModelSlug( - activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, + const sessionProvider = activeThread?.session?.provider ?? null; + const selectedProvider: ProviderKind = + (activeThread?.id ? selectedProviderByThread[activeThread.id] : undefined) ?? + sessionProvider ?? + "codex"; + const baseThreadModel = resolveModelSlugForProvider( + selectedProvider, + activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, ); - const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel); + const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel); const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; const modelOptions = useMemo( - () => getAppModelOptions(settings.customCodexModels, selectedModel), - [selectedModel, settings.customCodexModels], - ); - const slashModelOptions = useMemo( () => - getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel), - [composerTrigger?.query, selectedModel, settings.customCodexModels], + selectedProvider === "codex" + ? getAppModelOptions(settings.customCodexModels, selectedModel) + : getModelOptions(selectedProvider), + [selectedModel, selectedProvider, settings.customCodexModels], ); + const slashModelOptions = useMemo(() => { + if (selectedProvider === "codex") { + return getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel); + } + const query = (composerTrigger?.query ?? "").trim().toLowerCase(); + if (!query) { + return modelOptions; + } + return modelOptions.filter(({ slug, name }) => { + const searchSlug = slug.toLowerCase(); + const searchName = name.toLowerCase(); + return searchSlug.includes(query) || searchName.includes(query); + }); + }, [composerTrigger?.query, modelOptions, selectedModel, selectedProvider, settings.customCodexModels]); + useEffect(() => { + if (!activeThread?.id || !sessionProvider) return; + setSelectedProviderByThread((existing) => { + if (existing[activeThread.id]) return existing; + return { ...existing, [activeThread.id]: sessionProvider }; + }); + }, [activeThread?.id, sessionProvider]); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; @@ -2127,6 +2159,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, model: selectedModel || undefined, + provider: selectedProvider, effort: selectedEffort || undefined, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", approvalPolicy, @@ -2225,19 +2258,23 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "thread.meta.update", commandId: newCommandId(), threadId: activeThread.id, - model: resolvedModel, + model: resolveModelSlugForProvider(selectedProvider, model), }); } scheduleComposerFocus(); }, - [ - activeThread, - baseThreadModel, - isServerThread, - scheduleComposerFocus, - setComposerDraftModel, - threadId, - ], + [activeThread, scheduleComposerFocus, selectedProvider], + ); + const onProviderSelect = useCallback( + (provider: ProviderKind) => { + if (!activeThread) return; + setSelectedProviderByThread((existing) => ({ + ...existing, + [activeThread.id]: provider, + })); + scheduleComposerFocus(); + }, + [activeThread, scheduleComposerFocus], ); const onEffortSelect = useCallback( (effort: ReasoningEffort) => { @@ -2675,6 +2712,12 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Bottom toolbar */}
+ {/* Model picker */} + + + {/* Divider */} + + {/* Model picker */} ; - onModelChange: (model: string) => void; + model: ModelSlug; + options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>; + onModelChange: (model: ModelSlug) => void; }) { return ( option.available).map((option) => ({ + label: option.label, + value: option.value, + }))} + value={props.provider} + onValueChange={(value) => + value === "codex" || value === "claudeCode" ? props.onProviderChange(value) : undefined + } + > + + + + + {PROVIDER_OPTIONS.filter((option) => option.available).map(({ value, label }) => ( + + {label} + + ))} + + + ); +}); + const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { effort: ReasoningEffort; onEffortChange: (effort: ReasoningEffort) => void; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 26ff6221a2702498ab5a104b6c9e93b05f6d07b6..17147adb3b42275c814d7835482c1d3a4e1c33ef 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2,6 +2,7 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/cont import { describe, expect, it } from "vitest"; import { + PROVIDER_OPTIONS, derivePendingApprovals, deriveWorkLogEntries, hasToolActivityForTurn, @@ -200,3 +201,14 @@ describe("isLatestTurnSettled", () => { ).toBe(false); }); }); + +describe("PROVIDER_OPTIONS", () => { + it("marks claudeCode as available", () => { + const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); + expect(claude).toEqual({ + value: "claudeCode", + label: "Claude Code", + available: true, + }); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a44c12e79ccf8fe53a26b8d9af67a595f0e5e199..23b1480290f3b1b3baa3c90144b77a24be611f21 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -14,7 +14,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code (soon)", available: false }, + { value: "claudeCode", label: "Claude Code", available: true }, ]; export interface WorkLogEntry {