Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c6048dd
Redesign model picker with favorites and search
Chrono-byte Apr 18, 2026
bbf12b3
Fail test if sidebar provider button missing
Chrono-byte Apr 18, 2026
137723d
Add aria-label to Favorites button
Chrono-byte Apr 18, 2026
687470d
Remove modelOptionsByProvider prop from ModelPicker
Chrono-byte Apr 18, 2026
22da939
Use typed provider/model in settings favorites
Chrono-byte Apr 18, 2026
2a209d7
Exclude favorited models from all models list
Chrono-byte Apr 18, 2026
d1057fb
Add accessibility attributes to model picker
Chrono-byte Apr 18, 2026
55c46c1
Show favorites when provider is all or favorites
Chrono-byte Apr 18, 2026
e690645
Introduce ClientSettingsPatch and refine settings types
Chrono-byte Apr 18, 2026
13e57b1
Use provider label in ProviderModelPicker test
Chrono-byte Apr 18, 2026
05b83ad
Clear client settings localStorage in test
Chrono-byte Apr 18, 2026
3f21091
remove codex bug file.
Chrono-byte Apr 18, 2026
b6b5e51
Consolidate provider icons and options
Chrono-byte Apr 18, 2026
3f33982
Use scoped queries in ProviderModelPicker tests
Chrono-byte Apr 18, 2026
8b5abce
Fix ModelPickerSidebar to disable provider buttons when missing from …
Chrono-byte Apr 18, 2026
3952a47
Mock env runtime and reset local API in tests
Chrono-byte Apr 18, 2026
47032ad
Expand environment runtime mock in tests
Chrono-byte Apr 18, 2026
6e0297d
Add keyboard-driven model picker favorites
juliusmarminge Apr 19, 2026
5224a63
Add favorites-aware model picker combobox
juliusmarminge Apr 19, 2026
f35dcaf
Update model picker favorites and shortcut handling
juliusmarminge Apr 19, 2026
15f55e9
Mark Cursor as new in the model picker
juliusmarminge Apr 19, 2026
fc65ff8
Simplify model picker provider icon styling
juliusmarminge Apr 19, 2026
28445c5
Stabilize shortcut modifier state updates
juliusmarminge Apr 19, 2026
0a52512
Format shortcut modifier state test helper
juliusmarminge Apr 19, 2026
faf636f
Separate opencode model labels from provider names
juliusmarminge Apr 19, 2026
cc8004f
Remove obsolete .codex file
juliusmarminge Apr 19, 2026
958b79d
Move model picker open state to its own store
juliusmarminge Apr 20, 2026
d3119a9
rm react-scan
juliusmarminge Apr 20, 2026
ad3f024
Fall back to the active provider model in the picker
juliusmarminge Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .codex
Empty file.
1 change: 1 addition & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
confirmThreadArchive: true,
confirmThreadDelete: false,
diffWordWrap: true,
favorites: [],
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");
}),
);

Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
KeybindingShortcut,
KeybindingWhenNode,
MAX_KEYBINDINGS_COUNT,
MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
MAX_WHEN_EXPRESSION_DEPTH,
ResolvedKeybindingRule,
ResolvedKeybindingsConfig,
Expand Down Expand Up @@ -65,13 +66,19 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
{ key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
{ key: "mod+o", command: "editor.openFavorite" },
{ key: "mod+shift+[", command: "thread.previous" },
{ key: "mod+shift+]", command: "thread.next" },
...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
key: `mod+${index + 1}`,
command,
})),
...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
key: `mod+${index + 1}`,
command,
when: "modelPickerOpen",
})),
];

function normalizeKeyToken(token: string): string {
Expand Down
1 change: 1 addition & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<meta name="theme-color" content="#161616" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug tool react-scan committed to production HTML

High Severity

The react-scan development debugging script is loaded from unpkg CDN in the production index.html. This tool renders visual overlays highlighting React re-renders and adds significant performance overhead. It will load for every user on every page load, degrading performance and showing debug UI in production.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 28445c5. Configure here.

<script>
(() => {
const LIGHT_BACKGROUND = "#ffffff";
Expand Down
32 changes: 29 additions & 3 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,49 @@ import { useNavigate } from "@tanstack/react-router";

import ThreadSidebar from "./Sidebar";
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
import {
clearShortcutModifierState,
syncShortcutModifierStateFromKeyboardEvent,
} from "../shortcutModifierState";

const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;

export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
syncShortcutModifierStateFromKeyboardEvent(event);
};
const onWindowKeyUp = (event: KeyboardEvent) => {
syncShortcutModifierStateFromKeyboardEvent(event);
};
const onWindowBlur = () => {
clearShortcutModifierState();
};

window.addEventListener("keydown", onWindowKeyDown, true);
window.addEventListener("keyup", onWindowKeyUp, true);
window.addEventListener("blur", onWindowBlur);

return () => {
window.removeEventListener("keydown", onWindowKeyDown, true);
window.removeEventListener("keyup", onWindowKeyUp, true);
window.removeEventListener("blur", onWindowBlur);
};
}, []);

useEffect(() => {
const onMenuAction = window.desktopBridge?.onMenuAction;
if (typeof onMenuAction !== "function") {
return;
}

const unsubscribe = onMenuAction((action) => {
if (action !== "open-settings") return;
void navigate({ to: "/settings" });
if (action === "open-settings") {
void navigate({ to: "/settings" });
}
});

return () => {
Expand Down
237 changes: 237 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,18 @@ function dispatchChatNewShortcut(): void {
);
}

function releaseModShortcut(key?: string): void {
window.dispatchEvent(
new KeyboardEvent("keyup", {
key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"),
metaKey: false,
ctrlKey: false,
bubbles: true,
cancelable: true,
}),
);
}

async function triggerChatNewShortcutUntilPath(
router: ReturnType<typeof getRouter>,
predicate: (pathname: string) => boolean,
Expand Down Expand Up @@ -4006,6 +4018,29 @@ describe("ChatView timeline estimator parity (full app)", () => {
node: { type: "identifier", name: "terminalFocus" },
},
},
{
command: "thread.jump.1",
shortcut: {
key: "1",
metaKey: true,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: false,
},
},
{
command: "modelPicker.jump.1",
shortcut: {
key: "1",
metaKey: true,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: false,
},
whenAst: { type: "identifier", name: "modelPickerOpen" },
},
],
};
},
Expand Down Expand Up @@ -5520,6 +5555,208 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("opens the model picker when selecting /model", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-model-command-target" as MessageId,
targetText: "model command thread",
}),
});

try {
await waitForComposerEditor();
await page.getByTestId("composer-editor").fill("/mod");

const menuItem = await waitForComposerMenuItem("slash:model");
await menuItem.click();

await vi.waitFor(() => {
expect(document.querySelector(".model-picker-list")).not.toBeNull();
expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model");
});

await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});

await vi.waitFor(() => {
const searchInput = document.querySelector<HTMLInputElement>(
'input[placeholder="Search models..."]',
);
expect(searchInput).not.toBeNull();
expect(document.activeElement).toBe(searchInput);
});
} finally {
await mounted.cleanup();
}
});

it("toggles the model picker and shows jump keys immediately from the shortcut", async () => {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId,
targetText: "model picker shortcut thread",
});
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: {
...snapshot,
projects: snapshot.projects.map((project) =>
project.id === PROJECT_ID
? Object.assign({}, project, {
defaultModelSelection: { provider: "codex", model: "gpt-5.4" },
})
: project,
),
threads: snapshot.threads.map((thread) =>
thread.id === THREAD_ID
? Object.assign({}, thread, {
modelSelection: { provider: "codex", model: "gpt-5.4" },
})
: thread,
),
},
configureFixture: (nextFixture) => {
nextFixture.serverConfig = {
...nextFixture.serverConfig,
keybindings: [
{
command: "modelPicker.toggle",
shortcut: {
key: "m",
metaKey: false,
ctrlKey: true,
shiftKey: true,
altKey: false,
modKey: false,
},
whenAst: {
type: "not",
node: { type: "identifier", name: "terminalFocus" },
},
},
{
command: "thread.jump.1",
shortcut: {
key: "1",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
modKey: false,
},
},
{
command: "modelPicker.jump.1",
shortcut: {
key: "1",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
modKey: false,
},
whenAst: { type: "identifier", name: "modelPickerOpen" },
},
],
providers: [
{
...nextFixture.serverConfig.providers[0]!,
provider: "codex",
models: [
{
slug: "gpt-5.1-codex-max",
name: "GPT-5.1 Codex Max",
isCustom: false,
capabilities: {
supportsFastMode: true,
supportsThinkingToggle: false,
reasoningEffortLevels: [],
promptInjectedEffortLevels: [],
contextWindowOptions: [],
},
},
{
slug: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
isCustom: false,
capabilities: {
supportsFastMode: true,
supportsThinkingToggle: false,
reasoningEffortLevels: [],
promptInjectedEffortLevels: [],
contextWindowOptions: [],
},
},
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
capabilities: {
supportsFastMode: true,
supportsThinkingToggle: false,
reasoningEffortLevels: [],
promptInjectedEffortLevels: [],
contextWindowOptions: [],
},
},
],
},
],
};
},
});

try {
await waitForServerConfigToApply();
await waitForComposerEditor();

const initialPath = mounted.router.state.location.pathname;
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "m",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);

await vi.waitFor(() => {
expect(document.querySelector(".model-picker-list")).not.toBeNull();
});

const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1";
await vi.waitFor(() => {
expect(
Array.from(
document.querySelectorAll<HTMLElement>('.model-picker-list [data-slot="kbd"]'),
).some((element) => element.textContent?.trim() === jumpLabel),
).toBe(true);
});
expect(mounted.router.state.location.pathname).toBe(initialPath);

window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "m",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);

await vi.waitFor(() => {
expect(document.querySelector(".model-picker-list")).toBeNull();
});
} finally {
releaseModShortcut("Control");
await mounted.cleanup();
}
});

it("shows a tooltip with the skill description when hovering a skill pill", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2233,6 +2233,7 @@ export default function ChatView(props: ChatViewProps) {
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(terminalState.terminalOpen),
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
};

const command = resolveShortcutCommand(event, keybindings, {
Expand Down Expand Up @@ -2282,6 +2283,13 @@ export default function ChatView(props: ChatViewProps) {
return;
}

if (command === "modelPicker.toggle") {
event.preventDefault();
event.stopPropagation();
composerRef.current?.toggleModelPicker();
return;
}

const scriptId = projectScriptIdFromCommand(command);
if (!scriptId || !activeProject) return;
const script = activeProject.scripts.find((entry) => entry.id === scriptId);
Expand Down Expand Up @@ -3341,6 +3349,8 @@ export default function ChatView(props: ChatViewProps) {
activeThreadActivities={activeThread?.activities}
resolvedTheme={resolvedTheme}
settings={settings}
keybindings={keybindings}
terminalOpen={Boolean(terminalState.terminalOpen)}
gitCwd={gitCwd}
promptRef={promptRef}
composerImagesRef={composerImagesRef}
Expand Down
Loading
Loading