diff --git a/src/renderer/state/agentStatusesStore.test.ts b/src/renderer/state/agentStatusesStore.test.ts index 5b42bcfe..bc4f1e68 100644 --- a/src/renderer/state/agentStatusesStore.test.ts +++ b/src/renderer/state/agentStatusesStore.test.ts @@ -274,6 +274,26 @@ describe("mergeAgentStatus", () => { }); }); +describe("removeAgentStatus", () => { + it("removes matching statuses from native, WSL, and discovery lists", () => { + const profile = makeStatus({ kind: "claude:glm" }); + const wslProfile = makeStatus({ kind: "claude:glm", envKind: "wsl", envDistro: "Ubuntu" }); + const codex = makeStatus({ kind: "codex" }); + useAgentStatusesStore.setState({ + agentStatuses: [profile, codex], + wslAgentStatuses: [wslProfile], + discoveredAgents: [profile], + }); + + useAgentStatusesStore.getState().removeAgentStatus("claude:glm"); + + const state = useAgentStatusesStore.getState(); + expect(state.agentStatuses.map((status) => status.kind)).toEqual(["codex"]); + expect(state.wslAgentStatuses).toEqual([]); + expect(state.discoveredAgents).toEqual([]); + }); +}); + describe("isDetectingAgentsForLocation", () => { it("returns true for a windows location when windowsLoaded is false", () => { const loc: ProjectLocation = { kind: "windows", path: "C:\\tmp" }; diff --git a/src/renderer/state/agentStatusesStore.ts b/src/renderer/state/agentStatusesStore.ts index 9e6350fe..f15f3a94 100644 --- a/src/renderer/state/agentStatusesStore.ts +++ b/src/renderer/state/agentStatusesStore.ts @@ -51,6 +51,7 @@ interface AgentStatusesStore { * everything else lands in `agentStatuses`. */ mergeAgentStatus: (status: AgentStatus) => void; + removeAgentStatus: (kind: string) => void; } function capabilitiesEqual( @@ -214,6 +215,20 @@ export const useAgentStatusesStore = create()( : prev.agentStatuses.map((entry, i) => (i === idx ? status : entry)); return { agentStatuses: next, windowsLoaded: true }; }), + removeAgentStatus: (kind) => + set((prev) => { + const agentStatuses = prev.agentStatuses.filter((status) => status.kind !== kind); + const wslAgentStatuses = prev.wslAgentStatuses.filter((status) => status.kind !== kind); + const discoveredAgents = prev.discoveredAgents.filter((status) => status.kind !== kind); + if ( + agentStatuses.length === prev.agentStatuses.length && + wslAgentStatuses.length === prev.wslAgentStatuses.length && + discoveredAgents.length === prev.discoveredAgents.length + ) { + return prev; + } + return { agentStatuses, wslAgentStatuses, discoveredAgents }; + }), }), { name: "lightcode-agent-statuses-v1", diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx index 461d86d8..040a338b 100644 --- a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentInstanceConfig } from "@/shared/contracts"; +import type { AgentInstanceConfig, AgentStatus } from "@/shared/contracts"; const toastMock = vi.hoisted(() => ({ danger: vi.fn<(message: string) => void>(), @@ -81,6 +81,12 @@ const settingsState = { agentInstances: {} as Record, setAgentInstance: vi.fn<(instance: AgentInstanceConfig) => void>(), removeAgentInstance: vi.fn<(id: string) => void>(), + setHiddenModels: vi.fn<(kind: string, hidden: string[]) => void>(), +}; +const statusState = { + agentStatuses: [] as AgentStatus[], + wslAgentStatuses: [] as AgentStatus[], + removeAgentStatus: vi.fn<(kind: string) => void>(), }; vi.mock("@/renderer/state/sharedSettingsStore", () => ({ @@ -88,6 +94,11 @@ vi.mock("@/renderer/state/sharedSettingsStore", () => ({ selector(settingsState), })); +vi.mock("@/renderer/state/agentStatusesStore", () => ({ + useAgentStatusesStore: (selector: (state: typeof statusState) => unknown) => + selector(statusState), +})); + import { ClaudeProfileProviderSettings, ClaudeProfileSettings } from "./ClaudeProfileSettings"; function claudeProfile(overrides: Partial = {}): AgentInstanceConfig { @@ -100,11 +111,46 @@ function claudeProfile(overrides: Partial = {}): AgentInsta }; } +function agentStatus(overrides: Partial = {}): AgentStatus { + return { + kind: "claude:glm", + label: "Claude GLM", + installed: true, + authState: "authenticated", + capabilities: { + models: [ + { id: "claude-opus-4-8", label: "Opus 4.8" }, + { id: "claude-opus-4-7", label: "Opus 4.7" }, + { id: "claude-opus-4-6", label: "Opus 4.6" }, + { id: "sonnet", label: "Sonnet" }, + { id: "haiku", label: "Haiku" }, + { id: "glm-5.2[1m]", label: "GLM 5.2" }, + ], + efforts: [], + modelEfforts: {}, + modes: [], + approvalPolicies: [], + sandboxModes: [], + settingDefs: [], + supportsResume: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal"], + }, + ...overrides, + }; +} + describe("ClaudeProfileSettings", () => { beforeEach(() => { settingsState.agentInstances = {}; settingsState.setAgentInstance.mockReset(); settingsState.removeAgentInstance.mockReset(); + settingsState.setHiddenModels.mockReset(); + statusState.agentStatuses = [agentStatus()]; + statusState.wslAgentStatuses = []; + statusState.removeAgentStatus.mockReset(); refreshAgentStatusesMock.mockReset().mockResolvedValue(); setClaudeProfileEnvironmentMock.mockReset().mockImplementation(async () => claudeProfile()); toastMock.success.mockReset(); @@ -202,6 +248,16 @@ describe("ClaudeProfileSettings", () => { expect(onOpenProfile).toHaveBeenCalledWith("claude:work"); }); + + it("removes a profile from settings and agent statuses", () => { + settingsState.agentInstances = { glm: claudeProfile() }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove Claude profile" })); + + expect(settingsState.removeAgentInstance).toHaveBeenCalledWith("glm"); + expect(statusState.removeAgentStatus).toHaveBeenCalledWith("claude:glm"); + }); }); describe("ClaudeProfileProviderSettings", () => { @@ -239,13 +295,71 @@ describe("ClaudeProfileProviderSettings", () => { await waitFor(() => expect(settingsState.setAgentInstance).toHaveBeenCalled()); }); - it("fills the editor from the GLM preset", () => { + it("fills the editor from the z.ai preset", () => { render(); - fireEvent.click(screen.getByRole("button", { name: /glm preset/i })); + fireEvent.click(screen.getByRole("menuitem", { name: "z.ai" })); expect(screen.getByDisplayValue("https://api.z.ai/api/anthropic")).toBeInTheDocument(); expect(screen.getByDisplayValue("ANTHROPIC_AUTH_TOKEN")).toBeInTheDocument(); + expect(screen.getByDisplayValue("CLAUDE_CODE_AUTO_COMPACT_WINDOW")).toBeInTheDocument(); + // The preset's GLM 5.2 picker model id keeps its [1m] suffix verbatim. + expect(screen.getByLabelText("Model id")).toHaveValue("glm-5.2[1m]"); + expect(settingsState.setHiddenModels).toHaveBeenCalledWith("claude:glm", [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "sonnet", + "haiku", + ]); + }); + + it("fills the editor from the DeepSeek preset with two models", async () => { + render(); + + fireEvent.click(screen.getByRole("menuitem", { name: "DeepSeek" })); + + expect(screen.getByDisplayValue("https://api.deepseek.com/anthropic")).toBeInTheDocument(); + expect(screen.getByDisplayValue("ANTHROPIC_MODEL")).toBeInTheDocument(); + expect(screen.getByDisplayValue("CLAUDE_CODE_SUBAGENT_MODEL")).toBeInTheDocument(); + expect( + screen.getAllByLabelText("Model id").map((input) => input.getAttribute("value")), + ).toEqual(["deepseek-v4-pro[1m]", "deepseek-v4-flash"]); + expect(settingsState.setAgentInstance).toHaveBeenCalledWith({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { + configDir: "~/.lightcode/claude-profiles/glm", + models: [ + { id: "deepseek-v4-pro[1m]", label: "DeepSeek V4 Pro" }, + { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" }, + ], + efforts: ["max"], + }, + }); + expect(settingsState.setHiddenModels).toHaveBeenCalledWith("claude:glm", [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "sonnet", + "haiku", + "glm-5.2[1m]", + ]); + await waitFor(() => expect(refreshAgentStatusesMock).toHaveBeenCalled()); + }); + + it("fills the editor from the MiniMax preset", () => { + render(); + + fireEvent.click(screen.getByRole("menuitem", { name: "MiniMax" })); + + expect(screen.getByDisplayValue("https://api.minimax.io/anthropic")).toBeInTheDocument(); + expect( + screen.getByDisplayValue("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), + ).toBeInTheDocument(); + expect(screen.getByDisplayValue("CLAUDE_CODE_AUTO_COMPACT_WINDOW")).toBeInTheDocument(); + expect(screen.getByLabelText("Model id")).toHaveValue("MiniMax-M3"); }); it("masks an already-sealed secret value", () => { diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx index 9ab58c46..904142d0 100644 --- a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx @@ -22,16 +22,18 @@ import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; import { readBridge } from "@/renderer/bridge"; import { Input } from "@/renderer/components/common"; import { formatEffortLabel } from "@/renderer/components/thread/threadDraftViewHelpers"; +import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { currentWslDistros } from "@/renderer/utils/acpRegistryAuth"; import { - appendGlmPresetRows, + applyPresetEnvRows, cleanModels, defaultConfigDir, effortsConfigFromSelection, environmentFromRows, modelsFromConfig, profileUsesExternalProvider, + PROFILE_PRESETS, rowsFromEnvironment, SAVED_SECRET_MASK, selectedEffortsFromConfig, @@ -39,8 +41,17 @@ import { uniqueProfileId, type EnvRow, type ModelRow, + type ProfilePreset, } from "./ClaudeProfileSettingsModel"; +const CLAUDE_PROFILE_BASE_MODEL_IDS = [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "sonnet", + "haiku", +]; + function refreshClaudeProfile(kind?: string): void { window.setTimeout(() => { void readBridge() @@ -108,6 +119,52 @@ function EffortMultiSelect(props: { selected: Set; onToggle: (tier: stri ); } +// ── Preset selector ────────────────────────────────────────────────────────── + +/** + * Dropdown of external-provider presets (z.ai, …). Picking one seeds the editor; + * extend the list by adding to `PROFILE_PRESETS`. + */ +function PresetMenu(props: { onApply: (preset: ProfilePreset) => void }) { + const [isOpen, setIsOpen] = useState(false); + return ( + + + + + + +
+ {PROFILE_PRESETS.map((preset) => ( + + ))} +
+
+
+
+ ); +} + // ── Per-profile editor (rendered on the profile's own settings page) ────────── /** @@ -133,6 +190,13 @@ function ClaudeProfileEditor(props: { config: ClaudeProfileInstanceConfig; }) { const setAgentInstance = useSharedSettings((s) => s.setAgentInstance); + const setHiddenModels = useSharedSettings((s) => s.setHiddenModels); + const profileKind = claudeProfileKind(props.instance.id); + const profileStatus = useAgentStatusesStore( + (s) => + s.agentStatuses.find((status) => status.kind === profileKind) ?? + s.wslAgentStatuses.find((status) => status.kind === profileKind), + ); const rowIdCounter = useRef(0); const nextRowId = () => `r${(rowIdCounter.current += 1)}`; @@ -168,7 +232,38 @@ function ClaudeProfileEditor(props: { { rowId: nextRowId(), key: "", value: "", sensitive: false, replacing: false }, ]); - const applyGlmPreset = () => setEnvRows((rows) => appendGlmPresetRows(rows, nextRowId)); + // Applying a preset seeds the editor and persists picker models so the shared + // "Visible models" section can refresh immediately. + const applyPreset = (preset: ProfilePreset) => { + const nextModelRows = (() => { + const existing = new Set(modelRows.map((row) => row.id.trim())); + const additions = preset.models + .filter((model) => !existing.has(model.id)) + .map((model) => ({ rowId: nextRowId(), id: model.id, label: model.label })); + return additions.length > 0 ? [...modelRows, ...additions] : modelRows; + })(); + const nextEfforts = new Set(preset.efforts); + const models = cleanModels(nextModelRows); + const efforts = effortsConfigFromSelection(nextEfforts); + const config: ClaudeProfileInstanceConfig = { ...props.config }; + const presetModelIds = new Set(preset.models.map((model) => model.id)); + const currentModelIds = + profileStatus?.capabilities.models.map((model) => model.id) ?? CLAUDE_PROFILE_BASE_MODEL_IDS; + if (models) config.models = models; + else delete config.models; + if (efforts) config.efforts = efforts; + else delete config.efforts; + + setEnvRows((rows) => applyPresetEnvRows(preset.envRows, rows, nextRowId)); + setSelectedEfforts(nextEfforts); + setModelRows(nextModelRows); + setAgentInstance({ ...props.instance, config }); + setHiddenModels( + profileKind, + currentModelIds.filter((id) => id !== "auto" && !presetModelIds.has(id)), + ); + refreshClaudeProfile(profileKind); + }; const updateModelRow = (rowId: string, patch: Partial) => setModelRows((rows) => rows.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row))); @@ -220,7 +315,7 @@ function ClaudeProfileEditor(props: {

External provider

- Point this profile at a non-Anthropic provider (GLM, …) with custom env vars, model + Point this profile at a non-Anthropic provider (z.ai, …) with custom env vars, model names, and effort levels.

@@ -263,15 +358,7 @@ function ClaudeProfileEditor(props: {

Environment variables

- +