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
44 changes: 42 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";

import ThreadSidebar from "./Sidebar";
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
import { Sidebar, SidebarProvider, SidebarRail, useSidebar } from "./ui/sidebar";
import {
clearShortcutModifierState,
syncShortcutModifierStateFromKeyboardEvent,
} from "../shortcutModifierState";
import { resolveShortcutCommand, shouldIgnoreGlobalNavigationShortcut } from "../keybindings";
import { isTerminalFocused } from "../lib/terminalFocus";
import { useServerKeybindings } from "../rpc/serverState";
import { useSettingsDialogStore } from "../settingsDialogStore";

const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
Expand Down Expand Up @@ -37,6 +40,42 @@ function LazySettingsDialogMount() {
);
}

// The sidebar writes its open/closed state to the `sidebar_state` cookie; read
// it back here so the collapsed state persists across reloads (SidebarProvider
// only seeds its initial state from `defaultOpen`).
function readSidebarOpenPreference(): boolean {
if (typeof document === "undefined") return true;
const match = document.cookie.match(/(?:^|;\s*)sidebar_state=([^;]+)/);
return match ? match[1] !== "false" : true;
}

// Global Mod+B (configurable as `sidebar.toggle`) handler. Lives inside the
// SidebarProvider so the toggle works on every route — chat, settings, and the
// no-active-thread state — not just inside the chat view's shortcut handler.
function SidebarToggleShortcut() {
const { toggleSidebar } = useSidebar();
const keybindings = useServerKeybindings();

useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const command = resolveShortcutCommand(event, keybindings, {
context: { terminalFocus: isTerminalFocused() },
});
if (command !== "sidebar.toggle") return;
// Don't steal the shortcut while typing (e.g. Mod+B for bold in the composer).
if (shouldIgnoreGlobalNavigationShortcut(event)) return;
event.preventDefault();
event.stopPropagation();
toggleSidebar();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [keybindings, toggleSidebar]);

return null;
}

export function AppSidebarLayout({ children }: { children: ReactNode }) {
const openSettings = useSettingsDialogStore((s) => s.openSettings);

Expand Down Expand Up @@ -80,7 +119,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}, [openSettings]);

return (
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen>
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen={readSidebarOpenPreference()}>
<SidebarToggleShortcut />
<Sidebar
side="left"
collapsible="offcanvas"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/NoActiveThreadState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function NoActiveThreadState() {
</span>
) : (
<div className="flex items-center gap-2">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<SidebarTrigger className="size-7 shrink-0 group-data-[state=expanded]/sidebar-wrapper:md:hidden" />
<span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
No active thread
</span>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const ChatHeader = memo(function ChatHeader(props: ChatHeaderProps) {
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2 pt-4 pb-2.5">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<SidebarTrigger className="size-7 shrink-0 group-data-[state=expanded]/sidebar-wrapper:md:hidden" />
<ChatHeaderBar
projectName={props.activeProjectName}
isGitRepo={props.isGitRepo}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ function SidebarProvider({
className,
)}
data-slot="sidebar-wrapper"
data-state={state}
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ const DEFAULT_BINDINGS = compile([
command: "commandPalette.toggle",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
{
shortcut: modShortcut("b"),
command: "sidebar.toggle",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
{
shortcut: modShortcut("m", { shiftKey: true }),
command: "modelPicker.toggle",
Expand Down Expand Up @@ -318,6 +323,10 @@ describe("shortcutLabelForCommand", () => {
shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),
"⌘K",
);
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"),
"⌘B",
);
assert.strictEqual(
shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"),
"Ctrl+Shift+M",
Expand Down Expand Up @@ -505,6 +514,23 @@ describe("chat/editor shortcuts", () => {
);
});

it("matches sidebar.toggle (Mod+B) outside terminal focus", () => {
assert.strictEqual(
resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { terminalFocus: false },
}),
"sidebar.toggle",
);
assert.notStrictEqual(
resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { terminalFocus: true },
}),
"sidebar.toggle",
);
});

it("matches diff.toggle shortcut outside terminal focus", () => {
assert.isTrue(
isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/keybindingCategories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const KEYBINDING_CATEGORIES: Record<string, KeybindingCategory> = {
workspace: { id: "workspace", label: "Workspace", sortWeight: 20 },
diff: { id: "diff", label: "Diff", sortWeight: 30 },
commandPalette: { id: "commandPalette", label: "Command palette", sortWeight: 40 },
sidebar: { id: "sidebar", label: "Sidebar", sortWeight: 45 },
chat: { id: "chat", label: "Chat", sortWeight: 50 },
editor: { id: "editor", label: "Editor", sortWeight: 60 },
modelPicker: { id: "modelPicker", label: "Model picker", sortWeight: 70 },
Expand All @@ -34,6 +35,7 @@ const STATIC_COMMAND_META: Record<string, Omit<KeybindingCommandMeta, "category"
"workspace.terminal": { title: "Open workspace terminal", sortWeight: 3 },
"diff.toggle": { title: "Toggle diff view", sortWeight: 1 },
"commandPalette.toggle": { title: "Open command palette", sortWeight: 1 },
"sidebar.toggle": { title: "Toggle sidebar", sortWeight: 1 },
"chat.new": { title: "New chat", sortWeight: 1 },
"chat.newLocal": { title: "New chat (local environment)", sortWeight: 2 },
"editor.openFavorite": { title: "Open in preferred editor", sortWeight: 1 },
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function HostedStaticOnboardingState() {
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
<header className="border-b border-border px-3 py-2 sm:px-5 sm:py-3">
<div className="flex items-center gap-2">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<SidebarTrigger className="size-7 shrink-0 group-data-[state=expanded]/sidebar-wrapper:md:hidden" />
<span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
{APP_DISPLAY_NAME}
</span>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/_settings.diagnostics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function DiagnosticsRouteView() {
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
<header className="flex items-center gap-2 border-b border-border px-3 py-2 sm:px-5 sm:py-3">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<SidebarTrigger className="size-7 shrink-0 group-data-[state=expanded]/sidebar-wrapper:md:hidden" />
<Button
size="xs"
variant="ghost"
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 @@ -57,6 +57,7 @@ const STATIC_KEYBINDING_COMMANDS = [
"workspace.terminal",
"diff.toggle",
"commandPalette.toggle",
"sidebar.toggle",
"chat.new",
"chat.newLocal",
"editor.openFavorite",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "ctrl+`", command: "workspace.terminal", when: "!terminalFocus" },
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
{ key: "mod+k", command: "commandPalette.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
Loading