Skip to content

Commit 72eaa14

Browse files
gaoyu06claude
andcommitted
fix(composer): scope effort tiers per-backend, fill full track, keep popovers anchored & sticky
- EffortSlider: color top tier by backend — claude max→orange, codex ultra→purple (codex max now stays on accent); fill reaches the track's right end at max (+22px). - Effort popover no longer closes when you pick a level or drag out — only the backdrop / toggle button dismisses it. - Model picker renders as an in-composer popover above the model button (like the effort/approval popovers) instead of a screen-centered modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 77bf212 commit 72eaa14

4 files changed

Lines changed: 150 additions & 43 deletions

File tree

src/lib/Composer.svelte

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,44 @@
1313
import MentionMenu from '$lib/composer/MentionMenu.svelte';
1414
import AttachmentChips from '$lib/composer/AttachmentChips.svelte';
1515
import ContextIndicator from '$lib/composer/ContextIndicator.svelte';
16+
import Picker from '$lib/shell/Picker.svelte';
1617
import type { ChatState } from '$lib/chat.svelte';
1718
import type { ApprovalMode } from '$lib/approval';
1819
import { caps } from '$lib/backends';
1920
21+
type PickerRow = {
22+
id: string;
23+
label: string;
24+
vendor?: string;
25+
detail: string;
26+
active: boolean;
27+
command: string;
28+
depth: number | undefined;
29+
};
30+
2031
let {
2132
chat,
2233
input = $bindable(),
2334
attachments = $bindable(),
2435
videos = $bindable([]),
2536
el = $bindable(),
2637
recording = false,
38+
pickerQuery = $bindable(''),
39+
pickerSelIdx = $bindable(0),
40+
modelRows = [],
41+
modelActive,
42+
modelTitle = '',
43+
modelSearch = false,
2744
onSubmit,
2845
onStop,
2946
onSteer,
3047
onPick,
3148
onScreenshot,
3249
onRecord,
3350
onModel,
51+
onModelSelect,
52+
onModelEffort,
53+
onModelClose,
3454
onEffort,
3555
onApproval
3656
}: {
@@ -40,13 +60,22 @@
4060
videos?: { path: string; frames: string[]; duration: number }[];
4161
el: HTMLElement | null;
4262
recording?: boolean;
63+
pickerQuery?: string;
64+
pickerSelIdx?: number;
65+
modelRows?: PickerRow[];
66+
modelActive?: { model: string; reasoning_efforts: string[]; active: boolean };
67+
modelTitle?: string;
68+
modelSearch?: boolean;
4369
onSubmit: () => void;
4470
onStop: () => void;
4571
onSteer: () => void;
4672
onPick: () => void;
4773
onScreenshot?: () => void;
4874
onRecord?: () => void;
4975
onModel: () => void;
76+
onModelSelect?: (command: string) => void;
77+
onModelEffort?: (effort: string) => void;
78+
onModelClose?: () => void;
5079
onEffort: (ef: string) => void;
5180
onApproval: (mode: ApprovalMode) => void;
5281
} = $props();
@@ -487,9 +516,30 @@
487516
{#if voice === 'busy'}<span class="vspin"><LoaderCircle size={16} /></span>{:else if voice === 'rec'}<CircleStop size={16} />{:else}<Mic size={16} />{/if}
488517
</button>
489518
{#if bcaps.modelPicker}
490-
<button class="flatbtn model" onclick={onModel} title={t('chat.switchModel')}>
491-
<Vendor model={chat.model} size={15} /><span>{chat.modelLabel || chat.model || 'model'}</span>
492-
</button>
519+
<div class="effortsel">
520+
<button
521+
class="flatbtn model"
522+
onclick={() => (chat.picker?.kind === 'model' ? onModelClose?.() : onModel())}
523+
title={t('chat.switchModel')}
524+
>
525+
<Vendor model={chat.model} size={15} /><span>{chat.modelLabel || chat.model || 'model'}</span>
526+
</button>
527+
{#if chat.picker?.kind === 'model'}
528+
<Picker
529+
anchored
530+
{chat}
531+
title={modelTitle}
532+
activeModel={modelActive}
533+
rows={modelRows}
534+
showSearch={modelSearch}
535+
bind:query={pickerQuery}
536+
bind:selIdx={pickerSelIdx}
537+
onClose={() => onModelClose?.()}
538+
onSelect={(c) => onModelSelect?.(c)}
539+
onEffort={(e) => onModelEffort?.(e)}
540+
/>
541+
{/if}
542+
</div>
493543
{:else if chat.model}
494544
<span class="flatbtn model static"><Vendor model={chat.model} size={15} /><span>{chat.modelLabel || chat.model}</span></span>
495545
{/if}
@@ -501,7 +551,7 @@
501551
{#if showEffort}
502552
<button class="pop-backdrop" aria-label="close" onclick={() => (showEffort = false)}></button>
503553
<div class="effort-pop wide">
504-
<EffortSlider value={chat.effort} options={chat.efforts} onChange={(e) => { onEffort(e); showEffort = false; }} />
554+
<EffortSlider value={chat.effort} options={chat.efforts} backendId={chat.backendId} onChange={onEffort} />
505555
</div>
506556
{/if}
507557
</div>

src/lib/shell/Picker.svelte

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
activeModel,
2323
rows,
2424
showSearch,
25+
anchored = false,
2526
query = $bindable(),
2627
selIdx = $bindable(),
2728
onClose,
@@ -33,6 +34,9 @@
3334
activeModel: { model: string; reasoning_efforts: string[]; active: boolean } | undefined;
3435
rows: Row[];
3536
showSearch: boolean;
37+
// Anchored: render as a compact popover above the caller (no dimmed overlay,
38+
// no focus trap) instead of a screen-centered modal.
39+
anchored?: boolean;
3640
query: string;
3741
selIdx: number;
3842
onClose: () => void;
@@ -41,42 +45,53 @@
4145
} = $props();
4246
</script>
4347

44-
<div class="overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && onClose()}>
45-
<div class="modal" role="dialog" aria-modal="true" tabindex="-1" aria-label={title} use:focusTrap>
46-
<div class="modal-head">
47-
<span>{title}</span>
48-
<IconButton onclick={onClose} label="close"><X size={15} /></IconButton>
49-
</div>
50-
{#if chat.picker?.kind === 'model' && activeModel}
51-
<div class="efforts">
52-
<span class="dim">effort</span>
53-
{#each activeModel.reasoning_efforts as ef (ef)}
54-
<button class="eff" class:on={ef === chat.picker.activeEffort} onclick={() => onEffort(ef)}>{ef}</button>
55-
{/each}
56-
</div>
57-
{/if}
58-
{#if showSearch}
59-
<div class="psearch">
60-
<Search size={14} />
61-
<!-- svelte-ignore a11y_autofocus -->
62-
<input bind:value={query} placeholder={t('shell.pickerSearchPlaceholder')} autofocus />
63-
</div>
64-
{/if}
65-
<div class="rows">
66-
{#each rows as row, i (row.id)}
67-
<button class="prow" class:sel={i === selIdx} onclick={() => onSelect(row.command)} onmouseenter={() => (selIdx = i)} style:padding-left={row.depth != null ? `${11 + row.depth * 16}px` : null}>
68-
{#if chat.picker?.kind === 'model'}<Vendor model={row.vendor ?? row.label} size={15} />{/if}
69-
{#if row.depth != null && row.depth > 0}<span class="twig">↳</span>{/if}
70-
<span class="prow-main">{row.label || t('shell.empty')}</span>
71-
<span class="prow-detail">{row.detail}</span>
72-
{#if row.active}<Check size={14} class="prow-check" />{/if}
73-
</button>
48+
{#snippet body()}
49+
<div class="modal-head">
50+
<span>{title}</span>
51+
<IconButton onclick={onClose} label="close"><X size={15} /></IconButton>
52+
</div>
53+
{#if chat.picker?.kind === 'model' && activeModel}
54+
<div class="efforts">
55+
<span class="dim">effort</span>
56+
{#each activeModel.reasoning_efforts as ef (ef)}
57+
<button class="eff" class:on={ef === chat.picker.activeEffort} onclick={() => onEffort(ef)}>{ef}</button>
7458
{/each}
75-
{#if rows.length === 0}<div class="pempty">{query.trim() ? t('shell.noMatch') : t('shell.noOptions')}</div>{/if}
7659
</div>
77-
<div class="modal-foot dim">{t('shell.pickerFoot')}</div>
60+
{/if}
61+
{#if showSearch}
62+
<div class="psearch">
63+
<Search size={14} />
64+
<!-- svelte-ignore a11y_autofocus -->
65+
<input bind:value={query} placeholder={t('shell.pickerSearchPlaceholder')} autofocus />
66+
</div>
67+
{/if}
68+
<div class="rows">
69+
{#each rows as row, i (row.id)}
70+
<button class="prow" class:sel={i === selIdx} onclick={() => onSelect(row.command)} onmouseenter={() => (selIdx = i)} style:padding-left={row.depth != null ? `${11 + row.depth * 16}px` : null}>
71+
{#if chat.picker?.kind === 'model'}<Vendor model={row.vendor ?? row.label} size={15} />{/if}
72+
{#if row.depth != null && row.depth > 0}<span class="twig">↳</span>{/if}
73+
<span class="prow-main">{row.label || t('shell.empty')}</span>
74+
<span class="prow-detail">{row.detail}</span>
75+
{#if row.active}<Check size={14} class="prow-check" />{/if}
76+
</button>
77+
{/each}
78+
{#if rows.length === 0}<div class="pempty">{query.trim() ? t('shell.noMatch') : t('shell.noOptions')}</div>{/if}
79+
</div>
80+
<div class="modal-foot dim">{t('shell.pickerFoot')}</div>
81+
{/snippet}
82+
83+
{#if anchored}
84+
<button class="pop-backdrop" aria-label="close" onclick={onClose}></button>
85+
<div class="modal anchored" role="dialog" aria-label={title}>
86+
{@render body()}
87+
</div>
88+
{:else}
89+
<div class="overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && onClose()}>
90+
<div class="modal" role="dialog" aria-modal="true" tabindex="-1" aria-label={title} use:focusTrap>
91+
{@render body()}
92+
</div>
7893
</div>
79-
</div>
94+
{/if}
8095

8196
<style>
8297
.overlay {
@@ -99,6 +114,33 @@
99114
box-shadow: var(--shadow-modal);
100115
overflow: hidden;
101116
}
117+
/* Anchored popover: sits above the caller (composer's model button), not
118+
centered. The caller wraps us in a position:relative container. */
119+
.pop-backdrop {
120+
position: fixed;
121+
inset: 0;
122+
background: none;
123+
border: none;
124+
z-index: 20;
125+
cursor: default;
126+
}
127+
.modal.anchored {
128+
position: absolute;
129+
bottom: calc(100% + 8px);
130+
left: 0;
131+
z-index: 21;
132+
width: min(380px, 82vw);
133+
max-height: min(60vh, 420px);
134+
border-radius: var(--r-md);
135+
box-shadow: var(--shadow-pop);
136+
animation: rise 0.12s ease;
137+
}
138+
@keyframes rise {
139+
from {
140+
opacity: 0;
141+
transform: translateY(4px);
142+
}
143+
}
102144
.modal-head {
103145
display: flex;
104146
align-items: center;

src/lib/ui/EffortSlider.svelte

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
let {
99
value,
1010
options,
11+
backendId = '',
1112
onChange
1213
}: {
1314
value: string;
1415
options: string[];
16+
backendId?: string;
1517
onChange: (v: string) => void;
1618
} = $props();
1719
@@ -35,11 +37,13 @@
3537
return v.charAt(0).toUpperCase() + v.slice(1);
3638
}
3739
const currentLabel = $derived(labelFor(options[shownIndex]));
38-
// Special top tiers get their own accent + a flowing shimmer.
40+
// Special top tiers get their own accent + a flowing shimmer. Backend-scoped:
41+
// claude's `max` → orange, codex's `ultra` → purple. Codex's own `max` (and any
42+
// other backend) stays on the default accent.
3943
const tier = $derived.by(() => {
4044
const v = (options[shownIndex] ?? '').toLowerCase();
41-
if (v === 'max') return 'max';
42-
if (v === 'ultra') return 'ultra';
45+
if (backendId === 'claude' && v === 'max') return 'max';
46+
if (backendId === 'codex' && v === 'ultra') return 'ultra';
4347
return '';
4448
});
4549
@@ -103,7 +107,7 @@
103107
onkeydown={onKey}
104108
>
105109
<div class="es-inner" bind:this={inner}>
106-
<div class="es-fill" class:flowing={tier !== ''} style="width: calc({pct}% + 11px)"></div>
110+
<div class="es-fill" class:flowing={tier !== ''} style="width: calc({pct}% + 22px)"></div>
107111
{#each options as opt, i (opt)}
108112
<span
109113
class="es-dot"

src/routes/+page.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,9 @@
276276
showQuickOpen ||
277277
!!taskDialogFor ||
278278
!!backendPickFor ||
279-
!!chat?.picker ||
279+
// The model picker is now an in-composer popover (like effort/approval),
280+
// not a centered overlay, so it needn't collapse the browser webview.
281+
(!!chat?.picker && chat.picker.kind !== 'model') ||
280282
!!chat?.trustPrompt ||
281283
!!chat?.pendingRewind;
282284
browser.setSuspended(modalOpen);
@@ -1166,6 +1168,15 @@
11661168
onScreenshot={screenshot}
11671169
onRecord={toggleRecord}
11681170
onModel={() => nav('/model')}
1171+
onModelSelect={selectRow}
1172+
onModelEffort={setEffort}
1173+
onModelClose={() => chat?.closePicker()}
1174+
modelRows={filteredRows}
1175+
modelActive={activeModel}
1176+
modelTitle={pickerTitle}
1177+
modelSearch={showPickerSearch}
1178+
bind:pickerQuery
1179+
bind:pickerSelIdx={selIdx}
11691180
onEffort={chooseEffort}
11701181
onApproval={setApprovalMode}
11711182
/>
@@ -1245,7 +1256,7 @@
12451256
</div>
12461257
{/if}
12471258

1248-
{#if chat?.picker}
1259+
{#if chat?.picker && chat.picker.kind !== 'model'}
12491260
<Picker
12501261
{chat}
12511262
title={pickerTitle}

0 commit comments

Comments
 (0)