From 699be75d846cf978428de669e3675b754a80dca2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 21 Jun 2026 14:30:44 -0700 Subject: [PATCH 1/6] Add main sidebar toggle --- apps/server/src/keybindings.test.ts | 1 + apps/web/src/components/AppSidebarLayout.tsx | 50 ++++++++- .../src/components/NoActiveThreadState.tsx | 7 +- apps/web/src/components/Sidebar.tsx | 104 +++++++++--------- apps/web/src/components/chat/ChatHeader.tsx | 11 +- apps/web/src/components/ui/sidebar.tsx | 14 ++- apps/web/src/index.css | 14 +++ apps/web/src/keybindings.test.ts | 5 + apps/web/src/routes/_chat.index.tsx | 12 +- apps/web/src/routes/settings.tsx | 19 +++- packages/contracts/src/keybindings.test.ts | 6 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 13 files changed, 171 insertions(+), 74 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index ba95422735c..a51ad20afbe 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index cbfce7b43d0..57d712a03d9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,14 +1,57 @@ -import { useEffect, type ReactNode } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { useEffect, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; +import { isElectron } from "../env"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isMacPlatform } from "../lib/utils"; +import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; +const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; + +function SidebarControl() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { toggleSidebar } = useSidebar(); + const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; + + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, toggleSidebar]); + + return ( +
+ + } /> + + Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + + +
+ ); +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const macosWindowControlsStyle = + isElectron && isMacPlatform(navigator.platform) + ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) + : undefined; useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -28,7 +71,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + +
{isElectron ? ( @@ -19,7 +21,6 @@ export function NoActiveThreadState() { ) : (
- No active thread diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3ed88bd3b9..3d4013799c8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -70,7 +70,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -187,12 +187,12 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - resolveSidebarStageBadgeLabel, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -2452,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); -function T3Wordmark() { - return ( - - - - ); -} - type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" @@ -2664,52 +2648,59 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - const stageBadgeLabel = resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); - const wordmark = ( -
- - - - - - Code - - - {stageBadgeLabel} - - - } - /> - - Version {APP_VERSION} - - -
- ); - return isElectron ? ( - - {wordmark} + + + ) : ( - {wordmark} + + + + ); }); +function SidebarBrand() { + return ( + + + + Code + + + ); +} + +function T3Wordmark() { + return ( + + + + ); +} + const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + const stageLabel = resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); const handleSettingsClick = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -2730,6 +2721,9 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { > Settings + + {stageLabel} + diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index efc160b0bd1..bcbddef96ad 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -14,8 +14,8 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; +import { useSidebarVisibility } from "../ui/sidebar"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { cn } from "~/lib/utils"; @@ -72,6 +72,7 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteProjectScript, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); + const sidebarOpen = useSidebarVisibility(); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, @@ -79,8 +80,12 @@ export const ChatHeader = memo(function ChatHeader({ }); return (
-
- +
) { - const { toggleSidebar, openMobile } = useSidebar(); + const { toggleSidebar } = useSidebar(); + const isOpen = useSidebarVisibility(); return ( ); @@ -1004,4 +1011,5 @@ export { SidebarSeparator, SidebarTrigger, useSidebar, + useSidebarVisibility, }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 09ef006a638..031048854aa 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -7,6 +7,7 @@ :root { --workspace-topbar-height: 52px; --workspace-controls-top: 0px; + --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); --workspace-native-controls-inset: 0px; } @@ -14,6 +15,7 @@ .wco { --workspace-topbar-height: env(titlebar-area-height, 52px); --workspace-controls-top: env(titlebar-area-y, 0px); + --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem); --workspace-controls-right: calc( 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem ); @@ -90,6 +92,18 @@ } @layer components { + .sidebar-brand { + display: none; + } + + @media (min-width: 48rem) { + @container sidebar-header (min-width: 14.5rem) { + .sidebar-brand { + display: flex; + } + } + } + .workspace-topbar { display: flex; height: var(--workspace-topbar-height); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d4fc945cc04..c0d326edd55 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { } const DEFAULT_BINDINGS = compile([ + { shortcut: modShortcut("b"), command: "sidebar.toggle" }, { shortcut: modShortcut("j"), command: "terminal.toggle" }, { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, { @@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => { }); it("returns effective labels for non-terminal commands", () => { + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", + ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 7be0f50414e..727f10f8d45 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,10 +4,11 @@ import { LinkIcon, PlusIcon } from "lucide-react"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset, useSidebarVisibility } from "../components/ui/sidebar"; import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { cn } from "~/lib/utils"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -26,13 +27,18 @@ export const Route = createFileRoute("/_chat/")({ function HostedStaticOnboardingState() { const cloudEnabled = hasCloudPublicConfig(); + const sidebarOpen = useSidebarVisibility(); return (
-
+
- {APP_DISPLAY_NAME} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index fa5c6a4201d..064f7b22314 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -11,8 +11,9 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset, useSidebarVisibility } from "../components/ui/sidebar"; import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -35,6 +36,7 @@ function SettingsContentLayout() { const navigate = useNavigate(); const canGoBack = useCanGoBack(); const [restoreSignal, setRestoreSignal] = useState(0); + const sidebarOpen = useSidebarVisibility(); const showRestoreDefaults = location.pathname === "/settings/general"; const handleRestored = () => setRestoreSignal((value) => value + 1); const navigateBackWithinApp = useCallback(() => { @@ -64,9 +66,13 @@ function SettingsContentLayout() {
{!isElectron && ( -
+
- Settings {showRestoreDefaults ? (
@@ -78,7 +84,12 @@ function SettingsContentLayout() { )} {isElectron && ( -
+
Settings diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 19c98c390c3..33ecd38039f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsed.command, "terminal.toggle"); + const parsedSidebarToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "sidebar.toggle", + }); + assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", command: "rightPanel.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4a5ffd0c3dd..c7cff9943cd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -48,6 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; const STATIC_KEYBINDING_COMMANDS = [ + "sidebar.toggle", "terminal.toggle", "terminal.split", "terminal.splitVertical", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 4abe53f2053..b6bdd7b4783 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -19,6 +19,7 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+b", command: "sidebar.toggle" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, From 8399d1b9a8930d643985556909d96d4c6f746bac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 10:39:48 -0700 Subject: [PATCH 2/6] Keep topbar padding aligned when sidebar is closed - Apply the closed-sidebar left padding at the `sm` breakpoint too - Keep the topbar/header spacing consistent on chat, onboarding, and settings screens --- apps/web/src/components/NoActiveThreadState.tsx | 3 ++- apps/web/src/routes/_chat.index.tsx | 3 ++- apps/web/src/routes/settings.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 3697f09f15c..73d36ff3a8c 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -12,7 +12,8 @@ export function NoActiveThreadState() { className={cn( "border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", isElectron ? "workspace-topbar drag-region" : "workspace-topbar", - !sidebarOpen && "pl-[calc(var(--workspace-controls-left)+2.5rem)]", + !sidebarOpen && + "pl-[calc(var(--workspace-controls-left)+2.5rem)] sm:pl-[calc(var(--workspace-controls-left)+2.5rem)]", )} > {isElectron ? ( diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 727f10f8d45..6f882c57eb3 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -35,7 +35,8 @@ function HostedStaticOnboardingState() {
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 064f7b22314..4b7490ee6e2 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -69,7 +69,8 @@ function SettingsContentLayout() {
From 7889515d556e4c80c3dfad9751e523f5ca760fd6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 13:26:48 -0700 Subject: [PATCH 3/6] Refine sidebar titlebar inset handling - Move the sidebar toggle and shared titlebar inset styling into reusable helpers - Keep header controls aligned when the sidebar is collapsed - Update sidebar tests for the new trigger sizing and collapsed state --- .../src/components/AppSidebarLayout.test.tsx | 48 +++++++++++++++++++ apps/web/src/components/AppSidebarLayout.tsx | 13 +++-- apps/web/src/components/ChatView.tsx | 4 +- .../src/components/NoActiveThreadState.tsx | 7 ++- apps/web/src/components/Sidebar.tsx | 30 +++++++----- apps/web/src/components/chat/ChatHeader.tsx | 9 +--- apps/web/src/components/ui/sidebar.test.tsx | 22 +++++++++ apps/web/src/components/ui/sidebar.tsx | 6 ++- apps/web/src/index.css | 21 +++++++- apps/web/src/routes/_chat.index.tsx | 7 ++- apps/web/src/routes/settings.tsx | 9 ++-- apps/web/src/workspaceTitlebar.ts | 2 + 12 files changed, 139 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/AppSidebarLayout.test.tsx create mode 100644 apps/web/src/workspaceTitlebar.ts diff --git a/apps/web/src/components/AppSidebarLayout.test.tsx b/apps/web/src/components/AppSidebarLayout.test.tsx new file mode 100644 index 00000000000..423f04cdf8b --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.test.tsx @@ -0,0 +1,48 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { AppSidebarLayout } from "./AppSidebarLayout"; + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({}), +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), +})); + +vi.mock("../keybindings", () => ({ + resolveShortcutCommand: () => null, + shortcutLabelForCommand: () => null, +})); + +vi.mock("../state/server", () => ({ + primaryServerKeybindingsAtom: {}, +})); + +vi.mock("./Sidebar", () => ({ + default: () =>
, +})); + +describe("AppSidebarLayout", () => { + it("paints the sidebar trigger after titlebar drag surfaces", () => { + const html = renderToStaticMarkup( + +
+ , + ); + + const mainContentIndex = html.indexOf('data-main-content=""'); + const sidebarControlIndex = html.indexOf('data-sidebar-control=""'); + + expect(mainContentIndex).toBeGreaterThan(-1); + expect(sidebarControlIndex).toBeGreaterThan(mainContentIndex); + + const controlTag = html.slice( + html.lastIndexOf("", sidebarControlIndex), + ); + expect(controlTag).toContain("pointer-events-none"); + expect(controlTag).not.toContain("-webkit-app-region:no-drag"); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 57d712a03d9..0f1a8f9d429 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -35,9 +35,16 @@ function SidebarControl() { }, [keybindings, toggleSidebar]); return ( -
+
- } /> + + } + /> Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} @@ -72,7 +79,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( - {children} + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf5bb9de5e9..44429614b44 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) {
{!rightPanelOpen ? panelLayoutControls : null} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 73d36ff3a8c..68a5855c1a2 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,10 +1,10 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; -import { SidebarInset, useSidebarVisibility } from "./ui/sidebar"; +import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export function NoActiveThreadState() { - const sidebarOpen = useSidebarVisibility(); return (
@@ -12,8 +12,7 @@ export function NoActiveThreadState() { className={cn( "border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5", isElectron ? "workspace-topbar drag-region" : "workspace-topbar", - !sidebarOpen && - "pl-[calc(var(--workspace-controls-left)+2.5rem)] sm:pl-[calc(var(--workspace-controls-left)+2.5rem)]", + COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > {isElectron ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3d4013799c8..ce925618caa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2649,12 +2649,12 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron: boolean; }) { return isElectron ? ( - + ) : ( - + @@ -2662,20 +2662,35 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }); function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + return ( Code + + {stageLabel} + ); } +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + function T3Wordmark() { return ( { if (isMobile) { setOpenMobile(false); @@ -2721,9 +2730,6 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { > Settings - - {stageLabel} - diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index bcbddef96ad..ef3ec863d0b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -15,7 +15,6 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; -import { useSidebarVisibility } from "../ui/sidebar"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { cn } from "~/lib/utils"; @@ -72,7 +71,6 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteProjectScript, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); - const sidebarOpen = useSidebarVisibility(); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, @@ -80,12 +78,7 @@ export const ChatHeader = memo(function ChatHeader({ }); return (
-
+
{ + it("exposes collapsed state for shared titlebar inset styling", () => { + const html = renderToStaticMarkup( + +
+ , + ); + + expect(html).toContain('data-sidebar-state="collapsed"'); + }); + + it("keeps the sidebar trigger interactive inside Electron drag regions", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("[-webkit-app-region:no-drag]"); + expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!"); + }); + it("uses a pointer cursor for menu buttons by default", () => { const html = renderSidebarButton(); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 773a41cb4ff..2abcbc3cdb4 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -159,6 +159,7 @@ function SidebarProvider({ "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className, )} + data-sidebar-state={state} data-slot="sidebar-wrapper" style={ { @@ -320,7 +321,10 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps @@ -35,8 +35,7 @@ function HostedStaticOnboardingState() {
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 4b7490ee6e2..40507321066 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -11,9 +11,10 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; -import { SidebarInset, useSidebarVisibility } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -36,7 +37,6 @@ function SettingsContentLayout() { const navigate = useNavigate(); const canGoBack = useCanGoBack(); const [restoreSignal, setRestoreSignal] = useState(0); - const sidebarOpen = useSidebarVisibility(); const showRestoreDefaults = location.pathname === "/settings/general"; const handleRestored = () => setRestoreSignal((value) => value + 1); const navigateBackWithinApp = useCallback(() => { @@ -69,8 +69,7 @@ function SettingsContentLayout() {
@@ -88,7 +87,7 @@ function SettingsContentLayout() {
diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts new file mode 100644 index 00000000000..b481221e63a --- /dev/null +++ b/apps/web/src/workspaceTitlebar.ts @@ -0,0 +1,2 @@ +export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = + "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; From 6cd4760c9b9afc969879df67893532cd8b29cd25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 13:27:49 -0700 Subject: [PATCH 4/6] Delete apps/web/src/components/AppSidebarLayout.test.tsx --- .../src/components/AppSidebarLayout.test.tsx | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 apps/web/src/components/AppSidebarLayout.test.tsx diff --git a/apps/web/src/components/AppSidebarLayout.test.tsx b/apps/web/src/components/AppSidebarLayout.test.tsx deleted file mode 100644 index 423f04cdf8b..00000000000 --- a/apps/web/src/components/AppSidebarLayout.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { AppSidebarLayout } from "./AppSidebarLayout"; - -vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({}), -})); - -vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => vi.fn(), -})); - -vi.mock("../keybindings", () => ({ - resolveShortcutCommand: () => null, - shortcutLabelForCommand: () => null, -})); - -vi.mock("../state/server", () => ({ - primaryServerKeybindingsAtom: {}, -})); - -vi.mock("./Sidebar", () => ({ - default: () =>
, -})); - -describe("AppSidebarLayout", () => { - it("paints the sidebar trigger after titlebar drag surfaces", () => { - const html = renderToStaticMarkup( - -
- , - ); - - const mainContentIndex = html.indexOf('data-main-content=""'); - const sidebarControlIndex = html.indexOf('data-sidebar-control=""'); - - expect(mainContentIndex).toBeGreaterThan(-1); - expect(sidebarControlIndex).toBeGreaterThan(mainContentIndex); - - const controlTag = html.slice( - html.lastIndexOf("", sidebarControlIndex), - ); - expect(controlTag).toContain("pointer-events-none"); - expect(controlTag).not.toContain("-webkit-app-region:no-drag"); - }); -}); From 86f1fb300b2363978cba6f258bec5abcb22a3148 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 13:37:07 -0700 Subject: [PATCH 5/6] Fix sidebar state on mobile - Resolve shared sidebar state from the active responsive flag - Add coverage for mobile sheet visibility precedence --- apps/web/src/components/ui/sidebar.test.tsx | 11 +++++++++++ apps/web/src/components/ui/sidebar.tsx | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index 2cb1ae3dabf..350a9d2e03e 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -7,6 +7,7 @@ import { SidebarMenuSubButton, SidebarProvider, SidebarTrigger, + resolveSidebarState, } from "./sidebar"; function renderSidebarButton(className?: string) { @@ -18,6 +19,16 @@ function renderSidebarButton(className?: string) { } describe("sidebar interactive cursors", () => { + it("uses mobile sheet visibility for the shared responsive state", () => { + expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( + "collapsed", + ); + expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded"); + expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe( + "expanded", + ); + }); + it("exposes collapsed state for shared titlebar inset styling", () => { const html = renderToStaticMarkup( diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 2abcbc3cdb4..03ae4ffc9d3 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -90,6 +90,14 @@ function useSidebarVisibility() { return isMobile ? openMobile : open; } +function resolveSidebarState(input: { + isMobile: boolean; + open: boolean; + openMobile: boolean; +}): SidebarContextProps["state"] { + return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed"; +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -137,7 +145,7 @@ function SidebarProvider({ // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + const state = resolveSidebarState({ isMobile, open, openMobile }); const contextValue = React.useMemo( () => ({ @@ -1014,6 +1022,7 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, + resolveSidebarState, useSidebar, useSidebarVisibility, }; From c0f9412a45e5d85a6b31ad0025e2e4cd58ec3475 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 13:42:51 -0700 Subject: [PATCH 6/6] Keep sidebar state helper Fast Refresh safe Co-authored-by: codex --- apps/web/src/components/ui/sidebar.test.tsx | 2 +- apps/web/src/components/ui/sidebar.tsx | 12 ++---------- apps/web/src/components/ui/sidebarState.ts | 9 +++++++++ 3 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/ui/sidebarState.ts diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index 350a9d2e03e..904f8664772 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -7,8 +7,8 @@ import { SidebarMenuSubButton, SidebarProvider, SidebarTrigger, - resolveSidebarState, } from "./sidebar"; +import { resolveSidebarState } from "./sidebarState"; function renderSidebarButton(className?: string) { return renderToStaticMarkup( diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 03ae4ffc9d3..097568f77f0 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; @@ -29,7 +30,7 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; type SidebarContextProps = { - state: "expanded" | "collapsed"; + state: ResponsiveSidebarState; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; @@ -90,14 +91,6 @@ function useSidebarVisibility() { return isMobile ? openMobile : open; } -function resolveSidebarState(input: { - isMobile: boolean; - open: boolean; - openMobile: boolean; -}): SidebarContextProps["state"] { - return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed"; -} - function SidebarProvider({ defaultOpen = true, open: openProp, @@ -1022,7 +1015,6 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, - resolveSidebarState, useSidebar, useSidebarVisibility, }; diff --git a/apps/web/src/components/ui/sidebarState.ts b/apps/web/src/components/ui/sidebarState.ts new file mode 100644 index 00000000000..fcdfed10521 --- /dev/null +++ b/apps/web/src/components/ui/sidebarState.ts @@ -0,0 +1,9 @@ +export type ResponsiveSidebarState = "expanded" | "collapsed"; + +export function resolveSidebarState(input: { + isMobile: boolean; + open: boolean; + openMobile: boolean; +}): ResponsiveSidebarState { + return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed"; +}