Skip to content
Open
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
108 changes: 80 additions & 28 deletions apps/staged/src/lib/features/agents/AcpConfigPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,26 @@
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 {
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 +152,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,11 +190,23 @@
}
});

// 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, persisted?.effort ?? null);
selectedEffortValue = reconciled.valueId;
effortSelectionExplicit = reconciled.explicit;
}
effortSelectorKey = nextKey;
}
});
Expand All @@ -207,19 +232,63 @@
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 });
}
}

/**
* 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;

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 +312,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
33 changes: 26 additions & 7 deletions apps/staged/src/lib/features/sessions/SessionChatPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
import AcpFixedConfigPicker from '../agents/AcpFixedConfigPicker.svelte';
import { agentState } from '../agents/agent.svelte';
import { buildAcpConfigSelection } from '../agents/acpConfigSelection';
import { setAcpConfigPref } from '../settings/preferences.svelte';
import ChatComposer, { composerControlClass } from './ChatComposer.svelte';
import {
buildBranchHashtagItems,
Expand Down Expand Up @@ -1312,18 +1313,30 @@
}
});

// Mirror of the model effect above: a touched selection survives selector
// changes while it stays valid. While model-specific options load the
// selector is null; the selection is retained as the desired value and
// reconciled against the options once they arrive.
$effect(() => {
const key = selectorStateKey(
session?.id,
followupEffortSelector,
session?.acpConfigSelection?.effort ?? null
);
if (key !== followupEffortStateKey) {
selectedFollowupEffortValue = initialSelectorValue(
followupEffortSelector,
session?.acpConfigSelection?.effort ?? null
);
followupEffortTouched = false;
if (
followupEffortSelector &&
!(
followupEffortTouched &&
selectorHasValue(followupEffortSelector, selectedFollowupEffortValue)
)
) {
selectedFollowupEffortValue = initialSelectorValue(
followupEffortSelector,
session?.acpConfigSelection?.effort ?? null
);
followupEffortTouched = false;
}
followupEffortStateKey = key;
}
});
Expand Down Expand Up @@ -1392,13 +1405,16 @@
function handleFollowupModelChange(value: string) {
selectedFollowupModelValue = value;
followupModelTouched = true;
selectedFollowupEffortValue = null;
followupEffortTouched = false;
// The effort selection is kept as the desired value; the effort effect
// reconciles it once the model-specific options arrive.
Comment on lines +1408 to +1409

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep follow-up effort explicit across model switches

When a user replies to an existing session that already has an effort selected, then changes only the follow-up model to another model that still offers that effort, this code keeps showing the old effort value but leaves followupEffortTouched false. In that state followupAcpConfigSelection passes null as the stored effort for model-specific configs, so buildAcpConfigSelection omits the effort and resumeSession receives a model-only requested selection, clearing/not applying the displayed effort. Mark the retained valid effort as explicit or avoid displaying it as selected unless it will be sent.

Useful? React with 👍 / 👎.

modelSpecificFollowupConfig = null;
modelSpecificFollowupModelValue = value;
modelSpecificFollowupSessionId = session?.id ?? null;

const providerId = session?.provider ?? null;
if (providerId) {
setAcpConfigPref(providerId, { model: value });
}
const workingDir = session?.workingDir ?? null;
if (!active || !providerId) return;

Expand Down Expand Up @@ -1439,6 +1455,9 @@
function handleFollowupEffortChange(value: string) {
selectedFollowupEffortValue = value;
followupEffortTouched = true;
if (session?.provider) {
setAcpConfigPref(session.provider, { effort: value });
}
}

let grouped = $derived.by(() =>
Expand Down
34 changes: 34 additions & 0 deletions apps/staged/src/lib/features/settings/acpConfigPrefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { mergeAcpConfigPref } from './acpConfigPrefs';

describe('mergeAcpConfigPref', () => {
it('creates a pref from scratch', () => {
expect(mergeAcpConfigPref(undefined, { model: 'opus' })).toEqual({ model: 'opus' });
});

it('updates one field without touching the other', () => {
expect(mergeAcpConfigPref({ model: 'opus', effort: 'high' }, { effort: 'medium' })).toEqual({
model: 'opus',
effort: 'medium',
});
});

it('leaves absent fields untouched', () => {
expect(mergeAcpConfigPref({ effort: 'high' }, { model: 'opus' })).toEqual({
model: 'opus',
effort: 'high',
});
});

it('clears a field with null', () => {
expect(mergeAcpConfigPref({ model: 'opus', effort: 'high' }, { effort: null })).toEqual({
model: 'opus',
});
});

it('does not mutate the current pref', () => {
const current = { model: 'opus' };
mergeAcpConfigPref(current, { model: 'sonnet' });
expect(current).toEqual({ model: 'opus' });
});
});
Loading