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
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function DesktopShell({
runtimeCues,
attachmentSupport,
agentCatalogs,
selectableHarnesses,
accountModels,
newSessionPreferredEfforts,
newSessionPreferredBranches,
Expand Down Expand Up @@ -428,6 +429,7 @@ export function DesktopShell({
runtimeCues={runtimeCues}
attachmentSupport={attachmentSupport}
agentCatalogs={agentCatalogs}
selectableHarnesses={selectableHarnesses}
accountModels={accountModels}
preferredEfforts={newSessionPreferredEfforts}
preferredBranches={newSessionPreferredBranches}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));

Expand Down Expand Up @@ -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',
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<AgentKind, ModelOption[]>> | null {
Expand Down
11 changes: 9 additions & 2 deletions packages/client/workbench/src/surface/workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { MessageIdSchema, workspaceKind } from '@linkcode/schema';
import {
archiveWorkspace,
cancelTurn,
getProviderConfig,
hostArtifact,
hostWorkspaceFile,
readWorkspaceFile,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -660,6 +666,7 @@ function WorkbenchSessionSurface({
newSessionWorkspaceId={newSessionWorkspaceId}
onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange}
accountModels={accountModels}
selectableHarnesses={selectableHarnesses}
agentCatalogs={agentCatalogs}
newSessionPreferredEfforts={newSessionPreferredEfforts}
newSessionPreferredBranches={newSessionPreferredBranches}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand Down Expand Up @@ -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(
<NewSessionSurface
chatWorkspace={CHAT_WORKSPACE}
draft={{ initialHarness: 'opencode', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }}
mentionItems={[]}
onMentionQueryChange={vi.fn()}
onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)}
onSubmit={onSubmit}
selectableHarnesses={['claude-code', 'codex']}
workspaces={[]}
/>,
);

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(
<NewSessionSurface
chatWorkspace={CHAT_WORKSPACE}
draft={{ initialHarness: 'opencode', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }}
mentionItems={[]}
onMentionQueryChange={vi.fn()}
onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)}
onSubmit={onSubmit}
selectableHarnesses={[]}
workspaces={[]}
/>,
);

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(
<NewSessionSurface
Expand Down
57 changes: 36 additions & 21 deletions packages/presentation/ui/src/shell/new-session-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface NewSessionSurfaceProps {
/** Frontend capability stub used until attachment support is advertised by sessions. */
attachmentSupport?: AttachmentSupportByAgent;
agentCatalogs?: AgentStartCatalogs;
/** Harnesses enabled for new threads; null while configuration is loading. */
selectableHarnesses?: AgentKind[] | null;
/** The models each agent offers, from every account enabled for it. They lead the picker and their
* head is the agent's default; the agent's own catalog follows for a run on its own login. */
accountModels?: Readonly<Partial<Record<AgentKind, ModelOption[]>>> | null;
Expand Down Expand Up @@ -144,6 +146,7 @@ export function NewSessionSurface({
runtimeCues,
attachmentSupport,
agentCatalogs,
selectableHarnesses,
accountModels,
preferredEfforts,
preferredBranches,
Expand All @@ -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 ?? []);
Comment thread
lucas77778 marked this conversation as resolved.
const [preferredHarness, setPreferredHarness] = useState(draft.initialHarness);
const harness = availableHarnesses.includes(preferredHarness)
? preferredHarness
: availableHarnesses.at(0);
const [selectedModels, setSelectedModels] = useState<Partial<Record<AgentKind, string | null>>>(
{},
);
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -233,6 +244,7 @@ export function NewSessionSurface({

async function submit(input: NewSessionSubmission['input']): Promise<void> {
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({
Expand Down Expand Up @@ -270,11 +282,12 @@ export function NewSessionSurface({
}

function handleHarnessChange(nextHarness: AgentKind): Promise<void> {
setHarness(nextHarness);
setPreferredHarness(nextHarness);
return Promise.resolve();
}

function handleModelChange(next: ModelOption): Promise<void> {
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.
Expand All @@ -283,16 +296,19 @@ export function NewSessionSurface({
}

function handleEffortChange(nextEffort: EffortLevel): Promise<void> {
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 }));
}

Expand All @@ -302,6 +318,7 @@ export function NewSessionSurface({
}

function handleApprovalPolicyChange(policyId: string): Promise<void> {
if (!harness) return Promise.resolve();
setSelectedPolicies((current) => ({ ...current, [harness]: policyId }));
return Promise.resolve();
}
Expand All @@ -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' },
};
Expand Down Expand Up @@ -350,7 +367,7 @@ export function NewSessionSurface({
<h1 className="px-4 pb-8 text-center font-semibold text-2xl text-foreground">
{heading}
</h1>
{cue && (
{cue && harness && (
<div className="px-4 pb-3">
<div className="mx-auto max-w-3xl">
<AgentOnboardingCard
Expand All @@ -364,28 +381,26 @@ export function NewSessionSurface({
</div>
)}
<Composer
agentLabel={AGENT_LABELS[harness]}
agentLabel={harness === undefined ? undefined : AGENT_LABELS[harness]}
agentKind={harness}
attachmentsSupported={Boolean(attachmentSupport?.[harness])}
attachmentsSupported={Boolean(harness && attachmentSupport?.[harness])}
blockDirectivesWithAttachments
disabled={pending || !selected}
directiveControls={directiveControls}
isRunning={false}
mentionItems={mentionItems}
onMentionQueryChange={(query) => 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}
Comment thread
lucas77778 marked this conversation as resolved.
currentModeId={modeId}
currentModel={displayedModel}
currentEffort={displayedEffort}
agentModels={pickable.length > 0 ? pickable : null}
currentAccountId={modelOption?.accountId}
approvalPolicy={approvalPolicy}
approvalPolicyPlaceholder={t('permissionMode')}
selectableHarnesses={SELECTABLE_HARNESSES}
selectableHarnesses={availableHarnesses}
onSend={handleSend}
onStop={noop}
onPickAttachmentFiles={onPickAttachmentFiles}
Expand Down
4 changes: 4 additions & 0 deletions packages/presentation/ui/src/shell/shell-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Partial<Record<AgentKind, ModelOption[]>>> | null;
Expand Down Expand Up @@ -132,6 +134,7 @@ export function ShellFrame({
runtimeCues,
attachmentSupport,
agentCatalogs,
selectableHarnesses,
accountModels,
newSessionPreferredEfforts,
newSessionPreferredBranches,
Expand Down Expand Up @@ -223,6 +226,7 @@ export function ShellFrame({
runtimeCues={runtimeCues}
attachmentSupport={attachmentSupport}
agentCatalogs={agentCatalogs}
selectableHarnesses={selectableHarnesses}
accountModels={accountModels}
preferredEfforts={newSessionPreferredEfforts}
preferredBranches={newSessionPreferredBranches}
Expand Down
Loading