diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts index 420b9dd36aab..b872f3a35a80 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -7,6 +7,9 @@ import { type ServerConfig, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { createThreadListEnvironmentsAtom } from "../../state/thread-list-environments"; import { createThreadRowProviderInstanceResolver, @@ -21,7 +24,11 @@ function makeConfig( readonly accentColor?: string; }>, ): ServerConfig { - return { providers } as unknown as ServerConfig; + return { + providers: providers.map((provider) => ({ enabled: true, ...provider })), + environment: { capabilities: {}, platform: { machine: "laptop" } }, + settings: {}, + } as unknown as ServerConfig; } function makeThread(environmentId: EnvironmentId, instanceId: string): EnvironmentThreadShell { @@ -147,6 +154,58 @@ describe("resolveThreadProviderInstance", () => { }); describe("createThreadRowProviderInstanceResolver", () => { + it("updates thread badges through the environment projection when a sibling is disabled and re-enabled", () => { + const environmentId = EnvironmentId.make("environment-a"); + const personal = makeThread(environmentId, "codex"); + const work = makeThread(environmentId, "codex_work"); + const config = makeConfig([ + { instanceId: "codex", driver: "codex" }, + { instanceId: "codex_work", driver: "codex" }, + ]); + const configs = Atom.make>( + new Map([[environmentId, config]]), + ); + const environments = createThreadListEnvironmentsAtom(configs); + const registry = AtomRegistry.make(); + registry.mount(environments); + try { + const before = createThreadRowProviderInstanceResolver( + registry.get(environments).providersByEnvironmentId, + ); + expect(before(personal)?.showBadge).toBe(true); + expect(before(work)?.showBadge).toBe(true); + + registry.set( + configs, + new Map([ + [ + environmentId, + { + ...config, + providers: config.providers.map((provider) => + provider.instanceId === "codex_work" ? { ...provider, enabled: false } : provider, + ), + }, + ], + ]), + ); + const disabled = createThreadRowProviderInstanceResolver( + registry.get(environments).providersByEnvironmentId, + ); + expect(disabled(personal)?.showBadge).toBe(false); + expect(disabled(work)?.showBadge).toBe(true); + + registry.set(configs, new Map([[environmentId, config]])); + const restored = createThreadRowProviderInstanceResolver( + registry.get(environments).providersByEnvironmentId, + ); + expect(restored(personal)?.showBadge).toBe(true); + expect(restored(work)?.showBadge).toBe(true); + } finally { + registry.dispose(); + } + }); + const environmentId = EnvironmentId.make("environment-a"); const serverConfigs = new Map([ [ diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts index 3fa2662cb964..d699f24a88e9 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -37,8 +37,8 @@ export function resolveThreadProviderInstance( return { ...entry, showBadge: shouldShowInstanceBadge( - entry, - providers.map((provider) => ({ driverKind: provider.driver })), + { ...entry, enabled: snapshot.enabled }, + providers.map((provider) => ({ driverKind: provider.driver, enabled: provider.enabled })), ), }; } diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index f1aab3756f5a..04389802f1a8 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -9,7 +9,7 @@ import { Atom } from "effect/reactivity"; export type ThreadListProvider = Pick< ServerProvider, - "instanceId" | "driver" | "displayName" | "accentColor" | "iconUrl" + "instanceId" | "driver" | "enabled" | "displayName" | "accentColor" | "iconUrl" >; const capabilityKeys = [ @@ -25,9 +25,10 @@ const capabilityKeys = [ function selectEnvironment(config: ServerConfig) { return { providers: config.providers.map( - ({ instanceId, driver, displayName, accentColor, iconUrl }) => ({ + ({ instanceId, driver, enabled, displayName, accentColor, iconUrl }) => ({ instanceId, driver, + enabled, displayName, accentColor, iconUrl, @@ -51,6 +52,7 @@ function sameProviders( return ( provider.instanceId === other.instanceId && provider.driver === other.driver && + provider.enabled === other.enabled && provider.displayName === other.displayName && provider.accentColor === other.accentColor && provider.iconUrl === other.iconUrl diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index ab2caf9aafb6..e6c0e87b4df4 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -97,6 +97,21 @@ const noSpawn = ChildProcessSpawner.make(() => const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(layerTest)("CodexDriver", (it) => { + it.effect("reports a disabled managed account as disabled", () => + Effect.gen(function* () { + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("disabled-managed-account"), + displayName: "Disabled account", + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), setupMode: "managed" }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + + expect((yield* instance.snapshot.getSnapshot).enabled).toBe(false); + expect((yield* instance.snapshot.refresh).enabled).toBe(false); + }).pipe(Effect.scoped), + ); + it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("restored-managed-account"); diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index e3cede11fdea..ad41a94b4823 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -55,7 +55,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu homePath: runtime.homeLayout.sharedHomePath, shadowHomePath: runtime.homeLayout.mode === "authOverlay" ? runtime.homePath : null, }; - const pending = makePendingCodexProvider({ ...config, customModels: [] }).pipe( + const pending = makePendingCodexProvider({ ...config, enabled, customModels: [] }).pipe( Effect.map((draft) => stamp({ ...draft, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 81eb57b37ddc..ce6a9d8e88e8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -299,6 +299,7 @@ type SidebarProviderEntry = Pick< ProviderInstanceEntry, | "instanceId" | "driverKind" + | "enabled" | "displayName" | "accentColor" | "acpRegistryAgentId" @@ -316,6 +317,7 @@ function sameSidebarProviderEntries( if ( other === undefined || other.driverKind !== entry.driverKind || + other.enabled !== entry.enabled || other.displayName !== entry.displayName || other.accentColor !== entry.accentColor || other.acpRegistryAgentId !== entry.acpRegistryAgentId || diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx index 342fb968abe9..ae09b4f639af 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx @@ -295,12 +295,14 @@ it("opens the correct chat for every workflow phase and unphased member", async { instanceId: "claude-personal", driver: "claudeAgent", + enabled: true, displayName: "Personal account", models: [], }, { instanceId: "claude-work", driver: "claudeAgent", + enabled: true, displayName: "Work account", models: [{ slug: "claude-sonnet-4-6", name: "Sonnet" }], }, @@ -969,6 +971,7 @@ it("shows readable models and only differing workspace details in agent tooltips { instanceId: "codex", driver: "codex", + enabled: true, displayName: "Work account", models: [ { @@ -981,6 +984,7 @@ it("shows readable models and only differing workspace details in agent tooltips { instanceId: "codex_personal", driver: "codex", + enabled: true, displayName: "Personal account", models: [], }, diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 6daab197f987..b6aaac5960aa 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -1,4 +1,9 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { applyProviderInstanceSettings, @@ -10,6 +15,7 @@ import { resolveDefaultProviderModelSelection, resolveProviderCatalogAvailability, resolveSelectableProviderInstance, + shouldShowInstanceBadge, } from "./providerInstances"; function provider(input: { @@ -337,6 +343,33 @@ describe("deriveProviderEntriesByEnvironment", () => { expect(byEnvironment.get("remote")?.get("claude")?.accentColor).toBe("#445566"); }); + it("keeps an instance a project turns on as a sibling for the instance badge", () => { + const codex = ProviderDriverKind.make("codex"); + const byEnvironment = deriveProviderEntriesByEnvironment([ + [ + "local", + [ + provider({ provider: codex, instanceId: "codex" }), + provider({ provider: codex, instanceId: "codex_work" }), + ], + { + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { driver: codex, enabled: false }, + }, + projectSettingsOverrides: { + [ProjectId.make("project-work")]: { + providerInstances: { [ProviderInstanceId.make("codex_work")]: { enabled: true } }, + }, + }, + }, + ], + ]); + const entries = byEnvironment.get("local"); + const personal = entries?.get("codex"); + + expect(personal && shouldShowInstanceBadge(personal, entries?.values() ?? [])).toBe(true); + }); + it("never falls back to another environment's instances", () => { const byEnvironment = deriveProviderEntriesByEnvironment([ ["local", [provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" })]], diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c0871ca49cba..1ac8e5e7e779 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -121,9 +121,10 @@ export function deriveProviderEntriesByEnvironment( const byEnvironment = new Map>(); for (const [environmentId, providers, settings] of providersByEnvironment) { const derived = deriveProviderInstanceEntries(providers); - // One map per environment serves every project with a thread there, so - // there is no single project to scope `enabled` to; it stays machine-wide. - const entries = settings ? applyProviderInstanceSettings(derived, settings, null) : derived; + // The snapshot includes instances enabled by any project; machine settings alone do not. + const entries = settings + ? derived.map((entry) => applyProviderInstanceBranding(entry, settings)) + : derived; byEnvironment.set( environmentId, new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), @@ -145,35 +146,42 @@ export function applyProviderInstanceSettings( projectId: ProjectId | null, ): ReadonlyArray { return entries.map((entry) => { - const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) - ? settings.providerInstances[entry.instanceId] - : undefined; const enabled = resolveProjectProviderInstanceEnabled(settings, projectId, entry.instanceId); - if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { - return enabled === entry.enabled ? entry : { ...entry, enabled }; - } - const config = - explicitInstance.config !== null && typeof explicitInstance.config === "object" - ? (explicitInstance.config as Readonly>) - : null; - if (config?.source === "local") { - return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined }; - } - const agentId = config?.agentId; - const iconUrl = config?.registryIconUrl; - return { - ...entry, - enabled, - ...(typeof agentId === "string" && agentId.trim() - ? { acpRegistryAgentId: agentId.trim() } - : {}), - ...(typeof iconUrl === "string" && iconUrl.trim() - ? { acpRegistryIconUrl: iconUrl.trim() } - : {}), - }; + return applyProviderInstanceBranding( + enabled === entry.enabled ? entry : { ...entry, enabled }, + settings, + ); }); } +function applyProviderInstanceBranding( + entry: ProviderInstanceEntry, + settings: Pick, +): ProviderInstanceEntry { + const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) + ? settings.providerInstances[entry.instanceId] + : undefined; + if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) return entry; + const config = + explicitInstance.config !== null && typeof explicitInstance.config === "object" + ? (explicitInstance.config as Readonly>) + : null; + if (config?.source === "local") { + return { ...entry, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined }; + } + const agentId = config?.agentId; + const iconUrl = config?.registryIconUrl; + return { + ...entry, + ...(typeof agentId === "string" && agentId.trim() + ? { acpRegistryAgentId: agentId.trim() } + : {}), + ...(typeof iconUrl === "string" && iconUrl.trim() + ? { acpRegistryIconUrl: iconUrl.trim() } + : {}), + }; +} + export function sortProviderInstanceEntries( entries: ReadonlyArray, ): ReadonlyArray { diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts index ca112f46c2bf..d90fa60bdad3 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts @@ -91,14 +91,14 @@ describe("normalizeProviderAccentColor", () => { describe("shouldShowInstanceBadge", () => { it("hides badges for distinct ACP agents sharing the registry driver", () => { - const mistral = { driverKind: acpRegistry, acpRegistryAgentId: "mistral-vibe" }; - const devin = { driverKind: acpRegistry, acpRegistryAgentId: "devin" }; + const mistral = { driverKind: acpRegistry, acpRegistryAgentId: "mistral-vibe", enabled: true }; + const devin = { driverKind: acpRegistry, acpRegistryAgentId: "devin", enabled: true }; expect(shouldShowInstanceBadge(mistral, [mistral, devin])).toBe(false); expect(shouldShowInstanceBadge(devin, [mistral, devin])).toBe(false); }); it("shows badges for multiple instances of the same ACP agent", () => { - const first = { driverKind: acpRegistry, acpRegistryAgentId: "mistral-vibe" }; + const first = { driverKind: acpRegistry, acpRegistryAgentId: "mistral-vibe", enabled: true }; const second = { ...first }; expect(shouldShowInstanceBadge(first, [first, second])).toBe(true); }); @@ -108,29 +108,40 @@ describe("shouldShowInstanceBadge", () => { driverKind: acpRegistry, acpRegistryAgentId: "mistral-vibe", accentColor: "#ff8800", + enabled: true, }; expect(shouldShowInstanceBadge(entry, [entry])).toBe(true); }); it("distinguishes registry instances whose agent identity is unavailable", () => { - const entry = { driverKind: acpRegistry }; + const entry = { driverKind: acpRegistry, enabled: true }; expect(shouldShowInstanceBadge(entry, [entry, { ...entry }])).toBe(true); }); it("shows the badge when the entry has an accent color", () => { - const entry = { driverKind: codex, accentColor: "#ff8800" }; + const entry = { driverKind: codex, accentColor: "#ff8800", enabled: true }; expect(shouldShowInstanceBadge(entry, [entry])).toBe(true); }); it("shows the badge when two entries share a driver, even without an accent", () => { - const first = { driverKind: codex, accentColor: undefined }; - const second = { driverKind: codex, accentColor: undefined }; + const first = { driverKind: codex, accentColor: undefined, enabled: true }; + const second = { driverKind: codex, accentColor: undefined, enabled: true }; expect(shouldShowInstanceBadge(first, [first, second])).toBe(true); }); + it("ignores disabled siblings for enabled instances while distinguishing historical owners", () => { + const enabled = { driverKind: codex, enabled: true }; + const disabled = { driverKind: codex, enabled: false }; + + expect(shouldShowInstanceBadge(enabled, [enabled, disabled])).toBe(false); + expect(shouldShowInstanceBadge(disabled, [enabled, disabled])).toBe(true); + expect(shouldShowInstanceBadge(disabled, [disabled, { ...disabled }])).toBe(true); + expect(shouldShowInstanceBadge(disabled, [disabled])).toBe(false); + }); + it("hides the badge for a single instance of a driver with no accent", () => { - const entry = { driverKind: codex, accentColor: undefined }; - const other = { driverKind: claude, accentColor: undefined }; + const entry = { driverKind: codex, accentColor: undefined, enabled: true }; + const other = { driverKind: claude, accentColor: undefined, enabled: true }; expect(shouldShowInstanceBadge(entry, [entry, other])).toBe(false); }); }); diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.ts b/packages/client-runtime/src/state/providerInstanceDisplay.ts index 7cb7172254f0..21d4d0c462c4 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.ts @@ -47,16 +47,20 @@ export function shouldShowInstanceBadge( readonly driverKind: ProviderDriverKind; readonly accentColor?: string | undefined; readonly acpRegistryAgentId?: string | undefined; + readonly enabled: boolean; }, entries: Iterable<{ readonly driverKind: ProviderDriverKind; readonly acpRegistryAgentId?: string | undefined; + readonly enabled: boolean; }>, ): boolean { if (entry.accentColor) return true; let sharedProviderCount = 0; for (const candidate of entries) { if (candidate.driverKind !== entry.driverKind) continue; + // Historical threads still need to distinguish accounts that are all disabled. + if (entry.enabled && !candidate.enabled) continue; if ( entry.driverKind === "acpRegistry" && candidate.acpRegistryAgentId !== entry.acpRegistryAgentId