Skip to content
Open
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 @@ -19,6 +19,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](

```json
[
{ "key": "mod+b", "command": "sidebar.toggle" },
{ "key": "mod+j", "command": "terminal.toggle" },
{ "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
{ "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
Expand Down Expand Up @@ -46,6 +47,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged

### Available Commands

- `sidebar.toggle`: open/close the main thread sidebar in both desktop and web
- `terminal.toggle`: open/close terminal drawer
- `terminal.split`: split terminal (in focused terminal context by default)
- `terminal.new`: create new terminal (in focused terminal context by default)
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => {

it.effect("compiles valid rule with parsed when AST", () =>
Effect.sync(() => {
const compiledSidebar = compileResolvedKeybindingRule({
key: "mod+b",
command: "sidebar.toggle",
});

assert.deepEqual(compiledSidebar, {
command: "sidebar.toggle",
shortcut: {
key: "b",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: true,
},
});

const compiled = compileResolvedKeybindingRule({
key: "mod+d",
command: "terminal.split",
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 @@ -65,6 +65,7 @@ type WhenToken =
| { type: "rparen" };

export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+b", command: "sidebar.toggle" },
{ key: "mod+j", command: "terminal.toggle" },
{ key: "mod+d", command: "terminal.split", when: "terminalFocus" },
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
Expand Down
33 changes: 27 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ import {
projectScriptIdFromCommand,
setupProjectScript,
} from "~/projectScripts";
import { SidebarTrigger } from "./ui/sidebar";
import { SidebarToggleButton } from "./SidebarToggleButton";
import { useSidebar } from "./ui/sidebar";
import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
import { readNativeApi } from "~/nativeApi";
import {
Expand Down Expand Up @@ -241,6 +242,7 @@ interface ChatViewProps {
}

export default function ChatView({ threadId }: ChatViewProps) {
const { open: sidebarOpen } = useSidebar();
const threads = useStore((store) => store.threads);
const projects = useStore((store) => store.projects);
const markThreadVisited = useStore((store) => store.markThreadVisited);
Expand Down Expand Up @@ -1129,6 +1131,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => shortcutLabelForCommand(keybindings, "terminal.toggle"),
[keybindings],
);
const sidebarToggleShortcutLabel = useMemo(
() => shortcutLabelForCommand(keybindings, "sidebar.toggle"),
[keybindings],
);
const splitTerminalShortcutLabel = useMemo(
() => shortcutLabelForCommand(keybindings, "terminal.split"),
[keybindings],
Expand Down Expand Up @@ -3442,15 +3448,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
{!isElectron && (
<header className="border-b border-border px-3 py-2 md:hidden">
<header className="border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<SidebarTrigger className="size-7 shrink-0" />
<SidebarToggleButton
className="size-7 shrink-0"
shortcutLabel={sidebarToggleShortcutLabel}
/>
<span className="text-sm font-medium text-foreground">Threads</span>
</div>
</header>
)}
{isElectron && (
<div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
<div
className={cn(
"drag-region flex h-[52px] shrink-0 items-center gap-2 border-b border-border px-5",
!sidebarOpen && "pl-[90px]",
)}
>
<SidebarToggleButton
className="size-7 shrink-0 no-drag"
shortcutLabel={sidebarToggleShortcutLabel}
/>
<span className="text-xs text-muted-foreground/50">No active thread</span>
</div>
)}
Expand All @@ -3468,8 +3486,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Top bar */}
<header
className={cn(
"border-b border-border px-3 sm:px-5",
isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3",
"border-b border-border",
isElectron
? cn("drag-region flex h-[52px] items-center pr-5", sidebarOpen ? "pl-5" : "pl-[90px]")
: "px-3 py-2 sm:px-5 sm:py-3",
)}
Comment on lines 3487 to 3493
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

In the active-thread top bar header, the responsive horizontal padding for the non-Electron (web) layout was reduced from px-3 sm:px-5 to just px-3. This makes the header misalign with the rest of the chat column, which still uses sm:px-5 (e.g. messages wrapper / input bar). Consider restoring the sm:px-5 padding for the non-Electron branch while keeping the Electron-specific pl-[90px]/pl-5 logic.

Copilot uses AI. Check for mistakes.
>
<ChatHeader
Expand All @@ -3486,6 +3506,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
availableEditors={availableEditors}
terminalAvailable={activeProject !== undefined}
terminalOpen={terminalState.terminalOpen}
sidebarToggleShortcutLabel={sidebarToggleShortcutLabel}
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
diffToggleShortcutLabel={diffPanelShortcutLabel}
gitCwd={gitCwd}
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
SidebarTrigger,
} from "./ui/sidebar";
import { useThreadSelectionStore } from "../threadSelectionStore";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
Expand Down Expand Up @@ -1593,7 +1592,6 @@ export default function Sidebar() {

const wordmark = (
<div className="flex items-center gap-2">
<SidebarTrigger className="shrink-0 md:hidden" />
<Tooltip>
<TooltipTrigger
render={
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/SidebarToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SidebarTrigger } from "./ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

interface SidebarToggleButtonProps {
className?: string;
shortcutLabel?: string | null;
}

export function SidebarToggleButton({ className, shortcutLabel = null }: SidebarToggleButtonProps) {
const tooltipLabel = shortcutLabel ? `Toggle sidebar (${shortcutLabel})` : "Toggle sidebar";

return (
<Tooltip>
<TooltipTrigger
render={<SidebarTrigger aria-label="Toggle sidebar" className={className} />}
/>
<TooltipPopup side="bottom">{tooltipLabel}</TooltipPopup>
</Tooltip>
);
}
9 changes: 7 additions & 2 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { DiffIcon, TerminalSquareIcon } from "lucide-react";
import { Badge } from "../ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
import { SidebarToggleButton } from "../SidebarToggleButton";
import { Toggle } from "../ui/toggle";
import { SidebarTrigger } from "../ui/sidebar";
import { OpenInPicker } from "./OpenInPicker";

interface ChatHeaderProps {
Expand All @@ -26,6 +26,7 @@ interface ChatHeaderProps {
availableEditors: ReadonlyArray<EditorId>;
terminalAvailable: boolean;
terminalOpen: boolean;
sidebarToggleShortcutLabel: string | null;
terminalToggleShortcutLabel: string | null;
diffToggleShortcutLabel: string | null;
gitCwd: string | null;
Expand All @@ -50,6 +51,7 @@ export const ChatHeader = memo(function ChatHeader({
availableEditors,
terminalAvailable,
terminalOpen,
sidebarToggleShortcutLabel,
terminalToggleShortcutLabel,
diffToggleShortcutLabel,
gitCwd,
Expand All @@ -64,7 +66,10 @@ export const ChatHeader = memo(function ChatHeader({
return (
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<SidebarToggleButton
className="size-7 shrink-0"
shortcutLabel={sidebarToggleShortcutLabel}
/>
<h2
className="min-w-0 shrink truncate text-sm font-medium text-foreground"
title={activeThreadTitle}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ function Sidebar({
}

function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar, openMobile } = useSidebar();
const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
const isOpen = isMobile ? openMobile : open;

return (
<Button
Expand All @@ -318,7 +319,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
variant="ghost"
{...props}
>
{openMobile ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}
{isOpen ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
Expand Down
24 changes: 24 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 @@ -76,6 +77,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
}

const DEFAULT_BINDINGS = compile([
{ shortcut: modShortcut("b"), command: "sidebar.toggle" },
{ shortcut: modShortcut("j"), command: "terminal.toggle" },
{
shortcut: modShortcut("d"),
Expand All @@ -102,6 +104,24 @@ const DEFAULT_BINDINGS = compile([
{ shortcut: modShortcut("o"), command: "editor.openFavorite" },
]);

describe("isSidebarToggleShortcut", () => {
it("matches Cmd+B on macOS", () => {
assert.isTrue(
isSidebarToggleShortcut(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
}),
);
});

it("matches Ctrl+B on non-macOS", () => {
assert.isTrue(
isSidebarToggleShortcut(event({ key: "b", ctrlKey: true }), DEFAULT_BINDINGS, {
platform: "Win32",
}),
);
});
});

describe("isTerminalToggleShortcut", () => {
it("matches Cmd+J on macOS", () => {
assert.isTrue(
Expand Down Expand Up @@ -236,6 +256,10 @@ describe("shortcutLabelForCommand", () => {

it("returns labels for non-terminal commands", () => {
assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"),
"⌘B",
);
assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
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 @@ -174,6 +174,14 @@ export function isTerminalToggleShortcut(
return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
}

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

export function isTerminalSplitShortcut(
event: ShortcutEventLike,
keybindings: ResolvedKeybindingsConfig,
Expand Down
32 changes: 28 additions & 4 deletions apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";

import { SidebarToggleButton } from "../components/SidebarToggleButton";
import { isElectron } from "../env";
import { SidebarTrigger } from "../components/ui/sidebar";
import { shortcutLabelForCommand } from "../keybindings";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { cn } from "../lib/utils";
import { useSidebar } from "../components/ui/sidebar";

function ChatIndexRouteView() {
const { open: sidebarOpen } = useSidebar();
const serverConfigQuery = useQuery(serverConfigQueryOptions());
const sidebarToggleShortcutLabel = shortcutLabelForCommand(
serverConfigQuery.data?.keybindings ?? [],
"sidebar.toggle",
);

return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
{!isElectron && (
<header className="border-b border-border px-3 py-2 md:hidden">
<header className="border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<SidebarTrigger className="size-7 shrink-0" />
<SidebarToggleButton
className="size-7 shrink-0"
shortcutLabel={sidebarToggleShortcutLabel}
/>
<span className="text-sm font-medium text-foreground">Threads</span>
</div>
</header>
)}

{isElectron && (
<div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
<div
className={cn(
"drag-region flex h-[52px] shrink-0 items-center gap-2 border-b border-border px-5",
!sidebarOpen && "pl-[90px]",
)}
>
<SidebarToggleButton
className="size-7 shrink-0 no-drag"
shortcutLabel={sidebarToggleShortcutLabel}
/>
<span className="text-xs text-muted-foreground/50">No active thread</span>
</div>
)}
Expand Down
Loading