Skip to content
466 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The left-hand navigation takes less room and reads at a glance. The dock becomes a rail: a 56-pixel column of icons with the g1t mark on top, the current app marked by a filled square, hover hints naming each with its shortcut, the apps you pinned in a scrolling band, and Apps, People, Workspace and your account at the foot. Each mode's sidebar sits flush against it, 240 pixels wide, opening on Search or jump to and listing its groups under small labels in 36-pixel rows; Ctrl B hides it and the page grows into the room. Every mode's list is built on the shadcn Sidebar now in the kit, so Workspace, Code, People, Notifications, account settings, Chat, Artifacts and Agents all share one frame; the phone keeps its bottom bar and drawer. The workspaces guide describes the rail, the sidebar and the page.1import { type VariantProps, cva } from "class-variance-authority";
2import { PanelLeft } from "lucide-react";
3import { Slot } from "radix-ui";
4import { type ComponentProps, type CSSProperties, type ReactNode, createContext, useCallback, useContext, useMemo } from "react";
5
6import { cn } from "../../lib/cn";
7import { Button } from "./button";
8import { Hint } from "./hint";
9import { Input } from "./input";
10import { Separator } from "./separator";
11import { Sheet, SheetContent, SheetTitle } from "./sheet";
12
13// shadcn/ui's sidebar, styled with g1t's tokens: the frame of the rail and
14// the mode sidebar down the left of the app (components/shell.tsx,
15// components/rail.tsx), and of the drawer that stands in for them on a
16// phone. A provider holds whether it is open; `Sidebar` draws the column;
17// header, content and footer stack inside it; groups hold a label and a
18// menu of rows; `SidebarMenuButton` is one row, or one icon of the rail.
19//
20// The colours are the sidebar's own tokens (app.css): `sidebar` for its
21// surface, `sidebar-accent` for the current row, `sidebar-border` for the
22// hairlines, and `rail` for the rail's surface.
23//
24// Unlike shadcn's, the provider does not read the viewport: the page is
25// drawn on the server, so the desktop column and the phone's drawer are
26// both rendered and CSS shows one of them (`hiddenBelow`). Whoever toggles
27// the sidebar decides which one to move (the shell asks `matchMedia`).
28
29export const SIDEBAR_WIDTH = "15rem";
30export const SIDEBAR_WIDTH_ICON = "3.5rem";
31
32type SidebarContextValue = {
33 /** The desktop column: shown beside the page, or folded away. */
34 open: boolean;
35 setOpen: (open: boolean) => void;
36 /** The drawer, on a phone or a narrow window. */
37 openMobile: boolean;
38 setOpenMobile: (open: boolean) => void;
39 /** Shows or hides whichever of the two the window is using. */
40 toggleSidebar: () => void;
41 state: "expanded" | "collapsed";
42};
43
44const SidebarContext = createContext<SidebarContextValue | null>(null);
45
46export function useSidebar(): SidebarContextValue {
47 const context = useContext(SidebarContext);
48 if (!context) throw new Error("useSidebar must be used within a SidebarProvider.");
49 return context;
50}
51
52export function SidebarProvider({
53 open,
54 onOpenChange,
55 openMobile,
56 onOpenMobileChange,
57 toggleSidebar,
58 className,
59 style,
60 children,
61 ...props
62}: Omit<ComponentProps<"div">, "children"> & {
63 open: boolean;
64 onOpenChange: (open: boolean) => void;
65 openMobile: boolean;
66 onOpenMobileChange: (open: boolean) => void;
67 /** Shows or hides the column or the drawer, whichever the window is using. */
68 toggleSidebar: () => void;
69 children: ReactNode;
70}) {
71 const setOpen = useCallback((next: boolean) => onOpenChange(next), [onOpenChange]);
72 const setOpenMobile = useCallback((next: boolean) => onOpenMobileChange(next), [onOpenMobileChange]);
73 const value = useMemo<SidebarContextValue>(
74 () => ({ open, setOpen, openMobile, setOpenMobile, toggleSidebar, state: open ? "expanded" : "collapsed" }),
75 [open, setOpen, openMobile, setOpenMobile, toggleSidebar],
76 );
77 return (
78 <SidebarContext.Provider value={value}>
79 <div
80 data-slot="sidebar-wrapper"
81 style={{ "--sidebar-width": SIDEBAR_WIDTH, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, ...style } as CSSProperties}
82 className={cn("group/sidebar-wrapper contents", className)}
83 {...props}
84 >
85 {children}
86 </div>
87 </SidebarContext.Provider>
88 );
89}
90
91/**
92 * The column itself. `variant="sidebar"` is the mode sidebar: the
93 * sidebar's surface, a hairline on its page side. `variant="rail"` is the
94 * narrow column of icons: the rail's surface. `collapsible="offcanvas"`
95 * folds the column away when the provider says it is closed; `"icon"`
96 * narrows it to its icons (rows hide their words, `group-data-
97 * [collapsible=icon]`); `"none"` always shows it in full. The column is
98 * positioned by whoever holds it (`className`); `hiddenBelow` names the
99 * breakpoint under which only the drawer (`mobile`) is drawn.
100 */
101export function Sidebar({
102 side = "left",
103 variant = "sidebar",
104 collapsible = "offcanvas",
105 className,
106 children,
107 ...props
108}: ComponentProps<"div"> & {
109 side?: "left" | "right";
110 variant?: "sidebar" | "rail";
111 collapsible?: "offcanvas" | "icon" | "none";
112}) {
113 const { state } = useSidebar();
114 const collapsed = collapsible !== "none" && state === "collapsed";
115 return (
116 <div
117 data-slot="sidebar"
118 data-side={side}
119 data-variant={variant}
120 data-state={collapsible === "none" ? "expanded" : state}
121 data-collapsible={collapsed ? collapsible : ""}
122 className={cn(
123 "group/sidebar flex h-full flex-col text-fg",
124 variant === "rail" ? "w-(--sidebar-width-icon) bg-rail" : "w-(--sidebar-width) bg-sidebar",
125 side === "left" ? "border-r border-sidebar-border" : "border-l border-sidebar-border",
126 collapsible === "icon" && collapsed && "w-(--sidebar-width-icon)",
127 collapsible === "offcanvas" && collapsed && "hidden",
128 className,
129 )}
130 {...props}
131 >
132 {children}
133 </div>
134 );
135}
136
137/**
138 * The same column as a drawer over the page, for a phone or a narrow
139 * window: a sheet from the left that holds focus and closes on Escape or a
140 * tap outside. `title` names it for a screen reader.
141 */
142export function SidebarMobile({
143 title,
144 className,
145 children,
146 ...props
147}: Omit<ComponentProps<typeof SheetContent>, "side" | "showClose"> & { title: string }) {
148 const { openMobile, setOpenMobile } = useSidebar();
149 return (
150 <Sheet open={openMobile} onOpenChange={setOpenMobile}>
151 <SheetContent
152 side="left"
153 showClose={false}
154 aria-describedby={undefined}
155 data-slot="sidebar"
156 data-mobile="true"
157 data-state="expanded"
158 // Focus would land on the first row and open its hint; the sheet itself takes it instead.
159 onOpenAutoFocus={(event) => {
160 event.preventDefault();
161 (event.currentTarget as HTMLElement | null)?.focus();
162 }}
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)163 // A phone's drawer takes most of the screen: names read whole and rows reach a thumb, with the page still showing beside it.
164 className={cn("group/sidebar w-[min(22rem,86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)}
The left-hand navigation takes less room and reads at a glance. The dock becomes a rail: a 56-pixel column of icons with the g1t mark on top, the current app marked by a filled square, hover hints naming each with its shortcut, the apps you pinned in a scrolling band, and Apps, People, Workspace and your account at the foot. Each mode's sidebar sits flush against it, 240 pixels wide, opening on Search or jump to and listing its groups under small labels in 36-pixel rows; Ctrl B hides it and the page grows into the room. Every mode's list is built on the shadcn Sidebar now in the kit, so Workspace, Code, People, Notifications, account settings, Chat, Artifacts and Agents all share one frame; the phone keeps its bottom bar and drawer. The workspaces guide describes the rail, the sidebar and the page.165 {...props}
166 >
167 <SheetTitle className="sr-only">{title}</SheetTitle>
168 {children}
169 </SheetContent>
170 </Sheet>
171 );
172}
173
174/** The button that shows or hides the sidebar: the panel icon, with its shortcut as the hint. */
175export function SidebarTrigger({ hint, className, onClick, ...props }: ComponentProps<typeof Button> & { hint?: ReactNode }) {
176 const { open, toggleSidebar } = useSidebar();
177 const button = (
178 <Button
179 data-slot="sidebar-trigger"
180 variant="ghost"
181 size="icon-sm"
182 aria-label={open ? "Hide the sidebar" : "Show the sidebar"}
183 aria-expanded={open}
184 onClick={(event) => {
185 onClick?.(event);
186 if (!event.defaultPrevented) toggleSidebar();
187 }}
188 className={className}
189 {...props}
190 >
191 <PanelLeft size={17} />
192 </Button>
193 );
194 return hint ? <Hint label={hint}>{button}</Hint> : button;
195}
196
197/**
198 * The thin strip along the sidebar's page edge that toggles it when
199 * pressed: a second way to fold it, for the pointer, beside the trigger.
200 */
201export function SidebarRail({ className, ...props }: ComponentProps<"button">) {
202 const { open, toggleSidebar } = useSidebar();
203 return (
204 <button
205 type="button"
206 data-slot="sidebar-rail"
207 aria-label={open ? "Hide the sidebar" : "Show the sidebar"}
208 tabIndex={-1}
209 onClick={toggleSidebar}
210 className={cn(
211 "absolute inset-y-0 -right-2 z-20 hidden w-4 cursor-w-resize transition-colors after:absolute after:inset-y-0 after:left-1/2 after:w-px hover:after:bg-sidebar-border sm:flex",
212 "group-data-[state=collapsed]/sidebar:cursor-e-resize",
213 className,
214 )}
215 {...props}
216 />
217 );
218}
219
220/** The page beside the sidebar, when the two are laid out as one flex row. */
221export function SidebarInset({ className, ...props }: ComponentProps<"main">) {
222 return <main data-slot="sidebar-inset" className={cn("relative flex min-h-dvh min-w-0 flex-1 flex-col bg-bg", className)} {...props} />;
223}
224
225/** A field at the top of the sidebar: search, or jump to. */
226export function SidebarInput({ className, ...props }: ComponentProps<typeof Input>) {
227 return <Input data-slot="sidebar-input" className={cn("h-8 bg-bg shadow-none ring-sidebar-border", className)} {...props} />;
228}
229
230export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
231 return <div data-slot="sidebar-header" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />;
232}
233
234export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
235 return <div data-slot="sidebar-footer" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />;
236}
237
238export function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)239 return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 bg-sidebar-border data-[orientation=horizontal]:w-auto", className)} {...props} />;
The left-hand navigation takes less room and reads at a glance. The dock becomes a rail: a 56-pixel column of icons with the g1t mark on top, the current app marked by a filled square, hover hints naming each with its shortcut, the apps you pinned in a scrolling band, and Apps, People, Workspace and your account at the foot. Each mode's sidebar sits flush against it, 240 pixels wide, opening on Search or jump to and listing its groups under small labels in 36-pixel rows; Ctrl B hides it and the page grows into the room. Every mode's list is built on the shadcn Sidebar now in the kit, so Workspace, Code, People, Notifications, account settings, Chat, Artifacts and Agents all share one frame; the phone keeps its bottom bar and drawer. The workspaces guide describes the rail, the sidebar and the page.240}
241
242/** What scrolls: the groups, between the header and the footer. */
243export function SidebarContent({ className, ...props }: ComponentProps<"div">) {
244 return (
245 <div
246 data-slot="sidebar-content"
247 className={cn("flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto [scrollbar-width:thin] group-data-[collapsible=icon]/sidebar:overflow-hidden", className)}
248 {...props}
249 />
250 );
251}
252
253export function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
254 return <div data-slot="sidebar-group" className={cn("relative flex w-full min-w-0 flex-col px-2", className)} {...props} />;
255}
256
257/** A group's name: small capitals, quiet, with room at the end for an action. */
258export function SidebarGroupLabel({ className, asChild = false, ...props }: ComponentProps<"div"> & { asChild?: boolean }) {
259 const Comp = asChild ? Slot.Root : "div";
260 return (
261 <Comp
262 data-slot="sidebar-group-label"
263 className={cn(
264 "flex h-7 shrink-0 items-center rounded-md px-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase outline-none transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 focus-visible:ring-accent",
265 "group-data-[collapsible=icon]/sidebar:-mt-8 group-data-[collapsible=icon]/sidebar:opacity-0",
266 "[&>svg]:size-3.5 [&>svg]:shrink-0",
267 className,
268 )}
269 {...props}
270 />
271 );
272}
273
274/** A small button at the end of a group's label: what the group makes. */
275export function SidebarGroupAction({ className, asChild = false, ...props }: ComponentProps<"button"> & { asChild?: boolean }) {
276 const Comp = asChild ? Slot.Root : "button";
277 return (
278 <Comp
279 data-slot="sidebar-group-action"
280 className={cn(
281 "absolute top-1 right-3 flex aspect-square w-6 items-center justify-center rounded-md p-0 text-faint outline-none transition-colors hover:bg-sidebar-accent hover:text-fg focus-visible:ring-2 focus-visible:ring-accent [&>svg]:size-3.5 [&>svg]:shrink-0",
282 // A finger's target is bigger than the button.
283 "after:absolute after:-inset-2 md:after:hidden",
284 "group-data-[collapsible=icon]/sidebar:hidden",
285 className,
286 )}
287 {...props}
288 />
289 );
290}
291
292export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) {
293 return <div data-slot="sidebar-group-content" className={cn("w-full text-sm", className)} {...props} />;
294}
295
296export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
297 return <ul data-slot="sidebar-menu" className={cn("flex w-full min-w-0 flex-col gap-px", className)} {...props} />;
298}
299
300export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
301 return <li data-slot="sidebar-menu-item" className={cn("group/menu-item relative", className)} {...props} />;
302}
303
304/**
305 * One row of a menu: an icon, its words, and whatever sits at the end
306 * (a count, a chevron). `isActive` fills it; a `tooltip` shows its words
307 * while the sidebar is collapsed to icons, or always for `size="icon"`.
308 * `variant="faint"` is for what is coming rather than there.
309 */
310export const sidebarMenuButtonVariants = cva(
311 [
312 "peer/menu-button group/menu-button flex w-full items-center gap-2.5 overflow-hidden rounded-md text-left outline-none transition-[width,height,padding,color,background-color]",
313 "hover:bg-sidebar-accent/60 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent active:bg-sidebar-accent disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
314 "data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-fg data-[state=open]:bg-sidebar-accent data-[state=open]:text-fg",
315 "data-[pending=true]:bg-sidebar-accent/60 data-[pending=true]:text-fg",
316 "group-data-[collapsible=icon]/sidebar:size-10! group-data-[collapsible=icon]/sidebar:justify-center group-data-[collapsible=icon]/sidebar:p-0!",
317 "[&>span:last-child]:truncate [&>svg]:shrink-0 [&_svg]:pointer-events-none",
318 ],
319 {
320 variants: {
321 variant: {
322 default: "text-muted [&>svg]:text-faint hover:[&>svg]:text-muted data-[active=true]:[&>svg]:text-fg",
323 faint: "text-faint hover:text-muted [&>svg]:opacity-80",
324 outline: "border border-dashed border-sidebar-border text-muted hover:border-line-strong hover:bg-transparent data-[active=true]:border-accent/50 data-[active=true]:bg-accent/10 data-[active=true]:font-normal",
325 },
326 size: {
327 // 36px rows, 13px words and 16px icons: the sidebar's row.
328 default: "h-9 px-2 text-[0.8125rem] [&>svg]:size-4",
329 sm: "h-8 px-2 text-xs [&>svg]:size-3.5",
330 lg: "h-11 px-2 text-sm [&>svg]:size-4",
331 // The rail's: a 40px rounded square around a 20px icon, no words.
332 icon: "relative size-10 justify-center overflow-visible rounded-[10px] p-0 text-muted hover:bg-sidebar-accent hover:text-fg data-[active=true]:bg-sidebar-accent data-[active=true]:text-fg [&>svg]:size-5 [&>svg]:text-current",
333 },
334 },
335 defaultVariants: { variant: "default", size: "default" },
336 },
337);
338
339export function SidebarMenuButton({
340 asChild = false,
341 isActive = false,
342 isPending = false,
343 variant,
344 size,
345 tooltip,
346 className,
347 ...props
348}: ComponentProps<"button"> &
349 VariantProps<typeof sidebarMenuButtonVariants> & {
350 asChild?: boolean;
351 isActive?: boolean;
352 /** The page it leads to is on its way. */
353 isPending?: boolean;
354 /** Its words, shown beside it on hover: always for `size="icon"`, else only while collapsed to icons. */
355 tooltip?: ReactNode;
356 }) {
357 const Comp = asChild ? Slot.Root : "button";
358 const { state } = useSidebar();
359 const button = (
360 <Comp
361 data-slot="sidebar-menu-button"
362 data-size={size ?? "default"}
363 data-active={isActive || undefined}
364 data-pending={isPending || undefined}
365 className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
366 {...props}
367 />
368 );
369 if (!tooltip || (size !== "icon" && state !== "collapsed")) return button;
370 return (
371 <Hint label={tooltip} side="right">
372 {button}
373 </Hint>
374 );
375}
376
377/** A small button at the end of a row, shown on hover (`showOnHover`) or always. */
378export function SidebarMenuAction({
379 className,
380 asChild = false,
381 showOnHover = false,
382 ...props
383}: ComponentProps<"button"> & { asChild?: boolean; showOnHover?: boolean }) {
384 const Comp = asChild ? Slot.Root : "button";
385 return (
386 <Comp
387 data-slot="sidebar-menu-action"
388 className={cn(
389 "absolute top-1/2 right-1 flex aspect-square w-6 -translate-y-1/2 items-center justify-center rounded-md p-0 text-faint outline-none transition-colors hover:bg-line hover:text-fg focus-visible:ring-2 focus-visible:ring-accent [&>svg]:size-3.5 [&>svg]:shrink-0",
390 "after:absolute after:-inset-2 md:after:hidden",
391 "group-data-[collapsible=icon]/sidebar:hidden",
392 showOnHover &&
393 "opacity-0 group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100 [@media(hover:none)]:opacity-100",
394 className,
395 )}
396 {...props}
397 />
398 );
399}
400
401/** A count at the end of a row: how many of what the row lists. */
402export function SidebarMenuBadge({ className, ...props }: ComponentProps<"span">) {
403 return (
404 <span
405 data-slot="sidebar-menu-badge"
406 className={cn(
407 "pointer-events-none ml-auto flex h-5 min-w-5 shrink-0 items-center justify-center rounded px-1.5 text-[0.6875rem] font-medium text-muted tabular-nums select-none",
408 "bg-line peer-data-[active=true]/menu-button:bg-line-strong/60",
409 "group-data-[collapsible=icon]/sidebar:hidden",
410 className,
411 )}
412 {...props}
413 />
414 );
415}
416
417/** A row that is still on its way: a grey where the icon and words will be. */
418export function SidebarMenuSkeleton({ className, showIcon = false, ...props }: ComponentProps<"div"> & { showIcon?: boolean }) {
419 const width = useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []);
420 return (
421 <div data-slot="sidebar-menu-skeleton" className={cn("flex h-9 items-center gap-2.5 rounded-md px-2", className)} {...props}>
422 {showIcon && <div className="size-4 animate-pulse rounded-md bg-raised" />}
423 <div className="h-3.5 max-w-(--skeleton-width) flex-1 animate-pulse rounded bg-raised" style={{ "--skeleton-width": width } as CSSProperties} />
424 </div>
425 );
426}
427
428/** Rows under a row: a list indented by a hairline. */
429export function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
430 return (
431 <ul
432 data-slot="sidebar-menu-sub"
433 className={cn("mx-3.5 flex min-w-0 translate-x-px flex-col gap-px border-l border-sidebar-border px-2.5 py-0.5", "group-data-[collapsible=icon]/sidebar:hidden", className)}
434 {...props}
435 />
436 );
437}
438
439export function SidebarMenuSubItem({ className, ...props }: ComponentProps<"li">) {
440 return <li data-slot="sidebar-menu-sub-item" className={cn("group/menu-sub-item relative", className)} {...props} />;
441}
442
443export function SidebarMenuSubButton({
444 asChild = false,
445 size = "md",
446 isActive = false,
447 className,
448 ...props
449}: ComponentProps<"a"> & { asChild?: boolean; size?: "sm" | "md"; isActive?: boolean }) {
450 const Comp = asChild ? Slot.Root : "a";
451 return (
452 <Comp
453 data-slot="sidebar-menu-sub-button"
454 data-size={size}
455 data-active={isActive || undefined}
456 className={cn(
457 "flex h-8 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-muted outline-none hover:bg-sidebar-accent/60 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent",
458 "data-[active=true]:bg-sidebar-accent data-[active=true]:text-fg [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-faint",
459 size === "sm" ? "text-xs" : "text-[0.8125rem]",
460 "group-data-[collapsible=icon]/sidebar:hidden",
461 className,
462 )}
463 {...props}
464 />
465 );
466}

This file's history is long; its oldest lines are credited to the oldest commit read.