Unified · Split
| @@ -165,11 +165,10 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 164 unmodified lines | ||
| 165 | 165 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 166 | 166 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| 167 | 167 | import { |
| 168 | − deriveProviderInstanceEntries, | |
| 168 | + deriveProviderEntriesByEnvironment, | |
| 169 | 169 | shouldShowInstanceBadge, |
| 170 | 170 | type ProviderInstanceEntry, |
| 171 | 171 | } from "../providerInstances"; |
| 172 | −import { primaryServerProvidersAtom } from "../state/server"; | |
| 173 | 172 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 174 | 173 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 175 | 174 | import { Button } from "./ui/button"; |
| @@ -247,6 +246,8 @@ function WorkingDuration(props: { startedAt: string | null }) { · 71 unmodified lines | ||
| 247 | 246 | ); |
| 248 | 247 | } |
| 249 | 248 | |
| 249 | +const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map(); | |
| 250 | + | |
| 250 | 251 | function terminalProcessLabel(count: number): string { |
| 251 | 252 | return `${count} terminal ${count === 1 ? "process" : "processes"} running`; |
| 252 | 253 | } |
| @@ -1862,15 +1863,18 @@ export default function Sidebar() { · 1609 unmodified lines | ||
| 1862 | 1863 | () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), |
| 1863 | 1864 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], |
| 1864 | 1865 | ); |
| 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( | |
| 1867 | 1871 | () => |
| 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, | |
| 1871 | 1875 | ), |
| 1872 | 1876 | ), |
| 1873 | − [serverProviders], | |
| 1877 | + [serverConfigs], | |
| 1874 | 1878 | ); |
| 1875 | 1879 | const projectCwdByKey = useMemo( |
| 1876 | 1880 | () => |
| @@ -1991,7 +1995,6 @@ export default function Sidebar() { · 114 unmodified lines | ||
| 1991 | 1995 | // the partition works directly off live shells: no archived-snapshot |
| 1992 | 1996 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 1993 | 1997 | // archive keeps its original "remove from sidebar" meaning. |
| 1994 | − const serverConfigs = useAtomValue(environmentServerConfigsAtom); | |
| 1995 | 1998 | const { |
| 1996 | 1999 | pinnedThreads, |
| 1997 | 2000 | reorderablePinnedKeys, |
| @@ -3598,7 +3601,10 @@ export default function Sidebar() { · 1600 unmodified lines | ||
| 3598 | 3601 | ) ?? null |
| 3599 | 3602 | } |
| 3600 | 3603 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} |
| 3601 | − providerEntryByInstanceId={providerEntryByInstanceId} | |
| 3604 | + providerEntryByInstanceId={ | |
| 3605 | + providerEntriesByEnvironment.get(thread.environmentId) ?? | |
| 3606 | + EMPTY_PROVIDER_ENTRIES | |
| 3607 | + } | |
| 3602 | 3608 | isHighlighted={activeSearchResultIndex === index} |
| 3603 | 3609 | isRouteActive={routeThreadKey === threadKey} |
| 3604 | 3610 | resultId={`sidebar-thread-search-result-${index}`} |
| @@ -3707,7 +3713,10 @@ export default function Sidebar() { · 102 unmodified lines | ||
| 3707 | 3713 | `${thread.environmentId}:${thread.projectId}`, |
| 3708 | 3714 | ) ?? null |
| 3709 | 3715 | } |
| 3710 | − providerEntryByInstanceId={providerEntryByInstanceId} | |
| 3716 | + providerEntryByInstanceId={ | |
| 3717 | + providerEntriesByEnvironment.get(thread.environmentId) ?? | |
| 3718 | + EMPTY_PROVIDER_ENTRIES | |
| 3719 | + } | |
| 3711 | 3720 | timestampFormat={timestampFormat} |
| 3712 | 3721 | onThreadClick={handleThreadClick} |
| 3713 | 3722 | onThreadActivate={navigateToThread} |
| @@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 · 1 unmodified line | ||
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | applyProviderInstanceSettings, |
| 5 | + deriveProviderEntriesByEnvironment, | |
| 5 | 6 | deriveProviderInstanceEntries, |
| 6 | 7 | getDefaultProviderInstanceModel, |
| 7 | 8 | isProviderInstancePickerReady, |
| @@ -17,6 +18,7 @@ function provider(input: { · 9 unmodified lines | ||
| 17 | 18 | enabled?: boolean; |
| 18 | 19 | availability?: ServerProvider["availability"]; |
| 19 | 20 | displayName?: string; |
| 21 | + accentColor?: string; | |
| 20 | 22 | status?: ServerProvider["status"]; |
| 21 | 23 | models?: ServerProvider["models"]; |
| 22 | 24 | }): ServerProvider { |
| @@ -24,6 +26,7 @@ function provider(input: { · 1 unmodified line | ||
| 24 | 26 | instanceId: ProviderInstanceId.make(input.instanceId), |
| 25 | 27 | driver: input.provider, |
| 26 | 28 | ...(input.displayName ? { displayName: input.displayName } : {}), |
| 29 | + ...(input.accentColor ? { accentColor: input.accentColor } : {}), | |
| 27 | 30 | enabled: input.enabled ?? true, |
| 28 | 31 | installed: true, |
| 29 | 32 | version: null, |
| @@ -132,6 +135,52 @@ describe("deriveProviderInstanceEntries", () => { · 102 unmodified lines | ||
| 132 | 135 | }); |
| 133 | 136 | }); |
| 134 | 137 | |
| 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 | + | |
| 135 | 184 | describe("resolveSelectableProviderInstance", () => { |
| 136 | 185 | it("returns the requested instance when it is enabled and available", () => { |
| 137 | 186 | const requested = ProviderInstanceId.make("claude_work"); |
| @@ -200,6 +200,33 @@ export function deriveProviderInstanceEntries( · 199 unmodified lines | ||
| 200 | 200 | }); |
| 201 | 201 | } |
| 202 | 202 | |
| 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 | + | |
| 203 | 230 | /** |
| 204 | 231 | * Overlay the current settings configuration onto streamed provider snapshots. |
| 205 | 232 | * Provider probes can briefly retain their previous `enabled` value after a |