authorgravatar for vitalyiegorov@gmail.comVitaly Iegorov <vitalyiegorov@gmail.com> 2026-08-21 07:10:23+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 22:10:23-07:00
logf3fcfe1f63ac58cc7bdad2b459931a52ae5f6fa2
tree493d5980b1d1b2edc3f6b540cb046b6a90e836b3
parent18f6d034897d9cc5429dcb88c6a9795d183f1687
signature Signed by PGP key B5690EEEBB952194

fix(web): resolve sidebar provider icons from the thread's own environment (#7292)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 96 insertions(+), 11 deletions(-)

apps/web/src/components/Sidebar.tsx+20-11
......@@ -165,11 +165,10 @@ import { ProjectFavicon } from "./ProjectFavicon";
165165import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
166166import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
167167import {
168 deriveProviderInstanceEntries,
168 deriveProviderEntriesByEnvironment,
169169 shouldShowInstanceBadge,
170170 type ProviderInstanceEntry,
171171} from "../providerInstances";
172import { primaryServerProvidersAtom } from "../state/server";
173172import { useThreadRunningTerminalIds } from "../state/terminalSessions";
174173import { stackedThreadToast, toastManager } from "./ui/toast";
175174import { Button } from "./ui/button";
......@@ -247,6 +246,8 @@ function WorkingDuration(props: { startedAt: string | null }) {
247246 );
248247}
249248
249const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();
250
250251function terminalProcessLabel(count: number): string {
251252 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
252253}
......@@ -1862,15 +1863,18 @@ export default function Sidebar() {
18621863 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
18631864 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
18641865 );
1865 const serverProviders = useAtomValue(primaryServerProvidersAtom);
1866 const providerEntryByInstanceId = useMemo(
1866 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1867 // Threads on non-primary environments (T3 Connect, hosted) resolve their
1868 // provider entry from their own environment's config: default instance ids
1869 // are driver slugs, so a flat map would collide across environments.
1870 const providerEntriesByEnvironment = useMemo(
18671871 () =>
1868 new Map(
1869 deriveProviderInstanceEntries(serverProviders).map(
1870 (entry) => [entry.instanceId as string, entry] as const,
1872 deriveProviderEntriesByEnvironment(
1873 [...serverConfigs].map(
1874 ([environmentId, config]) => [environmentId, config.providers] as const,
18711875 ),
18721876 ),
1873 [serverProviders],
1877 [serverConfigs],
18741878 );
18751879 const projectCwdByKey = useMemo(
18761880 () =>
......@@ -1991,7 +1995,6 @@ export default function Sidebar() {
19911995 // the partition works directly off live shells: no archived-snapshot
19921996 // merging, no optimistic holds. Archived threads remain hidden here —
19931997 // archive keeps its original "remove from sidebar" meaning.
1994 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
19951998 const {
19961999 pinnedThreads,
19972000 reorderablePinnedKeys,
......@@ -3598,7 +3601,10 @@ export default function Sidebar() {
35983601 ) ?? null
35993602 }
36003603 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3601 providerEntryByInstanceId={providerEntryByInstanceId}
3604 providerEntryByInstanceId={
3605 providerEntriesByEnvironment.get(thread.environmentId) ??
3606 EMPTY_PROVIDER_ENTRIES
3607 }
36023608 isHighlighted={activeSearchResultIndex === index}
36033609 isRouteActive={routeThreadKey === threadKey}
36043610 resultId={`sidebar-thread-search-result-${index}`}
......@@ -3707,7 +3713,10 @@ export default function Sidebar() {
37073713 `${thread.environmentId}:${thread.projectId}`,
37083714 ) ?? null
37093715 }
3710 providerEntryByInstanceId={providerEntryByInstanceId}
3716 providerEntryByInstanceId={
3717 providerEntriesByEnvironment.get(thread.environmentId) ??
3718 EMPTY_PROVIDER_ENTRIES
3719 }
37113720 timestampFormat={timestampFormat}
37123721 onThreadClick={handleThreadClick}
37133722 onThreadActivate={navigateToThread}
apps/web/src/providerInstances.test.ts+49
......@@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3
22import { describe, expect, it } from "vite-plus/test";
33import {
44 applyProviderInstanceSettings,
5 deriveProviderEntriesByEnvironment,
56 deriveProviderInstanceEntries,
67 getDefaultProviderInstanceModel,
78 isProviderInstancePickerReady,
......@@ -17,6 +18,7 @@ function provider(input: {
1718 enabled?: boolean;
1819 availability?: ServerProvider["availability"];
1920 displayName?: string;
21 accentColor?: string;
2022 status?: ServerProvider["status"];
2123 models?: ServerProvider["models"];
2224}): ServerProvider {
......@@ -24,6 +26,7 @@ function provider(input: {
2426 instanceId: ProviderInstanceId.make(input.instanceId),
2527 driver: input.provider,
2628 ...(input.displayName ? { displayName: input.displayName } : {}),
29 ...(input.accentColor ? { accentColor: input.accentColor } : {}),
2730 enabled: input.enabled ?? true,
2831 installed: true,
2932 version: null,
......@@ -132,6 +135,52 @@ describe("deriveProviderInstanceEntries", () => {
132135 });
133136});
134137
138describe("deriveProviderEntriesByEnvironment", () => {
139 it("keeps same-id default instances distinct per environment", () => {
140 const byEnvironment = deriveProviderEntriesByEnvironment([
141 [
142 "local",
143 [
144 provider({
145 provider: ProviderDriverKind.make("claude"),
146 instanceId: "claude",
147 displayName: "Claude Local",
148 accentColor: "#112233",
149 }),
150 ],
151 ],
152 [
153 "remote",
154 [
155 provider({
156 provider: ProviderDriverKind.make("claude"),
157 instanceId: "claude",
158 displayName: "Claude Remote",
159 accentColor: "#445566",
160 }),
161 ],
162 ],
163 ]);
164
165 expect(byEnvironment.get("local")?.get("claude")?.displayName).toBe("Claude Local");
166 expect(byEnvironment.get("local")?.get("claude")?.accentColor).toBe("#112233");
167 expect(byEnvironment.get("remote")?.get("claude")?.displayName).toBe("Claude Remote");
168 expect(byEnvironment.get("remote")?.get("claude")?.accentColor).toBe("#445566");
169 });
170
171 it("never falls back to another environment's instances", () => {
172 const byEnvironment = deriveProviderEntriesByEnvironment([
173 ["local", [provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" })]],
174 ["empty", []],
175 ]);
176
177 expect(byEnvironment.get("empty")?.get("codex")).toBeUndefined();
178 // Every environment gets its own bucket, so an absent lookup is a real
179 // "this environment has no such instance", not a missing key.
180 expect(byEnvironment.get("empty")?.size).toBe(0);
181 });
182});
183
135184describe("resolveSelectableProviderInstance", () => {
136185 it("returns the requested instance when it is enabled and available", () => {
137186 const requested = ProviderInstanceId.make("claude_work");
apps/web/src/providerInstances.ts+27
......@@ -200,6 +200,33 @@ export function deriveProviderInstanceEntries(
200200 });
201201}
202202
203/**
204 * Project several environments' `ServerProvider[]` into a nested
205 * `environmentId → instanceId → entry` lookup.
206 *
207 * Instance ids are per-environment routing keys, and `defaultInstanceIdForDriver`
208 * makes the default id literally the driver slug, so every environment running
209 * the same driver reports the same id. Flattening across environments would
210 * clobber entries and mis-resolve accent colors; lookups must stay scoped to
211 * the thread's own environment.
212 */
213export function deriveProviderEntriesByEnvironment(
214 providersByEnvironment: Iterable<readonly [string, ReadonlyArray<ServerProvider>]>,
215): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> {
216 const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();
217 for (const [environmentId, providers] of providersByEnvironment) {
218 byEnvironment.set(
219 environmentId,
220 new Map(
221 deriveProviderInstanceEntries(providers).map(
222 (entry) => [entry.instanceId as string, entry] as const,
223 ),
224 ),
225 );
226 }
227 return byEnvironment;
228}
229
203230/**
204231 * Overlay the current settings configuration onto streamed provider snapshots.
205232 * Provider probes can briefly retain their previous `enabled` value after a