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