Skip to content

Commit c073cc2

Browse files
cursoragentgaoyu06
andcommitted
Move model effort chips to a fixed side column
Co-authored-by: Gao Yu <gaoyu06@users.noreply.github.com>
1 parent 62c209f commit c073cc2

4 files changed

Lines changed: 123 additions & 26 deletions

File tree

src/lib/composer/AgentModelPopover.svelte

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
import { t } from '$lib/i18n';
1111
import type { ChatState } from '$lib/chat.svelte';
1212
import type { ModelRow } from './modelRows';
13+
import { effortColumnIdx } from './effortColumn';
1314
1415
// The composer's model popover: the coding agent lives INSIDE the session,
1516
// so an unlocked session shows an agent rail (native engines + registered
1617
// ACP agents) on the left and the current agent's models in the center.
17-
// Hovering a model reveals its reasoning-effort chips; a chip picks that
18-
// model AND that effort in one step.
18+
// A fixed-width column on the right lists the hovered/focused model's
19+
// reasoning-effort chips (never inserted between rows, so the list never
20+
// reflows); a chip picks that model AND that effort in one step.
1921
let {
2022
chat,
2123
title,
@@ -107,9 +109,11 @@
107109
108110
// Chips are hover-driven for the mouse (mouseenter on a row, cleared when
109111
// the pointer leaves the list) and follow selIdx only after the user
110-
// actually arrow-keyed — never on the default/active selection.
112+
// actually arrow-keyed — never on the default/active selection. With no
113+
// focus the column falls back to the currently active model.
111114
let hoverIdx = $state<number | null>(null);
112-
const chipIdx = $derived(hoverIdx ?? (keyNav ? selIdx : null));
115+
const chipIdx = $derived(effortColumnIdx(rows, hoverIdx, keyNav, selIdx));
116+
const chipRow = $derived(chipIdx !== null ? rows[chipIdx] : null);
113117
function hoverRow(i: number) {
114118
hoverIdx = i;
115119
selIdx = i;
@@ -118,7 +122,7 @@
118122
</script>
119123

120124
<button class="pop-backdrop" aria-label="close" onclick={onClose}></button>
121-
<div class="pop" role="dialog" aria-label={title}>
125+
<div class="pop" class:norail={backendLocked} role="dialog" aria-label={title}>
122126
<div class="pop-head">
123127
<span>{title}</span>
124128
<IconButton onclick={onClose} label="close"><X size={15} /></IconButton>
@@ -181,25 +185,32 @@
181185
<span class="prow-detail">{row.detail}</span>
182186
{#if row.active}<Check size={14} class="prow-check" />{/if}
183187
</button>
184-
{#if i === chipIdx && chipEfforts(row).length}
185-
<div class="effrow">
186-
<span class="effcap">{t('chat.effortTitle')}</span>
187-
{#each chipEfforts(row) as ef (ef)}
188-
<button
189-
class="eff"
190-
class:on={row.active && ef === activeEffort}
191-
onclick={() => onSelect(`${row.command} ${ef}`)}
192-
>{ef}</button>
193-
{/each}
194-
</div>
195-
{/if}
196188
{/each}
197189
{#if rows.length === 0}
198190
<div class="pempty">{query.trim() ? t('shell.noMatch') : t('shell.noOptions')}</div>
199191
{/if}
200192
</div>
201193
<div class="pop-foot">{t('shell.pickerFoot')}</div>
202194
</div>
195+
<!-- Reserved-width effort column: content swaps with the focused row but
196+
the column itself never appears/disappears, so the popover width and
197+
the model rows' heights stay put. -->
198+
<div class="effcol" role="group" aria-label={t('chat.effortTitle')}>
199+
<span class="effcap">{t('chat.effortTitle')}</span>
200+
{#if chipRow && chipEfforts(chipRow).length}
201+
{@const row = chipRow}
202+
<span class="effmodel" title={row.label}>{row.label}</span>
203+
{#each chipEfforts(row) as ef (ef)}
204+
<button
205+
class="eff"
206+
class:on={row.active && ef === activeEffort}
207+
onclick={() => onSelect(`${row.command} ${ef}`)}
208+
>{ef}</button>
209+
{/each}
210+
{:else}
211+
<span class="effempty">{t('chat.effortNone')}</span>
212+
{/if}
213+
</div>
203214
</div>
204215
</div>
205216

@@ -219,7 +230,7 @@
219230
bottom: calc(100% + 8px);
220231
left: 0;
221232
z-index: 21;
222-
width: min(440px, 86vw);
233+
width: min(544px, 92vw);
223234
max-height: min(60vh, 440px);
224235
display: flex;
225236
flex-direction: column;
@@ -231,6 +242,10 @@
231242
transform-origin: bottom left;
232243
animation: pop-in var(--t-med) var(--ease-spring);
233244
}
245+
/* Locked sessions drop the agent rail — two columns need less room. */
246+
.pop.norail {
247+
width: min(500px, 90vw);
248+
}
234249
.pop-head {
235250
display: flex;
236251
align-items: center;
@@ -369,24 +384,45 @@
369384
color: var(--accent-bright);
370385
flex-shrink: 0;
371386
}
372-
/* Hovered model's thinking levels: one chip per effort, chip = model+effort. */
373-
.effrow {
387+
/* Fixed-width column of the focused model's thinking levels: one chip per
388+
effort, chip = model+effort. Width is reserved even when empty so
389+
hovering never resizes the popover or reflows the model list. */
390+
.effcol {
374391
display: flex;
375-
align-items: center;
376-
flex-wrap: wrap;
392+
flex-direction: column;
393+
align-items: stretch;
377394
gap: 5px;
378-
padding: 2px 11px 8px 36px;
379-
animation: rise var(--t-fast) var(--ease-out);
395+
width: 118px;
396+
flex-shrink: 0;
397+
padding: 10px;
398+
border-left: 1px solid var(--hairline);
399+
overflow-y: auto;
380400
}
381401
.effcap {
382402
font-size: 10.5px;
403+
font-weight: 600;
404+
letter-spacing: 0.04em;
405+
text-transform: uppercase;
406+
color: var(--dim2);
407+
}
408+
.effmodel {
409+
font-family: var(--font-mono);
410+
font-size: 10.5px;
411+
color: var(--dim);
412+
white-space: nowrap;
413+
overflow: hidden;
414+
text-overflow: ellipsis;
415+
margin-bottom: 2px;
416+
}
417+
.effempty {
418+
font-size: 11px;
383419
color: var(--dim2);
384-
margin-right: 2px;
385420
}
386421
.eff {
387422
font-family: var(--font-mono);
388423
font-size: 11px;
389-
padding: 2px 9px;
424+
text-align: center;
425+
padding: 3px 9px;
390426
border-radius: 999px;
391427
border: 1px solid var(--border);
392428
background: var(--surface2);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { effortColumnIdx } from './effortColumn';
3+
4+
const rows = (actives: boolean[]) => actives.map((active) => ({ active }));
5+
6+
describe('effortColumnIdx', () => {
7+
it('hovered row wins over keyboard focus', () => {
8+
expect(effortColumnIdx(rows([false, false, true]), 1, true, 2)).toBe(1);
9+
});
10+
11+
it('follows selIdx only after arrow-key navigation', () => {
12+
expect(effortColumnIdx(rows([true, false, false]), null, true, 2)).toBe(2);
13+
});
14+
15+
it('ignores the default selection and falls back to the active row', () => {
16+
expect(effortColumnIdx(rows([false, true, false]), null, false, 0)).toBe(1);
17+
});
18+
19+
it('returns null when nothing is focused and no row is active', () => {
20+
expect(effortColumnIdx(rows([false, false]), null, false, 0)).toBeNull();
21+
});
22+
23+
it('treats a stale out-of-range focus as unfocused', () => {
24+
expect(effortColumnIdx(rows([true, false]), 5, false, 0)).toBe(0);
25+
expect(effortColumnIdx(rows([false, false]), 5, true, 9)).toBeNull();
26+
});
27+
28+
it('handles empty row lists', () => {
29+
expect(effortColumnIdx([], null, false, 0)).toBeNull();
30+
});
31+
});

src/lib/composer/effortColumn.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Which row feeds the popover's fixed effort column. Pure so the resolution
2+
// order (hover > keyboard focus > active model) stays unit-testable.
3+
4+
export interface EffortSourceRow {
5+
active: boolean;
6+
}
7+
8+
/**
9+
* Resolve the row whose reasoning efforts the side column shows.
10+
*
11+
* The hovered row wins; keyboard focus counts only after the user actually
12+
* arrow-keyed (`keyNav`), never on the default selection; with no focus at
13+
* all the column falls back to the currently active model so it is never
14+
* blank while the popover is idle. Returns null when nothing applies.
15+
*/
16+
export function effortColumnIdx(
17+
rows: EffortSourceRow[],
18+
hoverIdx: number | null,
19+
keyNav: boolean,
20+
selIdx: number
21+
): number | null {
22+
const focused = hoverIdx ?? (keyNav ? selIdx : null);
23+
// A stale index (rows re-filtered under the pointer) falls through to the
24+
// active-model fallback instead of pointing at nothing.
25+
if (focused !== null && focused >= 0 && focused < rows.length) return focused;
26+
const active = rows.findIndex((r) => r.active);
27+
return active >= 0 ? active : null;
28+
}

src/lib/i18n/messages/chat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const chat = {
3030
switchBackend: '切换编程智能体',
3131
acpAgents: 'ACP 智能体',
3232
effortTitle: '思考强度',
33+
effortNone: '无强度选项',
3334
statusTitle: '状态',
3435
approvalModeTitle: '工具审批模式',
3536
gitBranch: '当前 git 分支',
@@ -88,6 +89,7 @@ const chat = {
8889
switchBackend: 'Switch coding agent',
8990
acpAgents: 'ACP agents',
9091
effortTitle: 'Thinking effort',
92+
effortNone: 'No effort options',
9193
statusTitle: 'Status',
9294
approvalModeTitle: 'Tool approval mode',
9395
gitBranch: 'Current git branch',

0 commit comments

Comments
 (0)