authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 17:37:59-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 19:03:10-08:00
logd47059a9db4546ce507341bbc8e7c25d0f60300a
tree53b95a3337f19ae9fb5ad9b1e172d0d5794a6aad
parent10c1cae6abb3c87887b3d4a9cc6d2d4788145bd2

Add diff panel keyboard toggle and shortcut hint

- Bind `mod+d` to `diff.toggle` when terminal is not focused - Wire ChatView to handle `diff.toggle` and show its shortcut in the diff button tooltip - Limit sidebar `mod+b` listener to mobile to avoid desktop shortcut conflicts - Extend web/contracts keybinding tests and command list for `diff.toggle`

7 files changed, 68 insertions(+), 11 deletions(-)

apps/server/src/keybindings.ts+1
......@@ -65,6 +65,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
6565 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
6666 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
6767 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
68 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
6869 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
6970 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
7071 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
apps/web/src/components/ChatView.tsx+28-10
......@@ -615,6 +615,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
615615 () => shortcutLabelForCommand(keybindings, "terminal.close"),
616616 [keybindings],
617617 );
618 const diffPanelShortcutLabel = useMemo(
619 () => shortcutLabelForCommand(keybindings, "diff.toggle"),
620 [keybindings],
621 );
622 const onToggleDiff = useCallback(() => {
623 void navigate({
624 to: "/$threadId",
625 params: { threadId },
626 search: (previous) => {
627 const rest = stripDiffSearchParams(previous);
628 return diffOpen ? rest : { ...rest, diff: "1" };
629 },
630 });
631 }, [diffOpen, navigate, threadId]);
618632
619633 const envLocked = Boolean(
620634 activeThread &&
......@@ -1147,6 +1161,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
11471161 return;
11481162 }
11491163
1164 if (command === "diff.toggle") {
1165 event.preventDefault();
1166 event.stopPropagation();
1167 onToggleDiff();
1168 return;
1169 }
1170
11501171 const scriptId = projectScriptIdFromCommand(command);
11511172 if (!scriptId || !activeProject) return;
11521173 const script = activeProject.scripts.find((entry) => entry.id === scriptId);
......@@ -1168,6 +1189,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11681189 runProjectScript,
11691190 splitTerminal,
11701191 keybindings,
1192 onToggleDiff,
11711193 toggleTerminalVisibility,
11721194 ]);
11731195
......@@ -1612,16 +1634,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
16121634 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {
16131635 setExpandedImage(image);
16141636 }, []);
1615 const onToggleDiff = useCallback(() => {
1616 void navigate({
1617 to: "/$threadId",
1618 params: { threadId },
1619 search: (previous) => {
1620 const rest = stripDiffSearchParams(previous);
1621 return diffOpen ? rest : { ...rest, diff: "1" };
1622 },
1623 });
1624 }, [diffOpen, navigate, threadId]);
16251637 const onOpenTurnDiff = useCallback(
16261638 (turnId: TurnId, filePath?: string) => {
16271639 void navigate({
......@@ -1692,6 +1704,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16921704 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
16931705 }
16941706 keybindings={keybindings}
1707 diffToggleShortcutLabel={diffPanelShortcutLabel}
16951708 gitCwd={gitCwd}
16961709 diffOpen={diffOpen}
16971710 onRunProjectScript={(script) => {
......@@ -2043,6 +2056,7 @@ interface ChatHeaderProps {
20432056 activeProjectScripts: ProjectScript[] | undefined;
20442057 preferredScriptId: string | null;
20452058 keybindings: ResolvedKeybindingsConfig;
2059 diffToggleShortcutLabel: string | null;
20462060 gitCwd: string | null;
20472061 diffOpen: boolean;
20482062 onRunProjectScript: (script: ProjectScript) => void;
......@@ -2058,6 +2072,7 @@ const ChatHeader = memo(function ChatHeader({
20582072 activeProjectScripts,
20592073 preferredScriptId,
20602074 keybindings,
2075 diffToggleShortcutLabel,
20612076 gitCwd,
20622077 diffOpen,
20632078 onRunProjectScript,
......@@ -2101,6 +2116,9 @@ const ChatHeader = memo(function ChatHeader({
21012116 pressed={diffOpen}
21022117 onPressedChange={onToggleDiff}
21032118 aria-label="Toggle diff panel"
2119 title={
2120 diffToggleShortcutLabel ? `Toggle diff panel (${diffToggleShortcutLabel})` : undefined
2121 }
21042122 variant="outline"
21052123 size="xs"
21062124 >
apps/web/src/components/ui/sidebar.tsx+2-1
......@@ -132,6 +132,7 @@ function SidebarProvider({
132132 // Adds a keyboard shortcut to toggle the sidebar.
133133 React.useEffect(() => {
134134 const handleKeyDown = (event: KeyboardEvent) => {
135 if (!isMobile) return;
135136 if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
136137 event.preventDefault();
137138 toggleSidebar();
......@@ -140,7 +141,7 @@ function SidebarProvider({
140141
141142 window.addEventListener("keydown", handleKeyDown);
142143 return () => window.removeEventListener("keydown", handleKeyDown);
143 }, [toggleSidebar]);
144 }, [isMobile, toggleSidebar]);
144145
145146 // We add a state so that we can do data-state="expanded" or "collapsed".
146147 // This makes it easier to style the sidebar with Tailwind classes.
apps/web/src/keybindings.test.ts+22
......@@ -10,6 +10,7 @@ import {
1010 formatShortcutLabel,
1111 isChatNewShortcut,
1212 isChatNewLocalShortcut,
13 isDiffToggleShortcut,
1314 isOpenFavoriteEditorShortcut,
1415 isTerminalClearShortcut,
1516 isTerminalCloseShortcut,
......@@ -91,6 +92,11 @@ const DEFAULT_BINDINGS = compile([
9192 command: "terminal.close",
9293 whenAst: whenIdentifier("terminalFocus"),
9394 },
95 {
96 shortcut: modShortcut("d"),
97 command: "diff.toggle",
98 whenAst: whenNot(whenIdentifier("terminalFocus")),
99 },
94100 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
95101 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
96102 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
......@@ -230,6 +236,7 @@ describe("shortcutLabelForCommand", () => {
230236
231237 it("returns labels for non-terminal commands", () => {
232238 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
239 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
233240 assert.strictEqual(
234241 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
235242 "Ctrl+O",
......@@ -276,6 +283,21 @@ describe("chat/editor shortcuts", () => {
276283 }),
277284 );
278285 });
286
287 it("matches diff.toggle shortcut outside terminal focus", () => {
288 assert.isTrue(
289 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
290 platform: "MacIntel",
291 context: { terminalFocus: false },
292 }),
293 );
294 assert.isFalse(
295 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
296 platform: "MacIntel",
297 context: { terminalFocus: true },
298 }),
299 );
300 });
279301});
280302
281303describe("cross-command precedence", () => {
apps/web/src/keybindings.ts+8
......@@ -198,6 +198,14 @@ export function isTerminalCloseShortcut(
198198 return matchesCommandShortcut(event, keybindings, "terminal.close", options);
199199}
200200
201export function isDiffToggleShortcut(
202 event: ShortcutEventLike,
203 keybindings: ResolvedKeybindingsConfig,
204 options?: ShortcutMatchOptions,
205): boolean {
206 return matchesCommandShortcut(event, keybindings, "diff.toggle", options);
207}
208
201209export function isChatNewShortcut(
202210 event: ShortcutEventLike,
203211 keybindings: ResolvedKeybindingsConfig,
packages/contracts/src/keybindings.test.ts+6
......@@ -35,6 +35,12 @@ it.effect("parses keybinding rules", () =>
3535 });
3636 assert.strictEqual(parsedClose.command, "terminal.close");
3737
38 const parsedDiffToggle = yield* decode(KeybindingRule, {
39 key: "mod+d",
40 command: "diff.toggle",
41 });
42 assert.strictEqual(parsedDiffToggle.command, "diff.toggle");
43
3844 const parsedLocal = yield* decode(KeybindingRule, {
3945 key: "mod+shift+n",
4046 command: "chat.newLocal",
packages/contracts/src/keybindings.ts+1
......@@ -14,6 +14,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
1414 "terminal.split",
1515 "terminal.new",
1616 "terminal.close",
17 "diff.toggle",
1718 "chat.new",
1819 "chat.newLocal",
1920 "editor.openFavorite",