diff --git a/src/lib/composer/AgentModelPopover.svelte b/src/lib/composer/AgentModelPopover.svelte index 9d016f7..9046ccf 100644 --- a/src/lib/composer/AgentModelPopover.svelte +++ b/src/lib/composer/AgentModelPopover.svelte @@ -10,12 +10,14 @@ import { t } from '$lib/i18n'; import type { ChatState } from '$lib/chat.svelte'; import type { ModelRow } from './modelRows'; + import { effortColumnIdx } from './effortColumn'; // The composer's model popover: the coding agent lives INSIDE the session, // so an unlocked session shows an agent rail (native engines + registered // ACP agents) on the left and the current agent's models in the center. - // Hovering a model reveals its reasoning-effort chips; a chip picks that - // model AND that effort in one step. + // A fixed-width column on the right lists the hovered/focused model's + // reasoning-effort chips (never inserted between rows, so the list never + // reflows); a chip picks that model AND that effort in one step. let { chat, title, @@ -107,18 +109,26 @@ // Chips are hover-driven for the mouse (mouseenter on a row, cleared when // the pointer leaves the list) and follow selIdx only after the user - // actually arrow-keyed — never on the default/active selection. + // actually arrow-keyed — never on the default/active selection. With no + // focus the column falls back to the currently active model. let hoverIdx = $state(null); - const chipIdx = $derived(hoverIdx ?? (keyNav ? selIdx : null)); + let rowsEl: HTMLDivElement; + let effortColumnEl: HTMLDivElement; + const chipIdx = $derived(effortColumnIdx(rows, hoverIdx, keyNav, selIdx)); + const chipRow = $derived(chipIdx !== null ? rows[chipIdx] : null); function hoverRow(i: number) { hoverIdx = i; selIdx = i; keyNav = false; } + function clearHoverUnlessEntering(event: MouseEvent, destination: HTMLElement) { + const next = event.relatedTarget; + if (!(next instanceof Node) || !destination.contains(next)) hoverIdx = null; + } - @@ -219,8 +248,12 @@ bottom: calc(100% + 8px); left: 0; z-index: 21; - width: min(440px, 86vw); - max-height: min(60vh, 440px); + width: min(544px, 92vw); + /* Fixed (not max) height: the body is the max of rail/models/efforts, + so swapping effort-chip content on hover must never change the + popover's size — the bottom-anchored top edge would jump. Inner + lists scroll instead. */ + height: min(60vh, 440px); display: flex; flex-direction: column; background: var(--panel); @@ -231,6 +264,10 @@ transform-origin: bottom left; animation: pop-in var(--t-med) var(--ease-spring); } + /* Locked sessions drop the agent rail — two columns need less room. */ + .pop.norail { + width: min(500px, 90vw); + } .pop-head { display: flex; align-items: center; @@ -369,24 +406,45 @@ color: var(--accent-bright); flex-shrink: 0; } - /* Hovered model's thinking levels: one chip per effort, chip = model+effort. */ - .effrow { + /* Fixed-width column of the focused model's thinking levels: one chip per + effort, chip = model+effort. Width is reserved even when empty so + hovering never resizes the popover or reflows the model list. */ + .effcol { display: flex; - align-items: center; - flex-wrap: wrap; + flex-direction: column; + align-items: stretch; gap: 5px; - padding: 2px 11px 8px 36px; - animation: rise var(--t-fast) var(--ease-out); + width: 118px; + flex-shrink: 0; + padding: 10px; + border-left: 1px solid var(--hairline); + overflow-y: auto; } .effcap { font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--dim2); + } + .effmodel { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; + } + .effempty { + font-size: 11px; color: var(--dim2); - margin-right: 2px; } .eff { font-family: var(--font-mono); font-size: 11px; - padding: 2px 9px; + text-align: center; + padding: 3px 9px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface2); diff --git a/src/lib/composer/effortColumn.test.ts b/src/lib/composer/effortColumn.test.ts new file mode 100644 index 0000000..2a68045 --- /dev/null +++ b/src/lib/composer/effortColumn.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { effortColumnIdx } from './effortColumn'; + +const rows = (actives: boolean[]) => actives.map((active) => ({ active })); + +describe('effortColumnIdx', () => { + it('hovered row wins over keyboard focus', () => { + expect(effortColumnIdx(rows([false, false, true]), 1, true, 2)).toBe(1); + }); + + it('follows selIdx only after arrow-key navigation', () => { + expect(effortColumnIdx(rows([true, false, false]), null, true, 2)).toBe(2); + }); + + it('ignores the default selection and falls back to the active row', () => { + expect(effortColumnIdx(rows([false, true, false]), null, false, 0)).toBe(1); + }); + + it('returns null when nothing is focused and no row is active', () => { + expect(effortColumnIdx(rows([false, false]), null, false, 0)).toBeNull(); + }); + + it('treats a stale out-of-range focus as unfocused', () => { + expect(effortColumnIdx(rows([true, false]), 5, false, 0)).toBe(0); + expect(effortColumnIdx(rows([false, false]), 5, true, 9)).toBeNull(); + }); + + it('handles empty row lists', () => { + expect(effortColumnIdx([], null, false, 0)).toBeNull(); + }); +}); diff --git a/src/lib/composer/effortColumn.ts b/src/lib/composer/effortColumn.ts new file mode 100644 index 0000000..b78ee31 --- /dev/null +++ b/src/lib/composer/effortColumn.ts @@ -0,0 +1,28 @@ +// Which row feeds the popover's fixed effort column. Pure so the resolution +// order (hover > keyboard focus > active model) stays unit-testable. + +export interface EffortSourceRow { + active: boolean; +} + +/** + * Resolve the row whose reasoning efforts the side column shows. + * + * The hovered row wins; keyboard focus counts only after the user actually + * arrow-keyed (`keyNav`), never on the default selection; with no focus at + * all the column falls back to the currently active model so it is never + * blank while the popover is idle. Returns null when nothing applies. + */ +export function effortColumnIdx( + rows: EffortSourceRow[], + hoverIdx: number | null, + keyNav: boolean, + selIdx: number +): number | null { + const focused = hoverIdx ?? (keyNav ? selIdx : null); + // A stale index (rows re-filtered under the pointer) falls through to the + // active-model fallback instead of pointing at nothing. + if (focused !== null && focused >= 0 && focused < rows.length) return focused; + const active = rows.findIndex((r) => r.active); + return active >= 0 ? active : null; +} diff --git a/src/lib/i18n/messages/chat.ts b/src/lib/i18n/messages/chat.ts index 682e1b7..3b48553 100644 --- a/src/lib/i18n/messages/chat.ts +++ b/src/lib/i18n/messages/chat.ts @@ -30,6 +30,7 @@ const chat = { switchBackend: '切换编程智能体', acpAgents: 'ACP 智能体', effortTitle: '思考强度', + effortNone: '无强度选项', statusTitle: '状态', approvalModeTitle: '工具审批模式', gitBranch: '当前 git 分支', @@ -88,6 +89,7 @@ const chat = { switchBackend: 'Switch coding agent', acpAgents: 'ACP agents', effortTitle: 'Thinking effort', + effortNone: 'No effort options', statusTitle: 'Status', approvalModeTitle: 'Tool approval mode', gitBranch: 'Current git branch',