Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<ReadonlyMap<EnvironmentId, ServerConfig>>(
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<EnvironmentId, ServerConfig>([
[
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/thread-provider-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
),
};
}
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/state/thread-list-environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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,
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/provider/Drivers/CodexDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/Drivers/CodexManagedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ type SidebarProviderEntry = Pick<
ProviderInstanceEntry,
| "instanceId"
| "driverKind"
| "enabled"
| "displayName"
| "accentColor"
| "acpRegistryAgentId"
Expand All @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
},
Expand Down Expand Up @@ -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: [
{
Expand All @@ -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: [],
},
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/providerInstances.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +15,7 @@ import {
resolveDefaultProviderModelSelection,
resolveProviderCatalogAvailability,
resolveSelectableProviderInstance,
shouldShowInstanceBadge,
} from "./providerInstances";

function provider(input: {
Expand Down Expand Up @@ -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" })]],
Expand Down
64 changes: 36 additions & 28 deletions apps/web/src/providerInstances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ export function deriveProviderEntriesByEnvironment(
const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();
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)),
Expand All @@ -145,35 +146,42 @@ export function applyProviderInstanceSettings(
projectId: ProjectId | null,
): ReadonlyArray<ProviderInstanceEntry> {
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<Record<string, unknown>>)
: 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<ServerSettings, "providerInstances">,
): 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<Record<string, unknown>>)
: 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<ProviderInstanceEntry>,
): ReadonlyArray<ProviderInstanceEntry> {
Expand Down
29 changes: 20 additions & 9 deletions packages/client-runtime/src/state/providerInstanceDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});
});
Loading
Loading