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
135 changes: 97 additions & 38 deletions apps/staged/src/lib/features/agents/AcpConfigPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,27 @@
import AcpConfigPickerSection from './AcpConfigPickerSection.svelte';
import AcpConfigPickerShell from './AcpConfigPickerShell.svelte';
import { agentState, REMOTE_AGENTS } from './agent.svelte';
import { setAiAgent, getPreferredAgent } from '../settings/preferences.svelte';
import {
setAiAgent,
getPreferredAgent,
setAcpConfigPref,
getAcpConfigPref,
} from '../settings/preferences.svelte';
import { preferredAcpEffort } from '../settings/acpConfigPrefs';
import {
discoverAcpConfig,
type AcpConfigDiscovery,
type AcpConfigSelector,
} from '../../api/commands';
import Spinner from '../../shared/Spinner.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { buildAcpConfigSelection, type AcpConfigPickerSelection } from './acpConfigSelection';
import {
buildAcpConfigSelection,
defaultSelectorValue,
reconcileSelectorValue,
selectorHasValue,
type AcpConfigPickerSelection,
} from './acpConfigSelection';

interface AgentOption {
id: string;
Expand Down Expand Up @@ -141,10 +153,12 @@
if (cancelled || run !== discoveryRun) return;
config = data;
configLoading = false;
restorePersistedModel(data);
revalidating
?.then((fresh) => {
if (!cancelled && run === discoveryRun) {
config = fresh;
restorePersistedModel(fresh);
}
})
.catch((error) => {
Expand Down Expand Up @@ -177,25 +191,30 @@
}
});

// Mirror of the model effect above, with two twists: while a model-specific
// fetch is in flight the selector is null, and the current selection is
// retained as the desired value for the incoming options; and when there is
// no valid explicit selection, the persisted preference is tried before the
// selector default.
$effect(() => {
const nextKey = selectorKey(effortSelector);
if (nextKey !== effortSelectorKey) {
selectedEffortValue = defaultSelectorValue(effortSelector);
effortSelectionExplicit = false;
if (
effortSelector &&
!(effortSelectionExplicit && selectorHasValue(effortSelector, selectedEffortValue))
) {
const persisted = selectedProviderId ? getAcpConfigPref(selectedProviderId) : null;
const reconciled = reconcileSelectorValue(
effortSelector,
preferredAcpEffort(persisted, selectedModelValue)
);
selectedEffortValue = reconciled.valueId;
effortSelectionExplicit = reconciled.explicit;
}
effortSelectorKey = nextKey;
}
});

// Snapshot the effort options so a model change can keep showing the old
// column as muted text while the model-specific options load.
$effect(() => {
if (!configLoading && effortSelector) {
retainedEffortSelector = effortSelector;
retainedEffortValue = selectedEffortValue;
retainedEffortTriggerLabel = effortTriggerLabel;
}
});

$effect(() => {
onSelectionChange?.(pickerSelection);
});
Expand All @@ -207,19 +226,76 @@
function handleModelChange(value: string) {
selectedModelValue = value;
modelSelectionExplicit = true;
selectedEffortValue = null;
effortSelectionExplicit = false;
config = config ? { ...config, effort: null } : config;
if (!remote && selectedProviderId) {
setAcpConfigPref(selectedProviderId, { model: value });
}
// The current effort selection is kept as the desired value; the effort
// effect reconciles it once the model-specific options arrive.
discoverEffortOptionsForModel(value);
}

function handleEffortChange(value: string) {
selectedEffortValue = value;
effortSelectionExplicit = true;
if (!remote && selectedProviderId) {
setAcpConfigPref(selectedProviderId, {
effort: value,
effortModel: selectedModelValue ?? undefined,
});
}
}

/**
* Re-apply the last explicitly chosen model for this provider once
* discovery lands. Restored values are marked explicit so they are sent at
* launch — non-explicit selections are display-only (see
* buildAcpConfigSelection) — which is safe because the driver skips config
* values the agent no longer offers. Persisted values missing from the
* options fall back silently; options can vary by working directory and
* agent version, so the pref is not deleted.
*/
function restorePersistedModel(discovered: AcpConfigDiscovery | null) {
if (remote || modelSelectionExplicit || !selectedProviderId) return;
const persistedModel = getAcpConfigPref(selectedProviderId)?.model ?? null;
const selector = discovered?.model ?? null;
if (!persistedModel || !selectorHasValue(selector, persistedModel)) return;

selectedModelValue = persistedModel;
modelSelectionExplicit = true;
// The effort options in the initial discovery belong to the provider's
// default model; fetch the ones for the restored model instead.
if (persistedModel !== defaultSelectorValue(selector)) {
discoverEffortOptionsForModel(persistedModel);
}
}

/**
* Fetch the effort options specific to a model. The effort selector is
* cleared so the column shows its loading state; the current effort
* selection is retained and reconciled when the new options arrive.
*/
function discoverEffortOptionsForModel(modelValue: string) {
const providerId = selectedProviderId;
const discoveryWorkingDir = workingDir ?? null;
if (remote || !providerId) return;

// Snapshot the outgoing effort options so the column keeps showing them
// as muted text while the model-specific ones load, and so the catch
// below can restore them. Captured here rather than in an effect because
// on the mount-restore path discovery lands and this fetch starts in the
// same tick, before any effect could observe the loaded selector.
if (config?.effort) {
retainedEffortSelector = config.effort;
retainedEffortValue = selectedEffortValue;
retainedEffortTriggerLabel = effortTriggerLabel;
}
config = config ? { ...config, effort: null } : config;

const run = ++discoveryRun;
configLoading = true;
configError = null;

discoverAcpConfig(providerId, discoveryWorkingDir, { selectedModelValue: value })
discoverAcpConfig(providerId, discoveryWorkingDir, { selectedModelValue: modelValue })
.then(({ data, revalidating }) => {
if (run !== discoveryRun) return;
config = data;
Expand All @@ -243,38 +319,21 @@
configError = error instanceof Error ? error.message : String(error);
// The effort column was cleared for the incoming model-specific
// options; restore the previous model's selector so the column stays
// usable after a transient failure. If a choice from it turns out to
// be stale for the new model, the driver skips it and the launch
// falls back to provider-default effort.
// usable — with its selection intact — after a transient failure. If
// the choice turns out to be stale for the new model, the driver
// skips it and the launch falls back to provider-default effort.
if (config && !config.effort && retainedEffortSelector) {
config = { ...config, effort: retainedEffortSelector };
}
});
}

function handleEffortChange(value: string) {
selectedEffortValue = value;
effortSelectionExplicit = true;
}

function selectorKey(selector: AcpConfigSelector | null): string | null {
if (!selector) return null;
const optionIds = selector.options.map((option) => option.valueId).join(',');
return `${selector.configId}:${selector.currentValueId}:${optionIds}`;
}

function defaultSelectorValue(selector: AcpConfigSelector | null): string | null {
if (!selector || selector.options.length === 0) return null;
if (selector.options.some((option) => option.valueId === selector.currentValueId)) {
return selector.currentValueId;
}
return selector.options[0]?.valueId ?? null;
}

function selectorHasValue(selector: AcpConfigSelector | null, valueId: string | null): boolean {
return !!selector && !!valueId && selector.options.some((option) => option.valueId === valueId);
}

function selectorValueLabel(
selector: AcpConfigSelector | null,
valueId: string | null
Expand Down
40 changes: 39 additions & 1 deletion apps/staged/src/lib/features/agents/acpConfigSelection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { AcpConfigSelector } from '../../api/commands';
import { buildAcpConfigSelection } from './acpConfigSelection';
import { buildAcpConfigSelection, reconcileSelectorValue } from './acpConfigSelection';

function selector(overrides: Partial<AcpConfigSelector> = {}): AcpConfigSelector {
return {
Expand Down Expand Up @@ -97,3 +97,41 @@ describe('buildAcpConfigSelection', () => {
).toBeNull();
});
});

describe('reconcileSelectorValue', () => {
it('keeps a desired value the selector still offers, marked explicit', () => {
expect(reconcileSelectorValue(selector(), 'opus')).toEqual({
valueId: 'opus',
explicit: true,
});
});

it('falls back to the selector default when the desired value is gone', () => {
expect(reconcileSelectorValue(selector(), 'removed-model')).toEqual({
valueId: 'sonnet',
explicit: false,
});
});

it('falls back to the selector default when there is no desired value', () => {
expect(reconcileSelectorValue(selector(), null)).toEqual({
valueId: 'sonnet',
explicit: false,
});
});

it('falls back to the first option when the selector default is not listed', () => {
expect(reconcileSelectorValue(selector({ currentValueId: 'missing' }), null)).toEqual({
valueId: 'sonnet',
explicit: false,
});
});

it('returns no value for a missing or empty selector', () => {
expect(reconcileSelectorValue(null, 'opus')).toEqual({ valueId: null, explicit: false });
expect(reconcileSelectorValue(selector({ options: [] }), 'opus')).toEqual({
valueId: null,
explicit: false,
});
});
});
40 changes: 40 additions & 0 deletions apps/staged/src/lib/features/agents/acpConfigSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,46 @@ interface AcpSelectorSelections {
effort?: SelectorSelection;
}

export interface ReconciledSelectorValue {
valueId: string | null;
explicit: boolean;
}

/**
* The value a selector shows when nothing has been chosen: its reported
* current value when listed, otherwise the first option.
*/
export function defaultSelectorValue(selector: AcpConfigSelector | null): string | null {
if (!selector || selector.options.length === 0) return null;
if (selector.options.some((option) => option.valueId === selector.currentValueId)) {
return selector.currentValueId;
}
return selector.options[0]?.valueId ?? null;
}

export function selectorHasValue(
selector: AcpConfigSelector | null,
valueId: string | null
): boolean {
return !!selector && !!valueId && selector.options.some((option) => option.valueId === valueId);
}

/**
* Reconcile a desired value (a prior explicit choice or persisted preference)
* against a selector's live options. Keeps the desired value — marked
* explicit so it is sent at launch — when the selector still offers it;
* otherwise falls back to the selector default as a display-only value.
*/
export function reconcileSelectorValue(
selector: AcpConfigSelector | null,
desiredValueId: string | null
): ReconciledSelectorValue {
if (selectorHasValue(selector, desiredValueId)) {
return { valueId: desiredValueId, explicit: true };
}
return { valueId: defaultSelectorValue(selector), explicit: false };
}

function selectedValueId(selector: AcpConfigSelector, valueId: string | null): string | null {
if (valueId && selector.options.some((option) => option.valueId === valueId)) {
return valueId;
Expand Down
Loading