From 4b49f4e93bd209290c31fb433918b822fb78e430 Mon Sep 17 00:00:00 2001 From: Youssef Chouay Date: Tue, 7 Apr 2026 11:40:19 -0400 Subject: [PATCH 1/2] Add provider icons to sidebar thread rows --- apps/web/src/components/ChatView.browser.tsx | 46 +++++++++++++++++-- apps/web/src/components/Sidebar.tsx | 26 +++++++++++ .../components/chat/ProviderModelPicker.tsx | 24 +++------- apps/web/src/providerPresentation.ts | 20 ++++++++ apps/web/src/store.test.ts | 36 +++++++++++++++ apps/web/src/store.ts | 2 + apps/web/src/types.ts | 1 + 7 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/providerPresentation.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 8e848205da..7977c943a8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -219,7 +219,9 @@ function createSnapshotForTargetUser(options: { targetText: string; targetAttachmentCount?: number; sessionStatus?: OrchestrationSessionStatus; + provider?: "codex" | "claudeAgent"; }): OrchestrationReadModel { + const provider = options.provider ?? "codex"; const messages: Array = []; for (let index = 0; index < 22; index += 1) { @@ -278,8 +280,8 @@ function createSnapshotForTargetUser(options: { projectId: PROJECT_ID, title: "Browser test thread", modelSelection: { - provider: "codex", - model: "gpt-5", + provider, + model: provider === "claudeAgent" ? "claude-sonnet-4-6" : "gpt-5", }, interactionMode: "default", runtimeMode: "full-access", @@ -297,7 +299,7 @@ function createSnapshotForTargetUser(options: { session: { threadId: THREAD_ID, status: options.sessionStatus ?? "ready", - providerName: "codex", + providerName: provider, runtimeMode: "full-access", activeTurnId: null, lastError: null, @@ -2398,6 +2400,44 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("renders the provider icon next to the sidebar thread title", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-provider-icon-test" as MessageId, + targetText: "provider icon target", + provider: "claudeAgent", + }), + configureFixture: (fixture) => { + fixture.serverConfig = { + ...fixture.serverConfig, + providers: [ + ...fixture.serverConfig.providers, + { + provider: "claudeAgent", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [], + }, + ], + }; + }, + }); + + try { + const providerIcon = page.getByTestId(`thread-provider-icon-${THREAD_ID}`); + + await expect.element(providerIcon).toBeInTheDocument(); + await expect.element(providerIcon).toHaveAttribute("aria-label", "Claude thread"); + } finally { + await mounted.cleanup(); + } + }); + it("shows the confirm archive action after clicking the archive button", async () => { localStorage.setItem( "t3code:client-settings:v1", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5b4da4655c..34dc209b7f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -130,6 +130,11 @@ import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { useSidebarThreadSummaryById } from "../storeSelectors"; import type { Project } from "../types"; +import { + PROVIDER_ICON_BY_KIND, + providerDisplayLabel, + providerIconClassName, +} from "../providerPresentation"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -255,6 +260,26 @@ function resolveThreadPr( return gitStatus.pr ?? null; } +function ThreadProviderIcon(props: { provider: "codex" | "claudeAgent"; threadId: ThreadId }) { + const ProviderIcon = PROVIDER_ICON_BY_KIND[props.provider]; + const label = `${providerDisplayLabel(props.provider)} thread`; + + return ( + + + ); +} + interface SidebarThreadRowProps { threadId: ThreadId; projectCwd: string | null; @@ -399,6 +424,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { )} {threadStatus && } + {props.renamingThreadId === thread.id ? ( = { - codex: OpenAI, - claudeAgent: ClaudeAI, - cursor: CursorIcon, -}; - export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); const COMING_SOON_PROVIDER_OPTIONS = [ @@ -43,13 +38,6 @@ const COMING_SOON_PROVIDER_OPTIONS = [ { id: "gemini", label: "Gemini", icon: Gemini }, ] as const; -function providerIconClassName( - provider: ProviderKind | ProviderPickerKind, - fallbackClassName: string, -): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; -} - export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: string; @@ -68,7 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; + const ProviderIcon = PROVIDER_ICON_BY_KIND[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; @@ -147,7 +135,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( <> {AVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const OptionIcon = PROVIDER_ICON_BY_KIND[option.value]; const liveProvider = props.providers ? getProviderSnapshot(props.providers, option.value) : undefined; @@ -208,7 +196,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { })} {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const OptionIcon = PROVIDER_ICON_BY_KIND[option.value]; return ( = { + codex: OpenAI, + claudeAgent: ClaudeAI, + cursor: CursorIcon, +}; + +export function providerDisplayLabel(provider: ProviderKind): string { + return provider === "claudeAgent" ? "Claude" : "Codex"; +} + +export function providerIconClassName( + provider: ProviderKind | ProviderPickerKind, + fallbackClassName: string, +): string { + return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 9deb578d3e..d6b35d35c9 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -194,6 +194,23 @@ describe("store read model sync", () => { expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); + it("captures the sidebar provider from the thread model selection", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + session: null, + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.sidebarThreadsById[ThreadId.makeUnsafe("thread-1")]?.provider).toBe("claudeAgent"); + }); + it("resolves claude aliases when session provider is claudeAgent", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( @@ -571,6 +588,25 @@ describe("incremental orchestration updates", () => { expect(next.threads[0]?.messages).toHaveLength(1); }); + it("updates the sidebar provider when thread model selection changes", () => { + const state = makeState(makeThread()); + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.meta-updated", { + threadId: ThreadId.makeUnsafe("thread-1"), + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + updatedAt: "2026-02-27T00:00:02.000Z", + }), + ); + + expect(next.threads[0]?.modelSelection.provider).toBe("claudeAgent"); + expect(next.sidebarThreadsById[ThreadId.makeUnsafe("thread-1")]?.provider).toBe("claudeAgent"); + }); + it("does not regress latestTurn when an older turn diff completes late", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 6e768c4ef8..5842fb3f38 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -215,6 +215,7 @@ function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { id: thread.id, projectId: thread.projectId, title: thread.title, + provider: thread.modelSelection.provider, interactionMode: thread.interactionMode, session: thread.session, createdAt: thread.createdAt, @@ -241,6 +242,7 @@ function sidebarThreadSummariesEqual( left.id === right.id && left.projectId === right.projectId && left.title === right.title && + left.provider === right.provider && left.interactionMode === right.interactionMode && left.session === right.session && left.createdAt === right.createdAt && diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0599b9c989..077d4bc6df 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -115,6 +115,7 @@ export interface SidebarThreadSummary { id: ThreadId; projectId: ProjectId; title: string; + provider: ProviderKind; interactionMode: ProviderInteractionMode; session: ThreadSession | null; createdAt: string; From bbe58884d7e67cfcd093129e599b765f57f0814e Mon Sep 17 00:00:00 2001 From: Youssef Chouay Date: Tue, 7 Apr 2026 11:57:04 -0400 Subject: [PATCH 2/2] Use canonical provider labels and types --- apps/web/src/components/ChatView.browser.tsx | 3 ++- apps/web/src/components/Sidebar.tsx | 12 +++++------- apps/web/src/providerPresentation.ts | 7 +------ 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7977c943a8..dea780c2b0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -8,6 +8,7 @@ import { type OrchestrationEvent, type OrchestrationReadModel, type ProjectId, + type ProviderKind, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, @@ -219,7 +220,7 @@ function createSnapshotForTargetUser(options: { targetText: string; targetAttachmentCount?: number; sessionStatus?: OrchestrationSessionStatus; - provider?: "codex" | "claudeAgent"; + provider?: ProviderKind; }): OrchestrationReadModel { const provider = options.provider ?? "codex"; const messages: Array = []; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 34dc209b7f..d0d2d393cf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -46,6 +46,8 @@ import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, + type ProviderKind, + PROVIDER_DISPLAY_NAMES, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; @@ -130,11 +132,7 @@ import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { useSidebarThreadSummaryById } from "../storeSelectors"; import type { Project } from "../types"; -import { - PROVIDER_ICON_BY_KIND, - providerDisplayLabel, - providerIconClassName, -} from "../providerPresentation"; +import { PROVIDER_ICON_BY_KIND, providerIconClassName } from "../providerPresentation"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -260,9 +258,9 @@ function resolveThreadPr( return gitStatus.pr ?? null; } -function ThreadProviderIcon(props: { provider: "codex" | "claudeAgent"; threadId: ThreadId }) { +function ThreadProviderIcon(props: { provider: ProviderKind; threadId: ThreadId }) { const ProviderIcon = PROVIDER_ICON_BY_KIND[props.provider]; - const label = `${providerDisplayLabel(props.provider)} thread`; + const label = `${PROVIDER_DISPLAY_NAMES[props.provider]} thread`; return ( = { cursor: CursorIcon, }; -export function providerDisplayLabel(provider: ProviderKind): string { - return provider === "claudeAgent" ? "Claude" : "Codex"; -} - export function providerIconClassName( - provider: ProviderKind | ProviderPickerKind, + provider: ProviderPickerKind, fallbackClassName: string, ): string { return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName;