Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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: [],
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
timestampFormat: "24-hour",
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
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 @@ -1387,6 +1387,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 @@ -3663,6 +3675,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 @@ -4477,6 +4512,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
Loading
Loading