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..0f1a8f9d429 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -1,14 +1,64 @@
-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 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}, [navigate]);
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 c874ee58a98..68a5855c1a2 100644
--- a/apps/web/src/components/NoActiveThreadState.tsx
+++ b/apps/web/src/components/NoActiveThreadState.tsx
@@ -1,7 +1,8 @@
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
-import { SidebarInset, SidebarTrigger } 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() {
return (
@@ -9,8 +10,9 @@ export function NoActiveThreadState() {
{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..ce925618caa 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,48 +2648,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
}: {
isElectron: boolean;
}) {
+ return isElectron ? (
+
+
+
+
+ ) : (
+
+
+
+
+ );
+});
+
+function SidebarBrand() {
+ const stageLabel = useSidebarStageLabel();
+
+ return (
+
+
+
+ Code
+
+
+ {stageLabel}
+
+
+ );
+}
+
+function useSidebarStageLabel() {
const primaryServerVersion =
useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
- const stageBadgeLabel = resolveSidebarStageBadgeLabel({
+
+ return resolveSidebarStageBadgeLabel({
primaryServerVersion,
fallbackStageLabel: APP_STAGE_LABEL,
});
- const wordmark = (
-
-
-
-
-
-
- Code
-
-
- {stageBadgeLabel}
-
-
- }
- />
-
- Version {APP_VERSION}
-
-
-
- );
+}
- return isElectron ? (
-
- {wordmark}
-
- ) : (
- {wordmark}
+function T3Wordmark() {
+ return (
+
);
-});
+}
const SidebarChromeFooter = memo(function SidebarChromeFooter() {
const navigate = useNavigate();
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index efc160b0bd1..ef3ec863d0b 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -14,7 +14,6 @@ import ProjectScriptsControl, {
type NewProjectScriptInput,
type ProjectScriptActionResult,
} from "../ProjectScriptsControl";
-import { SidebarTrigger } from "../ui/sidebar";
import { OpenInPicker } from "./OpenInPicker";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { cn } from "~/lib/utils";
@@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({
return (
-
{
+ 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(
+
+
+ ,
+ );
+
+ 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 718fc22b3fe..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;
@@ -85,6 +86,11 @@ function useSidebar() {
return context;
}
+function useSidebarVisibility() {
+ const { isMobile, open, openMobile } = useSidebar();
+ return isMobile ? openMobile : open;
+}
+
function SidebarProvider({
defaultOpen = true,
open: openProp,
@@ -132,7 +138,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(
() => ({
@@ -154,6 +160,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={
{
@@ -310,13 +317,18 @@ function Sidebar({
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) {
- const { toggleSidebar, openMobile } = useSidebar();
+ const { toggleSidebar } = useSidebar();
+ const isOpen = useSidebarVisibility();
return (
);
@@ -1004,4 +1016,5 @@ export {
SidebarSeparator,
SidebarTrigger,
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";
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 09ef006a638..148e8783b4a 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -7,13 +7,24 @@
: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;
+ --workspace-titlebar-control-size: 1.75rem;
+ --workspace-titlebar-control-gap: 0.75rem;
+}
+
+[data-slot="sidebar-wrapper"] {
+ --workspace-titlebar-content-left: calc(
+ var(--workspace-controls-left) + var(--workspace-titlebar-control-size) +
+ var(--workspace-titlebar-control-gap)
+ );
}
.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 +101,28 @@
}
@layer components {
+ .sidebar-brand {
+ display: none;
+ }
+
+ .sidebar-brand-stage {
+ display: none;
+ }
+
+ @media (min-width: 48rem) {
+ @container sidebar-header (min-width: 13.5rem) {
+ .sidebar-brand {
+ display: flex;
+ }
+ }
+
+ @container sidebar-header (min-width: 15.75rem) {
+ .sidebar-brand-stage {
+ display: inline-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..94d49d00afe 100644
--- a/apps/web/src/routes/_chat.index.tsx
+++ b/apps/web/src/routes/_chat.index.tsx
@@ -4,10 +4,12 @@ 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 } 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";
+import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
function ChatIndexRouteView() {
const { authGateState } = Route.useRouteContext();
@@ -30,9 +32,13 @@ function HostedStaticOnboardingState() {
return (
-
+
-
{APP_DISPLAY_NAME}
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx
index fa5c6a4201d..40507321066 100644
--- a/apps/web/src/routes/settings.tsx
+++ b/apps/web/src/routes/settings.tsx
@@ -11,8 +11,10 @@ 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 } 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);
@@ -64,9 +66,13 @@ function SettingsContentLayout() {
{!isElectron && (
-
+
-
Settings
{showRestoreDefaults ? (
@@ -78,7 +84,12 @@ function SettingsContentLayout() {
)}
{isElectron && (
-
+
Settings
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)]";
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" },