Skip to content

Commit 4108768

Browse files
committed
fix: repair model picker interactions
1 parent 437a0ac commit 4108768

3 files changed

Lines changed: 260 additions & 36 deletions

File tree

desktop/frontend/src/components/ModelSwitcher.tsx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
2+
import type { CSSProperties } from "react";
23
import { Check, ChevronsUpDown } from "lucide-react";
34
import { app } from "../lib/bridge";
45
import { useT } from "../lib/i18n";
@@ -9,30 +10,67 @@ import type { ModelInfo } from "../lib/types";
910
// providers. Selecting one switches the active model; the conversation is carried
1011
// over by the backend, so the chat continues. Mirrors the "switch model, keep the
1112
// session" behavior of comparable coding agents.
12-
export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name: string) => void }) {
13+
export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name: string) => void | Promise<void> }) {
1314
const t = useT();
1415
const [open, setOpen] = useState(false);
16+
const [picking, setPicking] = useState(false);
1517
const [models, setModels] = useState<ModelInfo[]>([]);
18+
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
19+
const triggerRef = useRef<HTMLButtonElement | null>(null);
1620

1721
useEffect(() => {
1822
if (open) app.Models().then(setModels).catch(() => {});
1923
}, [open]);
2024

21-
const pick = (name: string) => {
25+
useLayoutEffect(() => {
26+
if (!open) return;
27+
const place = () => {
28+
const rect = triggerRef.current?.getBoundingClientRect();
29+
if (!rect) return;
30+
const width = Math.min(320, Math.max(220, rect.width));
31+
const margin = 8;
32+
setMenuStyle({
33+
left: Math.min(Math.max(margin, rect.left), window.innerWidth - width - margin),
34+
bottom: Math.max(margin, window.innerHeight - rect.top + margin),
35+
width,
36+
});
37+
};
38+
place();
39+
window.addEventListener("resize", place);
40+
window.addEventListener("scroll", place, true);
41+
return () => {
42+
window.removeEventListener("resize", place);
43+
window.removeEventListener("scroll", place, true);
44+
};
45+
}, [open]);
46+
47+
const pick = async (name: string) => {
48+
if (picking) return;
2249
setOpen(false);
23-
onPick(name);
50+
setPicking(true);
51+
try {
52+
await onPick(name);
53+
} finally {
54+
setPicking(false);
55+
}
2456
};
2557

2658
return (
2759
<div className="modelsw">
28-
<button className="modelsw__trigger" onClick={() => setOpen((v) => !v)} title={t("status.switchModel")}>
60+
<button
61+
ref={triggerRef}
62+
className="modelsw__trigger"
63+
onClick={() => setOpen((v) => !v)}
64+
disabled={picking}
65+
title={t("status.switchModel")}
66+
>
2967
<span className="modelsw__label">{label}</span>
3068
<ChevronsUpDown size={11} />
3169
</button>
3270
{open && (
3371
<>
3472
<div className="modelsw__backdrop" onClick={() => setOpen(false)} />
35-
<div className="modelsw__menu" role="listbox">
73+
<div className="modelsw__menu" style={menuStyle} role="listbox">
3674
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
3775
{models.map((m) => (
3876
<button

desktop/frontend/src/components/SettingsPanel.tsx

Lines changed: 114 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
2+
import type { CSSProperties } from "react";
3+
import { Check, ChevronDown } from "lucide-react";
24
import { app } from "../lib/bridge";
35
import { useI18n, useT } from "../lib/i18n";
46
import { useUpdater } from "../lib/useUpdater";
@@ -175,35 +177,23 @@ function ModelsSection({ s, busy, apply, onManageProviders }: SectionProps & { o
175177

176178
<div className="set-row">
177179
<label className="set-label">{t("settings.defaultModel")}</label>
178-
<select
179-
className="mem-select set-grow"
180+
<ModelSelect
180181
value={toRef(s.defaultModel, s)}
182+
options={refs}
181183
disabled={busy}
182-
onChange={(e) => void apply(() => app.SetDefaultModel(e.target.value))}
183-
>
184-
{refs.map((r) => (
185-
<option key={r} value={r}>
186-
{r}
187-
</option>
188-
))}
189-
</select>
184+
onChange={(value) => void apply(() => app.SetDefaultModel(value))}
185+
/>
190186
</div>
191187

192188
<div className="set-row">
193189
<label className="set-label">{t("settings.plannerModel")}</label>
194-
<select
195-
className="mem-select set-grow"
190+
<ModelSelect
196191
value={toRef(s.plannerModel, s)}
192+
options={refs}
193+
emptyLabel={t("settings.plannerNone")}
197194
disabled={busy}
198-
onChange={(e) => void apply(() => app.SetPlannerModel(e.target.value))}
199-
>
200-
<option value="">{t("settings.plannerNone")}</option>
201-
{refs.map((r) => (
202-
<option key={r} value={r}>
203-
{r}
204-
</option>
205-
))}
206-
</select>
195+
onChange={(value) => void apply(() => app.SetPlannerModel(value))}
196+
/>
207197
</div>
208198

209199
<div className="settings-model-card">
@@ -233,6 +223,93 @@ function ModelsSection({ s, busy, apply, onManageProviders }: SectionProps & { o
233223
);
234224
}
235225

226+
function ModelSelect({
227+
value,
228+
options,
229+
emptyLabel,
230+
disabled,
231+
onChange,
232+
}: {
233+
value: string;
234+
options: string[];
235+
emptyLabel?: string;
236+
disabled: boolean;
237+
onChange: (value: string) => void;
238+
}) {
239+
const [open, setOpen] = useState(false);
240+
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
241+
const triggerRef = useRef<HTMLButtonElement | null>(null);
242+
const selectedLabel = value || emptyLabel || "";
243+
const items = emptyLabel ? ["", ...options] : options;
244+
245+
useLayoutEffect(() => {
246+
if (!open) return;
247+
const place = () => {
248+
const rect = triggerRef.current?.getBoundingClientRect();
249+
if (!rect) return;
250+
const margin = 8;
251+
const width = Math.min(Math.max(rect.width, 220), window.innerWidth - margin * 2);
252+
const spaceBelow = window.innerHeight - rect.bottom - margin;
253+
const spaceAbove = rect.top - margin;
254+
const openUp = spaceBelow < 180 && spaceAbove > spaceBelow;
255+
setMenuStyle({
256+
left: Math.min(Math.max(margin, rect.left), window.innerWidth - width - margin),
257+
top: openUp ? undefined : rect.bottom + 4,
258+
bottom: openUp ? window.innerHeight - rect.top + 4 : undefined,
259+
width,
260+
maxHeight: Math.max(140, Math.min(280, openUp ? spaceAbove : spaceBelow)),
261+
});
262+
};
263+
place();
264+
window.addEventListener("resize", place);
265+
window.addEventListener("scroll", place, true);
266+
return () => {
267+
window.removeEventListener("resize", place);
268+
window.removeEventListener("scroll", place, true);
269+
};
270+
}, [open]);
271+
272+
const choose = (next: string) => {
273+
setOpen(false);
274+
if (next !== value) onChange(next);
275+
};
276+
277+
return (
278+
<div className="model-select set-grow">
279+
<button
280+
ref={triggerRef}
281+
className="model-select__trigger"
282+
type="button"
283+
disabled={disabled}
284+
onClick={() => setOpen((v) => !v)}
285+
>
286+
<span>{selectedLabel}</span>
287+
<ChevronDown size={14} />
288+
</button>
289+
{open && (
290+
<>
291+
<div className="model-select__backdrop" onClick={() => setOpen(false)} />
292+
<div className="model-select__menu" style={menuStyle} role="listbox">
293+
{items.map((item) => (
294+
<button
295+
key={item || "__empty__"}
296+
type="button"
297+
role="option"
298+
aria-selected={item === value}
299+
className={`model-select__item${item === value ? " model-select__item--selected" : ""}`}
300+
onClick={() => choose(item)}
301+
>
302+
<span>{item || emptyLabel}</span>
303+
{item === value && <Check size={13} />}
304+
</button>
305+
))}
306+
</div>
307+
</>
308+
)}
309+
</div>
310+
);
311+
}
312+
236313
function ProvidersSection({ s, busy, apply }: SectionProps) {
237314
const t = useT();
238315
// The provider backing the default model — can't be deleted (would dangle the
@@ -386,7 +463,19 @@ function ProviderEditor({
386463
function KeyField({ apiKeyEnv, busy, onSet }: { apiKeyEnv: string; busy: boolean; onSet: (v: string) => Promise<void> }) {
387464
const t = useT();
388465
const [val, setVal] = useState("");
466+
const [saving, setSaving] = useState(false);
389467
if (!apiKeyEnv) return null;
468+
const save = async () => {
469+
const next = val.trim();
470+
if (!next || saving) return;
471+
setSaving(true);
472+
try {
473+
await onSet(next);
474+
setVal("");
475+
} finally {
476+
setSaving(false);
477+
}
478+
};
390479
return (
391480
<div className="set-key">
392481
<input
@@ -395,14 +484,12 @@ function KeyField({ apiKeyEnv, busy, onSet }: { apiKeyEnv: string; busy: boolean
395484
placeholder={t("settings.setKey", { env: apiKeyEnv })}
396485
value={val}
397486
onChange={(e) => setVal(e.target.value)}
487+
disabled={busy || saving}
398488
/>
399489
<button
400490
className="btn btn--small"
401-
disabled={busy || !val.trim()}
402-
onClick={() => {
403-
void onSet(val.trim());
404-
setVal("");
405-
}}
491+
disabled={busy || saving || !val.trim()}
492+
onClick={() => void save()}
406493
>
407494
{t("settings.saveKey")}
408495
</button>

desktop/frontend/src/styles.css

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,15 +1799,17 @@ body {
17991799
.modelsw__trigger:hover {
18001800
color: var(--fg);
18011801
}
1802+
.modelsw__trigger:disabled {
1803+
cursor: default;
1804+
opacity: 0.55;
1805+
}
18021806
.modelsw__backdrop {
18031807
position: fixed;
18041808
inset: 0;
18051809
z-index: 30;
18061810
}
18071811
.modelsw__menu {
1808-
position: absolute;
1809-
bottom: calc(100% + 8px);
1810-
left: 0;
1812+
position: fixed;
18111813
z-index: 31;
18121814
min-width: 200px;
18131815
max-width: 320px;
@@ -3475,6 +3477,100 @@ body {
34753477
flex: 1;
34763478
min-width: 140px;
34773479
}
3480+
.model-select {
3481+
position: relative;
3482+
min-width: 0;
3483+
}
3484+
.model-select__trigger {
3485+
--wails-draggable: no-drag;
3486+
display: flex;
3487+
align-items: center;
3488+
justify-content: space-between;
3489+
gap: 8px;
3490+
width: 100%;
3491+
min-width: 0;
3492+
min-height: 32px;
3493+
padding: 6px 8px;
3494+
border: 1px solid var(--border);
3495+
border-radius: 7px;
3496+
background: var(--bg);
3497+
color: var(--fg);
3498+
font: inherit;
3499+
font-size: 13px;
3500+
cursor: pointer;
3501+
}
3502+
.model-select__trigger span {
3503+
min-width: 0;
3504+
overflow: hidden;
3505+
text-overflow: ellipsis;
3506+
white-space: nowrap;
3507+
}
3508+
.model-select__trigger svg {
3509+
flex: 0 0 auto;
3510+
color: var(--fg-faint);
3511+
}
3512+
.model-select__trigger:hover:not(:disabled) {
3513+
border-color: var(--fg-faint);
3514+
}
3515+
.model-select__trigger:focus-visible {
3516+
border-color: var(--accent);
3517+
outline: none;
3518+
box-shadow: 0 0 0 2px var(--accent-soft);
3519+
}
3520+
.model-select__trigger:disabled {
3521+
cursor: default;
3522+
opacity: 0.58;
3523+
}
3524+
.model-select__backdrop {
3525+
position: fixed;
3526+
inset: 0;
3527+
z-index: 92;
3528+
}
3529+
.model-select__menu {
3530+
position: fixed;
3531+
z-index: 93;
3532+
overflow-y: auto;
3533+
padding: 5px;
3534+
border: 1px solid var(--border);
3535+
border-radius: 8px;
3536+
background: var(--bg-elev);
3537+
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
3538+
}
3539+
.model-select__item {
3540+
--wails-draggable: no-drag;
3541+
display: flex;
3542+
align-items: center;
3543+
gap: 8px;
3544+
width: 100%;
3545+
min-width: 0;
3546+
padding: 7px 8px;
3547+
border: none;
3548+
border-radius: 6px;
3549+
background: transparent;
3550+
color: var(--fg-dim);
3551+
font: inherit;
3552+
font-family: var(--mono);
3553+
font-size: 12px;
3554+
text-align: left;
3555+
cursor: pointer;
3556+
}
3557+
.model-select__item span {
3558+
flex: 1;
3559+
min-width: 0;
3560+
overflow: hidden;
3561+
text-overflow: ellipsis;
3562+
white-space: nowrap;
3563+
}
3564+
.model-select__item svg {
3565+
flex: 0 0 auto;
3566+
}
3567+
.model-select__item:hover {
3568+
background: var(--bg-elev-2);
3569+
color: var(--fg);
3570+
}
3571+
.model-select__item--selected {
3572+
color: var(--accent);
3573+
}
34783574
.set-narrow {
34793575
width: 72px;
34803576
}
@@ -3537,6 +3633,9 @@ body {
35373633
display: flex;
35383634
gap: 6px;
35393635
}
3636+
.set-key .btn {
3637+
flex: 0 0 auto;
3638+
}
35403639
.set-rules {
35413640
margin-bottom: 10px;
35423641
}

0 commit comments

Comments
 (0)