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" },