History

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

Vitaly Iegorov committed 2 months ago

Commit f3fcfe11 parent 18f6d033 files · +96 −11Browse files

Unified · Split

3 files
  1. apps/web/src/components/Sidebar.tsx +20 −11
  2. apps/web/src/providerInstances.test.ts +49 −0
  3. apps/web/src/providerInstances.ts +27 −0
apps/web/src/components/Sidebar.tsx+20−11
@@ -165,11 +165,10 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 164 unmodified lines
165165 import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
166166 import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
167167 import {
168− deriveProviderInstanceEntries,
168+ deriveProviderEntriesByEnvironment,
169169 shouldShowInstanceBadge,
170170 type ProviderInstanceEntry,
171171 } from "../providerInstances";
172−import { primaryServerProvidersAtom } from "../state/server";
173172 import { useThreadRunningTerminalIds } from "../state/terminalSessions";
174173 import { stackedThreadToast, toastManager } from "./ui/toast";
175174 import { Button } from "./ui/button";
@@ -247,6 +246,8 @@ function WorkingDuration(props: { startedAt: string | null }) { · 71 unmodified lines
247246 );
248247 }
249248
249+const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();
250+
250251 function terminalProcessLabel(count: number): string {
251252 return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
252253 }
@@ -1862,15 +1863,18 @@ export default function Sidebar() { · 1609 unmodified lines
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() { · 114 unmodified lines
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() { · 1600 unmodified lines
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() { · 102 unmodified lines
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−0
@@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 · 1 unmodified line
22 import { describe, expect, it } from "vite-plus/test";
33 import {
44 applyProviderInstanceSettings,
5+ deriveProviderEntriesByEnvironment,
56 deriveProviderInstanceEntries,
67 getDefaultProviderInstanceModel,
78 isProviderInstancePickerReady,
@@ -17,6 +18,7 @@ function provider(input: { · 9 unmodified lines
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: { · 1 unmodified line
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", () => { · 102 unmodified lines
132135 });
133136 });
134137
138+describe("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+
135184 describe("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−0
@@ -200,6 +200,33 @@ export function deriveProviderInstanceEntries( · 199 unmodified lines
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+ */
213+export 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