authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 01:59:32-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
logff7e084a339d230afd974c1434773312d88f5d68
tree972d6b5f07e0fce00697c2c0c8c97e44d2469c95
parentc590beb4e3feb7c8bc87cf1307b3d018c73eca62

Enable Claude provider selection in chat composer

Add provider-aware model options and include provider in turn-start dispatch. Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 108 insertions(+), 24 deletions(-)

apps/web/src/components/ChatView.tsx+95-23
...@@ -6,6 +6,8 @@ import {...@@ -6,6 +6,8 @@ import {
6 type EditorId,6 type EditorId,
7 type KeybindingCommand,7 type KeybindingCommand,
8 type MessageId,8 type MessageId,
9 getDefaultModel,
10 getModelOptions,
9 type ProjectId,11 type ProjectId,
10 type ProjectEntry,12 type ProjectEntry,
11 type ProjectScript,13 type ProjectScript,
...@@ -16,10 +18,11 @@ import {...@@ -16,10 +18,11 @@ import {
16 type ResolvedKeybindingsConfig,18 type ResolvedKeybindingsConfig,
17 type ProviderApprovalDecision,19 type ProviderApprovalDecision,
18 type ServerProviderStatus,20 type ServerProviderStatus,
21 type ProviderKind,
19 type ThreadId,22 type ThreadId,
20 type TurnId,23 type TurnId,
21 normalizeModelSlug,24 normalizeModelSlug,
22 resolveModelSlug,25 resolveModelSlugForProvider,
23 OrchestrationThreadActivity,26 OrchestrationThreadActivity,
24} from "@t3tools/contracts";27} from "@t3tools/contracts";
25import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";28import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
...@@ -49,6 +52,7 @@ import {...@@ -49,6 +52,7 @@ import {
49 derivePhase,52 derivePhase,
50 deriveTimelineEntries,53 deriveTimelineEntries,
51 type PendingApproval,54 type PendingApproval,
55 PROVIDER_OPTIONS,
52 deriveWorkLogEntries,56 deriveWorkLogEntries,
53 hasToolActivityForTurn,57 hasToolActivityForTurn,
54 isLatestTurnSettled,58 isLatestTurnSettled,
...@@ -509,6 +513,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -509,6 +513,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
509 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");513 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
510 const [isConnecting, _setIsConnecting] = useState(false);514 const [isConnecting, _setIsConnecting] = useState(false);
511 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);515 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
516 const [selectedProviderByThread, setSelectedProviderByThread] = useState<
517 Partial<Record<ThreadId, ProviderKind>>
518 >({});
512 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);519 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
513 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);520 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
514 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});521 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
...@@ -632,20 +639,45 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -632,20 +639,45 @@ export default function ChatView({ threadId }: ChatViewProps) {
632 markThreadVisited,639 markThreadVisited,
633 ]);640 ]);
634641
635 const baseThreadModel = resolveModelSlug(642 const sessionProvider = activeThread?.session?.provider ?? null;
636 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,643 const selectedProvider: ProviderKind =
644 (activeThread?.id ? selectedProviderByThread[activeThread.id] : undefined) ??
645 sessionProvider ??
646 "codex";
647 const baseThreadModel = resolveModelSlugForProvider(
648 selectedProvider,
649 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,
637 );650 );
638 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);651 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);
639 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;652 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
640 const modelOptions = useMemo(653 const modelOptions = useMemo(
641 () => getAppModelOptions(settings.customCodexModels, selectedModel),
642 [selectedModel, settings.customCodexModels],
643 );
644 const slashModelOptions = useMemo(
645 () =>654 () =>
646 getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel),655 selectedProvider === "codex"
647 [composerTrigger?.query, selectedModel, settings.customCodexModels],656 ? getAppModelOptions(settings.customCodexModels, selectedModel)
648 );657 : getModelOptions(selectedProvider),
658 [selectedModel, selectedProvider, settings.customCodexModels],
659 );
660 const slashModelOptions = useMemo(() => {
661 if (selectedProvider === "codex") {
662 return getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel);
663 }
664 const query = (composerTrigger?.query ?? "").trim().toLowerCase();
665 if (!query) {
666 return modelOptions;
667 }
668 return modelOptions.filter(({ slug, name }) => {
669 const searchSlug = slug.toLowerCase();
670 const searchName = name.toLowerCase();
671 return searchSlug.includes(query) || searchName.includes(query);
672 });
673 }, [composerTrigger?.query, modelOptions, selectedModel, selectedProvider, settings.customCodexModels]);
674 useEffect(() => {
675 if (!activeThread?.id || !sessionProvider) return;
676 setSelectedProviderByThread((existing) => {
677 if (existing[activeThread.id]) return existing;
678 return { ...existing, [activeThread.id]: sessionProvider };
679 });
680 }, [activeThread?.id, sessionProvider]);
649 const phase = derivePhase(activeThread?.session ?? null);681 const phase = derivePhase(activeThread?.session ?? null);
650 const isSendBusy = sendPhase !== "idle";682 const isSendBusy = sendPhase !== "idle";
651 const isPreparingWorktree = sendPhase === "preparing-worktree";683 const isPreparingWorktree = sendPhase === "preparing-worktree";
...@@ -2127,6 +2159,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2127,6 +2159,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2127 attachments: turnAttachments,2159 attachments: turnAttachments,
2128 },2160 },
2129 model: selectedModel || undefined,2161 model: selectedModel || undefined,
2162 provider: selectedProvider,
2130 effort: selectedEffort || undefined,2163 effort: selectedEffort || undefined,
2131 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2164 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2132 approvalPolicy,2165 approvalPolicy,
...@@ -2225,19 +2258,23 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2225,19 +2258,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
2225 type: "thread.meta.update",2258 type: "thread.meta.update",
2226 commandId: newCommandId(),2259 commandId: newCommandId(),
2227 threadId: activeThread.id,2260 threadId: activeThread.id,
2228 model: resolvedModel,2261 model: resolveModelSlugForProvider(selectedProvider, model),
2229 });2262 });
2230 }2263 }
2231 scheduleComposerFocus();2264 scheduleComposerFocus();
2232 },2265 },
2233 [2266 [activeThread, scheduleComposerFocus, selectedProvider],
2234 activeThread,2267 );
2235 baseThreadModel,2268 const onProviderSelect = useCallback(
2236 isServerThread,2269 (provider: ProviderKind) => {
2237 scheduleComposerFocus,2270 if (!activeThread) return;
2238 setComposerDraftModel,2271 setSelectedProviderByThread((existing) => ({
2239 threadId,2272 ...existing,
2240 ],2273 [activeThread.id]: provider,
2274 }));
2275 scheduleComposerFocus();
2276 },
2277 [activeThread, scheduleComposerFocus],
2241 );2278 );
2242 const onEffortSelect = useCallback(2279 const onEffortSelect = useCallback(
2243 (effort: ReasoningEffort) => {2280 (effort: ReasoningEffort) => {
...@@ -2675,6 +2712,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2675,6 +2712,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2675 {/* Bottom toolbar */}2712 {/* Bottom toolbar */}
2676 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">2713 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">
2677 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">2714 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">
2715 {/* Model picker */}
2716 <ProviderPicker provider={selectedProvider} onProviderChange={onProviderSelect} />
2717
2718 {/* Divider */}
2719 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2720
2678 {/* Model picker */}2721 {/* Model picker */}
2679 <ModelPicker2722 <ModelPicker
2680 model={selectedModel}2723 model={selectedModel}
...@@ -3824,9 +3867,9 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3824,9 +3867,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
3824});3867});
38253868
3826const ModelPicker = memo(function ModelPicker(props: {3869const ModelPicker = memo(function ModelPicker(props: {
3827 model: string;3870 model: ModelSlug;
3828 options: ReadonlyArray<{ slug: string; name: string }>;3871 options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>;
3829 onModelChange: (model: string) => void;3872 onModelChange: (model: ModelSlug) => void;
3830}) {3873}) {
3831 return (3874 return (
3832 <Select3875 <Select
...@@ -3848,6 +3891,35 @@ const ModelPicker = memo(function ModelPicker(props: {...@@ -3848,6 +3891,35 @@ const ModelPicker = memo(function ModelPicker(props: {
3848 );3891 );
3849});3892});
38503893
3894const ProviderPicker = memo(function ProviderPicker(props: {
3895 provider: ProviderKind;
3896 onProviderChange: (provider: ProviderKind) => void;
3897}) {
3898 return (
3899 <Select
3900 items={PROVIDER_OPTIONS.filter((option) => option.available).map((option) => ({
3901 label: option.label,
3902 value: option.value,
3903 }))}
3904 value={props.provider}
3905 onValueChange={(value) =>
3906 value === "codex" || value === "claudeCode" ? props.onProviderChange(value) : undefined
3907 }
3908 >
3909 <SelectTrigger size="sm" variant="ghost">
3910 <SelectValue />
3911 </SelectTrigger>
3912 <SelectPopup alignItemWithTrigger={false}>
3913 {PROVIDER_OPTIONS.filter((option) => option.available).map(({ value, label }) => (
3914 <SelectItem key={value} value={value}>
3915 {label}
3916 </SelectItem>
3917 ))}
3918 </SelectPopup>
3919 </Select>
3920 );
3921});
3922
3851const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {3923const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
3852 effort: ReasoningEffort;3924 effort: ReasoningEffort;
3853 onEffortChange: (effort: ReasoningEffort) => void;3925 onEffortChange: (effort: ReasoningEffort) => void;
apps/web/src/session-logic.test.ts+12
...@@ -2,6 +2,7 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/cont...@@ -2,6 +2,7 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/cont
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import {4import {
5 PROVIDER_OPTIONS,
5 derivePendingApprovals,6 derivePendingApprovals,
6 deriveWorkLogEntries,7 deriveWorkLogEntries,
7 hasToolActivityForTurn,8 hasToolActivityForTurn,
...@@ -200,3 +201,14 @@ describe("isLatestTurnSettled", () => {...@@ -200,3 +201,14 @@ describe("isLatestTurnSettled", () => {
200 ).toBe(false);201 ).toBe(false);
201 });202 });
202});203});
204
205describe("PROVIDER_OPTIONS", () => {
206 it("marks claudeCode as available", () => {
207 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode");
208 expect(claude).toEqual({
209 value: "claudeCode",
210 label: "Claude Code",
211 available: true,
212 });
213 });
214});
apps/web/src/session-logic.ts+1-1
...@@ -14,7 +14,7 @@ export const PROVIDER_OPTIONS: Array<{...@@ -14,7 +14,7 @@ export const PROVIDER_OPTIONS: Array<{
14 available: boolean;14 available: boolean;
15}> = [15}> = [
16 { value: "codex", label: "Codex", available: true },16 { value: "codex", label: "Codex", available: true },
17 { value: "claudeCode", label: "Claude Code (soon)", available: false },17 { value: "claudeCode", label: "Claude Code", available: true },
18];18];
1919
20export interface WorkLogEntry {20export interface WorkLogEntry {