From a5718e217475703555ae6654c61882b4d8d545af Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Wed, 10 Jun 2026 15:11:51 -0700 Subject: [PATCH 1/2] feat(claude): add Claude profile support - add Claude profile management in settings and show profile entries in the sidebar, menus, and usage views - persist profile instance config and update shared settings cleanup when profiles are added or removed - scope Claude launches, auth, context extraction, and usage collection to each profile's config dir --- src/main/ipc/localHandlers.ts | 15 +- .../ProviderModelMenu.test.tsx | 24 ++ .../ProviderModelMenu/parts/buildItems.ts | 11 +- .../components/composer/browserMcpScope.ts | 9 +- .../providers/ProviderIcon.test.tsx | 13 + .../components/providers/ProviderIcon.tsx | 56 ++-- .../providers/ProviderUsageRail.tsx | 3 +- .../providers/claude/index.test.tsx | 11 + .../providers/usageProviders.test.ts | 63 +++++ .../components/providers/usageProviders.ts | 63 ++++- .../components/providers/utilityTask.ts | 4 +- src/renderer/state/agentStatusesStore.ts | 3 + .../state/sharedSettingsStore.test.ts | 46 +++ src/renderer/state/sharedSettingsStore.ts | 30 ++ .../parts/UsagePanel/UsagePanel.tsx | 3 +- .../parts/UsagePanelHeaderActions.tsx | 3 +- .../SettingsOverlay/SettingsOverlay.test.tsx | 34 +++ .../parts/ClaudeProfileSettings.test.tsx | 147 ++++++++++ .../parts/ClaudeProfileSettings.tsx | 266 ++++++++++++++++++ .../SettingsOverlay/parts/SettingsSidebar.tsx | 160 ++++++++--- .../parts/SingleAgentSettings.tsx | 3 + .../SettingsOverlay/parts/UsageSettings.tsx | 6 +- src/shared/contracts/agentInstance.test.ts | 31 +- src/shared/contracts/agentInstance.ts | 38 +++ src/supervisor/agents/base/index.ts | 18 +- src/supervisor/agents/base/types.ts | 13 +- src/supervisor/agents/claude/claude.test.ts | 40 ++- src/supervisor/agents/claude/detection.ts | 6 +- src/supervisor/agents/claude/index.ts | 162 +++++++++-- src/supervisor/agents/claude/probe.ts | 23 +- src/supervisor/agents/claude/sdkSession.ts | 12 +- src/supervisor/agents/registry.ts | 18 +- src/supervisor/commitMessageGenerator.test.ts | 26 +- src/supervisor/contextExtractor.ts | 4 +- src/supervisor/oneShotPromptRunner.ts | 7 +- src/supervisor/oneShotSpawn.ts | 16 +- src/supervisor/prSummaryGenerator.test.ts | 13 +- src/supervisor/runtime.ts | 4 + src/supervisor/runtime/claudeCredentials.ts | 42 ++- src/supervisor/runtime/macClaudeKeychain.ts | 6 +- .../runtime/threadSessionManager.ts | 38 +-- src/supervisor/runtime/usageCostScanner.ts | 23 +- src/supervisor/runtime/usageService.test.ts | 69 ++++- src/supervisor/runtime/usageService.ts | 144 ++++++++-- src/supervisor/titleGenerator.ts | 2 +- 45 files changed, 1518 insertions(+), 210 deletions(-) create mode 100644 src/renderer/components/providers/ProviderIcon.test.tsx create mode 100644 src/renderer/components/providers/usageProviders.test.ts create mode 100644 src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx create mode 100644 src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 05ed19ef..6f8270a7 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -140,10 +140,23 @@ export function createLocalIpcHandlers( // Preserve supervisor-managed fields so the renderer's persist cycle // doesn't clobber writes made out-of-band by the supervisor. const onDisk = readSharedSettingsFile(settingsPath); + const rendererManagedInstances = Object.fromEntries( + Object.entries(settings.agentInstances).filter( + ([, instance]) => instance.driver !== "acp-generic", + ), + ); + const supervisorManagedInstances = Object.fromEntries( + Object.entries(onDisk.agentInstances).filter( + ([, instance]) => instance.driver === "acp-generic", + ), + ); writeSharedSettingsFile(settingsPath, { ...settings, acpRegistryInstalledAgents: onDisk.acpRegistryInstalledAgents, - agentInstances: onDisk.agentInstances, + agentInstances: { + ...rendererManagedInstances, + ...supervisorManagedInstances, + }, agentHookSupport: onDisk.agentHookSupport, }); options.updatePowerSaveBlocker(); diff --git a/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx b/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx index 55177d27..199e12c9 100644 --- a/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx +++ b/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx @@ -389,6 +389,30 @@ describe("ProviderModelMenu", () => { }); }); + it("shows Claude profile models under the profile subprovider row", async () => { + const provider = makeNamedProvider("claude:work", "Claude Work", 2); + provider.capabilities.subProviders = [{ id: "claude-profile", label: "Work" }]; + provider.capabilities.modelSubProvider = { + "model-1": "claude-profile", + "model-2": "claude-profile", + }; + + render( + void>()} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Select model" })); + const listbox = await screen.findByRole("listbox", { name: "Models" }); + + expect(within(listbox).getByText("Work")).toBeInTheDocument(); + expect(within(listbox).getByText("Model 2")).toBeInTheDocument(); + }); + it("resets the window when a long list shrinks so rows do not render blank", async () => { const { rerender } = render( k.length > 0) ?? []; if (trimmed.length === 0) { return (kind) => { - const idx = PROVIDER_ORDER.indexOf(kind); + const idx = PROVIDER_ORDER.indexOf(baseAgentKind(kind)); return idx < 0 ? PROVIDER_ORDER.length : idx; }; } @@ -98,7 +103,7 @@ function makeProviderSortKey(userOrder: readonly string[] | undefined): (kind: s return (kind) => { const fromUser = userIndex.get(kind); if (fromUser !== undefined) return fromUser; - const fromDefault = PROVIDER_ORDER.indexOf(kind); + const fromDefault = PROVIDER_ORDER.indexOf(baseAgentKind(kind)); return userTailBase + (fromDefault < 0 ? PROVIDER_ORDER.length : fromDefault); }; } diff --git a/src/renderer/components/composer/browserMcpScope.ts b/src/renderer/components/composer/browserMcpScope.ts index 8281a4f4..796127e9 100644 --- a/src/renderer/components/composer/browserMcpScope.ts +++ b/src/renderer/components/composer/browserMcpScope.ts @@ -1,4 +1,4 @@ -import type { ThreadPresentationMode } from "@/shared/contracts"; +import { baseAgentKind, type ThreadPresentationMode } from "@/shared/contracts"; /** * How a given (agentKind, presentationMode) pair gates Browser MCP per-thread. @@ -20,12 +20,13 @@ export function getBrowserMcpScope( agentKind: string, presentationMode: ThreadPresentationMode, ): BrowserMcpScope { + const baseKind = baseAgentKind(agentKind); if (presentationMode === "gui") { - if (agentKind === "claude") return "always"; - if (agentKind === "opencode" || agentKind === "antigravity" || agentKind === "commandcode") + if (baseKind === "claude") return "always"; + if (baseKind === "opencode" || baseKind === "antigravity" || baseKind === "commandcode") return "none"; return "launch"; } - if (agentKind === "codex") return "launch"; + if (baseKind === "codex") return "launch"; return "none"; } diff --git a/src/renderer/components/providers/ProviderIcon.test.tsx b/src/renderer/components/providers/ProviderIcon.test.tsx new file mode 100644 index 00000000..7c4d2722 --- /dev/null +++ b/src/renderer/components/providers/ProviderIcon.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ProviderIcon } from "./ProviderIcon"; +import "./claude"; + +describe("ProviderIcon", () => { + it("uses the Claude profile label for the profile badge initial", () => { + render(); + + expect(screen.getByText("H")).toBeInTheDocument(); + expect(screen.queryByText("C")).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/providers/ProviderIcon.tsx b/src/renderer/components/providers/ProviderIcon.tsx index 87d8be7d..0ceeeb06 100644 --- a/src/renderer/components/providers/ProviderIcon.tsx +++ b/src/renderer/components/providers/ProviderIcon.tsx @@ -1,4 +1,5 @@ import type { CSSProperties, ReactNode } from "react"; +import { baseAgentKind } from "@/shared/contracts"; import type { StatusTone } from "./statusTone"; import { getUtilityTaskCandidates, @@ -74,6 +75,22 @@ function fallbackInitial(label: string | undefined): string { return (raw.match(/[A-Za-z0-9]/)?.[0] ?? "?").toUpperCase(); } +function claudeProfileBadgeLabel(kind: string, fallbackLabel: string | undefined): string { + const profileId = kind.slice("claude:".length); + const label = fallbackLabel?.trim(); + if (!label) return profileId; + const profileLabel = label.replace(/^claude\s+/i, "").trim(); + return profileLabel || profileId; +} + +/** Registry lookup that falls back to the base kind for instance-scoped kinds. */ +function lookupByKind(registry: Map, kind: string): T | undefined { + const exact = registry.get(kind); + if (exact !== undefined) return exact; + const baseKind = baseAgentKind(kind); + return baseKind !== kind ? registry.get(baseKind) : undefined; +} + function GenericProviderIcon(props: { label?: string; tone: StatusTone; className?: string }) { return ( ); } - return ; + const rendered = ( + + ); + if (props.kind.startsWith("claude:")) { + return ( + + {rendered} + + {fallbackInitial(claudeProfileBadgeLabel(props.kind, props.fallbackLabel))} + + + ); + } + return rendered; } // --- Provider label registry --- @@ -202,12 +232,7 @@ export function registerComposerControls(kind: string, registration: ComposerCon } export function getComposerControls(kind: string): ComposerControlsFactory | undefined { - const separatorIndex = kind.indexOf(":"); - const registration = - COMPOSER_CONTROLS_REGISTRY.get(kind) ?? - (separatorIndex > 0 - ? COMPOSER_CONTROLS_REGISTRY.get(kind.slice(0, separatorIndex)) - : undefined); + const registration = lookupByKind(COMPOSER_CONTROLS_REGISTRY, kind); if (!registration) return undefined; if (typeof registration === "function") return registration; return (input) => { @@ -252,7 +277,7 @@ export function registerGuiSlashCommands(kind: string, registration: GuiSlashCom } export function getGuiSlashCommands(kind: string): GuiSlashCommandRegistration | undefined { - return GUI_SLASH_COMMAND_REGISTRY.get(kind); + return lookupByKind(GUI_SLASH_COMMAND_REGISTRY, kind); } // --- Config normalizer registry --- @@ -276,7 +301,7 @@ export function registerConfigNormalizer(kind: string, normalizer: ConfigNormali } export function getConfigNormalizer(kind: string): ConfigNormalizer | undefined { - return CONFIG_NORMALIZER_REGISTRY.get(kind); + return lookupByKind(CONFIG_NORMALIZER_REGISTRY, kind); } // --- Trigger word registry --- @@ -303,10 +328,7 @@ export function getTriggerWords( model: string | undefined, ): readonly TriggerWordDef[] { if (!kind) return []; - const separatorIndex = kind.indexOf(":"); - const matcher = - TRIGGER_WORD_REGISTRY.get(kind) ?? - (separatorIndex > 0 ? TRIGGER_WORD_REGISTRY.get(kind.slice(0, separatorIndex)) : undefined); + const matcher = lookupByKind(TRIGGER_WORD_REGISTRY, kind); return matcher ? matcher(model) : []; } @@ -321,7 +343,7 @@ export function registerCommitGenDefaults(kind: string, defaults: CommitGenDefau } export function getCommitGenDefaults(kind: string): CommitGenDefaults | undefined { - return COMMIT_GEN_REGISTRY.get(kind); + return lookupByKind(COMMIT_GEN_REGISTRY, kind); } export function getCommitGenDefaultsHint(): string | undefined { @@ -339,7 +361,7 @@ export function registerTitleGenDefaults(kind: string, defaults: TitleGenDefault } export function getTitleGenDefaults(kind: string): TitleGenDefaults | undefined { - return TITLE_GEN_REGISTRY.get(kind); + return lookupByKind(TITLE_GEN_REGISTRY, kind); } export function getTitleGenDefaultsHint(): string | undefined { @@ -357,7 +379,7 @@ export function registerConflictResolverDefaults(kind: string, defaults: Conflic } export function getConflictResolverDefaults(kind: string): ConflictResolverDefaults | undefined { - return CONFLICT_RESOLVER_REGISTRY.get(kind); + return lookupByKind(CONFLICT_RESOLVER_REGISTRY, kind); } export function getConflictResolverDefaultsHint(): string | undefined { diff --git a/src/renderer/components/providers/ProviderUsageRail.tsx b/src/renderer/components/providers/ProviderUsageRail.tsx index f1a413aa..495158e3 100644 --- a/src/renderer/components/providers/ProviderUsageRail.tsx +++ b/src/renderer/components/providers/ProviderUsageRail.tsx @@ -140,6 +140,7 @@ export function ProviderUsageRail(props: { orientation?: "row" | "column" }) { const showInSidebar = useSharedSettings((s) => s.usage.showInSidebar); const disabledProviders = useSharedSettings((s) => s.usage.disabledProviders); const providerOrder = useSharedSettings((s) => s.usage.providerOrder); + const agentInstances = useSharedSettings((s) => s.agentInstances); const setUsageSetting = useSharedSettings((s) => s.setUsageSetting); useEffect(() => { @@ -166,7 +167,7 @@ export function ProviderUsageRail(props: { orientation?: "row" | "column" }) { KeyboardSensor, ]; - const providers = resolveDisplayedProviders(providerOrder, disabledProviders); + const providers = resolveDisplayedProviders(providerOrder, disabledProviders, agentInstances); if (!showInSidebar || providers.length === 0) return null; diff --git a/src/renderer/components/providers/claude/index.test.tsx b/src/renderer/components/providers/claude/index.test.tsx index abdd20c7..ecc23386 100644 --- a/src/renderer/components/providers/claude/index.test.tsx +++ b/src/renderer/components/providers/claude/index.test.tsx @@ -68,4 +68,15 @@ describe("Claude composer controls", () => { "bypassPermissions", ); }); + + it("uses the Claude composer controls for profile-backed Claude providers", () => { + const controls = getComposerControls("claude:work")?.({ + capabilities, + config: { model: "claude-fable-5" }, + isDisabled: false, + onConfigChange: () => undefined, + }); + + expect(controls?.some(isPermissionControl)).toBe(true); + }); }); diff --git a/src/renderer/components/providers/usageProviders.test.ts b/src/renderer/components/providers/usageProviders.test.ts new file mode 100644 index 00000000..09118e5d --- /dev/null +++ b/src/renderer/components/providers/usageProviders.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { UsageWindow } from "@lightcode/agents-usage"; +import type { AgentInstanceConfigMap } from "@/shared/contracts"; +import { + pickUsageRings, + resolveDisplayedProviders, + usageProvidersForAgentInstances, +} from "./usageProviders"; + +const agentInstances: AgentInstanceConfigMap = { + work: { + id: "work", + driver: "claude", + displayName: "Work", + config: { configDir: "~/.lightcode/claude-profiles/work" }, + }, + home: { + id: "home", + driver: "claude", + displayName: "Home", + config: { configDir: "~/.lightcode/claude-profiles/home" }, + }, + disabled: { + id: "disabled", + driver: "claude", + displayName: "Disabled", + enabled: false, + config: { configDir: "~/.lightcode/claude-profiles/disabled" }, + }, +}; + +describe("usageProviders", () => { + it("adds Claude profile providers after the base Claude provider", () => { + const providers = usageProvidersForAgentInstances(agentInstances); + const claudeIndex = providers.findIndex((provider) => provider.id === "claude"); + + expect(providers.slice(claudeIndex, claudeIndex + 3).map((provider) => provider.id)).toEqual([ + "claude", + "claude:home", + "claude:work", + ]); + expect(providers.find((provider) => provider.id === "claude:home")?.label).toBe("Claude Home"); + }); + + it("orders, disables, and rings Claude profiles like Claude", () => { + const providers = resolveDisplayedProviders( + ["claude:work", "claude"], + ["claude:home"], + agentInstances, + ); + expect(providers.slice(0, 2).map((provider) => provider.id)).toEqual(["claude:work", "claude"]); + expect(providers.some((provider) => provider.id === "claude:home")).toBe(false); + + const windows: UsageWindow[] = [ + { id: "weekly", label: "Weekly", usedPercent: 20, unit: "percent" }, + { id: "session-5h", label: "Session", usedPercent: 60, unit: "percent" }, + ]; + expect(pickUsageRings("claude:work", windows)).toEqual({ + outer: windows[1], + inner: windows[0], + }); + }); +}); diff --git a/src/renderer/components/providers/usageProviders.ts b/src/renderer/components/providers/usageProviders.ts index ec434015..c096a554 100644 --- a/src/renderer/components/providers/usageProviders.ts +++ b/src/renderer/components/providers/usageProviders.ts @@ -1,4 +1,10 @@ import { allUsageProviderDescriptors, type UsageWindow } from "@lightcode/agents-usage"; +import { + baseAgentKind, + claudeProfileKind, + parseClaudeProfileInstanceConfig, + type AgentInstanceConfigMap, +} from "@/shared/contracts"; /** * Providers the usage tracker shows in the renderer. The canonical id + label + @@ -44,18 +50,62 @@ const RENDERER_META: Record> = { opencode: { supportsBrowserLogin: true }, }; -export const USAGE_PROVIDERS: ReadonlyArray = allUsageProviderDescriptors().map( +const STATIC_USAGE_PROVIDERS: ReadonlyArray = allUsageProviderDescriptors().map( (d) => ({ id: d.id, label: d.label, ...RENDERER_META[d.id] }), ); +export const USAGE_PROVIDERS: ReadonlyArray = STATIC_USAGE_PROVIDERS; + +function rendererMeta(providerId: string): Omit | undefined { + return RENDERER_META[providerId] ?? RENDERER_META[baseAgentKind(providerId)]; +} + +function claudeProfileUsageProviders( + agentInstances: AgentInstanceConfigMap | undefined, +): UsageProvider[] { + if (!agentInstances) return []; + const profiles: UsageProvider[] = []; + for (const instance of Object.values(agentInstances)) { + if (instance.enabled === false || instance.driver !== "claude") continue; + try { + parseClaudeProfileInstanceConfig(instance.config); + } catch { + continue; + } + const label = instance.displayName ?? instance.id; + profiles.push({ + id: claudeProfileKind(instance.id), + label: `Claude ${label}`, + ...rendererMeta("claude"), + }); + } + profiles.sort((a, b) => a.label.localeCompare(b.label)); + return profiles; +} + +export function usageProvidersForAgentInstances( + agentInstances: AgentInstanceConfigMap | undefined, +): UsageProvider[] { + const profiles = claudeProfileUsageProviders(agentInstances); + if (profiles.length === 0) return [...STATIC_USAGE_PROVIDERS]; + const out: UsageProvider[] = []; + for (const provider of STATIC_USAGE_PROVIDERS) { + out.push(provider); + if (provider.id === "claude") { + out.push(...profiles); + } + } + return out; +} + /** Providers that expose the browser-overlay login (cookie or device flow). */ export function supportsBrowserLogin(providerId: string): boolean { - return USAGE_PROVIDERS.some((p) => p.id === providerId && p.supportsBrowserLogin === true); + return rendererMeta(providerId)?.supportsBrowserLogin === true; } /** Providers whose windows share one reset clock (one header countdown, no per-window resets). */ export function usesSharedWindowReset(providerId: string): boolean { - return USAGE_PROVIDERS.some((p) => p.id === providerId && p.sharedWindowReset === true); + return rendererMeta(providerId)?.sharedWindowReset === true; } function firstWindowMatching( @@ -80,7 +130,7 @@ export function pickUsageRings( windows: readonly UsageWindow[] | undefined, ): { outer?: UsageWindow; inner?: UsageWindow } { if (!windows || windows.length === 0) return {}; - const spec = USAGE_PROVIDERS.find((p) => p.id === providerId)?.rings; + const spec = rendererMeta(providerId)?.rings; if (spec) { const outer = firstWindowMatching(windows, spec.outer); const inner = firstWindowMatching(windows, spec.inner); @@ -100,8 +150,11 @@ export function pickUsageRings( export function resolveDisplayedProviders( providerOrder: readonly string[], disabledProviders: readonly string[], + agentInstances?: AgentInstanceConfigMap, ): UsageProvider[] { - const enabled = USAGE_PROVIDERS.filter((p) => !disabledProviders.includes(p.id)); + const enabled = usageProvidersForAgentInstances(agentInstances).filter( + (p) => !disabledProviders.includes(p.id), + ); const byId = new Map(enabled.map((p) => [p.id, p])); const ordered: UsageProvider[] = []; const seen = new Set(); diff --git a/src/renderer/components/providers/utilityTask.ts b/src/renderer/components/providers/utilityTask.ts index a0927199..0ecb1253 100644 --- a/src/renderer/components/providers/utilityTask.ts +++ b/src/renderer/components/providers/utilityTask.ts @@ -1,3 +1,5 @@ +import { baseAgentKind } from "@/shared/contracts"; + export interface UtilityTaskDefaults { label?: string; hint?: string; @@ -53,7 +55,7 @@ export const AUTO_PROVIDER_PREFERENCE_ORDER: readonly string[] = [ export function sortByAutoPreference(items: readonly T[]): T[] { const rank = (kind: string) => { - const idx = AUTO_PROVIDER_PREFERENCE_ORDER.indexOf(kind); + const idx = AUTO_PROVIDER_PREFERENCE_ORDER.indexOf(baseAgentKind(kind)); return idx < 0 ? AUTO_PROVIDER_PREFERENCE_ORDER.length : idx; }; return [...items].sort((a, b) => rank(a.kind) - rank(b.kind)); diff --git a/src/renderer/state/agentStatusesStore.ts b/src/renderer/state/agentStatusesStore.ts index bdd55456..9e6350fe 100644 --- a/src/renderer/state/agentStatusesStore.ts +++ b/src/renderer/state/agentStatusesStore.ts @@ -74,11 +74,14 @@ function statusesEqual(a: AgentStatus[], b: AgentStatus[]): boolean { return a.every( (x, i) => x.kind === b[i]!.kind && + x.label === b[i]!.label && x.installed === b[i]!.installed && x.icon === b[i]!.icon && x.version === b[i]!.version && x.authState === b[i]!.authState && x.loginCommand === b[i]!.loginCommand && + x.envKind === b[i]!.envKind && + x.envDistro === b[i]!.envDistro && JSON.stringify(x.authMethods ?? []) === JSON.stringify(b[i]!.authMethods ?? []) && areAgentProviderMetadataEqual(x.providerMetadata, b[i]!.providerMetadata) && capabilitiesEqual(x.capabilities, b[i]!.capabilities), diff --git a/src/renderer/state/sharedSettingsStore.test.ts b/src/renderer/state/sharedSettingsStore.test.ts index c305a17f..41cf4329 100644 --- a/src/renderer/state/sharedSettingsStore.test.ts +++ b/src/renderer/state/sharedSettingsStore.test.ts @@ -15,6 +15,14 @@ describe("sharedSettingsStore", () => { useWebGpu: true, }, providerConfigs: {}, + agentInstances: {}, + hiddenModels: {}, + agentSettings: {}, + lastPresentationModeByAgent: {}, + disabledAgents: [], + favoriteModels: [], + recentModels: [], + providerOrder: [], lastUsedProjectDirs: {}, }); }); @@ -80,4 +88,42 @@ describe("sharedSettingsStore", () => { expect(useSharedSettings.getState().lastUsedProjectDirs.native).toBe("/Users/me/b"); }); + + it("adds and removes Claude profile instances with their profile-scoped settings", () => { + useSharedSettings.getState().setAgentInstance({ + id: "work", + driver: "claude", + displayName: "Work", + config: { configDir: "~/.lightcode/claude-profiles/work" }, + }); + useSharedSettings.setState({ + providerConfigs: { + claude: { model: "sonnet" }, + "claude:work": { model: "haiku" }, + }, + hiddenModels: { "claude:work": ["sonnet"] }, + agentSettings: { "claude:work": { noFlicker: true } }, + lastPresentationModeByAgent: { "claude:work": "gui" }, + disabledAgents: ["claude:work"], + favoriteModels: [{ agentKind: "claude:work", modelId: "haiku", presentationMode: "gui" }], + recentModels: [{ agentKind: "claude:work", modelId: "sonnet", presentationMode: "gui" }], + providerOrder: ["claude", "claude:work"], + }); + + expect(useSharedSettings.getState().agentInstances.work?.displayName).toBe("Work"); + + useSharedSettings.getState().removeAgentInstance("work"); + + const state = useSharedSettings.getState(); + expect(state.agentInstances.work).toBeUndefined(); + expect(state.providerConfigs.claude).toEqual({ model: "sonnet" }); + expect(state.providerConfigs["claude:work"]).toBeUndefined(); + expect(state.hiddenModels["claude:work"]).toBeUndefined(); + expect(state.agentSettings["claude:work"]).toBeUndefined(); + expect(state.lastPresentationModeByAgent["claude:work"]).toBeUndefined(); + expect(state.disabledAgents).not.toContain("claude:work"); + expect(state.favoriteModels).toEqual([]); + expect(state.recentModels).toEqual([]); + expect(state.providerOrder).toEqual(["claude"]); + }); }); diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index b327b867..bf93aae5 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -8,6 +8,7 @@ import { } from "@/shared/settings"; import type { GitReviewMode, + AgentInstanceConfig, InstalledAcpRegistryAgent, NewThreadMode, NotificationFilter, @@ -76,6 +77,8 @@ interface SharedSettingsState extends SharedSettings { setNotificationSound: (value: boolean) => void; setNotificationFilter: (value: NotificationFilter) => void; syncAcpRegistryInstalledAgents: (installed: InstalledAcpRegistryAgent[]) => void; + setAgentInstance: (instance: AgentInstanceConfig) => void; + removeAgentInstance: (instanceId: string) => void; setNotificationStatuses: (value: { done?: boolean; needsAttention?: boolean; @@ -413,6 +416,33 @@ export const useSharedSettings = create()((set, get) => ({ }); cacheSettingsSnapshot(selectSharedSettings(get())); }, + setAgentInstance: (instance) => { + const current = get().agentInstances; + set({ agentInstances: { ...current, [instance.id]: instance } }); + persistSettings(selectSharedSettings(get())); + }, + removeAgentInstance: (instanceId) => { + const current = get().agentInstances; + if (!current[instanceId]) return; + const { [instanceId]: _removed, ...agentInstances } = current; + const prefix = `claude:${instanceId}`; + const removeProfileKey = (values: Record) => + Object.fromEntries(Object.entries(values).filter(([key]) => key !== prefix)); + set({ + agentInstances, + providerConfigs: removeProfileKey(get().providerConfigs) as SharedSettings["providerConfigs"], + hiddenModels: removeProfileKey(get().hiddenModels) as SharedSettings["hiddenModels"], + agentSettings: removeProfileKey(get().agentSettings) as SharedSettings["agentSettings"], + lastPresentationModeByAgent: removeProfileKey( + get().lastPresentationModeByAgent, + ) as SharedSettings["lastPresentationModeByAgent"], + disabledAgents: get().disabledAgents.filter((kind) => kind !== prefix), + favoriteModels: get().favoriteModels.filter((entry) => entry.agentKind !== prefix), + recentModels: get().recentModels.filter((entry) => entry.agentKind !== prefix), + providerOrder: get().providerOrder.filter((kind) => kind !== prefix), + }); + persistSettings(selectSharedSettings(get())); + }, setNotificationStatuses: (partial) => { const current = get().notificationStatuses; const next = { ...current, ...partial }; diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/UsagePanel.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/UsagePanel.tsx index 84bcfca6..ade52e1a 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/UsagePanel.tsx +++ b/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/UsagePanel.tsx @@ -26,6 +26,7 @@ export function UsagePanel() { const providerOrder = useSharedSettings((s) => s.usage.providerOrder); const disabledProviders = useSharedSettings((s) => s.usage.disabledProviders); const collapsedProviders = useSharedSettings((s) => s.usage.collapsedProviders); + const agentInstances = useSharedSettings((s) => s.agentInstances); const setUsageSetting = useSharedSettings((s) => s.setUsageSetting); const snapshots = useProviderUsageStore((s) => s.snapshots); const [nowTick, setNowTick] = useState(() => Date.now()); @@ -35,7 +36,7 @@ export function UsagePanel() { maxFadePx: 10, }); - const displayed = resolveDisplayedProviders(providerOrder, disabledProviders); + const displayed = resolveDisplayedProviders(providerOrder, disabledProviders, agentInstances); // Hydrate the store from the supervisor cache on open (and let the cache's // staleness trigger a background refresh whose events update the cards live). diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/parts/UsagePanelHeaderActions.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/parts/UsagePanelHeaderActions.tsx index 17a6f8b3..5e892d7e 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/parts/UsagePanelHeaderActions.tsx +++ b/src/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/parts/UsagePanelHeaderActions.tsx @@ -16,10 +16,11 @@ export function UsagePanelHeaderActions(props: { dragControlClass: string }) { const providerOrder = useSharedSettings((s) => s.usage.providerOrder); const disabledProviders = useSharedSettings((s) => s.usage.disabledProviders); const collapsedProviders = useSharedSettings((s) => s.usage.collapsedProviders); + const agentInstances = useSharedSettings((s) => s.agentInstances); const setUsageSetting = useSharedSettings((s) => s.setUsageSetting); const [isRefreshing, setIsRefreshing] = useState(false); - const displayed = resolveDisplayedProviders(providerOrder, disabledProviders); + const displayed = resolveDisplayedProviders(providerOrder, disabledProviders, agentInstances); const allCollapsed = displayed.length > 0 && displayed.every((p) => collapsedProviders.includes(p.id)); diff --git a/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx b/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx index f9a6ef0b..2752cc8c 100644 --- a/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx +++ b/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx @@ -252,6 +252,40 @@ describe("SettingsOverlay", () => { expect(screen.getByText("Agent Registry Settings")).toBeInTheDocument(); }); + it("groups Claude profile providers under Claude Code in the agents sidebar", () => { + statusesState.agentStatuses = [ + makeStatus("claude", { + label: "Claude Code", + envKind: "posix", + }), + makeStatus("codex", { + label: "Codex", + envKind: "posix", + }), + makeStatus("claude:home", { + label: "Claude Home", + envKind: "posix", + }), + ]; + + render( undefined} />); + + fireEvent.click(screen.getByRole("button", { name: "Agents" })); + + const buttons = screen + .getAllByRole("button") + .map((button) => button.textContent) + .filter(Boolean); + expect(buttons.slice(buttons.indexOf("Claude Code"), buttons.indexOf("Codex") + 1)).toEqual([ + "Claude Code", + "Home", + "Codex", + ]); + + fireEvent.click(screen.getByRole("button", { name: "Home" })); + expect(screen.getByText("Agent claude:home")).toBeInTheDocument(); + }); + it("opens Agents on General and toggles closed on a second click", () => { statusesState.agentStatuses = [ makeStatus("claude", { diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx new file mode 100644 index 00000000..3632438c --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentInstanceConfig } from "@/shared/contracts"; + +const toastMock = vi.hoisted(() => ({ + danger: vi.fn<(message: string) => void>(), + success: vi.fn<(message: string) => void>(), +})); + +vi.mock("@heroui/react", () => ({ + Button: (props: { + children?: ReactNode; + "aria-label"?: string; + isDisabled?: boolean; + onPress?: () => void; + }) => ( + + ), + toast: toastMock, +})); + +vi.mock("@/renderer/components/common", () => ({ + Input: (props: { + "aria-label"?: string; + placeholder?: string; + value?: string; + onChange?: (event: { target: { value: string } }) => void; + }) => ( + + ), +})); + +const refreshAgentStatusesMock = vi.hoisted(() => vi.fn<() => Promise>()); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => ({ refreshAgentStatuses: refreshAgentStatusesMock }), +})); + +vi.mock("@/renderer/utils/acpRegistryAuth", () => ({ + currentWslDistros: () => [], +})); + +const settingsState = { + agentInstances: {} as Record, + setAgentInstance: vi.fn<(instance: AgentInstanceConfig) => void>(), + removeAgentInstance: vi.fn<(id: string) => void>(), +}; + +vi.mock("@/renderer/state/sharedSettingsStore", () => ({ + useSharedSettings: (selector: (state: typeof settingsState) => unknown) => + selector(settingsState), +})); + +import { ClaudeProfileSettings } from "./ClaudeProfileSettings"; + +describe("ClaudeProfileSettings", () => { + beforeEach(() => { + settingsState.agentInstances = {}; + settingsState.setAgentInstance.mockReset(); + settingsState.removeAgentInstance.mockReset(); + refreshAgentStatusesMock.mockReset().mockResolvedValue(); + toastMock.success.mockReset(); + toastMock.danger.mockReset(); + }); + + it("hides the add form until the add button is pressed", () => { + render(); + expect(screen.queryByLabelText("New Claude profile name")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /add profile/i })); + + expect(screen.getByLabelText("New Claude profile name")).toHaveValue(""); + expect(screen.getByLabelText("New Claude profile name")).toHaveAttribute( + "placeholder", + "e.g. Work", + ); + expect(screen.getByLabelText("New Claude profile config directory")).toHaveAttribute( + "placeholder", + "~/.lightcode/claude-profiles/profile", + ); + }); + + it("disables Add until a name is typed and derives the dir placeholder from it", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add profile/i })); + + const addButton = screen.getByRole("button", { name: "Add Claude profile" }); + expect(addButton).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("New Claude profile name"), { + target: { value: "Work" }, + }); + + expect(addButton).toBeEnabled(); + expect(screen.getByLabelText("New Claude profile config directory")).toHaveAttribute( + "placeholder", + "~/.lightcode/claude-profiles/work", + ); + }); + + it("adds a profile with the derived config dir when the dir is left empty", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add profile/i })); + fireEvent.change(screen.getByLabelText("New Claude profile name"), { + target: { value: "Work" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add Claude profile" })); + + expect(settingsState.setAgentInstance).toHaveBeenCalledWith({ + id: "work", + driver: "claude", + displayName: "Work", + config: { configDir: "~/.lightcode/claude-profiles/work" }, + }); + // The form collapses back to the add button after a successful add. + expect(screen.queryByLabelText("New Claude profile name")).not.toBeInTheDocument(); + expect(toastMock.success).toHaveBeenCalledWith("Claude Work profile added."); + }); + + it("discards the draft on cancel", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add profile/i })); + fireEvent.change(screen.getByLabelText("New Claude profile name"), { + target: { value: "Work" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel new Claude profile" })); + + expect(screen.queryByLabelText("New Claude profile name")).not.toBeInTheDocument(); + expect(settingsState.setAgentInstance).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: /add profile/i })); + expect(screen.getByLabelText("New Claude profile name")).toHaveValue(""); + }); +}); diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx new file mode 100644 index 00000000..3c8a1f7a --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx @@ -0,0 +1,266 @@ +import { useState } from "react"; +import { Button, toast } from "@heroui/react"; +import { Check, Plus, RefreshCw, Save, Trash2, X } from "lucide-react"; +import { + claudeProfileKind, + parseClaudeProfileInstanceConfig, + type AgentInstanceConfig, +} from "@/shared/contracts"; +import { readBridge } from "@/renderer/bridge"; +import { Input } from "@/renderer/components/common"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { currentWslDistros } from "@/renderer/utils/acpRegistryAuth"; + +function slugifyProfileName(value: string): string { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") || "profile" + ); +} + +function defaultConfigDir(name: string): string { + return `~/.lightcode/claude-profiles/${slugifyProfileName(name)}`; +} + +function uniqueProfileId(name: string, existing: Readonly>): string { + const base = slugifyProfileName(name); + let candidate = base; + let index = 2; + while (existing[candidate]) { + candidate = `${base}-${index}`; + index += 1; + } + return candidate; +} + +function refreshClaudeProfile(kind?: string): void { + window.setTimeout(() => { + void readBridge() + .refreshAgentStatuses(currentWslDistros(), kind ? { agentKinds: [kind] } : undefined) + .catch((error) => + toast.danger(error instanceof Error ? error.message : "Unable to refresh Claude profiles."), + ); + }, 50); +} + +function ClaudeProfileRow(props: { + instance: AgentInstanceConfig; + configDir: string; + onSave: (instance: AgentInstanceConfig) => void; + onRemove: (id: string) => void; +}) { + const [name, setName] = useState(props.instance.displayName ?? props.instance.id); + const [configDir, setConfigDir] = useState(props.configDir); + const trimmedName = name.trim(); + const trimmedConfigDir = configDir.trim(); + const changed = + trimmedName !== (props.instance.displayName ?? props.instance.id) || + trimmedConfigDir !== props.configDir; + const canSave = trimmedName.length > 0 && trimmedConfigDir.length > 0 && changed; + + return ( +
+ setName(event.target.value)} + /> + setConfigDir(event.target.value)} + /> +
+ + +
+
+ ); +} + +export function ClaudeProfileSettings() { + const agentInstances = useSharedSettings((s) => s.agentInstances ?? {}); + const setAgentInstance = useSharedSettings((s) => s.setAgentInstance); + const removeAgentInstance = useSharedSettings((s) => s.removeAgentInstance); + const [isAdding, setIsAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [newConfigDir, setNewConfigDir] = useState(""); + + const profiles: Array<{ instance: AgentInstanceConfig; configDir: string }> = []; + for (const instance of Object.values(agentInstances)) { + if (instance.driver !== "claude") continue; + try { + const parsed = parseClaudeProfileInstanceConfig(instance.config); + profiles.push({ instance, configDir: parsed.configDir }); + } catch { + // Ignore malformed records here; the supervisor skips them too. + } + } + profiles.sort((a, b) => + (a.instance.displayName ?? a.instance.id).localeCompare( + b.instance.displayName ?? b.instance.id, + ), + ); + + const canAdd = newName.trim().length > 0; + // Live default shown as the dir placeholder; used verbatim when left empty. + const suggestedConfigDir = defaultConfigDir(newName); + + function closeAddForm(): void { + setIsAdding(false); + setNewName(""); + setNewConfigDir(""); + } + + function addProfile(): void { + const displayName = newName.trim(); + const configDir = newConfigDir.trim() || suggestedConfigDir; + if (!displayName) return; + const id = uniqueProfileId(displayName, agentInstances); + const instance: AgentInstanceConfig = { + id, + driver: "claude", + displayName, + config: { configDir }, + }; + setAgentInstance(instance); + refreshClaudeProfile(claudeProfileKind(id)); + closeAddForm(); + toast.success(`Claude ${displayName} profile added.`); + } + + return ( +
+
+
+

Profiles

+

Separate Claude Code accounts by config directory.

+
+ +
+ + {profiles.length === 0 && !isAdding ? ( +

No additional Claude profiles.

+ ) : null} + + {/* One grid for saved rows AND the draft row (subgrid rows), so the + action column — and therefore the input columns — stay aligned. */} +
+ {profiles.map(({ instance, configDir }) => ( + { + setAgentInstance(next); + refreshClaudeProfile(claudeProfileKind(next.id)); + toast.success(`Claude ${next.displayName ?? next.id} profile saved.`); + }} + onRemove={(id) => { + removeAgentInstance(id); + refreshClaudeProfile(); + toast.success("Claude profile removed."); + }} + /> + ))} + + {isAdding ? ( +
+ node?.focus()} + aria-label="New Claude profile name" + className="min-w-0" + placeholder="e.g. Work" + value={newName} + onChange={(event) => setNewName(event.target.value)} + /> + setNewConfigDir(event.target.value)} + /> + {/* Icon-only actions matching the saved rows' save/delete pair, so + the action column keeps the same width when the draft opens. */} +
+ + +
+
+ ) : null} +
+ + {!isAdding ? ( + + ) : null} +
+ ); +} diff --git a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx index 0967e798..bd53868f 100644 --- a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx @@ -21,7 +21,7 @@ import { Sparkles, TerminalSquare, } from "lucide-react"; -import type { AgentStatus } from "@/shared/contracts"; +import { isClaudeProfileKind, type AgentStatus } from "@/shared/contracts"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { overlaySidebarColumnClass, @@ -36,6 +36,29 @@ import { useSidebar } from "@/renderer/views/MainView/parts/AppShell/AppShell"; import { isDevApp } from "@/renderer/bridge"; import type { SettingsSection } from "./types"; +function claudeProfileSidebarLabel(agent: AgentStatus): string { + return agent.label.replace(/^Claude\s+/iu, "").trim() || agent.label; +} + +function renderAgentIcon( + agent: AgentStatus, + options: { + disabled: boolean; + className?: string; + }, +) { + return ( + + ); +} + export function SettingsSidebar(props: { activeSection: SettingsSection; onSectionChange: (section: SettingsSection) => void; @@ -56,6 +79,8 @@ export function SettingsSidebar(props: { } = props; const { isCollapsed, collapse, expand } = useSidebar(); const disabledAgents = useSharedSettings((s) => s.disabledAgents); + const primaryAgents = installedAgents.filter((agent) => !isClaudeProfileKind(agent.kind)); + const claudeProfileAgents = installedAgents.filter((agent) => isClaudeProfileKind(agent.kind)); const isAgentsActive = activeSection === "agents" || activeSection === "acpRegistry" || @@ -176,29 +201,53 @@ export function SettingsSidebar(props: { /> )} {isAgentsActive && - installedAgents.map((agent) => { + primaryAgents.map((agent) => { const needsAttention = attentionAgentKinds.has(agent.kind); return ( - - - {needsAttention ? ( - - ) : null} -
- } - label={agent.label} - isActive={activeSection === `agents:${agent.kind}`} - onPress={() => onSectionChange(`agents:${agent.kind}`)} - /> +
+ + {renderAgentIcon(agent, { + disabled: disabledAgents.includes(agent.kind), + })} + {needsAttention ? ( + + ) : null} + + } + label={agent.label} + isActive={activeSection === `agents:${agent.kind}`} + onPress={() => onSectionChange(`agents:${agent.kind}`)} + /> + {agent.kind === "claude" + ? claudeProfileAgents.map((profile) => { + const profileNeedsAttention = attentionAgentKinds.has(profile.kind); + return ( + + {renderAgentIcon(profile, { + disabled: disabledAgents.includes(profile.kind), + className: "size-3.5", + })} + {profileNeedsAttention ? ( + + ) : null} + + } + label={profile.label} + isActive={activeSection === `agents:${profile.kind}`} + onPress={() => onSectionChange(`agents:${profile.kind}`)} + /> + ); + }) + : null} +
); })} onSectionChange("acpRegistry")} /> - {installedAgents.map((agent) => { + {primaryAgents.map((agent) => { const agentDisabled = disabledAgents.includes(agent.kind); const needsAttention = attentionAgentKinds.has(agent.kind); return ( - - } - label={agent.label} - suffix={ - needsAttention ? ( -