Skip to content
465 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 }}
163 className={cn("group/sidebar w-[min(var(--sidebar-width),86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)}
164 {...props}
165 >
166 <SheetTitle className="sr-only">{title}</SheetTitle>
167 {children}
168 </SheetContent>
169 </Sheet>
170 );
171}
172
173/** The button that shows or hides the sidebar: the panel icon, with its shortcut as the hint. */
174export function SidebarTrigger({ hint, className, onClick, ...props }: ComponentProps<typeof Button> & { hint?: ReactNode }) {
175 const { open, toggleSidebar } = useSidebar();
176 const button = (
177 <Button
178 data-slot="sidebar-trigger"
179 variant="ghost"
180 size="icon-sm"
181 aria-label={open ? "Hide the sidebar" : "Show the sidebar"}
182 aria-expanded={open}
183 onClick={(event) => {
184 onClick?.(event);
185 if (!event.defaultPrevented) toggleSidebar();
186 }}
187 className={className}
188 {...props}
189 >
190 <PanelLeft size={17} />
191 </Button>
192 );
193 return hint ? <Hint label={hint}>{button}</Hint> : button;
194}
195
196/**
197 * The thin strip along the sidebar's page edge that toggles it when
198 * pressed: a second way to fold it, for the pointer, beside the trigger.
199 */
200export function SidebarRail({ className, ...props }: ComponentProps<"button">) {
201 const { open, toggleSidebar } = useSidebar();
202 return (
203 <button
204 type="button"
205 data-slot="sidebar-rail"
206 aria-label={open ? "Hide the sidebar" : "Show the sidebar"}
207 tabIndex={-1}
208 onClick={toggleSidebar}
209 className={cn(
210 "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",
211 "group-data-[state=collapsed]/sidebar:cursor-e-resize",
212 className,
213 )}
214 {...props}
215 />
216 );
217}
218
219/** The page beside the sidebar, when the two are laid out as one flex row. */
220export function SidebarInset({ className, ...props }: ComponentProps<"main">) {
221 return <main data-slot="sidebar-inset" className={cn("relative flex min-h-dvh min-w-0 flex-1 flex-col bg-bg", className)} {...props} />;
222}
223
224/** A field at the top of the sidebar: search, or jump to. */
225export function SidebarInput({ className, ...props }: ComponentProps<typeof Input>) {
226 return <Input data-slot="sidebar-input" className={cn("h-8 bg-bg shadow-none ring-sidebar-border", className)} {...props} />;
227}
228
229export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
230 return <div data-slot="sidebar-header" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />;
231}
232
233export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
234 return <div data-slot="sidebar-footer" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />;
235}
236
237export function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) {
238 return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 w-auto bg-sidebar-border", className)} {...props} />;
239}
240
241/** What scrolls: the groups, between the header and the footer. */
242export function SidebarContent({ className, ...props }: ComponentProps<"div">) {
243 return (
244 <div
245 data-slot="sidebar-content"
246 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)}
247 {...props}
248 />
249 );
250}
251
252export function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
253 return <div data-slot="sidebar-group" className={cn("relative flex w-full min-w-0 flex-col px-2", className)} {...props} />;
254}
255
256/** A group's name: small capitals, quiet, with room at the end for an action. */
257export function SidebarGroupLabel({ className, asChild = false, ...props }: ComponentProps<"div"> & { asChild?: boolean }) {
258 const Comp = asChild ? Slot.Root : "div";
259 return (
260 <Comp
261 data-slot="sidebar-group-label"
262 className={cn(
263 "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",
264 "group-data-[collapsible=icon]/sidebar:-mt-8 group-data-[collapsible=icon]/sidebar:opacity-0",
265 "[&>svg]:size-3.5 [&>svg]:shrink-0",
266 className,
267 )}
268 {...props}
269 />
270 );
271}
272
273/** A small button at the end of a group's label: what the group makes. */
274export function SidebarGroupAction({ className, asChild = false, ...props }: ComponentProps<"button"> & { asChild?: boolean }) {
275 const Comp = asChild ? Slot.Root : "button";
276 return (
277 <Comp
278 data-slot="sidebar-group-action"
279 className={cn(
280 "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",
281 // A finger's target is bigger than the button.
282 "after:absolute after:-inset-2 md:after:hidden",
283 "group-data-[collapsible=icon]/sidebar:hidden",
284 className,
285 )}
286 {...props}
287 />
288 );
289}
290
291export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) {
292 return <div data-slot="sidebar-group-content" className={cn("w-full text-sm", className)} {...props} />;
293}
294
295export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
296 return <ul data-slot="sidebar-menu" className={cn("flex w-full min-w-0 flex-col gap-px", className)} {...props} />;
297}
298
299export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
300 return <li data-slot="sidebar-menu-item" className={cn("group/menu-item relative", className)} {...props} />;
301}
302
303/**
304 * One row of a menu: an icon, its words, and whatever sits at the end
305 * (a count, a chevron). `isActive` fills it; a `tooltip` shows its words
306 * while the sidebar is collapsed to icons, or always for `size="icon"`.
307 * `variant="faint"` is for what is coming rather than there.
308 */
309export const sidebarMenuButtonVariants = cva(
310 [
311 "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]",
312 "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",
313 "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",
314 "data-[pending=true]:bg-sidebar-accent/60 data-[pending=true]:text-fg",
315 "group-data-[collapsible=icon]/sidebar:size-10! group-data-[collapsible=icon]/sidebar:justify-center group-data-[collapsible=icon]/sidebar:p-0!",
316 "[&>span:last-child]:truncate [&>svg]:shrink-0 [&_svg]:pointer-events-none",
317 ],
318 {
319 variants: {
320 variant: {
321 default: "text-muted [&>svg]:text-faint hover:[&>svg]:text-muted data-[active=true]:[&>svg]:text-fg",
322 faint: "text-faint hover:text-muted [&>svg]:opacity-80",
323 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",
324 },
325 size: {
326 // 36px rows, 13px words and 16px icons: the sidebar's row.
327 default: "h-9 px-2 text-[0.8125rem] [&>svg]:size-4",
328 sm: "h-8 px-2 text-xs [&>svg]:size-3.5",
329 lg: "h-11 px-2 text-sm [&>svg]:size-4",
330 // The rail's: a 40px rounded square around a 20px icon, no words.
331 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",
332 },
333 },
334 defaultVariants: { variant: "default", size: "default" },
335 },
336);
337
338export function SidebarMenuButton({
339 asChild = false,
340 isActive = false,
341 isPending = false,
342 variant,
343 size,
344 tooltip,
345 className,
346 ...props
347}: ComponentProps<"button"> &
348 VariantProps<typeof sidebarMenuButtonVariants> & {
349 asChild?: boolean;
350 isActive?: boolean;
351 /** The page it leads to is on its way. */
352 isPending?: boolean;
353 /** Its words, shown beside it on hover: always for `size="icon"`, else only while collapsed to icons. */
354 tooltip?: ReactNode;
355 }) {
356 const Comp = asChild ? Slot.Root : "button";
357 const { state } = useSidebar();
358 const button = (
359 <Comp
360 data-slot="sidebar-menu-button"
361 data-size={size ?? "default"}
362 data-active={isActive || undefined}
363 data-pending={isPending || undefined}
364 className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
365 {...props}
366 />
367 );
368 if (!tooltip || (size !== "icon" && state !== "collapsed")) return button;
369 return (
370 <Hint label={tooltip} side="right">
371 {button}
372 </Hint>
373 );
374}
375
376/** A small button at the end of a row, shown on hover (`showOnHover`) or always. */
377export function SidebarMenuAction({
378 className,
379 asChild = false,
380 showOnHover = false,
381 ...props
382}: ComponentProps<"button"> & { asChild?: boolean; showOnHover?: boolean }) {
383 const Comp = asChild ? Slot.Root : "button";
384 return (
385 <Comp
386 data-slot="sidebar-menu-action"
387 className={cn(
388 "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",
389 "after:absolute after:-inset-2 md:after:hidden",
390 "group-data-[collapsible=icon]/sidebar:hidden",
391 showOnHover &&
392 "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",
393 className,
394 )}
395 {...props}
396 />
397 );
398}
399
400/** A count at the end of a row: how many of what the row lists. */
401export function SidebarMenuBadge({ className, ...props }: ComponentProps<"span">) {
402 return (
403 <span
404 data-slot="sidebar-menu-badge"
405 className={cn(
406 "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",
407 "bg-line peer-data-[active=true]/menu-button:bg-line-strong/60",
408 "group-data-[collapsible=icon]/sidebar:hidden",
409 className,
410 )}
411 {...props}
412 />
413 );
414}
415
416/** A row that is still on its way: a grey where the icon and words will be. */
417export function SidebarMenuSkeleton({ className, showIcon = false, ...props }: ComponentProps<"div"> & { showIcon?: boolean }) {
418 const width = useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []);
419 return (
420 <div data-slot="sidebar-menu-skeleton" className={cn("flex h-9 items-center gap-2.5 rounded-md px-2", className)} {...props}>
421 {showIcon && <div className="size-4 animate-pulse rounded-md bg-raised" />}
422 <div className="h-3.5 max-w-(--skeleton-width) flex-1 animate-pulse rounded bg-raised" style={{ "--skeleton-width": width } as CSSProperties} />
423 </div>
424 );
425}
426
427/** Rows under a row: a list indented by a hairline. */
428export function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
429 return (
430 <ul
431 data-slot="sidebar-menu-sub"
432 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)}
433 {...props}
434 />
435 );
436}
437
438export function SidebarMenuSubItem({ className, ...props }: ComponentProps<"li">) {
439 return <li data-slot="sidebar-menu-sub-item" className={cn("group/menu-sub-item relative", className)} {...props} />;
440}
441
442export function SidebarMenuSubButton({
443 asChild = false,
444 size = "md",
445 isActive = false,
446 className,
447 ...props
448}: ComponentProps<"a"> & { asChild?: boolean; size?: "sm" | "md"; isActive?: boolean }) {
449 const Comp = asChild ? Slot.Root : "a";
450 return (
451 <Comp
452 data-slot="sidebar-menu-sub-button"
453 data-size={size}
454 data-active={isActive || undefined}
455 className={cn(
456 "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",
457 "data-[active=true]:bg-sidebar-accent data-[active=true]:text-fg [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-faint",
458 size === "sm" ? "text-xs" : "text-[0.8125rem]",
459 "group-data-[collapsible=icon]/sidebar:hidden",
460 className,
461 )}
462 {...props}
463 />
464 );
465}