Skip to content

Commit 9d96c33

Browse files
feat(desktop): redesign runtime selector
Co-authored-by: WUU Agent <305930189+wuu-agent[bot]@users.noreply.github.com>
1 parent da7bb81 commit 9d96c33

6 files changed

Lines changed: 1001 additions & 543 deletions

File tree

‎desktop/src/renderer/App.tsx‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,9 @@ export function App(): JSX.Element {
12501250
model: "",
12511251
effort: "",
12521252
});
1253+
// Switching the parent engine in the picker must not discard that engine's
1254+
// child model/effort choice when the user switches back within the draft.
1255+
const draftEngineRuntimeByID = useRef<Record<string, EngineRuntimeSelection>>({});
12531256
const [draftPermissionMode, setDraftPermissionMode] = useState<PermissionMode | "">("");
12541257
const draftEngineSeed = useRef<{ threadID?: string; done: boolean }>({
12551258
threadID: activeThreadID,
@@ -1277,18 +1280,24 @@ export function App(): JSX.Element {
12771280
draftEngineSeed.current.done = true;
12781281
setDraftEngine(remembered.engine);
12791282
setDraftEngineRuntime({ model: remembered.model, effort: remembered.effort });
1283+
draftEngineRuntimeByID.current[remembered.engine] = {
1284+
model: remembered.model,
1285+
effort: remembered.effort,
1286+
};
12801287
setDraftPermissionMode(remembered.engine === "wuu" ? "" : "unconfined");
12811288
}, [activeThreadID, engineInventory]);
12821289
const selectDraftEngine = useCallback((id: string) => {
1283-
const runtime = id === "wuu"
1284-
? { model: "", effort: "" }
1285-
: defaultEngineRuntimeSelection(
1286-
engineInventory?.engines.find((engine) => engine.id === id),
1287-
);
1290+
const runtime = draftEngineRuntimeByID.current[id]
1291+
?? (id === "wuu"
1292+
? { model: "", effort: "" }
1293+
: defaultEngineRuntimeSelection(
1294+
engineInventory?.engines.find((engine) => engine.id === id),
1295+
));
12881296
draftEngineSeed.current.done = true;
12891297
setDraftEngine(id);
12901298
setDraftPermissionMode(id === "wuu" ? "" : "unconfined");
12911299
setDraftEngineRuntime(runtime);
1300+
draftEngineRuntimeByID.current[id] = runtime;
12921301
writeDraftEngineMemory({ engine: id, ...runtime });
12931302
}, [engineInventory]);
12941303
useEffect(() => {
@@ -2881,15 +2890,21 @@ export function App(): JSX.Element {
28812890
engineEffort={effectiveEngineRuntime.effort}
28822891
onSelectEngine={selectDraftEngine}
28832892
onSelectEngineModel={(model, effort) => {
2884-
setDraftEngineRuntime({ model, effort });
2893+
const runtime = { model, effort };
2894+
setDraftEngineRuntime(runtime);
2895+
draftEngineRuntimeByID.current[effectiveEngine] = runtime;
28852896
// Only a new conversation writes the memory: for an existing thread
28862897
// the engine is already bound and the picker is locked.
28872898
if (!activeThread) {
28882899
writeDraftEngineMemory({ engine: effectiveEngine, model, effort });
28892900
}
28902901
}}
28912902
onSelectEngineEffort={(effort) => {
2892-
setDraftEngineRuntime((current) => ({ ...current, effort }));
2903+
setDraftEngineRuntime((current) => {
2904+
const runtime = { ...current, effort };
2905+
draftEngineRuntimeByID.current[effectiveEngine] = runtime;
2906+
return runtime;
2907+
});
28932908
if (!activeThread) {
28942909
writeDraftEngineMemory({
28952910
engine: effectiveEngine,

‎desktop/src/renderer/ComposerRuntimeMenus.test.tsx‎

Lines changed: 169 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, createRef } from "react";
1+
import { act, createRef, type ComponentProps } from "react";
22
import { createRoot, type Root } from "react-dom/client";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import type { InitializeResult } from "../shared/protocol";
@@ -31,7 +31,18 @@ describe("RuntimePicker", () => {
3131
onToggleMenu = vi.fn(),
3232
onSelectEffort = vi.fn(),
3333
onSelectModel = vi.fn(),
34-
anchorRef = createRef<HTMLDivElement>()
34+
anchorRef = createRef<HTMLDivElement>(),
35+
engineProps: Pick<
36+
ComponentProps<typeof RuntimePicker>,
37+
| "engines"
38+
| "activeEngine"
39+
| "engineLocked"
40+
| "engineModel"
41+
| "engineEffort"
42+
| "onSelectEngine"
43+
| "onSelectEngineModel"
44+
| "onSelectEngineEffort"
45+
> = {}
3546
): void {
3647
act(() => {
3748
root ??= createRoot(container);
@@ -46,6 +57,7 @@ describe("RuntimePicker", () => {
4657
onToggleMenu={onToggleMenu}
4758
onSelectModel={onSelectModel}
4859
onSelectEffort={onSelectEffort}
60+
{...engineProps}
4961
/>
5062
);
5163
});
@@ -89,45 +101,114 @@ describe("RuntimePicker", () => {
89101
expect(onToggleMenu).toHaveBeenCalledWith("model");
90102
});
91103

92-
it("opens the model panel directly with search, provider groups, and the effort slider", () => {
104+
it("opens as a compact summary and drills into the model list", () => {
93105
renderPicker("model", runtimeWithEffort());
94106

95107
const menu = document.querySelector<HTMLElement>(".codex-model-menu");
96108
expect(menu).not.toBeNull();
97109
// The intermediate main menu is gone.
98110
expect(document.querySelector(".codex-main-menu")).toBeNull();
99111

112+
expect(menu?.classList.contains("is-summary")).toBe(true);
113+
expect(menu?.textContent).toContain("Wuu");
114+
expect(menu?.textContent).toContain("work");
115+
expect(menu?.textContent).toContain("Claude Sonnet");
116+
expect(menu?.textContent).toContain("Medium");
117+
expect(menu?.querySelector(".select-menu-search input")).toBeNull();
118+
const effortSlider = menu?.querySelector<HTMLInputElement>('.codex-effort-slider input[type="range"]');
119+
expect(effortSlider?.value).toBe("2");
120+
expect(effortSlider?.getAttribute("aria-valuetext")).toBe("Medium");
121+
expect(menu?.querySelector(".codex-effort-slider")?.textContent).toBe("");
122+
123+
act(() => menu?.querySelector<HTMLButtonElement>(".runtime-panel-model")?.click());
124+
100125
const search = menu?.querySelector<HTMLInputElement>(".select-menu-search input");
101-
expect(search).not.toBeNull();
102126
expect(search?.placeholder).toBe("搜索模型");
103-
104-
const groupLabels = Array.from(menu?.querySelectorAll<HTMLElement>(".codex-model-group-label") ?? []);
105-
expect(groupLabels.map((label) => label.textContent)).toEqual(["work"]);
106127
const modelItems = Array.from(menu?.querySelectorAll<HTMLButtonElement>(".codex-model-item") ?? []);
107128
expect(modelItems.map((item) => item.textContent?.trim())).toEqual(["Claude Sonnet"]);
108129
expect(modelItems[0]?.getAttribute("aria-checked")).toBe("true");
130+
});
109131

110-
const slider = menu?.querySelector<HTMLInputElement>(".codex-effort-slider");
111-
expect(slider?.type).toBe("range");
112-
expect(slider?.min).toBe("0");
113-
expect(slider?.max).toBe("3");
114-
expect(slider?.value).toBe("2");
115-
expect(menu?.textContent).toContain("Medium");
132+
it("presents engines as the parent navigation for the selected engine's models", () => {
133+
const onSelectEngine = vi.fn();
134+
renderPicker(
135+
"model",
136+
runtimeWithEffort(),
137+
vi.fn(),
138+
vi.fn(),
139+
vi.fn(),
140+
createRef<HTMLDivElement>(),
141+
{
142+
engines: [
143+
{ id: "wuu", enabled: true, binary_ok: true },
144+
{
145+
id: "codex",
146+
enabled: true,
147+
binary_ok: true,
148+
models: [
149+
{
150+
id: "gpt-5.6-sol",
151+
display_name: "GPT-5.6-Sol",
152+
supported_efforts: ["low", "medium", "high"]
153+
}
154+
]
155+
}
156+
],
157+
activeEngine: "codex",
158+
engineModel: "gpt-5.6-sol",
159+
engineEffort: "low",
160+
onSelectEngine,
161+
onSelectEngineModel: vi.fn(),
162+
onSelectEngineEffort: vi.fn()
163+
}
164+
);
165+
166+
const engineContext = Array.from(document.querySelectorAll<HTMLButtonElement>(".runtime-panel-context button"))
167+
.find((button) => button.textContent?.includes("Codex"));
168+
act(() => engineContext?.click());
169+
const engineRail = document.querySelector<HTMLElement>('.runtime-engine-options[role="group"]');
170+
expect(engineRail?.getAttribute("aria-label")).toBe("运行引擎");
171+
expect(engineRail?.textContent).not.toContain("Agent");
172+
const engineChoices = Array.from(
173+
engineRail?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? []
174+
);
175+
expect(engineChoices.map((choice) => choice.querySelector(".runtime-engine-option-name")?.textContent)).toEqual([
176+
"Wuu",
177+
"Codex"
178+
]);
179+
expect(engineChoices[1]?.getAttribute("aria-checked")).toBe("true");
180+
act(() => engineChoices[0]?.click());
181+
expect(onSelectEngine).toHaveBeenCalledWith("wuu");
182+
});
183+
184+
it("keeps every engine visible when the current conversation locks engine switching", () => {
185+
renderPicker(
186+
"model",
187+
runtimeWithEffort(),
188+
vi.fn(),
189+
vi.fn(),
190+
vi.fn(),
191+
createRef<HTMLDivElement>(),
192+
{
193+
engines: [
194+
{ id: "wuu", enabled: true, binary_ok: true },
195+
{ id: "codex", enabled: true, binary_ok: true }
196+
],
197+
activeEngine: "codex",
198+
engineLocked: true
199+
}
200+
);
116201

117-
// The capsule is a bare pill: no level text on it — the heading above
118-
// live-shows the current level, and small dots mark the available stops.
119-
expect(menu?.querySelector(".codex-effort-marks")).toBeNull();
120-
expect(menu?.querySelector(".codex-effort-mark")).toBeNull();
121-
expect(menu?.querySelector(".codex-effort-current")?.textContent).toBe("Medium");
122-
expect(menu?.querySelectorAll(".codex-effort-stop")).toHaveLength(3);
123-
const wrap = menu?.querySelector<HTMLElement>(".codex-effort-slider-wrap");
124-
expect(wrap?.style.getPropertyValue("--effort-slider-fill")).toBe("75%");
125-
expect(wrap?.style.getPropertyValue("--effort-slider-pos")).toBe("75%");
126-
// The drag pearl sits on the current stop; mid-scale is not charged.
127-
const capsule = menu?.querySelector<HTMLElement>(".codex-effort-capsule");
128-
expect(capsule?.querySelector(".codex-effort-knob")).not.toBeNull();
129-
expect(capsule?.classList.contains("maxed")).toBe(false);
130-
expect(capsule?.querySelector(".codex-effort-capsule-sheen")).toBeNull();
202+
const engineContext = document.querySelector<HTMLButtonElement>(".runtime-panel-context button");
203+
act(() => engineContext?.click());
204+
const choices = Array.from(
205+
document.querySelectorAll<HTMLButtonElement>(".runtime-engine-option")
206+
);
207+
expect(choices.map((choice) => choice.querySelector(".runtime-engine-option-name")?.textContent)).toEqual([
208+
"Wuu",
209+
"Codex"
210+
]);
211+
expect(choices.every((choice) => choice.disabled)).toBe(true);
131212
});
132213

133214
it("builds permission labels in the active language", () => {
@@ -154,14 +235,15 @@ describe("RuntimePicker", () => {
154235
},
155236
);
156237

157-
it("hides the effort slider when the model does not expose reasoning levels", () => {
238+
it("hides effort choices when the model does not expose reasoning levels", () => {
158239
const initialized = runtimeWithEffort();
159240
initialized.variant = "";
160241
initialized.providers![0].models = [{ id: "claude-sonnet", display_name: "Claude Sonnet" }];
161242

162243
renderPicker("model", initialized);
163244

164245
expect(document.querySelector(".codex-effort-slider")).toBeNull();
246+
act(() => document.querySelector<HTMLButtonElement>(".runtime-panel-model")?.click());
165247
const menu = document.querySelector<HTMLElement>(".codex-model-menu");
166248
expect(menu?.querySelectorAll(".codex-model-item")).toHaveLength(1);
167249
});
@@ -174,6 +256,8 @@ describe("RuntimePicker", () => {
174256
];
175257
renderPicker("model", initialized);
176258

259+
act(() => document.querySelector<HTMLButtonElement>(".runtime-panel-model")?.click());
260+
177261
const search = document.querySelector<HTMLInputElement>(".select-menu-search input")!;
178262
const setSearchValue = (value: string): void => {
179263
const valueSetter = Object.getOwnPropertyDescriptor(
@@ -192,28 +276,64 @@ describe("RuntimePicker", () => {
192276
expect(document.querySelector(".composer-menu-empty")?.textContent).toBe("没有匹配的模型");
193277
});
194278

195-
it("previews the dragged effort in the heading and commits once on release", () => {
279+
it("makes every Wuu provider directly selectable without burying its models in one long list", () => {
280+
const initialized = runtimeWithEffort();
281+
initialized.provider = "tokenhub";
282+
initialized.model = "gpt-5.6-sol";
283+
initialized.providers = [
284+
{
285+
name: "deepseek",
286+
type: "openai-compatible",
287+
model: "deepseek-chat",
288+
models: [{ id: "deepseek-chat", display_name: "DeepSeek Chat" }]
289+
},
290+
{
291+
name: "tokenhub",
292+
type: "openai-compatible",
293+
model: "gpt-5.6-sol",
294+
models: [
295+
{ id: "gpt-5.6-sol", display_name: "GPT-5.6 Sol" },
296+
{ id: "gpt-5.6-terra", display_name: "GPT-5.6 Terra" },
297+
{ id: "gpt-5.6-luna", display_name: "GPT-5.6 Luna" }
298+
]
299+
}
300+
];
301+
302+
renderPicker("model", initialized);
303+
304+
const providerContext = Array.from(document.querySelectorAll<HTMLButtonElement>(".runtime-panel-context button"))
305+
.find((button) => button.textContent?.includes("tokenhub"));
306+
act(() => providerContext?.click());
307+
308+
const providerOptions = Array.from(document.querySelectorAll<HTMLButtonElement>(".runtime-provider-option"));
309+
expect(providerOptions.map((option) => option.textContent?.trim())).toEqual(["deepseek", "tokenhub"]);
310+
expect(providerOptions[1]?.getAttribute("aria-checked")).toBe("true");
311+
312+
act(() => providerOptions[0]?.click());
313+
expect(document.querySelector(".runtime-panel-context")?.textContent).toContain("deepseek");
314+
act(() => document.querySelector<HTMLButtonElement>(".runtime-panel-model")?.click());
315+
expect(document.querySelector(".codex-model-item-name")?.textContent).toBe("DeepSeek Chat");
316+
});
317+
318+
it("selects a discrete effort by dragging the unlabeled slider", () => {
196319
const onSelectEffort = vi.fn();
197320
renderPicker("model", runtimeWithEffort(), vi.fn(), onSelectEffort);
198321

199-
const slider = document.querySelector<HTMLInputElement>(".codex-effort-slider")!;
322+
const slider = document.querySelector<HTMLInputElement>('.codex-effort-slider input[type="range"]')!;
200323
act(() => {
201-
const valueSetter = Object.getOwnPropertyDescriptor(
202-
HTMLInputElement.prototype,
203-
"value",
204-
)?.set;
205-
valueSetter?.call(slider, "3");
324+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(slider, "3");
206325
slider.dispatchEvent(new Event("input", { bubbles: true }));
207-
slider.dispatchEvent(new Event("pointerup", { bubbles: true }));
208326
});
209327

328+
expect(document.querySelector(".runtime-panel-effort-value")?.textContent).toBe("High");
329+
expect(onSelectEffort).not.toHaveBeenCalled();
330+
331+
act(() => slider.dispatchEvent(new Event("pointerup", { bubbles: true })));
332+
210333
expect(onSelectEffort).toHaveBeenCalledTimes(1);
211334
expect(onSelectEffort).toHaveBeenCalledWith("high");
212-
expect(document.querySelector(".codex-effort-current")?.textContent).toBe("High");
213-
// Landing on the top level switches on the charged state.
214-
const capsule = document.querySelector(".codex-effort-capsule");
215-
expect(capsule?.classList.contains("maxed")).toBe(true);
216-
expect(capsule?.querySelector(".codex-effort-capsule-sheen")).not.toBeNull();
335+
expect(slider.value).toBe("3");
336+
expect(slider.getAttribute("aria-valuetext")).toBe("High");
217337
});
218338

219339
it("flips the model menu below the trigger when the window top has too little room", () => {
@@ -238,7 +358,7 @@ describe("RuntimePicker", () => {
238358
'[data-floating-menu-owner="codex-runtime"]'
239359
);
240360
expect(layer?.classList.contains("floating-menu-below")).toBe(true);
241-
expect(layer?.style.left).toBe("620px");
361+
expect(layer?.style.left).toBe("560px");
242362
expect(layer?.style.top).toBe("78px");
243363
expect(layer?.style.bottom).toBe("");
244364
expect(layer?.style.getPropertyValue("--floating-menu-available-height")).toBe(
@@ -269,17 +389,19 @@ describe("RuntimePicker", () => {
269389

270390
renderPicker("model", initialized, vi.fn(), vi.fn(), onSelectModel);
271391

392+
act(() => document.querySelector<HTMLButtonElement>(".runtime-panel-model")?.click());
393+
272394
const choices = Array.from(
273395
document.querySelectorAll<HTMLButtonElement>(".codex-model-menu .codex-model-item")
274396
);
275397
const modelB = choices.find((choice) => choice.textContent?.includes("Model B"))!;
276398
act(() => modelB?.click());
277399

278400
expect(onSelectModel).toHaveBeenCalledWith("work", "model-b", "medium");
279-
// Optimistic in-panel state: the clicked row highlights and the slider
280-
// snaps to the target model's default before the stream round-trip.
281-
expect(modelB.getAttribute("aria-checked")).toBe("true");
282-
const slider = document.querySelector<HTMLInputElement>(".codex-effort-slider");
283-
expect(slider?.value).toBe("1");
401+
// The picker returns to the compact summary and previews the target
402+
// model's own default before the stream round-trip.
403+
expect(document.querySelector(".runtime-panel-model-name")?.textContent).toBe("Model B");
404+
const selectedEffort = document.querySelector<HTMLInputElement>('.codex-effort-slider input[type="range"]');
405+
expect(selectedEffort?.getAttribute("aria-valuetext")).toBe("Medium");
284406
});
285407
});

0 commit comments

Comments
 (0)