Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type OrchestrationEvent,
type OrchestrationReadModel,
type ProjectId,
type ProviderKind,
type ServerConfig,
type ServerLifecycleWelcomePayload,
type ThreadId,
Expand Down Expand Up @@ -219,7 +220,9 @@ function createSnapshotForTargetUser(options: {
targetText: string;
targetAttachmentCount?: number;
sessionStatus?: OrchestrationSessionStatus;
provider?: ProviderKind;
}): OrchestrationReadModel {
const provider = options.provider ?? "codex";
const messages: Array<OrchestrationReadModel["threads"][number]["messages"][number]> = [];

for (let index = 0; index < 22; index += 1) {
Expand Down Expand Up @@ -278,8 +281,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",
Expand All @@ -297,7 +300,7 @@ function createSnapshotForTargetUser(options: {
session: {
threadId: THREAD_ID,
status: options.sessionStatus ?? "ready",
providerName: "codex",
providerName: provider,
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
Expand Down Expand Up @@ -2398,6 +2401,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",
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
DEFAULT_MODEL_BY_PROVIDER,
type DesktopUpdateState,
ProjectId,
type ProviderKind,
PROVIDER_DISPLAY_NAMES,
ThreadId,
type GitStatusResult,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -130,6 +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, providerIconClassName } from "../providerPresentation";
const THREAD_PREVIEW_LIMIT = 6;
const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
updated_at: "Last user message",
Expand Down Expand Up @@ -255,6 +258,26 @@ function resolveThreadPr(
return gitStatus.pr ?? null;
}

function ThreadProviderIcon(props: { provider: ProviderKind; threadId: ThreadId }) {
const ProviderIcon = PROVIDER_ICON_BY_KIND[props.provider];
const label = `${PROVIDER_DISPLAY_NAMES[props.provider]} thread`;

return (
<span
role="img"
aria-label={label}
title={label}
data-testid={`thread-provider-icon-${props.threadId}`}
className="inline-flex size-3 shrink-0 items-center justify-center"
>
<ProviderIcon
aria-hidden="true"
className={`size-3 ${providerIconClassName(props.provider, "text-muted-foreground/70")}`}
/>
</span>
);
}

interface SidebarThreadRowProps {
threadId: ThreadId;
projectCwd: string | null;
Expand Down Expand Up @@ -399,6 +422,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
</Tooltip>
)}
{threadStatus && <ThreadStatusLabel status={threadStatus} />}
<ThreadProviderIcon provider={thread.provider} threadId={thread.id} />
{props.renamingThreadId === thread.id ? (
<input
ref={props.onRenamingInputMount}
Expand Down
24 changes: 6 additions & 18 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
import { resolveSelectableModel } from "@t3tools/shared/model";
import { memo, useState } from "react";
import type { VariantProps } from "class-variance-authority";
import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
import { PROVIDER_OPTIONS } from "../../session-logic";
import { ChevronDownIcon } from "lucide-react";
import { Button, buttonVariants } from "../ui/button";
import {
Expand All @@ -18,9 +18,10 @@ import {
MenuSubTrigger,
MenuTrigger,
} from "../ui/menu";
import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";
import { Gemini, OpenCodeIcon } from "../Icons";
import { cn } from "~/lib/utils";
import { getProviderSnapshot } from "../../providerModels";
import { PROVIDER_ICON_BY_KIND, providerIconClassName } from "../../providerPresentation";

function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
value: ProviderKind;
Expand All @@ -30,26 +31,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o
return option.available;
}

const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
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 = [
{ id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
{ 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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -208,7 +196,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
})}
{UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />}
{UNAVAILABLE_PROVIDER_OPTIONS.map((option) => {
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
const OptionIcon = PROVIDER_ICON_BY_KIND[option.value];
return (
<MenuItem key={option.value} disabled>
<OptionIcon
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/providerPresentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type ProviderPickerKind } from "./session-logic";
import { type Icon, ClaudeAI, CursorIcon, OpenAI } from "./components/Icons";

export const PROVIDER_ICON_BY_KIND: Record<ProviderPickerKind, Icon> = {
codex: OpenAI,
claudeAgent: ClaudeAI,
cursor: CursorIcon,
};

export function providerIconClassName(
provider: ProviderPickerKind,
fallbackClassName: string,
): string {
return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName;
}
36 changes: 36 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 &&
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export interface SidebarThreadSummary {
id: ThreadId;
projectId: ProjectId;
title: string;
provider: ProviderKind;
interactionMode: ProviderInteractionMode;
session: ThreadSession | null;
createdAt: string;
Expand Down
Loading