Skip to content

Commit 41ddce8

Browse files
Chrono-byteclaude
authored andcommitted
feat(model-picker): port upstream sexy redesign with favorites and search
Port T3 Code upstream commit 66c326b ("Redesign model picker with favorites and search (pingdotgg#2153)") to MarCode. Replaces the nested Menu dropdown with a Popover composed of a vertical provider sidebar and content pane with tokenized fuzzy search, favorites, and keyboard-jump shortcuts 1-9 (held under the modifier key). MarCode-specific preservations: - Claude brand orange (text-[#d97757]) tint on the trigger and sidebar icons. - PROVIDER_OPTIONS keeps claudeAgent first; opencode and cursor remain non-selectable "Coming soon" sidebar entries via the new pickerSidebarBadge field. AVAILABLE_PROVIDER_OPTIONS filters them out of the selectable list; the sidebar still renders them with Clock3Icon + tooltip. - /model slash-command now opens the new picker instead of inserting text. - modelPicker.toggle keybinding clicks the picker trigger via the data-chat-provider-model-picker attribute. - keybindings + terminalOpen props forwarded so jump labels (\u22c31-\u22c39) render immediately when the modifier is held. - @marcode/contracts import path (upstream used @t3tools/contracts). - Opencode runtime stays deleted (kept coming-soon only in UI). Regression guards (service.notification-wiring, windowState.integration) remain green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0754390 commit 41ddce8

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
@@ -1382,6 +1382,18 @@ function dispatchChatNewShortcut(): void {
13821382
);
13831383
}
13841384

1385+
function releaseModShortcut(key?: string): void {
1386+
window.dispatchEvent(
1387+
new KeyboardEvent("keyup", {
1388+
key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"),
1389+
metaKey: false,
1390+
ctrlKey: false,
1391+
bubbles: true,
1392+
cancelable: true,
1393+
}),
1394+
);
1395+
}
1396+
13851397
async function triggerChatNewShortcutUntilPath(
13861398
router: ReturnType<typeof getRouter>,
13871399
predicate: (pathname: string) => boolean,
@@ -3658,6 +3670,29 @@ describe("ChatView timeline estimator parity (full app)", () => {
36583670
node: { type: "identifier", name: "terminalFocus" },
36593671
},
36603672
},
3673+
{
3674+
command: "thread.jump.1",
3675+
shortcut: {
3676+
key: "1",
3677+
metaKey: true,
3678+
ctrlKey: false,
3679+
shiftKey: false,
3680+
altKey: false,
3681+
modKey: false,
3682+
},
3683+
},
3684+
{
3685+
command: "modelPicker.jump.1",
3686+
shortcut: {
3687+
key: "1",
3688+
metaKey: true,
3689+
ctrlKey: false,
3690+
shiftKey: false,
3691+
altKey: false,
3692+
modKey: false,
3693+
},
3694+
whenAst: { type: "identifier", name: "modelPickerOpen" },
3695+
},
36613696
],
36623697
};
36633698
},
@@ -4472,6 +4507,208 @@ describe("ChatView timeline estimator parity (full app)", () => {
44724507
}
44734508
});
44744509

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

0 commit comments

Comments
 (0)