From 5a96f07cdece12205d390a73bfccae59b3835e63 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 17:08:43 +0800 Subject: [PATCH 1/4] fix(ui): keep composer expanded when switching picker popovers --- .../src/features/composer/ModelSelector.tsx | 216 ++++++++++-------- .../src/features/session/SessionView.tsx | 142 ++++++------ 2 files changed, 195 insertions(+), 163 deletions(-) diff --git a/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx b/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx index e2fe4ac6..e2bdc623 100644 --- a/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx +++ b/apps/desktop/src/renderer/src/features/composer/ModelSelector.tsx @@ -66,6 +66,7 @@ export function ModelSelector({ const [loaded, setLoaded] = useState(false) const listRef = useRef(null) const searchRef = useRef(null) + const rootRef = useRef(null) const listboxId = useId() const activeId = `${listboxId}-opt-${activeIndex}` @@ -206,11 +207,26 @@ export function ModelSelector({ ?.scrollIntoView?.({ block: 'nearest' }) }, [activeIndex]) - const close = (): void => { + const close = useCallback((): void => { setOpen(false) setQuery('') setActiveKind(null) - } + }, []) + + /** + * Outside pointerdown (not a fullscreen backdrop) dismisses the picker, so + * a click on a sibling composer chip reaches that chip: the open picker + * closes and the new one opens in the same gesture instead of the click + * being swallowed and the composer collapsing underneath. + */ + useEffect(() => { + if (!open) return + const onPointerDown = (e: PointerEvent): void => { + if (rootRef.current?.contains(e.target as Node) !== true) close() + } + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [open, close]) const pickModel = (opt: SelectorOption): void => { // Endpoint options carry `ep:`; they ride the ari-core driver, @@ -228,7 +244,10 @@ export function ModelSelector({ const switchProvider = (step: 1 | -1): void => { if (lockedTo !== null || providers.length === 0) return - const at = Math.max(0, providers.findIndex((p) => p.kind === activeKind)) + const at = Math.max( + 0, + providers.findIndex((p) => p.kind === activeKind), + ) const next = providers[(at + step + providers.length) % providers.length] if (next !== undefined) { setActiveKind(next.kind) @@ -288,7 +307,8 @@ export function ModelSelector({ // by prefix so their label still resolves. const exact = endpointModels.find((e) => e.id === modelId) const legacy = - exact ?? (modelId != null ? endpointModels.find((e) => e.id.startsWith(`${modelId}:`)) : undefined) + exact ?? + (modelId != null ? endpointModels.find((e) => e.id.startsWith(`${modelId}:`)) : undefined) return legacy?.label ?? modelId ?? 'Ari Core' } const list = optionsFor(driverKind) @@ -337,7 +357,7 @@ export function ModelSelector({ ) return ( -
+
{open ? ( - <> -
-
-
- - setQuery(e.target.value)} - placeholder="Search models…" - aria-label="Search models" - aria-controls={listboxId} - aria-activedescendant={visibleCount > 0 ? activeId : undefined} - role="combobox" - aria-autocomplete="list" - aria-expanded - autoComplete="off" - spellCheck={false} - className="h-8 w-full bg-transparent pe-2 ps-7 text-xs text-fg placeholder:text-fg-subtle focus:outline-none" - /> -
+
+
+ + setQuery(e.target.value)} + placeholder="Search models…" + aria-label="Search models" + aria-controls={listboxId} + aria-activedescendant={visibleCount > 0 ? activeId : undefined} + role="combobox" + aria-autocomplete="list" + aria-expanded + autoComplete="off" + spellCheck={false} + className="h-8 w-full bg-transparent pe-2 ps-7 text-xs text-fg placeholder:text-fg-subtle focus:outline-none" + /> +
- {searching ? ( + {searching ? ( +
+ {visibleCount === 0 + ? emptyState + : results.map((group) => ( +
+

+ {group.label} · {group.options.length} +

+ {group.options.map((opt, i) => optionRow(opt, group.start + i, group.kind))} +
+ ))} +
+ ) : ( +
+ {lockedTo === null && providers.length > 0 ? ( +
+ {providers.map((provider) => ( + + ))} +
+ ) : null}
{visibleCount === 0 ? emptyState - : results.map((group) => ( -
-

- {group.label} · {group.options.length} -

- {group.options.map((opt, i) => optionRow(opt, group.start + i, group.kind))} -
- ))} + : paneModels.map((opt, index) => optionRow(opt, index, null))}
- ) : ( -
- {lockedTo === null && providers.length > 0 ? ( -
- {providers.map((provider) => ( - - ))} -
- ) : null} -
- {visibleCount === 0 - ? emptyState - : paneModels.map((opt, index) => optionRow(opt, index, null))} -
-
- )} +
+ )} - {lockedTo !== null ? ( -

- This session runs on {driverLabel(lockedTo)}. Start a new session to use another agent. -

- ) : null} -
- + {lockedTo !== null ? ( +

+ This session runs on {driverLabel(lockedTo)}. Start a new session to use another + agent. +

+ ) : null} +
) : null}
) diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.tsx index 55ac2d4f..bc834ecc 100644 --- a/apps/desktop/src/renderer/src/features/session/SessionView.tsx +++ b/apps/desktop/src/renderer/src/features/session/SessionView.tsx @@ -1034,6 +1034,16 @@ export function EffortChip({ const [open, setOpen] = useState(false) const [loaded, setLoaded] = useState(false) const [options, setOptions] = useState([]) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + const onPointerDown = (e: PointerEvent): void => { + if (rootRef.current?.contains(e.target as Node) !== true) setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [open]) useEffect(() => { let cancelled = false @@ -1080,7 +1090,7 @@ export function EffortChip({ if (current === undefined) return null return ( -
+
{open ? ( - <> -
setOpen(false)} /> -
- {options.map((option) => ( - - ))} -
- +
+ {options.map((option) => ( + + ))} +
) : null}
) @@ -1199,6 +1206,16 @@ export function PermissionModeChip({ const [open, setOpen] = useState(false) const [discovered, setDiscovered] = useState([]) const menuRef = useRef(null) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + const onPointerDown = (e: PointerEvent): void => { + if (rootRef.current?.contains(e.target as Node) !== true) setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [open]) useEffect(() => { let cancelled = false @@ -1257,7 +1274,7 @@ export function PermissionModeChip({ } return ( -
+
{open ? ( - <> -
setOpen(false)} /> -
- {entries.map((m) => { - const selected = m === current - return ( - - ) - })} -
- +
+ {entries.map((m) => { + const selected = m === current + return ( + + ) + })} +
) : null}
) From 603646c57df041572099828c51df4a60a6847228 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 17:30:09 +0800 Subject: [PATCH 2/4] feat(providers): re-query effort options after model switch --- apps/desktop/src/main/ipc-methods.ts | 1 + apps/desktop/src/main/rpc.ts | 76 +++++++++++++++++++ .../src/features/session/SessionView.test.tsx | 25 ++++++ .../src/features/session/SessionView.tsx | 29 ++++++- packages/contracts/src/rpc.ts | 17 +++++ packages/providers/src/acp/acp-driver.test.ts | 71 +++++++++++++++++ packages/providers/src/acp/acp-driver.ts | 57 ++++++++++++-- 7 files changed, 267 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/ipc-methods.ts b/apps/desktop/src/main/ipc-methods.ts index f4470ccd..3b5315db 100644 --- a/apps/desktop/src/main/ipc-methods.ts +++ b/apps/desktop/src/main/ipc-methods.ts @@ -40,6 +40,7 @@ export const IPC_METHODS = [ 'attachments.read', 'providers.detect', 'providers.models', + 'providers.efforts', 'providers.plan', 'providers.install', 'providers.cancelInstall', diff --git a/apps/desktop/src/main/rpc.ts b/apps/desktop/src/main/rpc.ts index 0f966ac4..1a2a050a 100644 --- a/apps/desktop/src/main/rpc.ts +++ b/apps/desktop/src/main/rpc.ts @@ -206,6 +206,66 @@ async function probeAcpModels( } } +/** + * Thought/reasoning levels for one kind resolved against a specific model. + * Effort is per-model on some agents (OpenCode's `effort` option appears + * only for reasoning-capable models), so the picker asks with the selected + * model rather than trusting the default-model catalog probe. Throws on + * transport failure so the caller keeps its catalog fallback. + */ +async function probeEffortsForModel( + kind: DriverKind, + modelId: string | null | undefined, +): Promise<{ id: string; label: string; description?: string; current?: boolean }[]> { + const { detectDriver } = await import('@ari/providers/detector') + const environment = await resolveDetectionEnvironment() + const detection = await detectDriver(kind, environment) + if (!detection.binaryPath) return [] + if (process.env['ARI_ACP'] === '0') return [] + const launch = resolveAcpLaunch(kind, { cliBinaryPath: detection.binaryPath }, environment) + if (launch === null) return [] + const { AcpConnection } = await import('@ari/providers/acp/connection') + const probeEnv = processEnvWithPath(environment.pathEnv) + const probe = probeLaunch(launch) + const connection = await AcpConnection.connect({ + launch: probe, + cwd: homedir(), + initializeTimeoutMs: 20_000, + runtimeEnv: { ...probeEnv, ...probe.env }, + }) + try { + const created = await connection.newSession(homedir()) + let configOptions = created.configOptions ?? [] + if (typeof modelId === 'string' && modelId.length > 0) { + const modelOption = configOptions.find((o) => o.category === 'model' && o.type === 'select') + const offered = modelOption?.options?.some((v) => v.value === modelId) === true + if (modelOption?.id !== undefined && offered) { + const refreshed = await connection.setConfigOption( + created.sessionId as string, + modelOption.id, + modelId, + ) + if (refreshed !== null && refreshed !== undefined && refreshed.length > 0) { + configOptions = refreshed + } + } + } + const catalog = mergeEffortCatalogs( + thoughtEffortsFromSession({ ...created, configOptions }), + thoughtEffortsFromMeta(connection.initialize._meta), + ) + if (catalog.options.length > 0) setDynamicEfforts(kind, catalog) + return catalog.options.map((option) => ({ + id: option.id, + label: option.label, + ...(option.description !== undefined ? { description: option.description } : {}), + ...(catalog.currentId === option.id ? { current: true as const } : {}), + })) + } finally { + await connection.shutdown() + } +} + /** * Every CLI kind probed by `providers.detect`, regardless of hydration state, * so the providers grid is complete even while drivers are still registering. @@ -1072,6 +1132,22 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions = }) }) + // Model-aware effort lookup for the picker: a throwaway ACP session has + // the selected model applied, then reports that model's own thought + // levels. Empty on any failure — the caller keeps the catalog fallback. + r.register('providers.efforts', async (params) => { + try { + const efforts = await probeEffortsForModel(params.kind, params.modelId ?? null) + return { efforts } + } catch (error) { + log.debug('model-specific effort probe failed; keeping catalog fallback', { + kind: params.kind, + error: String(error), + }) + return { efforts: [] } + } + }) + r.register('window.minimize', () => { BrowserWindow.fromWebContents(contents)?.minimize() return { done: true } diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx index 1e5314f2..5213f34e 100644 --- a/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx +++ b/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx @@ -1643,6 +1643,31 @@ describe('EffortChip', () => { await user.click(screen.getByRole('option', { name: /High/ })) expect(onChange).toHaveBeenCalledWith('high') }) + + it('re-queries the selected model when the catalog has no levels', async () => { + invokeMock.mockImplementation(async (method: string) => { + if (method === 'providers.models') { + return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }] + } + if (method === 'providers.efforts') { + return { efforts: [{ id: 'low', label: 'Low' }, { id: 'high', label: 'High' }] } + } + throw new Error(`unexpected method: ${method}`) + }) + render( + undefined} + />, + ) + expect(await screen.findByRole('button', { name: 'Effort: Low' })).toBeInTheDocument() + expect(invokeMock).toHaveBeenCalledWith('providers.efforts', { + kind: 'opencode', + modelId: 'opencode/muse-spark-1.3-contributor-free', + }) + }) }) /** diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.tsx index bc834ecc..d2992636 100644 --- a/apps/desktop/src/renderer/src/features/session/SessionView.tsx +++ b/apps/desktop/src/renderer/src/features/session/SessionView.tsx @@ -986,6 +986,7 @@ export function SessionView({ /> @@ -1021,13 +1022,19 @@ interface EffortOption { * (`thought_level`, `effort`, `reasoning_effort`, or a thinking-shaped mode * list). Hidden when the agent advertises nothing — we never invent * low/medium/high. + * + * Effort can be per-model (OpenCode only advertises it for + * reasoning-capable models), so with a model selected the chip re-queries + * that model's own levels and prefers them over the default-model catalog. */ export function EffortChip({ driverKind, + modelId, effort, onChange, }: { driverKind: DriverKind + modelId?: string | null effort: string | null onChange: (effort: string | null) => void }) { @@ -1054,7 +1061,7 @@ export function EffortChip({ setLoaded(true) } const load = (): void => { - void rpc + const base = rpc .invoke('providers.models') .then(apply) .catch(() => { @@ -1063,6 +1070,24 @@ export function EffortChip({ setLoaded(true) } }) + // A selected model may advertise thought levels the default-model + // catalog probe never saw; a non-empty answer wins, an empty one + // keeps whatever the catalog reported. + if (modelId !== undefined && modelId !== null && modelId.length > 0) { + void base.finally(() => { + if (cancelled) return + void rpc + .invoke('providers.efforts', { kind: driverKind, modelId }) + .then((result) => { + if (cancelled) return + if (result.efforts.length > 0) { + setOptions(result.efforts) + setLoaded(true) + } + }) + .catch(() => undefined) + }) + } } load() const unsubscribe = rpc.subscribe('providers.updates', {}, (payload) => { @@ -1073,7 +1098,7 @@ export function EffortChip({ cancelled = true unsubscribe() } - }, [driverKind]) + }, [driverKind, modelId]) useEffect(() => { if (!loaded || effort === null) return diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e9961e38..95de9745 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -429,6 +429,16 @@ export const rpcParams = { 'attachments.read': z.object({ id: z.string().min(1).max(128) }), 'providers.detect': z.undefined(), 'providers.models': z.undefined(), + /** + * Thought/reasoning levels for one kind, optionally resolved against a + * specific model. Effort is per-model on some agents (OpenCode advertises + * `effort` only for reasoning-capable models), so the picker re-queries + * with the selected model instead of trusting the default-model probe. + */ + 'providers.efforts': z.object({ + kind: driverKindSchema, + modelId: z.string().min(1).max(256).nullish(), + }), 'providers.plan': z.object({ kind: driverKindSchema }), 'providers.install': z.object({ kind: driverKindSchema, @@ -670,6 +680,13 @@ export interface RpcResults { current?: boolean }[] }[] + /** + * Thought/reasoning levels for one kind, resolved live against the + * requested model when given; empty when the harness has no selector. + */ + 'providers.efforts': { + efforts: { id: string; label: string; description?: string; current?: boolean }[] + } /** * The literal argv Ari would run to install or upgrade a provider CLI, so * the confirm dialog can show the exact command before anything executes. diff --git a/packages/providers/src/acp/acp-driver.test.ts b/packages/providers/src/acp/acp-driver.test.ts index 82064f4e..399f8cf7 100644 --- a/packages/providers/src/acp/acp-driver.test.ts +++ b/packages/providers/src/acp/acp-driver.test.ts @@ -273,6 +273,77 @@ describe('createAcpAdapter', () => { await adapter.dispose() }, 15000) + it('re-queries effort from the options a model switch returns', async () => { + const child = fakeChild() + script(child, (method, params, id) => { + if (method === 'session/new') { + return { + sessionId: 'sess_acp_1', + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'plain', + options: [ + { value: 'plain', name: 'Plain' }, + { value: 'thinker', name: 'Thinker' }, + ], + }, + ], + } + } + if (method === 'session/set_config_option') { + const p = params as { configId?: string; value?: string } + if (p.configId === 'model') { + return { + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'thinker', + options: [ + { value: 'plain', name: 'Plain' }, + { value: 'thinker', name: 'Thinker' }, + ], + }, + { + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select', + currentValue: 'medium', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + ], + }, + ], + } + } + return { configOptions: [] as unknown[] } + } + return standardAgent()(method, params, id) + }) + const adapter = await createAcpAdapter( + LAUNCH, + { ...SESSION, modelId: 'thinker', effort: 'high' }, + () => child, + ) + expect(adapter.advertisedEfforts.options.map((o) => o.id)).toEqual(['low', 'medium', 'high']) + const thought = child.sent.filter( + (m) => + m['method'] === 'session/set_config_option' && + (m['params'] as { configId?: string })?.configId === 'effort', + ) as { params?: { value?: string } }[] + expect(thought.map((r) => r.params?.value)).toEqual(['high']) + await adapter.dispose() + }, 15000) + it('sets pi-style thinking modes via session/set_mode for an effort pick', async () => { const child = fakeChild() script(child, PI_THINKING_AGENT) diff --git a/packages/providers/src/acp/acp-driver.ts b/packages/providers/src/acp/acp-driver.ts index 4f6b9827..fe17e282 100644 --- a/packages/providers/src/acp/acp-driver.ts +++ b/packages/providers/src/acp/acp-driver.ts @@ -22,10 +22,12 @@ import { replyPermissionChoice, replyPlanExit, } from './client-requests' -import { findThoughtOption, looksLikeThoughtAxis } from './thought' +import { findThoughtOption, looksLikeThoughtAxis, thoughtEffortsFromSession } from './thought' +import type { EffortCatalog } from './thought' import { classifyAgentMode, findModeOption, pickAgentMode } from './modes' export { pickAgentMode } from './modes' import { loadImageData, missingImagesNote, stagedImagesOf } from '../attachments' +import { setDynamicEfforts } from '../catalogs' import { AcpUpdateFolder, stopReasonEvents } from './protocol' import type { AcpConfigOption, @@ -52,6 +54,14 @@ export interface AcpAdapter extends ProviderAdapter { respondApproval(approvalId: string, decision: AdapterApprovalDecision): void /** Answers a pending `input-requested` question (elicitation, ask-user, plan). */ respondInput(inputId: string, value: string): void + /** + * Thought/reasoning levels the session advertises after the model pick was + * applied. Effort is per-model on some agents (OpenCode exposes `effort` + * only for reasoning-capable models), so the host refreshes the kind's + * live effort catalog from this: the picker re-queries after a model + * switch instead of trusting the default-model probe forever. + */ + advertisedEfforts: EffortCatalog } /** @@ -285,7 +295,20 @@ export async function createAcpAdapter( const selectors = resolveSelectors(launch.label, created, resumed) try { - await applyModel(connection, sessionId, selectors.configOptions, session.modelId) + const refreshed = await applyModel(connection, sessionId, selectors.configOptions, session.modelId) + // A model switch can reshape the session's own selectors: OpenCode only + // advertises `effort` for reasoning-capable models, so the options that + // came back with the switch replace the default-model vocabulary for + // everything below (permission modes, thought level) and for the picker. + // An empty reply carries no information (older agents answer `{}`), so it + // must not wipe the vocabulary the session opened with. + if (refreshed !== null && refreshed.length > 0) { + selectors.configOptions = refreshed + LEARNED_SELECTORS.set(launch.label, { + configOptions: selectors.configOptions, + availableModes: selectors.availableModes, + }) + } await applyPermissionMode( connection, sessionId, @@ -470,6 +493,13 @@ export async function createAcpAdapter( if (!connection.closed) connection.cancel(sessionId) await connection.shutdown() }, + advertisedEfforts: thoughtEffortsFromSession({ + configOptions: selectors.configOptions, + modes: + selectors.availableModes.length > 0 + ? { availableModes: selectors.availableModes } + : undefined, + }), } } @@ -570,19 +600,20 @@ async function applyModel( sessionId: string, configOptions: AcpConfigOption[], modelId: string | null, -): Promise { - if (modelId === null || modelId === 'default') return +): Promise { + if (modelId === null || modelId === 'default') return null const option = findModelOption(configOptions) - if (option === null || option.options === undefined) return + if (option === null || option.options === undefined) return null const value = option.options.find((v) => v.value === modelId)?.value if (value === undefined) { log.debug('acp: requested model not advertised by agent', { modelId }) - return + return null } try { - await connection.setConfigOption(sessionId, option.id as string, value) + return (await connection.setConfigOption(sessionId, option.id as string, value)) ?? null } catch (error) { log.debug('acp: set_config_option(model) failed', { error: String(error) }) + return null } } /** @@ -716,6 +747,17 @@ export function shouldFallBack(error: unknown): boolean { * while adapters drift — except for the auth walls {@link shouldFallBack} * excludes, which propagate as {@link AcpAuthRequiredError}. */ +/** + * Installs the thought/reasoning levels the running session advertises after + * its model pick, so the effort picker follows model switches. An empty + * catalog clears a stale overlay (a model without effort genuinely offers + * none); kinds with a static fallback (Grok, Ari Core) fall back to it. + */ +export function publishAdvertisedEfforts(kind: DriverKind, efforts: EffortCatalog): void { + setDynamicEfforts(kind, efforts) + log.info('effort catalog taken from the session agent', { kind, count: efforts.options.length }) +} + export class AcpDriver implements Driver { readonly kind: DriverKind @@ -739,6 +781,7 @@ export class AcpDriver implements Driver { this.onAuthRequired ?? undefined, ) log.info('turn started over ACP', { kind: this.kind, launch: this.launch.label }) + publishAdvertisedEfforts(this.kind, adapter.advertisedEfforts) return adapter } catch (error) { if (!shouldFallBack(error)) throw error From d503a5f0c69e44ef17726a6efd613b9202364803 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 21:49:05 +0800 Subject: [PATCH 3/4] fix(ui): keep a saved effort until the model's own levels have answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The effort chip released `loaded` as soon as the kind's catalog resolved, but that catalog is probed against the default model. For a model whose levels only the per-model query knows, the reset effect saw a saved effort missing from the generic list and cleared it before the model's own answer arrived — losing the setting this control exists to keep. Withhold `loaded` until the per-model probe settles when there is a model to probe. An effort no list advertises is still cleared, just later. Co-Authored-By: Claude Opus 5 --- .../src/features/session/SessionView.test.tsx | 197 +++++++++++++++--- .../src/features/session/SessionView.tsx | 19 +- 2 files changed, 179 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx index 5213f34e..66737893 100644 --- a/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx +++ b/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx @@ -359,7 +359,12 @@ describe('SessionView question panel', () => { await user.click(await screen.findByRole('button', { name: 'Approve plan' })) await waitFor(() => { expect(invokeMock).toHaveBeenCalledWith('command.dispatch', { - command: { type: 'input.respond', sessionId: 'sess_1', inputId: 'plan1', value: 'approved' }, + command: { + type: 'input.respond', + sessionId: 'sess_1', + inputId: 'plan1', + value: 'approved', + }, }) }) expect(screen.queryByRole('complementary', { name: 'Plan review' })).not.toBeInTheDocument() @@ -445,7 +450,12 @@ describe('SessionView edit and resend', () => { await waitFor(() => { expect(invokeMock).toHaveBeenCalledWith('command.dispatch', { - command: { type: 'turn.start', sessionId: 'sess_1', text: 'please retry the build, verbose', attachments: [] }, + command: { + type: 'turn.start', + sessionId: 'sess_1', + text: 'please retry the build, verbose', + attachments: [], + }, }) }) }) @@ -483,10 +493,7 @@ describe('SessionView regenerate and retry', () => { vi.clearAllMocks() }) - function emitTurn( - stopReason: 'completed' | 'error', - errorMessage: string | null, - ): void { + function emitTurn(stopReason: 'completed' | 'error', errorMessage: string | null): void { emitSessionEvent({ seq: 1, at: 1, @@ -535,7 +542,12 @@ describe('SessionView regenerate and retry', () => { await waitFor(() => { expect(invokeMock).toHaveBeenCalledWith('command.dispatch', { - command: { type: 'turn.start', sessionId: 'sess_1', text: 'run the test suite', attachments: [] }, + command: { + type: 'turn.start', + sessionId: 'sess_1', + text: 'run the test suite', + attachments: [], + }, }) }) }) @@ -598,7 +610,12 @@ describe('SessionView regenerate and retry', () => { await waitFor(() => { expect(invokeMock).toHaveBeenCalledWith('command.dispatch', { - command: { type: 'turn.start', sessionId: 'sess_1', text: 'run the test suite', attachments: [] }, + command: { + type: 'turn.start', + sessionId: 'sess_1', + text: 'run the test suite', + attachments: [], + }, }) }) }) @@ -705,7 +722,9 @@ describe('SessionView per-turn diff cards', () => { turnId: 'turn_1', }) }) - expect(await screen.findByRole('button', { name: 'Turn diff: 1 file changed' })).toBeInTheDocument() + expect( + await screen.findByRole('button', { name: 'Turn diff: 1 file changed' }), + ).toBeInTheDocument() }) it('shows no card when the settled turn has a null diff', async () => { @@ -831,7 +850,12 @@ describe('SessionView context meter', () => { undefined} /> , @@ -935,8 +959,12 @@ describe('SessionView replay/live dedupe (M23.12)', () => { emitFrame({ event: userMessage(1, 'who are you') }) emitFrame({ event: { - seq: 2, at: 2, sessionId: 'sess_1', type: 'assistant.parts.appended', - messageId: 'm2', parts: [{ type: 'text', text: 'I am the agent.' }], + seq: 2, + at: 2, + sessionId: 'sess_1', + type: 'assistant.parts.appended', + messageId: 'm2', + parts: [{ type: 'text', text: 'I am the agent.' }], }, }) @@ -944,8 +972,12 @@ describe('SessionView replay/live dedupe (M23.12)', () => { emitFrame({ event: userMessage(1, 'who are you'), replay: true }) emitFrame({ event: { - seq: 2, at: 2, sessionId: 'sess_1', type: 'assistant.parts.appended', - messageId: 'm2', parts: [{ type: 'text', text: 'I am the agent.' }], + seq: 2, + at: 2, + sessionId: 'sess_1', + type: 'assistant.parts.appended', + messageId: 'm2', + parts: [{ type: 'text', text: 'I am the agent.' }], }, replay: true, }) @@ -1023,7 +1055,12 @@ describe('SessionView queued messages', () => { await user.type(screen.getByLabelText('Message'), 'second prompt{Enter}') expect(invokeMock).toHaveBeenCalledWith('command.dispatch', { - command: { type: 'message.enqueue', sessionId: 'sess_1', text: 'second prompt', attachments: [] }, + command: { + type: 'message.enqueue', + sessionId: 'sess_1', + text: 'second prompt', + attachments: [], + }, }) emitSessionEvent({ @@ -1055,9 +1092,7 @@ describe('SessionView queued messages', () => { errorMessage: null, }) await waitFor(() => { - expect( - invokeMock.mock.calls.some(([method]) => method === 'command.dispatch'), - ).toBe(false) + expect(invokeMock.mock.calls.some(([method]) => method === 'command.dispatch')).toBe(false) }) // Engine dequeues the next message; a fresh turn begins. @@ -1222,7 +1257,9 @@ describe('SessionView image attachments', () => { if (method === 'command.dispatch') return { accepted: true } if (method === 'attachments.stage') return { attachments: [REF] } if (method === 'attachments.read') { - return { attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' } } + return { + attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' }, + } } throw new Error(`unexpected method: ${String(method)}`) }) @@ -1248,13 +1285,20 @@ describe('SessionView image attachments', () => { const input = await screen.findByLabelText('Message') fireEvent.paste(input, { - clipboardData: { files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]) }, + clipboardData: { + files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]), + }, }) await user.type(input, 'look at this{Enter}') await waitFor(() => { expect(invokeMock).toHaveBeenCalledWith('command.dispatch', { - command: { type: 'turn.start', sessionId: 'sess_1', text: 'look at this', attachments: [REF] }, + command: { + type: 'turn.start', + sessionId: 'sess_1', + text: 'look at this', + attachments: [REF], + }, }) }) const stageCall = invokeMock.mock.calls.find(([method]) => method === 'attachments.stage') @@ -1278,7 +1322,13 @@ describe('SessionView image attachments', () => { turnId: 'turn_1', role: 'user', parts: [ - { type: 'image', attachmentId: 'att_1', name: 'shot.png', mimeType: 'image/png', size: 8 }, + { + type: 'image', + attachmentId: 'att_1', + name: 'shot.png', + mimeType: 'image/png', + size: 8, + }, { type: 'text', text: 'look at this' }, ], createdAt: 1, @@ -1308,7 +1358,9 @@ describe('SessionView image attachments', () => { const input = await screen.findByLabelText('Message') fireEvent.paste(input, { - clipboardData: { files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]) }, + clipboardData: { + files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]), + }, }) await user.type(input, 'do not lose this{Enter}') @@ -1335,7 +1387,9 @@ describe('SessionView image attachments', () => { if (method === 'command.dispatch') return { accepted: false, reason: 'over capacity' } if (method === 'attachments.stage') return { attachments: [REF] } if (method === 'attachments.read') { - return { attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' } } + return { + attachment: { name: 'shot.png', mimeType: 'image/png', size: 8, dataBase64: 'aGk=' }, + } } throw new Error(`unexpected method: ${String(method)}`) }) @@ -1343,7 +1397,9 @@ describe('SessionView image attachments', () => { const input = await screen.findByLabelText('Message') fireEvent.paste(input, { - clipboardData: { files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]) }, + clipboardData: { + files: fakeFileList([new File([new Uint8Array(8)], 'shot.png', { type: 'image/png' })]), + }, }) await user.type(input, 'look at this{Enter}') @@ -1426,7 +1482,9 @@ describe('SessionView refused sends with review notes', () => { await user.click((await screen.findAllByRole('button', { name: 'Comment on src/a.ts:1' }))[0]!) await user.type(screen.getByLabelText('Review note for src/a.ts'), 'extract a helper') await user.click(screen.getByRole('button', { name: 'Save note' })) - expect(await screen.findByLabelText('Review notes attached to next message')).toBeInTheDocument() + expect( + await screen.findByLabelText('Review notes attached to next message'), + ).toBeInTheDocument() await user.type(screen.getByLabelText('Message'), 'do it{Enter}') expect(await screen.findByText('over capacity')).toBeInTheDocument() @@ -1582,9 +1640,7 @@ describe('EffortChip', () => { }) it('hides when the harness advertised no thought levels', async () => { - invokeMock.mockResolvedValue([ - { kind: 'grok', source: 'live', models: [], efforts: [] }, - ]) + invokeMock.mockResolvedValue([{ kind: 'grok', source: 'live', models: [], efforts: [] }]) render( undefined} />) await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('providers.models')) expect(screen.queryByRole('button', { name: /Effort:/ })).not.toBeInTheDocument() @@ -1650,7 +1706,12 @@ describe('EffortChip', () => { return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }] } if (method === 'providers.efforts') { - return { efforts: [{ id: 'low', label: 'Low' }, { id: 'high', label: 'High' }] } + return { + efforts: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' }, + ], + } } throw new Error(`unexpected method: ${method}`) }) @@ -1668,6 +1729,82 @@ describe('EffortChip', () => { modelId: 'opencode/muse-spark-1.3-contributor-free', }) }) + + it('keeps a saved effort that only the model-specific list advertises', async () => { + const onChange = vi.fn() + // The per-model answer is held open so the component really does sit in + // the gap the generic catalog opens, which is where the effort was lost. + let releaseEfforts: (() => void) | undefined + const efforts = new Promise<{ efforts: { id: string; label: string }[] }>((resolve) => { + releaseEfforts = () => + resolve({ + efforts: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' }, + ], + }) + }) + invokeMock.mockImplementation(async (method: string) => { + // The kind's catalog was probed against the default model and knows + // nothing of `high`; only the per-model query does. + if (method === 'providers.models') { + return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }] + } + if (method === 'providers.efforts') return efforts + throw new Error(`unexpected method: ${method}`) + }) + + render( + , + ) + + // The generic catalog has landed and knows nothing of `high`; the saved + // effort must survive until the model's own list has had its say. + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith('providers.efforts', { + kind: 'opencode', + modelId: 'opencode/reasoner-1', + }), + ) + expect(onChange).not.toHaveBeenCalled() + + await act(async () => { + releaseEfforts?.() + await efforts + }) + + expect(await screen.findByRole('button', { name: 'Effort: High' })).toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) + + it('still clears an effort no list advertises once both queries have settled', async () => { + const onChange = vi.fn() + invokeMock.mockImplementation(async (method: string) => { + if (method === 'providers.models') { + return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }] + } + if (method === 'providers.efforts') { + return { efforts: [{ id: 'low', label: 'Low' }] } + } + throw new Error(`unexpected method: ${method}`) + }) + + render( + , + ) + + await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)) + }) }) /** diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.tsx index d2992636..49be0746 100644 --- a/apps/desktop/src/renderer/src/features/session/SessionView.tsx +++ b/apps/desktop/src/renderer/src/features/session/SessionView.tsx @@ -1054,11 +1054,16 @@ export function EffortChip({ useEffect(() => { let cancelled = false + // A model-specific probe is the authority on this model's vocabulary, so + // `loaded` is withheld until it settles. Releasing it on the generic + // catalog alone lets the reset effect below clear a saved effort that only + // the model's own list advertises — the very case this control exists for. + const probesModel = modelId !== undefined && modelId !== null && modelId.length > 0 const apply = (rows: { kind: string; efforts?: EffortOption[] }[]): void => { if (cancelled) return const row = rows.find((r) => r.kind === driverKind) setOptions(row?.efforts ?? []) - setLoaded(true) + if (!probesModel) setLoaded(true) } const load = (): void => { const base = rpc @@ -1067,25 +1072,25 @@ export function EffortChip({ .catch(() => { if (!cancelled) { setOptions([]) - setLoaded(true) + if (!probesModel) setLoaded(true) } }) // A selected model may advertise thought levels the default-model // catalog probe never saw; a non-empty answer wins, an empty one // keeps whatever the catalog reported. - if (modelId !== undefined && modelId !== null && modelId.length > 0) { + if (probesModel) { void base.finally(() => { if (cancelled) return void rpc .invoke('providers.efforts', { kind: driverKind, modelId }) .then((result) => { if (cancelled) return - if (result.efforts.length > 0) { - setOptions(result.efforts) - setLoaded(true) - } + if (result.efforts.length > 0) setOptions(result.efforts) }) .catch(() => undefined) + .finally(() => { + if (!cancelled) setLoaded(true) + }) }) } } From b26e1ffbf839f86321f6457ee1288cf87f00183c Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 22:07:04 +0800 Subject: [PATCH 4/4] fix(ui): withhold effort reset while a model switch is in flight --- .../src/features/session/SessionView.test.tsx | 67 +++++++++++++++++++ .../src/features/session/SessionView.tsx | 4 ++ 2 files changed, 71 insertions(+) diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx index 66737893..97452457 100644 --- a/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx +++ b/apps/desktop/src/renderer/src/features/session/SessionView.test.tsx @@ -1805,6 +1805,73 @@ describe('EffortChip', () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)) }) + + it('keeps a saved effort across a model switch until the new model answers', async () => { + const onChange = vi.fn() + let releaseNext: (() => void) | undefined + const nextEfforts = new Promise<{ efforts: { id: string; label: string }[] }>((resolve) => { + releaseNext = () => + resolve({ + efforts: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' }, + ], + }) + }) + invokeMock.mockImplementation(async (method: string, params?: unknown) => { + if (method === 'providers.models') { + return [{ kind: 'opencode', source: 'live', models: [], efforts: [] }] + } + if (method === 'providers.efforts') { + const modelId = + typeof params === 'object' && params !== null && 'modelId' in params + ? (params as { modelId?: string }).modelId + : undefined + if (modelId === 'opencode/reasoner-2') return nextEfforts + return { + efforts: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' }, + ], + } + } + throw new Error(`unexpected method: ${method}`) + }) + + const { rerender } = render( + , + ) + expect(await screen.findByRole('button', { name: 'Effort: High' })).toBeInTheDocument() + + rerender( + , + ) + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith('providers.efforts', { + kind: 'opencode', + modelId: 'opencode/reasoner-2', + }), + ) + expect(onChange).not.toHaveBeenCalled() + + await act(async () => { + releaseNext?.() + await nextEfforts + }) + + expect(await screen.findByRole('button', { name: 'Effort: High' })).toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) }) /** diff --git a/apps/desktop/src/renderer/src/features/session/SessionView.tsx b/apps/desktop/src/renderer/src/features/session/SessionView.tsx index 49be0746..fdebc19e 100644 --- a/apps/desktop/src/renderer/src/features/session/SessionView.tsx +++ b/apps/desktop/src/renderer/src/features/session/SessionView.tsx @@ -1058,6 +1058,10 @@ export function EffortChip({ // `loaded` is withheld until it settles. Releasing it on the generic // catalog alone lets the reset effect below clear a saved effort that only // the model's own list advertises — the very case this control exists for. + // Reset on every generation: leaving `loaded` true across a model switch + // lets the catalog's first paint clear a saved effort the new model still + // advertises. + setLoaded(false) const probesModel = modelId !== undefined && modelId !== null && modelId.length > 0 const apply = (rows: { kind: string; efforts?: EffortOption[] }[]): void => { if (cancelled) return