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 {
66 type EditorId,
77 type KeybindingCommand,
88 type MessageId,
9 getDefaultModel,
10 getModelOptions,
911 type ProjectId,
1012 type ProjectEntry,
1113 type ProjectScript,
......@@ -16,10 +18,11 @@ import {
1618 type ResolvedKeybindingsConfig,
1719 type ProviderApprovalDecision,
1820 type ServerProviderStatus,
21 type ProviderKind,
1922 type ThreadId,
2023 type TurnId,
2124 normalizeModelSlug,
22 resolveModelSlug,
25 resolveModelSlugForProvider,
2326 OrchestrationThreadActivity,
2427} from "@t3tools/contracts";
2528import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
......@@ -49,6 +52,7 @@ import {
4952 derivePhase,
5053 deriveTimelineEntries,
5154 type PendingApproval,
55 PROVIDER_OPTIONS,
5256 deriveWorkLogEntries,
5357 hasToolActivityForTurn,
5458 isLatestTurnSettled,
......@@ -509,6 +513,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
509513 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
510514 const [isConnecting, _setIsConnecting] = useState(false);
511515 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
516 const [selectedProviderByThread, setSelectedProviderByThread] = useState<
517 Partial<Record<ThreadId, ProviderKind>>
518 >({});
512519 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
513520 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
514521 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
......@@ -632,20 +639,45 @@ export default function ChatView({ threadId }: ChatViewProps) {
632639 markThreadVisited,
633640 ]);
634641
635 const baseThreadModel = resolveModelSlug(
636 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
642 const sessionProvider = activeThread?.session?.provider ?? null;
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,
637650 );
638 const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel);
651 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);
639652 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
640653 const modelOptions = useMemo(
641 () => getAppModelOptions(settings.customCodexModels, selectedModel),
642 [selectedModel, settings.customCodexModels],
643 );
644 const slashModelOptions = useMemo(
645654 () =>
646 getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel),
647 [composerTrigger?.query, selectedModel, settings.customCodexModels],
648 );
655 selectedProvider === "codex"
656 ? getAppModelOptions(settings.customCodexModels, selectedModel)
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]);
649681 const phase = derivePhase(activeThread?.session ?? null);
650682 const isSendBusy = sendPhase !== "idle";
651683 const isPreparingWorktree = sendPhase === "preparing-worktree";
......@@ -2127,6 +2159,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
21272159 attachments: turnAttachments,
21282160 },
21292161 model: selectedModel || undefined,
2162 provider: selectedProvider,
21302163 effort: selectedEffort || undefined,
21312164 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
21322165 approvalPolicy,
......@@ -2225,19 +2258,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
22252258 type: "thread.meta.update",
22262259 commandId: newCommandId(),
22272260 threadId: activeThread.id,
2228 model: resolvedModel,
2261 model: resolveModelSlugForProvider(selectedProvider, model),
22292262 });
22302263 }
22312264 scheduleComposerFocus();
22322265 },
2233 [
2234 activeThread,
2235 baseThreadModel,
2236 isServerThread,
2237 scheduleComposerFocus,
2238 setComposerDraftModel,
2239 threadId,
2240 ],
2266 [activeThread, scheduleComposerFocus, selectedProvider],
2267 );
2268 const onProviderSelect = useCallback(
2269 (provider: ProviderKind) => {
2270 if (!activeThread) return;
2271 setSelectedProviderByThread((existing) => ({
2272 ...existing,
2273 [activeThread.id]: provider,
2274 }));
2275 scheduleComposerFocus();
2276 },
2277 [activeThread, scheduleComposerFocus],
22412278 );
22422279 const onEffortSelect = useCallback(
22432280 (effort: ReasoningEffort) => {
......@@ -2675,6 +2712,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
26752712 {/* Bottom toolbar */}
26762713 <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">
26772714 <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
26782721 {/* Model picker */}
26792722 <ModelPicker
26802723 model={selectedModel}
......@@ -3824,9 +3867,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
38243867});
38253868
38263869const ModelPicker = memo(function ModelPicker(props: {
3827 model: string;
3828 options: ReadonlyArray<{ slug: string; name: string }>;
3829 onModelChange: (model: string) => void;
3870 model: ModelSlug;
3871 options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>;
3872 onModelChange: (model: ModelSlug) => void;
38303873}) {
38313874 return (
38323875 <Select
......@@ -3848,6 +3891,35 @@ const ModelPicker = memo(function ModelPicker(props: {
38483891 );
38493892});
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
38513923const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
38523924 effort: ReasoningEffort;
38533925 onEffortChange: (effort: ReasoningEffort) => void;
apps/web/src/session-logic.test.ts+12
......@@ -2,6 +2,7 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/cont
22import { describe, expect, it } from "vitest";
33
44import {
5 PROVIDER_OPTIONS,
56 derivePendingApprovals,
67 deriveWorkLogEntries,
78 hasToolActivityForTurn,
......@@ -200,3 +201,14 @@ describe("isLatestTurnSettled", () => {
200201 ).toBe(false);
201202 });
202203});
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<{
1414 available: boolean;
1515}> = [
1616 { value: "codex", label: "Codex", available: true },
17 { value: "claudeCode", label: "Claude Code (soon)", available: false },
17 { value: "claudeCode", label: "Claude Code", available: true },
1818];
1919
2020export interface WorkLogEntry {