|
24 | 24 | backendLocked = true, |
25 | 25 | query = $bindable(''), |
26 | 26 | selIdx = $bindable(0), |
| 27 | + keyNav = $bindable(false), |
27 | 28 | onClose, |
28 | 29 | onSelect, |
29 | 30 | onBackend, |
|
37 | 38 | backendLocked?: boolean; |
38 | 39 | query?: string; |
39 | 40 | selIdx?: number; |
| 41 | + /** The pane's arrow keys moved selIdx — chips follow the focused row. */ |
| 42 | + keyNav?: boolean; |
40 | 43 | onClose: () => void; |
41 | 44 | onSelect: (command: string) => void; |
42 | 45 | onBackend?: (b: BackendId, acpAgent?: { id: string; name: string }) => void | Promise<void>; |
|
68 | 71 | return p.version ? `${BACKEND_LABELS[id]} · ${p.version}` : BACKEND_LABELS[id]; |
69 | 72 | }; |
70 | 73 |
|
| 74 | + // An agent switch tears down and respawns the session's engine — a second |
| 75 | + // rail click while one is in flight would race it, so gate on a local flag. |
| 76 | + let switching = $state(false); |
71 | 77 | async function pickNative(id: BackendId) { |
72 | | - if (id === chat.backendId) return; |
73 | | - await onBackend?.(id); |
74 | | - if (CAPS[id].modelPicker) onRefreshModels(); |
| 78 | + if (switching || id === chat.backendId) return; |
| 79 | + switching = true; |
| 80 | + try { |
| 81 | + await onBackend?.(id); |
| 82 | + if (CAPS[id].modelPicker) onRefreshModels(); |
| 83 | + } finally { |
| 84 | + switching = false; |
| 85 | + } |
75 | 86 | } |
76 | 87 | async function pickAcp(agent: AcpAgent) { |
77 | | - if (chat.backendId === 'acp' && chat.acpAgentId === agent.id) return; |
78 | | - // ACP agents expose no model catalog — nothing to refresh afterwards. |
79 | | - await onBackend?.('acp', { id: agent.id, name: agent.name }); |
| 88 | + if (switching || (chat.backendId === 'acp' && chat.acpAgentId === agent.id)) return; |
| 89 | + switching = true; |
| 90 | + try { |
| 91 | + // ACP agents expose no model catalog — nothing to refresh afterwards. |
| 92 | + await onBackend?.('acp', { id: agent.id, name: agent.name }); |
| 93 | + } finally { |
| 94 | + switching = false; |
| 95 | + } |
80 | 96 | } |
81 | 97 |
|
82 | 98 | // Effort highlighted on the active row (engine-reported, falling back to |
83 | 99 | // the session's current effort). |
84 | 100 | const activeEffort = $derived( |
85 | 101 | chat.picker?.kind === 'model' ? chat.picker.activeEffort || chat.effort : chat.effort |
86 | 102 | ); |
87 | | - // Effort chips only apply to same-engine rows (/model takes an effort |
88 | | - // argument); cross-provider @switch rows restart the engine, which picks |
89 | | - // its own default effort. |
90 | | - const chipEfforts = (row: ModelRow) => |
91 | | - row.command.startsWith('/model ') ? (row.efforts ?? []) : []; |
| 103 | + // Every row with efforts gets chips: same-engine rows via `/model <name> |
| 104 | + // <effort>`, cross-provider rows via `@switch <provider> <model> <effort>` |
| 105 | + // (the restart applies the picked effort instead of the provider default). |
| 106 | + const chipEfforts = (row: ModelRow) => row.efforts ?? []; |
| 107 | +
|
| 108 | + // Chips are hover-driven for the mouse (mouseenter on a row, cleared when |
| 109 | + // the pointer leaves the list) and follow selIdx only after the user |
| 110 | + // actually arrow-keyed — never on the default/active selection. |
| 111 | + let hoverIdx = $state<number | null>(null); |
| 112 | + const chipIdx = $derived(hoverIdx ?? (keyNav ? selIdx : null)); |
| 113 | + function hoverRow(i: number) { |
| 114 | + hoverIdx = i; |
| 115 | + selIdx = i; |
| 116 | + keyNav = false; |
| 117 | + } |
92 | 118 | </script> |
93 | 119 |
|
94 | 120 | <button class="pop-backdrop" aria-label="close" onclick={onClose}></button> |
|
106 | 132 | class="railbtn" |
107 | 133 | class:on={chat.backendId === id} |
108 | 134 | class:miss={p ? !p.found : false} |
| 135 | + disabled={switching} |
109 | 136 | onclick={() => pickNative(id)} |
110 | 137 | title={railTitle(id)} |
111 | 138 | aria-label={BACKEND_LABELS[id]} |
|
119 | 146 | <button |
120 | 147 | class="railbtn" |
121 | 148 | class:on={chat.backendId === 'acp' && chat.acpAgentId === agent.id} |
| 149 | + disabled={switching} |
122 | 150 | onclick={() => pickAcp(agent)} |
123 | 151 | title={agent.name} |
124 | 152 | aria-label={agent.name} |
|
137 | 165 | <input bind:value={query} placeholder={t('shell.pickerSearchPlaceholder')} autofocus /> |
138 | 166 | </div> |
139 | 167 | {/if} |
140 | | - <div class="rows"> |
| 168 | + <div class="rows" role="presentation" onmouseleave={() => (hoverIdx = null)}> |
141 | 169 | {#each rows as row, i (row.id)} |
142 | 170 | {#if row.group && (i === 0 || rows[i - 1]?.group !== row.group)} |
143 | 171 | <div class="row-group">{row.group}</div> |
|
146 | 174 | class="prow" |
147 | 175 | class:sel={i === selIdx} |
148 | 176 | onclick={() => onSelect(row.command)} |
149 | | - onmouseenter={() => (selIdx = i)} |
| 177 | + onmouseenter={() => hoverRow(i)} |
150 | 178 | > |
151 | 179 | <Vendor model={row.vendor ?? row.label} size={15} /> |
152 | 180 | <span class="prow-main">{row.label || t('shell.empty')}</span> |
153 | 181 | <span class="prow-detail">{row.detail}</span> |
154 | 182 | {#if row.active}<Check size={14} class="prow-check" />{/if} |
155 | 183 | </button> |
156 | | - {#if i === selIdx && chipEfforts(row).length} |
| 184 | + {#if i === chipIdx && chipEfforts(row).length} |
157 | 185 | <div class="effrow"> |
158 | 186 | <span class="effcap">{t('chat.effortTitle')}</span> |
159 | 187 | {#each chipEfforts(row) as ef (ef)} |
|
252 | 280 | .railbtn.miss { |
253 | 281 | opacity: 0.45; |
254 | 282 | } |
| 283 | + .railbtn:disabled { |
| 284 | + cursor: default; |
| 285 | + opacity: 0.5; |
| 286 | + } |
255 | 287 | .railsep { |
256 | 288 | width: 20px; |
257 | 289 | border-top: 1px solid var(--hairline); |
|
0 commit comments