diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e7..d4f7052ff9 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -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" }, @@ -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) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 846c3778b0..fc9cd54135 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -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", diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf58467825..d17982677a 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -65,6 +65,7 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { 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" }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 59ff4f73c8..47f1c96364 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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 { @@ -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); @@ -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], @@ -3442,15 +3448,27 @@ export default function ChatView({ threadId }: ChatViewProps) { return (
{!isElectron && ( -
+
- + Threads
)} {isElectron && ( -
+
+ No active thread
)} @@ -3468,8 +3486,10 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Top bar */}
- + } + /> + {tooltipLabel} + + ); +} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 39a4f6eedc..997f560f1f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -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 { @@ -26,6 +26,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; terminalAvailable: boolean; terminalOpen: boolean; + sidebarToggleShortcutLabel: string | null; terminalToggleShortcutLabel: string | null; diffToggleShortcutLabel: string | null; gitCwd: string | null; @@ -50,6 +51,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, terminalAvailable, terminalOpen, + sidebarToggleShortcutLabel, terminalToggleShortcutLabel, diffToggleShortcutLabel, gitCwd, @@ -64,7 +66,10 @@ export const ChatHeader = memo(function ChatHeader({ return (
- +

) { - const { toggleSidebar, openMobile } = useSidebar(); + const { isMobile, open, openMobile, toggleSidebar } = useSidebar(); + const isOpen = isMobile ? openMobile : open; return ( ); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f8..a26133fded 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -12,6 +12,7 @@ import { isChatNewLocalShortcut, isDiffToggleShortcut, isOpenFavoriteEditorShortcut, + isSidebarToggleShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, @@ -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"), @@ -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( @@ -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"), diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aad..a636d99833 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -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, diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 888e6ee74b..cec1ff3a0b 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -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 (
{!isElectron && ( -
+
- + Threads
)} {isElectron && ( -
+
+ No active thread
)} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 05fd640d0f..4ac745a156 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -13,6 +13,7 @@ import { useAppSettings, } from "../appSettings"; import { APP_VERSION } from "../branding"; +import { SidebarToggleButton } from "../components/SidebarToggleButton"; import { Button } from "../components/ui/button"; import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { Input } from "../components/ui/input"; @@ -23,16 +24,17 @@ import { SelectTrigger, SelectValue, } from "../components/ui/select"; -import { SidebarTrigger } from "../components/ui/sidebar"; import { Switch } from "../components/ui/switch"; import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { shortcutLabelForCommand } from "../keybindings"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; +import { useSidebar } from "../components/ui/sidebar"; const THEME_OPTIONS = [ { @@ -186,6 +188,7 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () => } function SettingsRouteView() { + const { open: sidebarOpen } = useSidebar(); const { theme, setTheme } = useTheme(); const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); @@ -212,6 +215,10 @@ function SettingsRouteView() { const codexHomePath = settings.codexHomePath; const claudeBinaryPath = settings.claudeBinaryPath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const sidebarToggleShortcutLabel = shortcutLabelForCommand( + serverConfigQuery.data?.keybindings ?? [], + "sidebar.toggle", + ); const availableEditors = serverConfigQuery.data?.availableEditors; const gitTextGenerationModelOptions = getAppModelOptions( @@ -385,7 +392,10 @@ function SettingsRouteView() { {!isElectron && (
- + Settings