|
10 | 10 | import { t } from '$lib/i18n'; |
11 | 11 | import type { ChatState } from '$lib/chat.svelte'; |
12 | 12 | import type { ModelRow } from './modelRows'; |
| 13 | + import { effortColumnIdx } from './effortColumn'; |
13 | 14 |
|
14 | 15 | // The composer's model popover: the coding agent lives INSIDE the session, |
15 | 16 | // so an unlocked session shows an agent rail (native engines + registered |
16 | 17 | // 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. |
19 | 21 | let { |
20 | 22 | chat, |
21 | 23 | title, |
|
107 | 109 |
|
108 | 110 | // Chips are hover-driven for the mouse (mouseenter on a row, cleared when |
109 | 111 | // 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. |
111 | 114 | 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); |
113 | 117 | function hoverRow(i: number) { |
114 | 118 | hoverIdx = i; |
115 | 119 | selIdx = i; |
|
118 | 122 | </script> |
119 | 123 |
|
120 | 124 | <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}> |
122 | 126 | <div class="pop-head"> |
123 | 127 | <span>{title}</span> |
124 | 128 | <IconButton onclick={onClose} label="close"><X size={15} /></IconButton> |
|
181 | 185 | <span class="prow-detail">{row.detail}</span> |
182 | 186 | {#if row.active}<Check size={14} class="prow-check" />{/if} |
183 | 187 | </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} |
196 | 188 | {/each} |
197 | 189 | {#if rows.length === 0} |
198 | 190 | <div class="pempty">{query.trim() ? t('shell.noMatch') : t('shell.noOptions')}</div> |
199 | 191 | {/if} |
200 | 192 | </div> |
201 | 193 | <div class="pop-foot">{t('shell.pickerFoot')}</div> |
202 | 194 | </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> |
203 | 214 | </div> |
204 | 215 | </div> |
205 | 216 |
|
|
219 | 230 | bottom: calc(100% + 8px); |
220 | 231 | left: 0; |
221 | 232 | z-index: 21; |
222 | | - width: min(440px, 86vw); |
| 233 | + width: min(544px, 92vw); |
223 | 234 | max-height: min(60vh, 440px); |
224 | 235 | display: flex; |
225 | 236 | flex-direction: column; |
|
231 | 242 | transform-origin: bottom left; |
232 | 243 | animation: pop-in var(--t-med) var(--ease-spring); |
233 | 244 | } |
| 245 | + /* Locked sessions drop the agent rail — two columns need less room. */ |
| 246 | + .pop.norail { |
| 247 | + width: min(500px, 90vw); |
| 248 | + } |
234 | 249 | .pop-head { |
235 | 250 | display: flex; |
236 | 251 | align-items: center; |
|
369 | 384 | color: var(--accent-bright); |
370 | 385 | flex-shrink: 0; |
371 | 386 | } |
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 { |
374 | 391 | display: flex; |
375 | | - align-items: center; |
376 | | - flex-wrap: wrap; |
| 392 | + flex-direction: column; |
| 393 | + align-items: stretch; |
377 | 394 | 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; |
380 | 400 | } |
381 | 401 | .effcap { |
382 | 402 | 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; |
383 | 419 | color: var(--dim2); |
384 | | - margin-right: 2px; |
385 | 420 | } |
386 | 421 | .eff { |
387 | 422 | font-family: var(--font-mono); |
388 | 423 | font-size: 11px; |
389 | | - padding: 2px 9px; |
| 424 | + text-align: center; |
| 425 | + padding: 3px 9px; |
390 | 426 | border-radius: 999px; |
391 | 427 | border: 1px solid var(--border); |
392 | 428 | background: var(--surface2); |
|
0 commit comments