authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-22 14:00:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-22 14:00:30-07:00
logb2d17b7108766a989374d670ff12de39b6c4d033
treec555eb0670a5cbff9244901e35f9e9be88a9c033
parentfb1034546f989c11568ef1c4b5801e6a5372a07d
signature Signed by PGP key B5690EEEBB952194

Add main sidebar toggle (#3497)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: codex <codex@users.noreply.github.com>

17 files changed, 247 insertions(+), 74 deletions(-)

apps/server/src/keybindings.test.ts+1
...@@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
200 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");200 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
201 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
201 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");202 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
202 assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");203 assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
203 assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");204 assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
apps/web/src/components/AppSidebarLayout.tsx+54-3
...@@ -1,14 +1,64 @@...@@ -1,14 +1,64 @@
1import { useEffect, type ReactNode } from "react";1import { useAtomValue } from "@effect/atom-react";
2import { useEffect, type CSSProperties, type ReactNode } from "react";
2import { useNavigate } from "@tanstack/react-router";3import { useNavigate } from "@tanstack/react-router";
34
5import { isElectron } from "../env";
6import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
7import { isMacPlatform } from "../lib/utils";
8import { primaryServerKeybindingsAtom } from "../state/server";
4import ThreadSidebar from "./Sidebar";9import ThreadSidebar from "./Sidebar";
5import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";10import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
612
7const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";13const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
8const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;14const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
9const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;15const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
16const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
17
18function SidebarControl() {
19 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
20 const { toggleSidebar } = useSidebar();
21 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
22
23 useEffect(() => {
24 const onKeyDown = (event: KeyboardEvent) => {
25 if (event.defaultPrevented) return;
26 if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return;
27
28 event.preventDefault();
29 event.stopPropagation();
30 toggleSidebar();
31 };
32
33 window.addEventListener("keydown", onKeyDown);
34 return () => window.removeEventListener("keydown", onKeyDown);
35 }, [keybindings, toggleSidebar]);
36
37 return (
38 <div
39 className="pointer-events-none fixed left-[var(--workspace-controls-left)] top-[var(--workspace-controls-top)] z-50 flex h-[var(--workspace-topbar-height)] items-center"
40 data-sidebar-control=""
41 >
42 <Tooltip>
43 <TooltipTrigger
44 render={
45 <SidebarTrigger className="pointer-events-auto" aria-label="Toggle main sidebar" />
46 }
47 />
48 <TooltipPopup side="bottom">
49 Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
50 </TooltipPopup>
51 </Tooltip>
52 </div>
53 );
54}
55
10export function AppSidebarLayout({ children }: { children: ReactNode }) {56export function AppSidebarLayout({ children }: { children: ReactNode }) {
11 const navigate = useNavigate();57 const navigate = useNavigate();
58 const macosWindowControlsStyle =
59 isElectron && isMacPlatform(navigator.platform)
60 ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)
61 : undefined;
1262
13 useEffect(() => {63 useEffect(() => {
14 const onMenuAction = window.desktopBridge?.onMenuAction;64 const onMenuAction = window.desktopBridge?.onMenuAction;
...@@ -28,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -28,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
28 }, [navigate]);78 }, [navigate]);
2979
30 return (80 return (
31 <SidebarProvider className="h-dvh! min-h-0!" defaultOpen>81 <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>
32 <Sidebar82 <Sidebar
33 side="left"83 side="left"
34 collapsible="offcanvas"84 collapsible="offcanvas"
...@@ -44,6 +94,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -44,6 +94,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
44 <SidebarRail />94 <SidebarRail />
45 </Sidebar>95 </Sidebar>
46 {children}96 {children}
97 <SidebarControl />
47 </SidebarProvider>98 </SidebarProvider>
48 );99 );
49}100}
apps/web/src/components/ChatView.tsx+3-1
...@@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar";...@@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar";
128import ThreadTerminalDrawer from "./ThreadTerminalDrawer";128import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
129import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";129import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
130import { cn, randomHex } from "~/lib/utils";130import { cn, randomHex } from "~/lib/utils";
131import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
131import { stackedThreadToast, toastManager } from "./ui/toast";132import { stackedThreadToast, toastManager } from "./ui/toast";
132import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";133import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
133import { type NewProjectScriptInput } from "./ProjectScriptsControl";134import { type NewProjectScriptInput } from "./ProjectScriptsControl";
...@@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) {
4677 <header4678 <header
4678 data-chat-header4679 data-chat-header
4679 className={cn(4680 className={cn(
4680 "border-b border-border",4681 "border-b border-border transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none",
4681 isElectron4682 isElectron
4682 ? cn(4683 ? cn(
4683 "workspace-topbar drag-region relative px-3 sm:px-5",4684 "workspace-topbar drag-region relative px-3 sm:px-5",
...@@ -4686,6 +4687,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4686,6 +4687,7 @@ function ChatViewContent(props: ChatViewProps) {
4686 "wco:pr-[var(--workspace-native-controls-inset)]",4687 "wco:pr-[var(--workspace-native-controls-inset)]",
4687 )4688 )
4688 : "workspace-topbar pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",4689 : "workspace-topbar pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
4690 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
4689 )}4691 )}
4690 >4692 >
4691 {!rightPanelOpen ? panelLayoutControls : null}4693 {!rightPanelOpen ? panelLayoutControls : null}
apps/web/src/components/NoActiveThreadState.tsx+4-3
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";1import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
2import { SidebarInset, SidebarTrigger } from "./ui/sidebar";2import { SidebarInset } from "./ui/sidebar";
3import { isElectron } from "../env";3import { isElectron } from "../env";
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
56
6export function NoActiveThreadState() {7export function NoActiveThreadState() {
7 return (8 return (
...@@ -9,8 +10,9 @@ export function NoActiveThreadState() {...@@ -9,8 +10,9 @@ export function NoActiveThreadState() {
9 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">10 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
10 <header11 <header
11 className={cn(12 className={cn(
12 "border-b border-border px-3 sm:px-5",13 "border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
13 isElectron ? "workspace-topbar drag-region" : "workspace-topbar",14 isElectron ? "workspace-topbar drag-region" : "workspace-topbar",
15 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
14 )}16 )}
15 >17 >
16 {isElectron ? (18 {isElectron ? (
...@@ -19,7 +21,6 @@ export function NoActiveThreadState() {...@@ -19,7 +21,6 @@ export function NoActiveThreadState() {
19 </span>21 </span>
20 ) : (22 ) : (
21 <div className="flex items-center gap-2">23 <div className="flex items-center gap-2">
22 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
23 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">24 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
24 No active thread25 No active thread
25 </span>26 </span>
apps/web/src/components/Sidebar.tsx+53-53
...@@ -70,7 +70,7 @@ import {...@@ -70,7 +70,7 @@ import {
70 type SidebarThreadSortOrder,70 type SidebarThreadSortOrder,
71} from "@t3tools/contracts/settings";71} from "@t3tools/contracts/settings";
72import { isElectron } from "../env";72import { isElectron } from "../env";
73import { APP_STAGE_LABEL, APP_VERSION } from "../branding";73import { APP_STAGE_LABEL } from "../branding";
74import { useOpenPrLink } from "../lib/openPullRequestLink";74import { useOpenPrLink } from "../lib/openPullRequestLink";
75import { isTerminalFocused } from "../lib/terminalFocus";75import { isTerminalFocused } from "../lib/terminalFocus";
76import { isMacPlatform } from "../lib/utils";76import { isMacPlatform } from "../lib/utils";
...@@ -187,12 +187,12 @@ import {...@@ -187,12 +187,12 @@ import {
187 resolveProjectStatusIndicator,187 resolveProjectStatusIndicator,
188 resolveSidebarNewThreadSeedContext,188 resolveSidebarNewThreadSeedContext,
189 resolveSidebarNewThreadEnvMode,189 resolveSidebarNewThreadEnvMode,
190 resolveSidebarStageBadgeLabel,
190 resolveThreadRowClassName,191 resolveThreadRowClassName,
191 resolveThreadStatusPill,192 resolveThreadStatusPill,
192 orderItemsByPreferredIds,193 orderItemsByPreferredIds,
193 shouldClearThreadSelectionOnMouseDown,194 shouldClearThreadSelectionOnMouseDown,
194 sortProjectsForSidebar,195 sortProjectsForSidebar,
195 resolveSidebarStageBadgeLabel,
196 useThreadJumpHintVisibility,196 useThreadJumpHintVisibility,
197 ThreadStatusPill,197 ThreadStatusPill,
198} from "./Sidebar.logic";198} from "./Sidebar.logic";
...@@ -2452,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar...@@ -2452,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar
2452 );2452 );
2453});2453});
24542454
2455function T3Wordmark() {
2456 return (
2457 <svg
2458 aria-label="T3"
2459 className="h-2.5 w-auto shrink-0 text-foreground"
2460 viewBox="15.5309 37 94.3941 56.96"
2461 xmlns="http://www.w3.org/2000/svg"
2462 >
2463 <path
2464 d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
2465 fill="currentColor"
2466 />
2467 </svg>
2468 );
2469}
2470
2471type SortableProjectHandleProps = Pick<2455type SortableProjectHandleProps = Pick<
2472 ReturnType<typeof useSortable>,2456 ReturnType<typeof useSortable>,
2473 "attributes" | "listeners" | "setActivatorNodeRef"2457 "attributes" | "listeners" | "setActivatorNodeRef"
...@@ -2664,48 +2648,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -2664,48 +2648,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
2664}: {2648}: {
2665 isElectron: boolean;2649 isElectron: boolean;
2666}) {2650}) {
2651 return isElectron ? (
2652 <SidebarHeader className="@container/sidebar-header drag-region h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0 md:px-0">
2653 <SidebarTrigger className="md:hidden" />
2654 <SidebarBrand />
2655 </SidebarHeader>
2656 ) : (
2657 <SidebarHeader className="@container/sidebar-header h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0 md:px-0">
2658 <SidebarTrigger className="md:hidden" />
2659 <SidebarBrand />
2660 </SidebarHeader>
2661 );
2662});
2663
2664function SidebarBrand() {
2665 const stageLabel = useSidebarStageLabel();
2666
2667 return (
2668 <Link
2669 aria-label="Go to threads"
2670 className="sidebar-brand ml-[var(--workspace-titlebar-content-left)] h-7 w-fit min-w-0 shrink-0 items-center gap-1 overflow-hidden rounded-md text-foreground outline-hidden ring-ring focus-visible:ring-2"
2671 to="/"
2672 >
2673 <T3Wordmark />
2674 <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
2675 Code
2676 </span>
2677 <span className="sidebar-brand-stage shrink-0 items-center whitespace-nowrap rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
2678 {stageLabel}
2679 </span>
2680 </Link>
2681 );
2682}
2683
2684function useSidebarStageLabel() {
2667 const primaryServerVersion =2685 const primaryServerVersion =
2668 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;2686 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
2669 const stageBadgeLabel = resolveSidebarStageBadgeLabel({2687
2688 return resolveSidebarStageBadgeLabel({
2670 primaryServerVersion,2689 primaryServerVersion,
2671 fallbackStageLabel: APP_STAGE_LABEL,2690 fallbackStageLabel: APP_STAGE_LABEL,
2672 });2691 });
2673 const wordmark = (2692}
2674 <div className="flex items-center gap-2">
2675 <SidebarTrigger className="shrink-0 md:hidden" />
2676 <Tooltip>
2677 <TooltipTrigger
2678 render={
2679 <Link
2680 aria-label="Go to threads"
2681 className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2"
2682 to="/"
2683 >
2684 <T3Wordmark />
2685 <span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
2686 Code
2687 </span>
2688 <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
2689 {stageBadgeLabel}
2690 </span>
2691 </Link>
2692 }
2693 />
2694 <TooltipPopup side="bottom" sideOffset={2}>
2695 Version {APP_VERSION}
2696 </TooltipPopup>
2697 </Tooltip>
2698 </div>
2699 );
27002693
2701 return isElectron ? (2694function T3Wordmark() {
2702 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px] wco:h-[env(titlebar-area-height)] wco:pl-[calc(env(titlebar-area-x)+1em)]">2695 return (
2703 {wordmark}2696 <svg
2704 </SidebarHeader>2697 aria-label="T3"
2705 ) : (2698 className="h-2.5 w-auto shrink-0 text-foreground"
2706 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">{wordmark}</SidebarHeader>2699 viewBox="15.5309 37 94.3941 56.96"
2700 xmlns="http://www.w3.org/2000/svg"
2701 >
2702 <path
2703 d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
2704 fill="currentColor"
2705 />
2706 </svg>
2707 );2707 );
2708});2708}
27092709
2710const SidebarChromeFooter = memo(function SidebarChromeFooter() {2710const SidebarChromeFooter = memo(function SidebarChromeFooter() {
2711 const navigate = useNavigate();2711 const navigate = useNavigate();
apps/web/src/components/chat/ChatHeader.tsx-2
...@@ -14,7 +14,6 @@ import ProjectScriptsControl, {...@@ -14,7 +14,6 @@ import ProjectScriptsControl, {
14 type NewProjectScriptInput,14 type NewProjectScriptInput,
15 type ProjectScriptActionResult,15 type ProjectScriptActionResult,
16} from "../ProjectScriptsControl";16} from "../ProjectScriptsControl";
17import { SidebarTrigger } from "../ui/sidebar";
18import { OpenInPicker } from "./OpenInPicker";17import { OpenInPicker } from "./OpenInPicker";
19import { usePrimaryEnvironmentId } from "../../state/environments";18import { usePrimaryEnvironmentId } from "../../state/environments";
20import { cn } from "~/lib/utils";19import { cn } from "~/lib/utils";
...@@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({...@@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({
80 return (79 return (
81 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">80 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
82 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">81 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
83 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
84 <Tooltip>82 <Tooltip>
85 <TooltipTrigger83 <TooltipTrigger
86 render={84 render={
apps/web/src/components/ui/sidebar.test.tsx+33
...@@ -6,7 +6,9 @@ import {...@@ -6,7 +6,9 @@ import {
6 SidebarMenuButton,6 SidebarMenuButton,
7 SidebarMenuSubButton,7 SidebarMenuSubButton,
8 SidebarProvider,8 SidebarProvider,
9 SidebarTrigger,
9} from "./sidebar";10} from "./sidebar";
11import { resolveSidebarState } from "./sidebarState";
1012
11function renderSidebarButton(className?: string) {13function renderSidebarButton(className?: string) {
12 return renderToStaticMarkup(14 return renderToStaticMarkup(
...@@ -17,6 +19,37 @@ function renderSidebarButton(className?: string) {...@@ -17,6 +19,37 @@ function renderSidebarButton(className?: string) {
17}19}
1820
19describe("sidebar interactive cursors", () => {21describe("sidebar interactive cursors", () => {
22 it("uses mobile sheet visibility for the shared responsive state", () => {
23 expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe(
24 "collapsed",
25 );
26 expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded");
27 expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe(
28 "expanded",
29 );
30 });
31
32 it("exposes collapsed state for shared titlebar inset styling", () => {
33 const html = renderToStaticMarkup(
34 <SidebarProvider defaultOpen={false}>
35 <div />
36 </SidebarProvider>,
37 );
38
39 expect(html).toContain('data-sidebar-state="collapsed"');
40 });
41
42 it("keeps the sidebar trigger interactive inside Electron drag regions", () => {
43 const html = renderToStaticMarkup(
44 <SidebarProvider>
45 <SidebarTrigger />
46 </SidebarProvider>,
47 );
48
49 expect(html).toContain("[-webkit-app-region:no-drag]");
50 expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!");
51 });
52
20 it("uses a pointer cursor for menu buttons by default", () => {53 it("uses a pointer cursor for menu buttons by default", () => {
21 const html = renderSidebarButton();54 const html = renderSidebarButton();
2255
apps/web/src/components/ui/sidebar.tsx+18-5
...@@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton";...@@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";19import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
20import { useIsMobile } from "~/hooks/useMediaQuery";20import { useIsMobile } from "~/hooks/useMediaQuery";
21import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";21import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
22import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState";
22import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
2324
24const SIDEBAR_COOKIE_NAME = "sidebar_state";25const SIDEBAR_COOKIE_NAME = "sidebar_state";
...@@ -29,7 +30,7 @@ const SIDEBAR_WIDTH_ICON = "3rem";...@@ -29,7 +30,7 @@ const SIDEBAR_WIDTH_ICON = "3rem";
29const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16;30const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16;
3031
31type SidebarContextProps = {32type SidebarContextProps = {
32 state: "expanded" | "collapsed";33 state: ResponsiveSidebarState;
33 open: boolean;34 open: boolean;
34 setOpen: (open: boolean) => void;35 setOpen: (open: boolean) => void;
35 openMobile: boolean;36 openMobile: boolean;
...@@ -85,6 +86,11 @@ function useSidebar() {...@@ -85,6 +86,11 @@ function useSidebar() {
85 return context;86 return context;
86}87}
8788
89function useSidebarVisibility() {
90 const { isMobile, open, openMobile } = useSidebar();
91 return isMobile ? openMobile : open;
92}
93
88function SidebarProvider({94function SidebarProvider({
89 defaultOpen = true,95 defaultOpen = true,
90 open: openProp,96 open: openProp,
...@@ -132,7 +138,7 @@ function SidebarProvider({...@@ -132,7 +138,7 @@ function SidebarProvider({
132138
133 // We add a state so that we can do data-state="expanded" or "collapsed".139 // We add a state so that we can do data-state="expanded" or "collapsed".
134 // This makes it easier to style the sidebar with Tailwind classes.140 // This makes it easier to style the sidebar with Tailwind classes.
135 const state = open ? "expanded" : "collapsed";141 const state = resolveSidebarState({ isMobile, open, openMobile });
136142
137 const contextValue = React.useMemo<SidebarContextProps>(143 const contextValue = React.useMemo<SidebarContextProps>(
138 () => ({144 () => ({
...@@ -154,6 +160,7 @@ function SidebarProvider({...@@ -154,6 +160,7 @@ function SidebarProvider({
154 "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",160 "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
155 className,161 className,
156 )}162 )}
163 data-sidebar-state={state}
157 data-slot="sidebar-wrapper"164 data-slot="sidebar-wrapper"
158 style={165 style={
159 {166 {
...@@ -310,13 +317,18 @@ function Sidebar({...@@ -310,13 +317,18 @@ function Sidebar({
310}317}
311318
312function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {319function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
313 const { toggleSidebar, openMobile } = useSidebar();320 const { toggleSidebar } = useSidebar();
321 const isOpen = useSidebarVisibility();
314322
315 return (323 return (
316 <Button324 <Button
317 className={cn("size-7", className)}325 className={cn(
326 "size-[var(--workspace-titlebar-control-size)]! [-webkit-app-region:no-drag]",
327 className,
328 )}
318 data-sidebar="trigger"329 data-sidebar="trigger"
319 data-slot="sidebar-trigger"330 data-slot="sidebar-trigger"
331 aria-pressed={isOpen}
320 onClick={(event) => {332 onClick={(event) => {
321 onClick?.(event);333 onClick?.(event);
322 toggleSidebar();334 toggleSidebar();
...@@ -325,7 +337,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t...@@ -325,7 +337,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
325 variant="ghost"337 variant="ghost"
326 {...props}338 {...props}
327 >339 >
328 {openMobile ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}340 {isOpen ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}
329 <span className="sr-only">Toggle Sidebar</span>341 <span className="sr-only">Toggle Sidebar</span>
330 </Button>342 </Button>
331 );343 );
...@@ -1004,4 +1016,5 @@ export {...@@ -1004,4 +1016,5 @@ export {
1004 SidebarSeparator,1016 SidebarSeparator,
1005 SidebarTrigger,1017 SidebarTrigger,
1006 useSidebar,1018 useSidebar,
1019 useSidebarVisibility,
1007};1020};
apps/web/src/components/ui/sidebarState.ts created+9
...@@ -0,0 +1,9 @@
1export type ResponsiveSidebarState = "expanded" | "collapsed";
2
3export function resolveSidebarState(input: {
4 isMobile: boolean;
5 open: boolean;
6 openMobile: boolean;
7}): ResponsiveSidebarState {
8 return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed";
9}
apps/web/src/index.css+33
...@@ -7,13 +7,24 @@...@@ -7,13 +7,24 @@
7:root {7:root {
8 --workspace-topbar-height: 52px;8 --workspace-topbar-height: 52px;
9 --workspace-controls-top: 0px;9 --workspace-controls-top: 0px;
10 --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem);
10 --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem);11 --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem);
11 --workspace-native-controls-inset: 0px;12 --workspace-native-controls-inset: 0px;
13 --workspace-titlebar-control-size: 1.75rem;
14 --workspace-titlebar-control-gap: 0.75rem;
15}
16
17[data-slot="sidebar-wrapper"] {
18 --workspace-titlebar-content-left: calc(
19 var(--workspace-controls-left) + var(--workspace-titlebar-control-size) +
20 var(--workspace-titlebar-control-gap)
21 );
12}22}
1323
14.wco {24.wco {
15 --workspace-topbar-height: env(titlebar-area-height, 52px);25 --workspace-topbar-height: env(titlebar-area-height, 52px);
16 --workspace-controls-top: env(titlebar-area-y, 0px);26 --workspace-controls-top: env(titlebar-area-y, 0px);
27 --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem);
17 --workspace-controls-right: calc(28 --workspace-controls-right: calc(
18 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem29 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem
19 );30 );
...@@ -90,6 +101,28 @@...@@ -90,6 +101,28 @@
90}101}
91102
92@layer components {103@layer components {
104 .sidebar-brand {
105 display: none;
106 }
107
108 .sidebar-brand-stage {
109 display: none;
110 }
111
112 @media (min-width: 48rem) {
113 @container sidebar-header (min-width: 13.5rem) {
114 .sidebar-brand {
115 display: flex;
116 }
117 }
118
119 @container sidebar-header (min-width: 15.75rem) {
120 .sidebar-brand-stage {
121 display: inline-flex;
122 }
123 }
124 }
125
93 .workspace-topbar {126 .workspace-topbar {
94 display: flex;127 display: flex;
95 height: var(--workspace-topbar-height);128 height: var(--workspace-topbar-height);
apps/web/src/keybindings.test.ts+5
...@@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {...@@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
85}85}
8686
87const DEFAULT_BINDINGS = compile([87const DEFAULT_BINDINGS = compile([
88 { shortcut: modShortcut("b"), command: "sidebar.toggle" },
88 { shortcut: modShortcut("j"), command: "terminal.toggle" },89 { shortcut: modShortcut("j"), command: "terminal.toggle" },
89 { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" },90 { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" },
90 {91 {
...@@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => {...@@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => {
312 });313 });
313314
314 it("returns effective labels for non-terminal commands", () => {315 it("returns effective labels for non-terminal commands", () => {
316 assert.strictEqual(
317 shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"),
318 "⌘B",
319 );
315 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");320 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
316 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");321 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
317 assert.strictEqual(322 assert.strictEqual(
apps/web/src/routes/_chat.index.tsx+9-3
...@@ -4,10 +4,12 @@ import { LinkIcon, PlusIcon } from "lucide-react";...@@ -4,10 +4,12 @@ import { LinkIcon, PlusIcon } from "lucide-react";
4import { NoActiveThreadState } from "../components/NoActiveThreadState";4import { NoActiveThreadState } from "../components/NoActiveThreadState";
5import { Button } from "../components/ui/button";5import { Button } from "../components/ui/button";
6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";7import { SidebarInset } from "../components/ui/sidebar";
8import { useEnvironments } from "../state/environments";8import { useEnvironments } from "../state/environments";
9import { APP_DISPLAY_NAME } from "~/branding";9import { APP_DISPLAY_NAME } from "~/branding";
10import { hasCloudPublicConfig } from "~/cloud/publicConfig";10import { hasCloudPublicConfig } from "~/cloud/publicConfig";
11import { cn } from "~/lib/utils";
12import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
1113
12function ChatIndexRouteView() {14function ChatIndexRouteView() {
13 const { authGateState } = Route.useRouteContext();15 const { authGateState } = Route.useRouteContext();
...@@ -30,9 +32,13 @@ function HostedStaticOnboardingState() {...@@ -30,9 +32,13 @@ function HostedStaticOnboardingState() {
30 return (32 return (
31 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">33 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
32 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">34 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
33 <header className="border-b border-border px-3 py-2 sm:px-5 sm:py-3">35 <header
36 className={cn(
37 "border-b border-border px-3 py-2 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5 sm:py-3",
38 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
39 )}
40 >
34 <div className="flex items-center gap-2">41 <div className="flex items-center gap-2">
35 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
36 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">42 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
37 {APP_DISPLAY_NAME}43 {APP_DISPLAY_NAME}
38 </span>44 </span>
apps/web/src/routes/settings.tsx+15-4
...@@ -11,8 +11,10 @@ import { useCallback, useEffect, useState } from "react";...@@ -11,8 +11,10 @@ import { useCallback, useEffect, useState } from "react";
1111
12import { useSettingsRestore } from "../components/settings/SettingsPanels";12import { useSettingsRestore } from "../components/settings/SettingsPanels";
13import { Button } from "../components/ui/button";13import { Button } from "../components/ui/button";
14import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";14import { SidebarInset } from "../components/ui/sidebar";
15import { isElectron } from "../env";15import { isElectron } from "../env";
16import { cn } from "~/lib/utils";
17import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
1618
17function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {19function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
18 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);20 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
...@@ -64,9 +66,13 @@ function SettingsContentLayout() {...@@ -64,9 +66,13 @@ function SettingsContentLayout() {
64 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">66 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
65 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">67 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
66 {!isElectron && (68 {!isElectron && (
67 <header className="border-b border-border px-3 py-2 sm:px-5">69 <header
70 className={cn(
71 "border-b border-border px-3 py-2 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
72 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
73 )}
74 >
68 <div className="flex min-h-7 items-center gap-2 sm:min-h-6">75 <div className="flex min-h-7 items-center gap-2 sm:min-h-6">
69 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
70 <span className="text-sm font-medium text-foreground">Settings</span>76 <span className="text-sm font-medium text-foreground">Settings</span>
71 {showRestoreDefaults ? (77 {showRestoreDefaults ? (
72 <div className="ms-auto flex items-center gap-2">78 <div className="ms-auto flex items-center gap-2">
...@@ -78,7 +84,12 @@ function SettingsContentLayout() {...@@ -78,7 +84,12 @@ function SettingsContentLayout() {
78 )}84 )}
7985
80 {isElectron && (86 {isElectron && (
81 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5 wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]">87 <div
88 className={cn(
89 "drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
90 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
91 )}
92 >
82 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">93 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
83 Settings94 Settings
84 </span>95 </span>
apps/web/src/workspaceTitlebar.ts created+2
...@@ -0,0 +1,2 @@
1export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS =
2 "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]";
packages/contracts/src/keybindings.test.ts+6
...@@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () =>...@@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () =>
29 });29 });
30 assert.strictEqual(parsed.command, "terminal.toggle");30 assert.strictEqual(parsed.command, "terminal.toggle");
3131
32 const parsedSidebarToggle = yield* decode(KeybindingRule, {
33 key: "mod+b",
34 command: "sidebar.toggle",
35 });
36 assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle");
37
32 const parsedRightPanelToggle = yield* decode(KeybindingRule, {38 const parsedRightPanelToggle = yield* decode(KeybindingRule, {
33 key: "mod+alt+b",39 key: "mod+alt+b",
34 command: "rightPanel.toggle",40 command: "rightPanel.toggle",
packages/contracts/src/keybindings.ts+1
...@@ -48,6 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [...@@ -48,6 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [
48export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];48export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];
4949
50const STATIC_KEYBINDING_COMMANDS = [50const STATIC_KEYBINDING_COMMANDS = [
51 "sidebar.toggle",
51 "terminal.toggle",52 "terminal.toggle",
52 "terminal.split",53 "terminal.split",
53 "terminal.splitVertical",54 "terminal.splitVertical",
packages/shared/src/keybindings.ts+1
...@@ -19,6 +19,7 @@ type WhenToken =...@@ -19,6 +19,7 @@ type WhenToken =
19 | { type: "rparen" };19 | { type: "rparen" };
2020
21export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [21export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
22 { key: "mod+b", command: "sidebar.toggle" },
22 { key: "mod+j", command: "terminal.toggle" },23 { key: "mod+j", command: "terminal.toggle" },
23 { key: "mod+alt+b", command: "rightPanel.toggle" },24 { key: "mod+alt+b", command: "rightPanel.toggle" },
24 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },25 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },