| author | |
| committer | |
| log | b2d17b7108766a989374d670ff12de39b6c4d033 |
| tree | c555eb0670a5cbff9244901e35f9e9be88a9c033 |
| parent | fb1034546f989c11568ef1c4b5801e6a5372a07d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 198 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 199 | 199 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 200 | 200 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); |
| 201 | assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); | |
| 201 | 202 | assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); |
| 202 | 203 | assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); |
| 203 | 204 | assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); |
apps/web/src/components/AppSidebarLayout.tsx+54-3| ... | ... | @@ -1,14 +1,64 @@ |
| 1 | import { useEffect, type ReactNode } from "react"; | |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { useEffect, type CSSProperties, type ReactNode } from "react"; | |
| 2 | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | 4 | |
| 5 | import { isElectron } from "../env"; | |
| 6 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | |
| 7 | import { isMacPlatform } from "../lib/utils"; | |
| 8 | import { primaryServerKeybindingsAtom } from "../state/server"; | |
| 4 | 9 | import ThreadSidebar from "./Sidebar"; |
| 5 | import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; | |
| 10 | import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; | |
| 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | |
| 6 | 12 | |
| 7 | 13 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; |
| 8 | 14 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; |
| 9 | 15 | const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; |
| 16 | const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; | |
| 17 | ||
| 18 | function 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 | ||
| 10 | 56 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 11 | 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; | |
| 12 | 62 | |
| 13 | 63 | useEffect(() => { |
| 14 | 64 | const onMenuAction = window.desktopBridge?.onMenuAction; |
| ... | ... | @@ -28,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 28 | 78 | }, [navigate]); |
| 29 | 79 | |
| 30 | 80 | return ( |
| 31 | <SidebarProvider className="h-dvh! min-h-0!" defaultOpen> | |
| 81 | <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}> | |
| 32 | 82 | <Sidebar |
| 33 | 83 | side="left" |
| 34 | 84 | collapsible="offcanvas" |
| ... | ... | @@ -44,6 +94,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 44 | 94 | <SidebarRail /> |
| 45 | 95 | </Sidebar> |
| 46 | 96 | {children} |
| 97 | <SidebarControl /> | |
| 47 | 98 | </SidebarProvider> |
| 48 | 99 | ); |
| 49 | 100 | } |
apps/web/src/components/ChatView.tsx+3-1| ... | ... | @@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar"; |
| 128 | 128 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 129 | 129 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 130 | 130 | import { cn, randomHex } from "~/lib/utils"; |
| 131 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 131 | 132 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 132 | 133 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 133 | 134 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| ... | ... | @@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4677 | 4678 | <header |
| 4678 | 4679 | data-chat-header |
| 4679 | 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 | 4682 | isElectron |
| 4682 | 4683 | ? cn( |
| 4683 | 4684 | "workspace-topbar drag-region relative px-3 sm:px-5", |
| ... | ... | @@ -4686,6 +4687,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4686 | 4687 | "wco:pr-[var(--workspace-native-controls-inset)]", |
| 4687 | 4688 | ) |
| 4688 | 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 | 4693 | {!rightPanelOpen ? panelLayoutControls : null} |
apps/web/src/components/NoActiveThreadState.tsx+4-3| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; |
| 2 | import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; | |
| 2 | import { SidebarInset } from "./ui/sidebar"; | |
| 3 | 3 | import { isElectron } from "../env"; |
| 4 | 4 | import { cn } from "~/lib/utils"; |
| 5 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 5 | 6 | |
| 6 | 7 | export function NoActiveThreadState() { |
| 7 | 8 | return ( |
| ... | ... | @@ -9,8 +10,9 @@ export function NoActiveThreadState() { |
| 9 | 10 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> |
| 10 | 11 | <header |
| 11 | 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 | 14 | isElectron ? "workspace-topbar drag-region" : "workspace-topbar", |
| 15 | COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, | |
| 14 | 16 | )} |
| 15 | 17 | > |
| 16 | 18 | {isElectron ? ( |
| ... | ... | @@ -19,7 +21,6 @@ export function NoActiveThreadState() { |
| 19 | 21 | </span> |
| 20 | 22 | ) : ( |
| 21 | 23 | <div className="flex items-center gap-2"> |
| 22 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | |
| 23 | 24 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> |
| 24 | 25 | No active thread |
| 25 | 26 | </span> |
apps/web/src/components/Sidebar.tsx+53-53| ... | ... | @@ -70,7 +70,7 @@ import { |
| 70 | 70 | type SidebarThreadSortOrder, |
| 71 | 71 | } from "@t3tools/contracts/settings"; |
| 72 | 72 | import { isElectron } from "../env"; |
| 73 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; | |
| 73 | import { APP_STAGE_LABEL } from "../branding"; | |
| 74 | 74 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 75 | 75 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 76 | 76 | import { isMacPlatform } from "../lib/utils"; |
| ... | ... | @@ -187,12 +187,12 @@ import { |
| 187 | 187 | resolveProjectStatusIndicator, |
| 188 | 188 | resolveSidebarNewThreadSeedContext, |
| 189 | 189 | resolveSidebarNewThreadEnvMode, |
| 190 | resolveSidebarStageBadgeLabel, | |
| 190 | 191 | resolveThreadRowClassName, |
| 191 | 192 | resolveThreadStatusPill, |
| 192 | 193 | orderItemsByPreferredIds, |
| 193 | 194 | shouldClearThreadSelectionOnMouseDown, |
| 194 | 195 | sortProjectsForSidebar, |
| 195 | resolveSidebarStageBadgeLabel, | |
| 196 | 196 | useThreadJumpHintVisibility, |
| 197 | 197 | ThreadStatusPill, |
| 198 | 198 | } from "./Sidebar.logic"; |
| ... | ... | @@ -2452,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar |
| 2452 | 2452 | ); |
| 2453 | 2453 | }); |
| 2454 | 2454 | |
| 2455 | function 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 | ||
| 2471 | 2455 | type SortableProjectHandleProps = Pick< |
| 2472 | 2456 | ReturnType<typeof useSortable>, |
| 2473 | 2457 | "attributes" | "listeners" | "setActivatorNodeRef" |
| ... | ... | @@ -2664,48 +2648,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 2664 | 2648 | }: { |
| 2665 | 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 | ||
| 2664 | function 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 | ||
| 2684 | function useSidebarStageLabel() { | |
| 2667 | 2685 | const primaryServerVersion = |
| 2668 | 2686 | useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; |
| 2669 | const stageBadgeLabel = resolveSidebarStageBadgeLabel({ | |
| 2687 | ||
| 2688 | return resolveSidebarStageBadgeLabel({ | |
| 2670 | 2689 | primaryServerVersion, |
| 2671 | 2690 | fallbackStageLabel: APP_STAGE_LABEL, |
| 2672 | 2691 | }); |
| 2673 | const wordmark = ( | |
| 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 | ); | |
| 2692 | } | |
| 2700 | 2693 | |
| 2701 | return isElectron ? ( | |
| 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)]"> | |
| 2703 | {wordmark} | |
| 2704 | </SidebarHeader> | |
| 2705 | ) : ( | |
| 2706 | <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">{wordmark}</SidebarHeader> | |
| 2694 | function T3Wordmark() { | |
| 2695 | return ( | |
| 2696 | <svg | |
| 2697 | aria-label="T3" | |
| 2698 | className="h-2.5 w-auto shrink-0 text-foreground" | |
| 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 | } | |
| 2709 | 2709 | |
| 2710 | 2710 | const SidebarChromeFooter = memo(function SidebarChromeFooter() { |
| 2711 | 2711 | const navigate = useNavigate(); |
apps/web/src/components/chat/ChatHeader.tsx-2| ... | ... | @@ -14,7 +14,6 @@ import ProjectScriptsControl, { |
| 14 | 14 | type NewProjectScriptInput, |
| 15 | 15 | type ProjectScriptActionResult, |
| 16 | 16 | } from "../ProjectScriptsControl"; |
| 17 | import { SidebarTrigger } from "../ui/sidebar"; | |
| 18 | 17 | import { OpenInPicker } from "./OpenInPicker"; |
| 19 | 18 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 20 | 19 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({ |
| 80 | 79 | return ( |
| 81 | 80 | <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> |
| 82 | 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 | 82 | <Tooltip> |
| 85 | 83 | <TooltipTrigger |
| 86 | 84 | render={ |
apps/web/src/components/ui/sidebar.test.tsx+33| ... | ... | @@ -6,7 +6,9 @@ import { |
| 6 | 6 | SidebarMenuButton, |
| 7 | 7 | SidebarMenuSubButton, |
| 8 | 8 | SidebarProvider, |
| 9 | SidebarTrigger, | |
| 9 | 10 | } from "./sidebar"; |
| 11 | import { resolveSidebarState } from "./sidebarState"; | |
| 10 | 12 | |
| 11 | 13 | function renderSidebarButton(className?: string) { |
| 12 | 14 | return renderToStaticMarkup( |
| ... | ... | @@ -17,6 +19,37 @@ function renderSidebarButton(className?: string) { |
| 17 | 19 | } |
| 18 | 20 | |
| 19 | 21 | describe("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 | 53 | it("uses a pointer cursor for menu buttons by default", () => { |
| 21 | 54 | const html = renderSidebarButton(); |
| 22 | 55 |
apps/web/src/components/ui/sidebar.tsx+18-5| ... | ... | @@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton"; |
| 19 | 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 20 | 20 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 21 | 21 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; |
| 22 | import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; | |
| 22 | 23 | import * as Schema from "effect/Schema"; |
| 23 | 24 | |
| 24 | 25 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; |
| ... | ... | @@ -29,7 +30,7 @@ const SIDEBAR_WIDTH_ICON = "3rem"; |
| 29 | 30 | const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; |
| 30 | 31 | |
| 31 | 32 | type SidebarContextProps = { |
| 32 | state: "expanded" | "collapsed"; | |
| 33 | state: ResponsiveSidebarState; | |
| 33 | 34 | open: boolean; |
| 34 | 35 | setOpen: (open: boolean) => void; |
| 35 | 36 | openMobile: boolean; |
| ... | ... | @@ -85,6 +86,11 @@ function useSidebar() { |
| 85 | 86 | return context; |
| 86 | 87 | } |
| 87 | 88 | |
| 89 | function useSidebarVisibility() { | |
| 90 | const { isMobile, open, openMobile } = useSidebar(); | |
| 91 | return isMobile ? openMobile : open; | |
| 92 | } | |
| 93 | ||
| 88 | 94 | function SidebarProvider({ |
| 89 | 95 | defaultOpen = true, |
| 90 | 96 | open: openProp, |
| ... | ... | @@ -132,7 +138,7 @@ function SidebarProvider({ |
| 132 | 138 | |
| 133 | 139 | // We add a state so that we can do data-state="expanded" or "collapsed". |
| 134 | 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 }); | |
| 136 | 142 | |
| 137 | 143 | const contextValue = React.useMemo<SidebarContextProps>( |
| 138 | 144 | () => ({ |
| ... | ... | @@ -154,6 +160,7 @@ function SidebarProvider({ |
| 154 | 160 | "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", |
| 155 | 161 | className, |
| 156 | 162 | )} |
| 163 | data-sidebar-state={state} | |
| 157 | 164 | data-slot="sidebar-wrapper" |
| 158 | 165 | style={ |
| 159 | 166 | { |
| ... | ... | @@ -310,13 +317,18 @@ function Sidebar({ |
| 310 | 317 | } |
| 311 | 318 | |
| 312 | 319 | function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { |
| 313 | const { toggleSidebar, openMobile } = useSidebar(); | |
| 320 | const { toggleSidebar } = useSidebar(); | |
| 321 | const isOpen = useSidebarVisibility(); | |
| 314 | 322 | |
| 315 | 323 | return ( |
| 316 | 324 | <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 | 329 | data-sidebar="trigger" |
| 319 | 330 | data-slot="sidebar-trigger" |
| 331 | aria-pressed={isOpen} | |
| 320 | 332 | onClick={(event) => { |
| 321 | 333 | onClick?.(event); |
| 322 | 334 | toggleSidebar(); |
| ... | ... | @@ -325,7 +337,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t |
| 325 | 337 | variant="ghost" |
| 326 | 338 | {...props} |
| 327 | 339 | > |
| 328 | {openMobile ? <PanelLeftCloseIcon /> : <PanelLeftIcon />} | |
| 340 | {isOpen ? <PanelLeftCloseIcon /> : <PanelLeftIcon />} | |
| 329 | 341 | <span className="sr-only">Toggle Sidebar</span> |
| 330 | 342 | </Button> |
| 331 | 343 | ); |
| ... | ... | @@ -1004,4 +1016,5 @@ export { |
| 1004 | 1016 | SidebarSeparator, |
| 1005 | 1017 | SidebarTrigger, |
| 1006 | 1018 | useSidebar, |
| 1019 | useSidebarVisibility, | |
| 1007 | 1020 | }; |
apps/web/src/components/ui/sidebarState.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | export type ResponsiveSidebarState = "expanded" | "collapsed"; | |
| 2 | ||
| 3 | export 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 | 7 | :root { |
| 8 | 8 | --workspace-topbar-height: 52px; |
| 9 | 9 | --workspace-controls-top: 0px; |
| 10 | --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); | |
| 10 | 11 | --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); |
| 11 | 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 | } |
| 13 | 23 | |
| 14 | 24 | .wco { |
| 15 | 25 | --workspace-topbar-height: env(titlebar-area-height, 52px); |
| 16 | 26 | --workspace-controls-top: env(titlebar-area-y, 0px); |
| 27 | --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem); | |
| 17 | 28 | --workspace-controls-right: calc( |
| 18 | 29 | 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem |
| 19 | 30 | ); |
| ... | ... | @@ -90,6 +101,28 @@ |
| 90 | 101 | } |
| 91 | 102 | |
| 92 | 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 | 126 | .workspace-topbar { |
| 94 | 127 | display: flex; |
| 95 | 128 | height: var(--workspace-topbar-height); |
apps/web/src/keybindings.test.ts+5| ... | ... | @@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | 87 | const DEFAULT_BINDINGS = compile([ |
| 88 | { shortcut: modShortcut("b"), command: "sidebar.toggle" }, | |
| 88 | 89 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, |
| 89 | 90 | { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, |
| 90 | 91 | { |
| ... | ... | @@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => { |
| 312 | 313 | }); |
| 313 | 314 | |
| 314 | 315 | it("returns effective labels for non-terminal commands", () => { |
| 316 | assert.strictEqual( | |
| 317 | shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), | |
| 318 | "⌘B", | |
| 319 | ); | |
| 315 | 320 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); |
| 316 | 321 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); |
| 317 | 322 | assert.strictEqual( |
apps/web/src/routes/_chat.index.tsx+9-3| ... | ... | @@ -4,10 +4,12 @@ import { LinkIcon, PlusIcon } from "lucide-react"; |
| 4 | 4 | import { NoActiveThreadState } from "../components/NoActiveThreadState"; |
| 5 | 5 | import { Button } from "../components/ui/button"; |
| 6 | 6 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; |
| 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | |
| 7 | import { SidebarInset } from "../components/ui/sidebar"; | |
| 8 | 8 | import { useEnvironments } from "../state/environments"; |
| 9 | 9 | import { APP_DISPLAY_NAME } from "~/branding"; |
| 10 | 10 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 11 | import { cn } from "~/lib/utils"; | |
| 12 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 11 | 13 | |
| 12 | 14 | function ChatIndexRouteView() { |
| 13 | 15 | const { authGateState } = Route.useRouteContext(); |
| ... | ... | @@ -30,9 +32,13 @@ function HostedStaticOnboardingState() { |
| 30 | 32 | return ( |
| 31 | 33 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 32 | 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 | 41 | <div className="flex items-center gap-2"> |
| 35 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | |
| 36 | 42 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> |
| 37 | 43 | {APP_DISPLAY_NAME} |
| 38 | 44 | </span> |
apps/web/src/routes/settings.tsx+15-4| ... | ... | @@ -11,8 +11,10 @@ import { useCallback, useEffect, useState } from "react"; |
| 11 | 11 | |
| 12 | 12 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 13 | 13 | import { Button } from "../components/ui/button"; |
| 14 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | |
| 14 | import { SidebarInset } from "../components/ui/sidebar"; | |
| 15 | 15 | import { isElectron } from "../env"; |
| 16 | import { cn } from "~/lib/utils"; | |
| 17 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | |
| 16 | 18 | |
| 17 | 19 | function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { |
| 18 | 20 | const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); |
| ... | ... | @@ -64,9 +66,13 @@ function SettingsContentLayout() { |
| 64 | 66 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 65 | 67 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 66 | 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 | 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 | 76 | <span className="text-sm font-medium text-foreground">Settings</span> |
| 71 | 77 | {showRestoreDefaults ? ( |
| 72 | 78 | <div className="ms-auto flex items-center gap-2"> |
| ... | ... | @@ -78,7 +84,12 @@ function SettingsContentLayout() { |
| 78 | 84 | )} |
| 79 | 85 | |
| 80 | 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 | 93 | <span className="text-xs font-medium tracking-wide text-muted-foreground/70"> |
| 83 | 94 | Settings |
| 84 | 95 | </span> |
apps/web/src/workspaceTitlebar.ts created+2| ... | ... | @@ -0,0 +1,2 @@ |
| 1 | export 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 | 29 | }); |
| 30 | 30 | assert.strictEqual(parsed.command, "terminal.toggle"); |
| 31 | 31 | |
| 32 | const parsedSidebarToggle = yield* decode(KeybindingRule, { | |
| 33 | key: "mod+b", | |
| 34 | command: "sidebar.toggle", | |
| 35 | }); | |
| 36 | assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); | |
| 37 | ||
| 32 | 38 | const parsedRightPanelToggle = yield* decode(KeybindingRule, { |
| 33 | 39 | key: "mod+alt+b", |
| 34 | 40 | command: "rightPanel.toggle", |
packages/contracts/src/keybindings.ts+1| ... | ... | @@ -48,6 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ |
| 48 | 48 | export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; |
| 49 | 49 | |
| 50 | 50 | const STATIC_KEYBINDING_COMMANDS = [ |
| 51 | "sidebar.toggle", | |
| 51 | 52 | "terminal.toggle", |
| 52 | 53 | "terminal.split", |
| 53 | 54 | "terminal.splitVertical", |
packages/shared/src/keybindings.ts+1| ... | ... | @@ -19,6 +19,7 @@ type WhenToken = |
| 19 | 19 | | { type: "rparen" }; |
| 20 | 20 | |
| 21 | 21 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 22 | { key: "mod+b", command: "sidebar.toggle" }, | |
| 22 | 23 | { key: "mod+j", command: "terminal.toggle" }, |
| 23 | 24 | { key: "mod+alt+b", command: "rightPanel.toggle" }, |
| 24 | 25 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |