diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index b13f082a..b51ca143 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -79,6 +79,7 @@ export function DesktopShell({ runtimeCues, attachmentSupport, agentCatalogs, + selectableHarnesses, accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -428,6 +429,7 @@ export function DesktopShell({ runtimeCues={runtimeCues} attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} + selectableHarnesses={selectableHarnesses} accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} diff --git a/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts index 542f8aa2..2d7730cd 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts @@ -5,7 +5,11 @@ import { getProviderConfig } from '@linkcode/sdk'; import { modelChoiceKey } from '@linkcode/ui'; import { cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { accountModelOptions, useAccountModelOptions } from '../model-options'; +import { + accountModelOptions, + selectableHarnessKinds, + useAccountModelOptions, +} from '../model-options'; const { useDataMock } = vi.hoisted(() => ({ useDataMock: vi.fn() })); @@ -127,3 +131,14 @@ describe('accountModelOptions', () => { expect(result.current).toEqual({}); }); }); + +describe('selectableHarnessKinds', () => { + it('includes unconfigured harnesses by default and removes disabled ones', () => { + expect(selectableHarnessKinds({ opencode: { enabled: false } })).toEqual([ + 'claude-code', + 'codex', + 'pi', + 'grok-build', + ]); + }); +}); diff --git a/packages/client/workbench/src/settings/providers/model-options.ts b/packages/client/workbench/src/settings/providers/model-options.ts index d243dd10..f9d91fa3 100644 --- a/packages/client/workbench/src/settings/providers/model-options.ts +++ b/packages/client/workbench/src/settings/providers/model-options.ts @@ -38,6 +38,11 @@ export function accountModelOptions( return options; } +/** Harnesses offered when creating a thread; missing config entries retain the enabled default. */ +export function selectableHarnessKinds(providers: ProvidersConfig): AgentKind[] { + return AgentKindSchema.options.filter((kind) => providers[kind]?.enabled ?? true); +} + /** `null` until both daemon-owned sources have loaded, so a picker never briefly offers a set that * is not actually available — or one the enabled list would have narrowed. */ export function useAccountModelOptions(): Partial> | null { diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 31347d12..74ac1e89 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -13,6 +13,7 @@ import { MessageIdSchema, workspaceKind } from '@linkcode/schema'; import { archiveWorkspace, cancelTurn, + getProviderConfig, hostArtifact, hostWorkspaceFile, readWorkspaceFile, @@ -57,8 +58,11 @@ import { RuntimeNewSessionBranchPicker } from '../git/new-session-branch-picker' import { WorkbenchCommandPalette } from '../palette/command-palette'; import { openCommandPalette } from '../palette/store'; import { useWorkbenchSdkClient } from '../runtime/provider'; -import { useMutation } from '../runtime/tayori'; -import { useAccountModelOptions } from '../settings/providers/model-options'; +import { useData, useMutation } from '../runtime/tayori'; +import { + selectableHarnessKinds, + useAccountModelOptions, +} from '../settings/providers/model-options'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; import { useSidebarGroupCollapseStore } from '../sidebar/collapse-store'; import { useSidebarOrderStore } from '../sidebar/order-store'; @@ -242,6 +246,8 @@ function WorkbenchSessionSurface({ const currentPlan: CurrentPlan | null = selectCurrentPlan(conversation); const { mentionItems, onMentionQueryChange } = useFileMentionSource(); const accountModels = useAccountModelOptions(); + const { data: providers } = useData(getProviderConfig, {}); + const selectableHarnesses = providers === undefined ? null : selectableHarnessKinds(providers); const sdkClient = useWorkbenchSdkClient(); const activeSessionId = sessions.activeId; // Announce observation of the focused session so the daemon replays buffered per-session state @@ -660,6 +666,7 @@ function WorkbenchSessionSurface({ newSessionWorkspaceId={newSessionWorkspaceId} onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} accountModels={accountModels} + selectableHarnesses={selectableHarnesses} agentCatalogs={agentCatalogs} newSessionPreferredEfforts={newSessionPreferredEfforts} newSessionPreferredBranches={newSessionPreferredBranches} diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 1fba8111..b9e3b938 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -56,6 +56,7 @@ const RE_PI_WIDE = /Pi Wide/; const RE_HIGH_EFFORT = /High/; const RE_LOW_EFFORT = /Low/; const RE_GPT_56_SOL = /GPT-5.6-Sol/; +const RE_HARNESS_CLAUDE_CODE_BUTTON = /Claude Code/; const RE_HARNESS_CLAUDE_CODE_MENU = /harness.*Claude Code/; const RE_MODEL_SONNET_5_MENU = /model.*Sonnet 5/; const RE_OPUS_5 = /Opus 5/; @@ -291,6 +292,60 @@ describe('NewSessionSurface', () => { }, ); + it('omits disabled harnesses and falls back from a disabled remembered harness', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await user.click(screen.getByRole('button', { name: RE_HARNESS_CLAUDE_CODE_BUTTON })); + const harnessItem = await screen.findByRole('menuitem', { + name: RE_HARNESS_CLAUDE_CODE_MENU, + }); + harnessItem.focus(); + await user.keyboard('{ArrowRight}'); + expect(await screen.findByRole('menuitemradio', { name: 'Claude Code' })).toBeTruthy(); + expect(screen.getByRole('menuitemradio', { name: 'Codex' })).toBeTruthy(); + expect(screen.queryByRole('menuitemradio', { name: 'OpenCode' })).toBeNull(); + await user.keyboard('{ArrowLeft}{Escape}'); + + typeInComposer('use enabled harness'); + await pressInComposer('Enter'); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'claude-code' })), + ); + }); + + it('blocks submission when every harness is disabled', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + typeInComposer('no harness can run this'); + await pressInComposer('Enter'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it('keeps permission mode visible while the agent catalog is unavailable', () => { render( >> | null; @@ -144,6 +146,7 @@ export function NewSessionSurface({ runtimeCues, attachmentSupport, agentCatalogs, + selectableHarnesses, accountModels, preferredEfforts, preferredBranches, @@ -159,7 +162,12 @@ export function NewSessionSurface({ onPickAttachmentFiles, }: NewSessionSurfaceProps): React.ReactNode { const t = useTranslations('workbench.newSession'); - const [harness, setHarness] = useState(draft.initialHarness); + const availableHarnesses = + selectableHarnesses === undefined ? SELECTABLE_HARNESSES : (selectableHarnesses ?? []); + const [preferredHarness, setPreferredHarness] = useState(draft.initialHarness); + const harness = availableHarnesses.includes(preferredHarness) + ? preferredHarness + : availableHarnesses.at(0); const [selectedModels, setSelectedModels] = useState>>( {}, ); @@ -182,17 +190,20 @@ export function NewSessionSurface({ ? (selectedBranches[selected.workspaceId] ?? preferredBranches?.[selected.workspaceId]) : undefined; const branchMode = selectedBranch?.mode ?? 'local'; - const catalog = agentCatalogs?.[harness]; - const localModel = selectedModels[harness]; + const catalog = harness === undefined ? undefined : agentCatalogs?.[harness]; + const localModel = harness === undefined ? undefined : selectedModels[harness]; const selectedModel = localModel === undefined ? null : localModel; - const localEffort = selectedEfforts[harness]; - const effort = localEffort === undefined ? (preferredEfforts?.[harness] ?? null) : localEffort; + const localEffort = harness === undefined ? undefined : selectedEfforts[harness]; + const effort = + localEffort === undefined && harness !== undefined + ? (preferredEfforts?.[harness] ?? null) + : (localEffort ?? null); // Every offered model comes from an account enabled for this agent, and its head is what an // untouched draft runs on — the same entry the daemon derives. The agent's own catalog is // deliberately absent: a model nobody enabled an account for is not on offer, so the account // switches govern this menu completely rather than sitting beside a list they cannot reach. - const pickable: ModelOption[] = accountModels?.[harness] ?? []; - const localAccount = selectedAccounts[harness]; + const pickable: ModelOption[] = harness === undefined ? [] : (accountModels?.[harness] ?? []); + const localAccount = harness === undefined ? undefined : selectedAccounts[harness]; // `null` is "the accounts have not loaded", so there is no head yet. const modelOption = selectedModel === null @@ -223,7 +234,7 @@ export function NewSessionSurface({ // Only a real pick travels to the adapter. Every catalog default — policy, model, effort — is a // display value: submitting one would read as an explicit choice and override the agent's own // startup resolution — claude's `permissions.defaultMode`, codex's configured `config.toml`. - const pickedPolicyId = selectedPolicies[harness]; + const pickedPolicyId = harness === undefined ? undefined : selectedPolicies[harness]; const currentPolicyId = pickedPolicyId ?? catalog?.defaultPolicyId ?? catalog?.policies[0]?.policyId; const approvalPolicy = @@ -233,6 +244,7 @@ export function NewSessionSurface({ async function submit(input: NewSessionSubmission['input']): Promise { if (!selected) throw new Error('Cannot start a session without a workspace'); + if (!harness) throw new Error('Cannot start a session without an enabled harness'); setPending(true); try { await onSubmit({ @@ -270,11 +282,12 @@ export function NewSessionSurface({ } function handleHarnessChange(nextHarness: AgentKind): Promise { - setHarness(nextHarness); + setPreferredHarness(nextHarness); return Promise.resolve(); } function handleModelChange(next: ModelOption): Promise { + if (!harness) return Promise.resolve(); setSelectedModels((current) => ({ ...current, [harness]: next.id })); // The account is part of the pick: two accounts can serve the same id, and the session must // start on the one whose entry was chosen. @@ -283,16 +296,19 @@ export function NewSessionSurface({ } function handleEffortChange(nextEffort: EffortLevel): Promise { + if (!harness) return Promise.resolve(); setSelectedEfforts((current) => ({ ...current, [harness]: nextEffort })); return Promise.resolve(); } function handleResetModel(): void { + if (!harness) return; setSelectedModels((current) => ({ ...current, [harness]: null })); setSelectedAccounts((current) => ({ ...current, [harness]: null })); } function handleResetEffort(): void { + if (!harness) return; setSelectedEfforts((current) => ({ ...current, [harness]: null })); } @@ -302,6 +318,7 @@ export function NewSessionSurface({ } function handleApprovalPolicyChange(policyId: string): Promise { + if (!harness) return Promise.resolve(); setSelectedPolicies((current) => ({ ...current, [harness]: policyId })); return Promise.resolve(); } @@ -310,13 +327,13 @@ export function NewSessionSurface({ selected && !isChatSelected ? t('headingIn', { name: selected.name ?? repositoryLabel(selected.cwd) }) : t('heading'); - const cue = runtimeCues?.[harness]; - const capabilities = AGENT_INPUT_CAPABILITIES[harness]; + const cue = harness === undefined ? undefined : runtimeCues?.[harness]; + const capabilities = harness === undefined ? undefined : AGENT_INPUT_CAPABILITIES[harness]; const directiveControls: ComposerDirectiveControls = { - slash: capabilities.slashCommands + slash: capabilities?.slashCommands ? { state: 'loading', onInvokeCommand: handleInvokeCommand } : { state: 'unsupported' }, - shell: capabilities.shellCommand + shell: capabilities?.shellCommand ? { state: 'ready', onRunShellCommand: handleRunShellCommand } : { state: 'unsupported' }, }; @@ -350,7 +367,7 @@ export function NewSessionSurface({

{heading}

- {cue && ( + {cue && harness && (
)} onMentionQueryChange(selected?.cwd, query)} runtimeCues={runtimeCues} - // Only the runtime gates sending. An empty list is not a refusal: an agent with no - // account still resolves its own model, and the one case the daemon does refuse — an - // account pinned with nothing picked — reports itself rather than greying the button. - sendBlocked={cue !== undefined} + // A missing runtime or enabled harness blocks sending; account/model failures report themselves. + sendBlocked={harness === undefined || cue !== undefined} currentModeId={modeId} currentModel={displayedModel} currentEffort={displayedEffort} @@ -385,7 +400,7 @@ export function NewSessionSurface({ currentAccountId={modelOption?.accountId} approvalPolicy={approvalPolicy} approvalPolicyPlaceholder={t('permissionMode')} - selectableHarnesses={SELECTABLE_HARNESSES} + selectableHarnesses={availableHarnesses} onSend={handleSend} onStop={noop} onPickAttachmentFiles={onPickAttachmentFiles} diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 77d7ce3c..390a6839 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -56,6 +56,8 @@ export interface ShellFrameProps /** Frontend capability stub used until attachment support is advertised by sessions. */ attachmentSupport?: AttachmentSupportByAgent; agentCatalogs?: AgentStartCatalogs; + /** Harnesses enabled for new threads; null while provider configuration is loading. */ + selectableHarnesses: AgentKind[] | null; /** The models each agent may run on, pooled from the accounts enabled for it, in the order the * pickers offer them — the head is the agent's default. */ accountModels: Readonly>> | null; @@ -132,6 +134,7 @@ export function ShellFrame({ runtimeCues, attachmentSupport, agentCatalogs, + selectableHarnesses, accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -223,6 +226,7 @@ export function ShellFrame({ runtimeCues={runtimeCues} attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} + selectableHarnesses={selectableHarnesses} accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches}