Skip to content
Closed
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
2 changes: 2 additions & 0 deletions KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
{ "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
{ "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
{ "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
{ "key": "mod+b", "command": "sidebar.toggle", "when": "!terminalFocus" },
{ "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
{ "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },
{ "key": "mod+o", "command": "editor.openFavorite" }
Expand Down Expand Up @@ -51,6 +52,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
- `terminal.new`: create new terminal (in focused terminal context by default)
- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
- `sidebar.toggle`: toggle the main thread/project sidebar (default `mod+b`, scoped to `!terminalFocus`)
- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
- `editor.openFavorite`: open current project/worktree in the last-used editor
- `script.{id}.run`: run a project script by id (for example `script.test.run`)
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,21 @@ it.layer(TestLayer)("git integration", (it) => {
yield* git(source, ["checkout", defaultBranch]);
yield* git(source, ["branch", "-D", featureBranch]);

yield* (yield* GitCore).checkoutBranch({
const realGitCore = yield* GitCore;
const core = yield* makeIsolatedGitCore((input) => {
if (input.args[0] === "fetch") {
return Effect.succeed({
code: 0,
stdout: "",
stderr: "",
stdoutTruncated: false,
stderrTruncated: false,
});
}
return realGitCore.execute(input);
});

yield* core.checkoutBranch({
cwd: source,
branch: `${remoteName}/${featureBranch}`,
});
Expand Down
14 changes: 8 additions & 6 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,18 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("ships configurable thread navigation defaults", () =>
it.effect("ships configurable sidebar and thread navigation defaults", () =>
Effect.sync(() => {
const defaultsByCommand = new Map(
DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const),
DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding] as const),
);

assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
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("sidebar.toggle")?.key, "mod+b");
assert.equal(defaultsByCommand.get("sidebar.toggle")?.when, "!terminalFocus");
assert.equal(defaultsByCommand.get("thread.previous")?.key, "mod+shift+[");
assert.equal(defaultsByCommand.get("thread.next")?.key, "mod+shift+]");
assert.equal(defaultsByCommand.get("thread.jump.1")?.key, "mod+1");
assert.equal(defaultsByCommand.get("thread.jump.9")?.key, "mod+9");
}),
);

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
{ key: "mod+b", command: "sidebar.toggle", when: "!terminalFocus" },
{ 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" },
Expand Down
50 changes: 48 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,58 @@
import { ThreadId } from "@t3tools/contracts";
import { useEffect, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useNavigate, useParams } from "@tanstack/react-router";

import { resolveShortcutCommand } from "../keybindings";
import { isTerminalFocused } from "../lib/terminalFocus";
import { useServerKeybindings } from "../rpc/serverState";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import ThreadSidebar from "./Sidebar";
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
import { Sidebar, SidebarProvider, SidebarRail, useSidebar } from "./ui/sidebar";

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;

function AppSidebarKeyboardShortcuts() {
const { toggleSidebar } = useSidebar();
const keybindings = useServerKeybindings();
const routeThreadId = useParams({
strict: false,
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
});
const terminalOpen = useTerminalStateStore((state) =>
routeThreadId
? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
: false,
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;

const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
terminalOpen,
},
});
if (command !== "sidebar.toggle") return;
if (event.repeat) return;

event.preventDefault();
event.stopPropagation();
toggleSidebar();
};

window.addEventListener("keydown", onWindowKeyDown);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
};
}, [keybindings, terminalOpen, toggleSidebar]);

return null;
}

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

Expand All @@ -29,6 +74,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {

return (
<SidebarProvider defaultOpen>
<AppSidebarKeyboardShortcuts />
<Sidebar
side="left"
collapsible="offcanvas"
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import { isMacPlatform } from "../lib/utils";
import { __resetNativeApiForTests } from "../nativeApi";
import { getRouter } from "../router";
import { getServerConfig } from "../rpc/serverState";
import { useStore } from "../store";
import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
import { estimateTimelineMessageHeight } from "./timelineHeight";
Expand Down Expand Up @@ -865,6 +866,7 @@
expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe(
true,
);
expect(getServerConfig()?.keybindings).toEqual(fixture.serverConfig.keybindings);
},
{ timeout: 8_000, interval: 16 },
);
Expand All @@ -885,6 +887,36 @@
);
}

function dispatchSidebarToggleShortcut(): void {
const useMetaForMod = isMacPlatform(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "b",
metaKey: useMetaForMod,
ctrlKey: !useMetaForMod,
bubbles: true,
cancelable: true,
}),
);
}

async function triggerSidebarToggleShortcutUntilState(
sidebarRoot: HTMLElement,
expectedState: "expanded" | "collapsed",
errorMessage: string,
): Promise<void> {
const deadline = Date.now() + 8_000;
while (Date.now() < deadline) {
dispatchSidebarToggleShortcut();
await waitForLayout();
if (sidebarRoot.dataset.state === expectedState) {
return;
}
}

throw new Error(`${errorMessage} Last state: ${sidebarRoot.dataset.state ?? "<missing>"}`);

Check failure on line 917 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

[chromium] src/components/ChatView.browser.tsx > ChatView timeline estimator parity (full app) > toggles the main left sidebar from the global sidebar.toggle shortcut

Error: Sidebar should collapse from the global sidebar.toggle shortcut. Last state: expanded ❯ triggerSidebarToggleShortcutUntilState src/components/ChatView.browser.tsx:917:8 ❯ src/components/ChatView.browser.tsx:2563:6
}
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.

Toggle retry loop may oscillate and never converge

Low Severity

triggerSidebarToggleShortcutUntilState dispatches a toggle shortcut on every loop iteration. Unlike the triggerChatNewShortcutUntilPath pattern it was modeled after (where each dispatch cumulatively creates a new thread), each sidebar toggle dispatch reverses the previous one. If the state check after waitForLayout ever misses the updated DOM state due to a rendering delay, the next iteration dispatches another toggle, flipping the sidebar back — creating an oscillation that can never converge to the target state and will time out after 8 seconds.

Fix in Cursor Fix in Web


async function triggerChatNewShortcutUntilPath(
router: ReturnType<typeof getRouter>,
predicate: (pathname: string) => boolean,
Expand Down Expand Up @@ -2487,6 +2519,63 @@
await mounted.cleanup();
}
});

it("toggles the main left sidebar from the global sidebar.toggle shortcut", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-sidebar-shortcut-test" as MessageId,
targetText: "sidebar shortcut test",
}),
configureFixture: (nextFixture) => {
nextFixture.serverConfig = {
...nextFixture.serverConfig,
keybindings: [
{
command: "sidebar.toggle",
shortcut: {
key: "b",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: true,
},
whenAst: {
type: "not",
node: { type: "identifier", name: "terminalFocus" },
},
},
],
};
},
});

try {
await waitForServerConfigToApply();
const sidebarRoot = await waitForElement(
() => document.querySelector<HTMLElement>('[data-slot="sidebar"][data-side="left"]'),
"Unable to find the main left sidebar root.",
);

expect(sidebarRoot.dataset.state).toBe("expanded");

await triggerSidebarToggleShortcutUntilState(
sidebarRoot,
"collapsed",
"Sidebar should collapse from the global sidebar.toggle shortcut.",
);

await triggerSidebarToggleShortcutUntilState(
sidebarRoot,
"expanded",
"Sidebar should expand from the global sidebar.toggle shortcut.",
);
} finally {
await mounted.cleanup();
}
});

it("creates a fresh draft after the previous draft thread is promoted", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isChatNewLocalShortcut,
isDiffToggleShortcut,
isOpenFavoriteEditorShortcut,
isSidebarToggleShortcut,
isTerminalClearShortcut,
isTerminalCloseShortcut,
isTerminalNewShortcut,
Expand Down Expand Up @@ -101,6 +102,11 @@ const DEFAULT_BINDINGS = compile([
command: "diff.toggle",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
{
shortcut: modShortcut("b"),
command: "sidebar.toggle",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
{ shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
{ shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
{ shortcut: modShortcut("o"), command: "editor.openFavorite" },
Expand Down Expand Up @@ -249,6 +255,14 @@ describe("shortcutLabelForCommand", () => {
it("returns effective labels for non-terminal commands", () => {
assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"),
"⌘B",
);
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "Linux"),
"Ctrl+B",
);
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
"Ctrl+O",
Expand Down Expand Up @@ -396,6 +410,21 @@ describe("chat/editor shortcuts", () => {
}),
);
});

it("matches sidebar.toggle outside terminal focus", () => {
assert.isTrue(
isSidebarToggleShortcut(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { terminalFocus: false },
}),
);
assert.isFalse(
isSidebarToggleShortcut(event({ key: "b", ctrlKey: true }), DEFAULT_BINDINGS, {
platform: "Linux",
context: { terminalFocus: true },
}),
);
});
});

describe("cross-command precedence", () => {
Expand Down Expand Up @@ -472,6 +501,22 @@ describe("resolveShortcutCommand", () => {
);
});

it("matches sidebar.toggle with the default when context", () => {
assert.strictEqual(
resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { terminalFocus: false },
}),
"sidebar.toggle",
);
assert.isNull(
resolveShortcutCommand(event({ key: "b", ctrlKey: true }), DEFAULT_BINDINGS, {
platform: "Linux",
context: { terminalFocus: true },
}),
);
});

it("matches bracket shortcuts using the physical key code", () => {
assert.strictEqual(
resolveShortcutCommand(
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ export function isDiffToggleShortcut(
return matchesCommandShortcut(event, keybindings, "diff.toggle", options);
}

export function isSidebarToggleShortcut(
event: ShortcutEventLike,
keybindings: ResolvedKeybindingsConfig,
options?: ShortcutMatchOptions,
): boolean {
return matchesCommandShortcut(event, keybindings, "sidebar.toggle", options);
}

export function isChatNewShortcut(
event: ShortcutEventLike,
keybindings: ResolvedKeybindingsConfig,
Expand Down
13 changes: 10 additions & 3 deletions packages/contracts/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ it.effect("parses keybinding rules", () =>
});
assert.strictEqual(parsedDiffToggle.command, "diff.toggle");

const parsedSidebarToggle = yield* decode(KeybindingRule, {
key: "mod+b",
command: "sidebar.toggle",
});
assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle");

const parsedLocal = yield* decode(KeybindingRule, {
key: "mod+shift+n",
command: "chat.newLocal",
Expand Down Expand Up @@ -90,9 +96,9 @@ it.effect("parses keybindings array payload", () =>
it.effect("parses resolved keybinding rules", () =>
Effect.gen(function* () {
const parsed = yield* decode(ResolvedKeybindingRule, {
command: "terminal.split",
command: "sidebar.toggle",
shortcut: {
key: "d",
key: "b",
metaKey: false,
ctrlKey: false,
shiftKey: false,
Expand All @@ -108,7 +114,8 @@ it.effect("parses resolved keybinding rules", () =>
},
},
});
assert.strictEqual(parsed.shortcut.key, "d");
assert.strictEqual(parsed.command, "sidebar.toggle");
assert.strictEqual(parsed.shortcut.key, "b");
}),
);

Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const STATIC_KEYBINDING_COMMANDS = [
"terminal.new",
"terminal.close",
"diff.toggle",
"sidebar.toggle",
"chat.new",
"chat.newLocal",
"editor.openFavorite",
Expand Down
Loading