| author | |
| committer | |
| log | f3fcfe1f63ac58cc7bdad2b459931a52ae5f6fa2 |
| tree | 493d5980b1d1b2edc3f6b540cb046b6a90e836b3 |
| parent | 18f6d034897d9cc5429dcb88c6a9795d183f1687 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 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 }) { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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} |
apps/web/src/providerInstances.test.ts+49| ... | ... | @@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 |
| 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: { |
| 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: { |
| 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", () => { |
| 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"); |
apps/web/src/providerInstances.ts+27| ... | ... | @@ -200,6 +200,33 @@ export function deriveProviderInstanceEntries( |
| 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 |