Skip to content

Commit 26c242c

Browse files
authored
Merge pull request #47 from tyulyukov/marcode/port-t3-model-picker
feat(model-picker): port upstream redesign with favorites and search
2 parents adf4747 + ead5008 commit 26c242c

40 files changed

Lines changed: 2716 additions & 583 deletions

apps/desktop/src/clientPersistence.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
5252
confirmThreadArchive: true,
5353
confirmThreadDelete: false,
5454
diffWordWrap: true,
55+
favorites: [],
5556
sidebarProjectSortOrder: "manual",
5657
sidebarThreadSortOrder: "created_at",
5758
timestampFormat: "24-hour",

apps/server/src/keybindings.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
192192
assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
193193
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
194194
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
195+
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
196+
assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
197+
assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");
195198
}),
196199
);
197200

apps/server/src/keybindings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
KeybindingShortcut,
1414
KeybindingWhenNode,
1515
MAX_KEYBINDINGS_COUNT,
16+
MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
1617
MAX_WHEN_EXPRESSION_DEPTH,
1718
ResolvedKeybindingRule,
1819
ResolvedKeybindingsConfig,
@@ -65,13 +66,19 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
6566
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
6667
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
6768
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
69+
{ key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
6870
{ key: "mod+o", command: "editor.openFavorite" },
6971
{ key: "mod+shift+[", command: "thread.previous" },
7072
{ key: "mod+shift+]", command: "thread.next" },
7173
...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
7274
key: `mod+${index + 1}`,
7375
command,
7476
})),
77+
...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
78+
key: `mod+${index + 1}`,
79+
command,
80+
when: "modelPickerOpen",
81+
})),
7582
];
7683

7784
function normalizeKeyToken(token: string): string {

apps/web/src/components/AppSidebarLayout.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,49 @@ import { useNavigate } from "@tanstack/react-router";
33

44
import ThreadSidebar from "./Sidebar";
55
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
6+
import {
7+
clearShortcutModifierState,
8+
syncShortcutModifierStateFromKeyboardEvent,
9+
} from "../shortcutModifierState";
610

711
const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
812
const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
913
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
10-
1114
export function AppSidebarLayout({ children }: { children: ReactNode }) {
1215
const navigate = useNavigate();
1316

17+
useEffect(() => {
18+
const onWindowKeyDown = (event: KeyboardEvent) => {
19+
syncShortcutModifierStateFromKeyboardEvent(event);
20+
};
21+
const onWindowKeyUp = (event: KeyboardEvent) => {
22+
syncShortcutModifierStateFromKeyboardEvent(event);
23+
};
24+
const onWindowBlur = () => {
25+
clearShortcutModifierState();
26+
};
27+
28+
window.addEventListener("keydown", onWindowKeyDown, true);
29+
window.addEventListener("keyup", onWindowKeyUp, true);
30+
window.addEventListener("blur", onWindowBlur);
31+
32+
return () => {
33+
window.removeEventListener("keydown", onWindowKeyDown, true);
34+
window.removeEventListener("keyup", onWindowKeyUp, true);
35+
window.removeEventListener("blur", onWindowBlur);
36+
};
37+
}, []);
38+
1439
useEffect(() => {
1540
const onMenuAction = window.desktopBridge?.onMenuAction;
1641
if (typeof onMenuAction !== "function") {
1742
return;
1843
}
1944

2045
const unsubscribe = onMenuAction((action) => {
21-
if (action !== "open-settings") return;
22-
void navigate({ to: "/settings" });
46+
if (action === "open-settings") {
47+
void navigate({ to: "/settings" });
48+
}
2349
});
2450

2551
return () => {

apps/web/src/components/ChatView.browser.tsx

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,18 @@ function dispatchChatNewShortcut(): void {
13871387
);
13881388
}
13891389

1390+
function releaseModShortcut(key?: string): void {
1391+
window.dispatchEvent(
1392+
new KeyboardEvent("keyup", {
1393+
key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"),
1394+
metaKey: false,
1395+
ctrlKey: false,
1396+
bubbles: true,
1397+
cancelable: true,
1398+
}),
1399+
);
1400+
}
1401+
13901402
async function triggerChatNewShortcutUntilPath(
13911403
router: ReturnType<typeof getRouter>,
13921404
predicate: (pathname: string) => boolean,
@@ -3663,6 +3675,29 @@ describe("ChatView timeline estimator parity (full app)", () => {
36633675
node: { type: "identifier", name: "terminalFocus" },
36643676
},
36653677
},
3678+
{
3679+
command: "thread.jump.1",
3680+
shortcut: {
3681+
key: "1",
3682+
metaKey: true,
3683+
ctrlKey: false,
3684+
shiftKey: false,
3685+
altKey: false,
3686+
modKey: false,
3687+
},
3688+
},
3689+
{
3690+
command: "modelPicker.jump.1",
3691+
shortcut: {
3692+
key: "1",
3693+
metaKey: true,
3694+
ctrlKey: false,
3695+
shiftKey: false,
3696+
altKey: false,
3697+
modKey: false,
3698+
},
3699+
whenAst: { type: "identifier", name: "modelPickerOpen" },
3700+
},
36663701
],
36673702
};
36683703
},
@@ -4477,6 +4512,208 @@ describe("ChatView timeline estimator parity (full app)", () => {
44774512
}
44784513
});
44794514

4515+
it("opens the model picker when selecting /model", async () => {
4516+
const mounted = await mountChatView({
4517+
viewport: DEFAULT_VIEWPORT,
4518+
snapshot: createSnapshotForTargetUser({
4519+
targetMessageId: "msg-user-model-command-target" as MessageId,
4520+
targetText: "model command thread",
4521+
}),
4522+
});
4523+
4524+
try {
4525+
await waitForComposerEditor();
4526+
await page.getByTestId("composer-editor").fill("/mod");
4527+
4528+
const menuItem = await waitForComposerMenuItem("slash:model");
4529+
await menuItem.click();
4530+
4531+
await vi.waitFor(() => {
4532+
expect(document.querySelector(".model-picker-list")).not.toBeNull();
4533+
expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model");
4534+
});
4535+
4536+
await new Promise<void>((resolve) => {
4537+
requestAnimationFrame(() => {
4538+
requestAnimationFrame(() => resolve());
4539+
});
4540+
});
4541+
4542+
await vi.waitFor(() => {
4543+
const searchInput = document.querySelector<HTMLInputElement>(
4544+
'input[placeholder="Search models..."]',
4545+
);
4546+
expect(searchInput).not.toBeNull();
4547+
expect(document.activeElement).toBe(searchInput);
4548+
});
4549+
} finally {
4550+
await mounted.cleanup();
4551+
}
4552+
});
4553+
4554+
it("toggles the model picker and shows jump keys immediately from the shortcut", async () => {
4555+
const snapshot = createSnapshotForTargetUser({
4556+
targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId,
4557+
targetText: "model picker shortcut thread",
4558+
});
4559+
const mounted = await mountChatView({
4560+
viewport: DEFAULT_VIEWPORT,
4561+
snapshot: {
4562+
...snapshot,
4563+
projects: snapshot.projects.map((project) =>
4564+
project.id === PROJECT_ID
4565+
? Object.assign({}, project, {
4566+
defaultModelSelection: { provider: "codex", model: "gpt-5.4" },
4567+
})
4568+
: project,
4569+
),
4570+
threads: snapshot.threads.map((thread) =>
4571+
thread.id === THREAD_ID
4572+
? Object.assign({}, thread, {
4573+
modelSelection: { provider: "codex", model: "gpt-5.4" },
4574+
})
4575+
: thread,
4576+
),
4577+
},
4578+
configureFixture: (nextFixture) => {
4579+
nextFixture.serverConfig = {
4580+
...nextFixture.serverConfig,
4581+
keybindings: [
4582+
{
4583+
command: "modelPicker.toggle",
4584+
shortcut: {
4585+
key: "m",
4586+
metaKey: false,
4587+
ctrlKey: true,
4588+
shiftKey: true,
4589+
altKey: false,
4590+
modKey: false,
4591+
},
4592+
whenAst: {
4593+
type: "not",
4594+
node: { type: "identifier", name: "terminalFocus" },
4595+
},
4596+
},
4597+
{
4598+
command: "thread.jump.1",
4599+
shortcut: {
4600+
key: "1",
4601+
metaKey: false,
4602+
ctrlKey: true,
4603+
shiftKey: false,
4604+
altKey: false,
4605+
modKey: false,
4606+
},
4607+
},
4608+
{
4609+
command: "modelPicker.jump.1",
4610+
shortcut: {
4611+
key: "1",
4612+
metaKey: false,
4613+
ctrlKey: true,
4614+
shiftKey: false,
4615+
altKey: false,
4616+
modKey: false,
4617+
},
4618+
whenAst: { type: "identifier", name: "modelPickerOpen" },
4619+
},
4620+
],
4621+
providers: [
4622+
{
4623+
...nextFixture.serverConfig.providers[0]!,
4624+
provider: "codex",
4625+
models: [
4626+
{
4627+
slug: "gpt-5.1-codex-max",
4628+
name: "GPT-5.1 Codex Max",
4629+
isCustom: false,
4630+
capabilities: {
4631+
supportsFastMode: true,
4632+
supportsThinkingToggle: false,
4633+
reasoningEffortLevels: [],
4634+
promptInjectedEffortLevels: [],
4635+
contextWindowOptions: [],
4636+
},
4637+
},
4638+
{
4639+
slug: "gpt-5.3-codex",
4640+
name: "GPT-5.3 Codex",
4641+
isCustom: false,
4642+
capabilities: {
4643+
supportsFastMode: true,
4644+
supportsThinkingToggle: false,
4645+
reasoningEffortLevels: [],
4646+
promptInjectedEffortLevels: [],
4647+
contextWindowOptions: [],
4648+
},
4649+
},
4650+
{
4651+
slug: "gpt-5.4",
4652+
name: "GPT-5.4",
4653+
isCustom: false,
4654+
capabilities: {
4655+
supportsFastMode: true,
4656+
supportsThinkingToggle: false,
4657+
reasoningEffortLevels: [],
4658+
promptInjectedEffortLevels: [],
4659+
contextWindowOptions: [],
4660+
},
4661+
},
4662+
],
4663+
},
4664+
],
4665+
};
4666+
},
4667+
});
4668+
4669+
try {
4670+
await waitForServerConfigToApply();
4671+
await waitForComposerEditor();
4672+
4673+
const initialPath = mounted.router.state.location.pathname;
4674+
window.dispatchEvent(
4675+
new KeyboardEvent("keydown", {
4676+
key: "m",
4677+
ctrlKey: true,
4678+
shiftKey: true,
4679+
bubbles: true,
4680+
cancelable: true,
4681+
}),
4682+
);
4683+
4684+
await vi.waitFor(() => {
4685+
expect(document.querySelector(".model-picker-list")).not.toBeNull();
4686+
});
4687+
4688+
const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1";
4689+
await vi.waitFor(() => {
4690+
expect(
4691+
Array.from(
4692+
document.querySelectorAll<HTMLElement>('.model-picker-list [data-slot="kbd"]'),
4693+
).some((element) => element.textContent?.trim() === jumpLabel),
4694+
).toBe(true);
4695+
});
4696+
expect(mounted.router.state.location.pathname).toBe(initialPath);
4697+
4698+
window.dispatchEvent(
4699+
new KeyboardEvent("keydown", {
4700+
key: "m",
4701+
ctrlKey: true,
4702+
shiftKey: true,
4703+
bubbles: true,
4704+
cancelable: true,
4705+
}),
4706+
);
4707+
4708+
await vi.waitFor(() => {
4709+
expect(document.querySelector(".model-picker-list")).toBeNull();
4710+
});
4711+
} finally {
4712+
releaseModShortcut("Control");
4713+
await mounted.cleanup();
4714+
}
4715+
});
4716+
44804717
it("shows a tooltip with the skill description when hovering a skill pill", async () => {
44814718
const mounted = await mountChatView({
44824719
viewport: DEFAULT_VIEWPORT,

0 commit comments

Comments
 (0)