Skip to content

Commit

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.

syntaqxcommitted Parent454c62dBrowse files
32 files+958−7350/32 viewed
+1−1
182182
183183 | | |
184184 | --- | --- |
185−| **Sees** | Chat, Artifacts, Agents and Notifications. The dock has no Code, and Home shows what needs them in chat, from agents and in their notifications, without reviews. |
185+| **Sees** | Chat, Artifacts, Agents and Notifications. The rail has no Code, and Home shows what needs them in chat, from agents and in their notifications, without reviews. |
186186 | **Can't open** | Any repository, issue, pull request, check or deploy page. The workspace's base permission and team grants don't apply to them. |
187187 | **Still sees work reach them** | Cards about issues, pull requests and deploys appear in their channels as summaries: title, state and who is on it. Opening one asks for Code access. |
188188 | **Can ask agents** | Anything about the product. Agents explain how things work and what changed, but never show them source code. Owners can tighten this so agents answer only from the workspace's docs. |
+1−1
4141
4242 ## See an agent's skills
4343
44−1. Open **Agents** in the dock and choose an agent.
44+1. Open **Agents** in the rail and choose an agent.
4545 2. Open its **Skills** tab.
4646
4747 **Foundational, from g1t** shows each foundational skill: a check on each
+2−2
55
66 Every project has an **Agents** section, beside its pull requests: who is
77 working on it right now, what each agent did before, and what agents have
8−learned about the project. Across the workspace, **Agents** in the dock
8+learned about the project. Across the workspace, **Agents** in the rail
99 shows every agent, its [sessions](/guides/agent-sessions/) and what it
1010 costs, and **Memory** holds what agents know in every project.
1111
1717 | Teach every agent something about this code | **Agents → Memory** |
1818 | Teach every agent something true in every project | **Memory**, in the workspace's sidebar |
1919 | Review what agents, reviews and docs taught | **Agents → Memory**, or **Context → Memory** for the workspace |
20−| See every agent across the workspace, and its cost | **Agents** in the dock: the overview, then each agent's **Spend** |
20+| See every agent across the workspace, and its cost | **Agents** in the rail: the overview, then each agent's **Spend** |
2121
2222 ## Runs
2323
+2−2
6868
6969 <Steps>
7070
71−1. Open **Agents** in the dock, then choose **New agent**, or go to
71+1. Open **Agents** in the rail, then choose **New agent**, or go to
7272 `g1t.sh/<workspace>/-/agents/new`.
7373 2. In **What should this agent do?**, describe the job in a sentence or a
7474 paragraph: what it does, for whom, and what good looks like. Owners
188188
189189 <Steps>
190190
191−1. Open **Agents** in the dock, then **Overview**.
191+1. Open **Agents** in the rail, then **Overview**.
192192 2. Under **Personal agents**, switch off **Members can create personal
193193 agents**.
194194
+1−1
1212 private until you share it, and agents read only what the people they work
1313 for can read.
1414
15−Open **Artifacts** in the dock on the left, or go to
15+Open **Artifacts** in the rail on the left, or go to
1616 `g1t.sh/<workspace>/-/artifacts`. On a phone, it's under **More** in the bar
1717 at the bottom. Artifacts is open to every member of a workspace, on every
1818 plan.
+2−2
2222 no history cutoff. Agents' replies are charged to the agent that wrote
2323 them; see [what an agent costs](/guides/agents/#what-an-agent-costs).
2424
25−Open **Chat** in the dock on the left, or go to
25+Open **Chat** in the rail on the left, or go to
2626 `g1t.sh/<workspace>/-/chat`.
2727
2828 ## A conversation
565565
566566 ### Set a status
567567
568−Open the menu on your avatar at the foot of the dock (on a phone, **More**,
568+Open the menu on your avatar at the foot of the rail (on a phone, **More**,
569569 then **You and help**) and choose **Set a status**. Give it an
570570 emoji and a few words, or pick one:
571571
+3−3
1616 Agents aren't in the Marketplace. To add one, start from a
1717 [template](/guides/agents/#role-templates) in Agents.
1818
19−Open it from **Apps** at the foot of the dock (the launcher's
19+Open it from **Apps** at the foot of the rail (the launcher's
2020 **Marketplace** link, or **Browse the Marketplace** on the Apps page), from
2121 **More** on a phone, or with <kbd>Ctrl</kbd> <kbd>K</kbd> and
2222 *Marketplace*. Its address is `g1t.sh/<workspace>/-/marketplace`. The
23−Marketplace isn't in the dock itself.
23+Marketplace isn't in the rail itself.
2424
2525 ## Who builds what, and whether you can add it
2626
220220
221221 What the workspace added from the Marketplace that you can use is under
222222 **Apps**: today, the integrations it connected. Each shows its tier, as
223−in the Marketplace. Pin the ones you want to your dock. See [Apps](/guides/workspaces/#apps).
223+in the Marketplace. Pin the ones you want to your rail. See [Apps](/guides/workspaces/#apps).
+2−2
99 request, a deployment failed, someone mentioned you. You are never told
1010 about what you did yourself.
1111
12−Open them from **Notifications** in the dock, or from the bottom bar on a
12+Open them from **Notifications** in the rail, or from the bottom bar on a
1313 phone. The number on it is what is unread. The bell at the right of the
1414 page's header opens the same notifications in a panel beside the page; its
1515 number is amber while something is waiting on you, red while a failure is
205205
206206 ## The Notifications page
207207
208−**Notifications** in the dock opens
208+**Notifications** in the rail opens
209209 [g1t.sh/notifications](https://g1t.sh/notifications): every thread, a page
210210 at a time, under the same tabs. Its sidebar chooses what you look at:
211211
+1−1
55
66 **People** is where a workspace's people and agents are listed together:
77 who they are, the teams they are on, what they own and who they report
8−to. Open it from **People** at the foot of the dock. Its sidebar has:
8+to. Open it from **People** at the foot of the rail. Its sidebar has:
99
1010 | Page | Address | What it is |
1111 | --- | --- | --- |
+1−1
1818
1919 | Where | Page | Who manages it |
2020 | --- | --- | --- |
21−| A workspace | **Workspace → Runners** in the dock, `g1t.sh/<workspace>/-/runners` | Owners only. |
21+| A workspace | **Workspace → Runners** in the rail, `g1t.sh/<workspace>/-/runners` | Owners only. |
2222 | A project | **Settings → Runners**, `g1t.sh/<workspace>/<project>/settings/runners` | People with the Admin [role](/guides/access-and-roles/) on its repository |
2323
2424 A workspace's runners serve the repositories their [group](#groups) allows.
+22−18
525525 Everything you choose is in the address, so a filtered list can be
526526 bookmarked or shared.
527527
528−## The dock, the sidebar and the page
528+## The rail, the sidebar and the page
529529
530530 Signed in, g1t is always about one workspace: the one you are in. On a
531531 workspace's pages, and on a project in one of your workspaces, that is the
537537
538538 | Part | Where | What it holds |
539539 | --- | --- | --- |
540−| **The dock** | A rounded bar down the left | g1t's mark, which goes to [Home](/guides/home/); the built-in apps; the apps you pinned; **Apps**; and at its foot **People**, **Workspace** and your account. |
541−| **The sidebar** | Beside the dock | The workspace and its switcher at the top, then the lists of the app you are in. Home and Apps have none. |
542−| **The page** | A rounded panel, the rest of the window | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>Ctrl</kbd> <kbd>K</kbd>, or <kbd>⌘</kbd> <kbd>K</kbd> on a Mac), [your spend this month](/guides/spend/#in-the-top-bar), **Ask g1t**, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. |
540+| **The rail** | A narrow column of icons down the left edge | g1t's mark, which goes to [Home](/guides/home/); the built-in apps; the apps you pinned; **Apps**; and at its foot **People**, **Workspace** and your account. Hold the pointer over an icon for its name. |
541+| **The sidebar** | Flush against the rail | The workspace and its switcher at the top, **Search or jump to**, then the lists of the app you are in, in named groups. Home and Apps have none. |
542+| **The page** | A rounded panel, the rest of the window | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>Ctrl</kbd> <kbd>K</kbd>, or <kbd>⌘</kbd> <kbd>K</kbd> on a Mac) when the sidebar is hidden or has a search of its own, [your spend this month](/guides/spend/#in-the-top-bar), **Ask g1t**, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. |
543543
544−### The dock
544+### The rail
545545
546−The built-in apps are always in the dock, each with its name and what is
547−unread:
546+The built-in apps are always in the rail, each an icon with what is
547+unread on it:
548548
549549 | App | Opens |
550550 | --- | --- |
557557 | **People** | Everyone in the workspace, people and agents; its [teams](/guides/teams/); the org chart; and, under **Membership**, **Members and invites**. See [people and teams](/guides/people-and-teams/). |
558558 | **Workspace** | Usage, billing, integrations, policies and settings. |
559559
560−A bar on the dock's edge marks the app you are in. Your account is the
560+The app you are in sits on a filled square. Your account is the
561561 avatar at the foot: your status, your profile and settings,
562562 [appearance](#appearance), the documentation, support, status, the
563563 keyboard's shortcuts, and signing out.
575575
576576 To change it:
577577
578−1. Select your avatar at the foot of the dock. On a phone, open **More**,
578+1. Select your avatar at the foot of the rail. On a phone, open **More**,
579579 then **You and help**.
580580 2. Under **Appearance**, select **Auto** (the screen icon), **Light** (the
581581 sun) or **Dark** (the moon).
611611 A pinned app shows as its mark under the built-in ones, in the order you
612612 pinned them. To pin one:
613613
614−1. Select **Apps** near the foot of the dock, or open the Apps page at
614+1. Select **Apps** near the foot of the rail, or open the Apps page at
615615 `g1t.sh/<workspace>/-/apps`.
616616 2. Find the app, by name if you like.
617617 3. Select the pin on its tile. Select it again to unpin it.
621621 link always does.
622622
623623 Your pins are yours alone and each workspace has its own. They are saved
624−to your account, so your dock is the same on every browser and device you
624+to your account, so your rail is the same on every browser and device you
625625 sign in on, in the order you pinned.
626626
627627 ### The sidebar
628628
629629 The sidebar's top row names the workspace you are in. Select it to switch
630630 to another of your workspaces, make a new one, or open your profile or
631−settings. The button beside it hides the sidebar, and the same button at
632−the left of the page's header brings it back; <kbd>Ctrl</kbd> <kbd>B</kbd>
633−(<kbd>⌘</kbd> <kbd>B</kbd> on a Mac) does both, except while you type. g1t
634−remembers which you chose. When the sidebar is hidden, or the app has none,
635−the page's header starts with the same switcher.
631+settings. The button beside it hides the sidebar, leaving the rail alone
632+and the page wider, and the same button at the left of the page's header
633+brings it back; <kbd>Ctrl</kbd> <kbd>B</kbd> (<kbd>⌘</kbd> <kbd>B</kbd> on
634+a Mac) does both, except while you type. g1t remembers which you chose.
635+When the sidebar is hidden, or the app has none, the page's header starts
636+with the same switcher. Under the top row, **Search or jump to** opens the
637+same palette as <kbd>Ctrl</kbd> <kbd>K</kbd>; in Chat and Artifacts, whose
638+sidebars search their own lists, it stays in the page's header.
636639
637−Below it are the lists of the app you are in. Code's lists the workspace's
640+Below it are the lists of the app you are in, each group under a small
641+heading. Code's lists the workspace's
638642 [projects](#pinned-and-recent-projects), and drills into a project's own
639643 list when you open one, with **‹ All projects** at the top to go back. Its
640644 **Settings** opens one level further: **General**, **Deployments**,
894898
895899 **How it is laid out.** Signed out, a profile, Explore and Search are
896900 shown with g1t's public header (search, Explore, Docs, signing in) and the page
897−at full width, with no workspace sidebar. Signed in, the dock stays, but
901+at full width, with no workspace sidebar. Signed in, the rail stays, but
898902 no mode is lit and no mode's sidebar opens beside these pages: they are
899903 nobody's workspace, and the profile's own left column says whose it is.
900904
+1−1
5555 ## The workspace
5656
5757 Everything happens in a workspace: your company, or one team in it. The
58−dock down the left holds Home, Chat, Notifications, Agents, Code and
58+rail down the left holds Home, Chat, Notifications, Agents, Code and
5959 Artifacts, the apps you pin, and People, Workspace and your account at its
6060 foot. On a phone it is a bar along the bottom.
6161
+2−2
3232
3333 2. **Open Chat**
3434
35− Choose **Chat** in the dock, or go to `g1t.sh/<workspace>/-/chat`. Every
35+ Choose **Chat** in the rail, or go to `g1t.sh/<workspace>/-/chat`. Every
3636 workspace starts with `#general`. Say hello; people chatting is free on
3737 every plan. See [Chat](/guides/chat/).
3838
4747
4848 4. **Create an agent**
4949
50− Open **Agents** in the dock and choose **New agent**. Under the box, pick
50+ Open **Agents** in the rail and choose **New agent**. Under the box, pick
5151 a role, such as **QA Engineer**, and keep its
5252 defaults: you hire Margo, with a title, responsibilities, a personality
5353 and sensible model limits. Choose
+14−6
1919 * `scheme-dark` and its tokens stay dark.
2020 */
2121 --color-bg: light-dark(var(--g1t-light-bg), var(--g1t-bg));
22− /* Behind the page panel, where the sidebar sits flat (components/shell.tsx). */
23− --color-shell: light-dark(#ececea, #0b0b0d);
24− /* The dock's own surface: a floating bar a step above the shell. */
25− --color-dock: light-dark(#ffffff, #15151a);
22+ /*
23+ * The app's frame (components/shell.tsx, components/ui/sidebar.tsx):
24+ * the page is a rounded panel on the shell; the sidebar sits flat on the
25+ * same surface beside it, and the rail, the narrow column of icons at
26+ * the window's edge, is a step apart: white in the light theme, near
27+ * black in the dark one. The current row or icon is a muted fill.
28+ */
29+ --color-shell: light-dark(#efefed, #0b0b0d);
30+ --color-sidebar: var(--color-shell);
31+ --color-sidebar-accent: light-dark(#e1e1de, #1c1c20);
32+ --color-sidebar-border: light-dark(#e0e0dd, #1d1d22);
33+ --color-rail: light-dark(#ffffff, #050507);
2634 --color-surface: light-dark(var(--g1t-light-surface), var(--g1t-surface));
2735 --color-raised: light-dark(var(--g1t-light-raised), var(--g1t-raised));
2836 --color-line: light-dark(var(--g1t-light-line), var(--g1t-line));
185193
186194 /*
187195 * The app's frame (components/shell.tsx), set on <body data-frame="app">:
188− * the page is a rounded panel inset from the window, with the dock beside
189− * it and, on a phone, the bottom bar under it. What sticks to the top or
196+ * the page is a rounded panel inset from the window, with the rail and the
197+ * sidebar beside it and, on a phone, the bottom bar under it. What sticks to the top or
190198 * the bottom of a page reads these:
191199 * --frame-top the gap above the panel
192200 * --topbar-h the panel's top edge plus its header: where sticky things sit
+6−6
3939 prefetch="intent"
4040 className={({ isActive }) =>
4141 `group flex h-9 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
42− isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
42+ isActive ? "bg-sidebar-accent font-medium text-fg" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"
4343 }`
4444 }
4545 >
111111 to={`/${slug}/-/agents/${agent.handle}`}
112112 prefetch="intent"
113113 className={({ isActive }) =>
114− `group flex h-11 items-center gap-2.5 rounded-md px-2 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
114+ `group flex h-11 items-center gap-2.5 rounded-md px-2 transition-colors ${isActive ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60"}`
115115 }
116116 >
117117 <span className="relative shrink-0">
118118 <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={26} />
119119 {!isOrchestrator(agent) && (
120− <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-shell" />
120+ <StatusDot status={agent.status} className="absolute -right-0.5 -bottom-0.5 ring-2 ring-sidebar" />
121121 )}
122122 </span>
123123 <span className="min-w-0 grow leading-tight">
147147 return (
148148 <div className="flex h-full flex-col">
149149 <div className={`flex h-9 shrink-0 items-center justify-between pr-1 pl-3 ${phone ? "hidden" : ""}`}>
150− <h2 className="text-xs font-medium text-faint">Agents</h2>
150+ <h2 className="text-[0.6875rem] font-medium tracking-wide text-faint uppercase">Agents</h2>
151151 {canCreate && (
152152 <NavLink
153153 to={`/${slug}/-/agents/new`}
246246 type="button"
247247 onClick={() => setOpen(!open)}
248248 aria-expanded={open}
249− className="mb-1 flex w-full items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted"
249+ className="mb-1 flex w-full items-center gap-1 rounded px-1 text-[0.6875rem] font-medium tracking-wide text-faint uppercase transition-colors hover:text-muted"
250250 >
251251 <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
252252 {title}
257257 }
258258 return (
259259 <section className="mt-4">
260− <h3 className="mb-1 px-2 text-xs font-medium text-faint">{title}</h3>
260+ <h3 className="mb-1 px-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase">{title}</h3>
261261 <ul className="space-y-px">{children}</ul>
262262 </section>
263263 );
+6−6
3030 return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />;
3131 }
3232
33−/** A pinned app as the dock draws it, from its key alone; null when the key names nothing installable. */
33+/** A pinned app as the rail draws it, from its key alone; null when the key names nothing installable. */
3434 export function pinnedApp(key: PinnableApp, slug: string): { key: string; name: string; to: string; icon: (size: number) => ReactNode } | null {
3535 const app = installedAppOf(key);
3636 if (!app) return null;
3737 return { key, name: app.name, to: app.path(slug), icon: (size) => <InstalledMark app={app} size={size} /> };
3838 }
3939
40−/** Every pin form shares this key, so the dock, the launcher and the Apps page see one change. */
40+/** Every pin form shares this key, so the rail, the launcher and the Apps page see one change. */
4141 const PIN_FETCHER = "app-pin";
4242
4343 /**
4444 * Someone's pins in the workspace `slug`, as saved (`saved`, from the
4545 * root's data), with any pin or unpin on its way already applied, so the
46− * dock changes the moment a pin is pressed. `toggle` pins or unpins.
46+ * rail changes the moment a pin is pressed. `toggle` pins or unpins.
4747 */
4848 export function useAppPins(slug: string, saved: PinnableApp[]) {
4949 const fetcher = useFetcher({ key: PIN_FETCHER });
127127 {body}
128128 </Link>
129129 {app.usable && (
130− <Hint label={pinned ? "Unpin from your dock" : "Pin to your dock"}>
130+ <Hint label={pinned ? "Unpin from your rail" : "Pin to your rail"}>
131131 <button
132132 type="button"
133− aria-label={pinned ? `Unpin ${app.name} from your dock` : `Pin ${app.name} to your dock`}
133+ aria-label={pinned ? `Unpin ${app.name} from your rail` : `Pin ${app.name} to your rail`}
134134 aria-pressed={pinned}
135135 onClick={() => onToggle(app.key)}
136136 className={cn(
155155 <Store size={18} />
156156 </span>
157157 <p className="mt-3 text-sm font-medium">No apps installed yet</p>
158− <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from the Marketplace: integrations your workspace connects, and extensions later. Each one you can use shows here, to pin to your dock.</p>
158+ <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from the Marketplace: integrations your workspace connects, and extensions later. Each one you can use shows here, to pin to your rail.</p>
159159 <Link to={`/${slug}/-/marketplace`} onClick={onOpen} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-[0.8125rem] font-medium text-bg hover:bg-fg-hover">
160160 <Store size={14} />
161161 Browse the Marketplace
+7−7
2222 const REFRESH_MS = FALLBACK_REFRESH_MS;
2323
2424 const ROW =
25− "group/row flex h-8 items-center gap-2 rounded-md pr-1.5 pl-2 text-[0.8125rem] transition-colors max-md:h-11 max-md:text-[0.9375rem] max-md:active:bg-raised";
25+ "group/row flex h-9 items-center gap-2 rounded-md pr-1.5 pl-2 text-[0.8125rem] transition-colors max-md:h-11 max-md:text-[0.9375rem] max-md:active:bg-raised";
2626
2727 /** Unread on a row: lavender for a mention, quiet for the rest. */
2828 function Count({ entry }: { entry: ChatSidebarEntry | undefined }) {
140140
141141 return (
142142 // The colour behind it, for the dots cut out of people's avatars.
143− <div className="flex h-full flex-col [--chat-sidebar-bg:var(--color-shell)]">
143+ <div className="flex h-full flex-col [--chat-sidebar-bg:var(--color-sidebar)]">
144144 {heading && (
145145 <div className="flex h-9 shrink-0 items-center justify-between pr-1 pl-3">
146− <h2 className="text-xs font-medium text-faint">Chat</h2>
146+ <h2 className="text-[0.6875rem] font-medium tracking-wide text-faint uppercase">Chat</h2>
147147 <NewMessageButton slug={slug} />
148148 </div>
149149 )}
327327 onClick={() => onFold(id)}
328328 aria-expanded={open}
329329 aria-controls={list}
330− className="flex min-w-0 grow items-center gap-1 rounded px-1 py-1 text-left text-xs font-medium text-faint transition-colors hover:text-muted focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none max-md:text-[0.8125rem]"
330+ className="flex min-w-0 grow items-center gap-1 rounded px-1 py-1 text-left text-[0.6875rem] font-medium tracking-wide text-faint uppercase transition-colors hover:text-muted focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none max-md:text-[0.8125rem]"
331331 >
332332 <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} />
333333 {title}
350350 <NavLink
351351 to={to}
352352 end
353− className={({ isActive }) => `${ROW} ${isActive ? "bg-raised text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"}`}
353+ className={({ isActive }) => `${ROW} ${isActive ? "bg-sidebar-accent text-fg" : "text-faint hover:bg-sidebar-accent/60 hover:text-muted"}`}
354354 >
355355 <span className="flex w-4.5 shrink-0 justify-center">{icon}</span>
356356 <span className="min-w-0 grow truncate">{children}</span>
468468 {...prefetch}
469469 aria-current={current ? "page" : undefined}
470470 className={`${ROW} ${
471− current ? "bg-raised text-fg" : unread ? "text-fg hover:bg-raised/60" : entry.muted ? "text-faint hover:bg-raised/60 hover:text-muted" : "text-muted hover:bg-raised/60 hover:text-fg"
471+ current ? "bg-sidebar-accent text-fg" : unread ? "text-fg hover:bg-sidebar-accent/60" : entry.muted ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"
472472 }`}
473473 >
474474 <span className={`flex w-4.5 shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
513513 onClick={onOpen}
514514 aria-busy={busy || undefined}
515515 aria-label={`Message ${agent.display_name}, ${g1t ? "orchestrator" : agent.title}${g1t ? "" : `, ${statusLabel(agent.status).toLowerCase()}`}`}
516− className={`${ROW} w-full text-left text-muted hover:bg-raised/60 hover:text-fg ${busy ? "opacity-60" : ""}`}
516+ className={`${ROW} w-full text-left text-muted hover:bg-sidebar-accent/60 hover:text-fg ${busy ? "opacity-60" : ""}`}
517517 >
518518 <span className="flex w-4.5 shrink-0 justify-center">
519519 <AgentFace agent={agent} g1t={g1t} />
+2−1
11 import { type ReactNode, Suspense, lazy, useEffect, useRef, useState, useSyncExternalStore } from "react";
22
33 import { cn } from "../lib/cn";
4+import { Kbd } from "./ui/kbd";
45 import { paletteKeyLabel } from "../lib/shortcut";
56
67 /*
4647 export function PaletteKey({ className }: { className?: string }) {
4748 // The server cannot know the computer: it says ⌘K, and the browser corrects it.
4849 const label = useSyncExternalStore(never, () => paletteKeyLabel(platform()), () => "⌘K");
49− return <kbd className={cn(className, "pointer-coarse:hidden")}>{label}</kbd>;
50+ return <Kbd className={cn(className, "pointer-coarse:hidden")}>{label}</Kbd>;
5051 }
5152
5253 /**
+0−331
1−import { Check, ChevronDown, CircleUserRound, LayoutGrid, Plus, Settings } from "lucide-react";
2−import { type ReactNode, useState } from "react";
3−import { Link, useLocation, useNavigation } from "react-router";
4−
5−import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
6−
7−import { AppsLauncher, appIcon, pinnedApp, useAppPins } from "./apps";
8−import { Mark } from "./logo";
9−
10−import { Avatar } from "./ui/avatar";
11−import { Card } from "./ui/card";
12−import { Hint } from "./ui/hint";
13−import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
14−import {
15− DropdownMenu,
16− DropdownMenuContent,
17− DropdownMenuItem,
18− DropdownMenuLabel,
19− DropdownMenuSeparator,
20− DropdownMenuTrigger,
21−} from "./ui/dropdown-menu";
22−import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
23−import { type BuiltinApp, type PinnableApp } from "../lib/apps";
24−import { cn } from "../lib/cn";
25−import { paletteKeyLabel } from "../lib/shortcut";
26−import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav";
27−
28−/** The dock's column: 8px from the window's edge, 80px wide. */
29−export const DOCK_COLUMN = "5.5rem";
30−
31−type Item = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } | null };
32−
33−/** A count on an icon: loud (the accent) for what waits on you, quiet for the rest. */
34−export function CountBadge({ count, loud, className }: { count: number; loud: boolean; className?: string }) {
35− if (count <= 0) return null;
36− return (
37− <span
38− className={cn(
39− "absolute -top-1 -right-1.5 min-w-[1.125rem] rounded-full px-1 text-center text-[0.625rem] leading-[1.125rem] font-bold tabular-nums ring-2 ring-dock",
40− loud ? "bg-accent text-bg" : "bg-line-strong text-fg",
41− className,
42− )}
43− >
44− {count > 99 ? "99+" : count}
45− </span>
46− );
47−}
48−
49−/** The square behind an icon in the dock: raised for the current one. */
50−function IconSquare({ current, children }: { current: boolean; children: ReactNode }) {
51− return (
52− <span
53− className={cn(
54− "relative flex size-9 items-center justify-center rounded-[11px] transition-colors",
55− current
56− ? "bg-raised text-fg shadow-[inset_0_0_0_1px_var(--color-line-strong)]"
57− : "text-muted group-hover:bg-raised group-hover:text-fg group-data-[state=open]:bg-raised group-data-[state=open]:text-fg",
58− )}
59− >
60− {children}
61− </span>
62− );
63−}
64−
65−/** The bar on the dock's left edge beside the current item. */
66−function CurrentBar() {
67− return <span aria-hidden="true" className="absolute top-[9px] -left-1.5 h-5 w-[3px] rounded-r-[3px] bg-fg" />;
68−}
69−
70−// Each item fills the dock's width and centres its icon and name on it.
71−const ITEM = "group relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg py-0.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent";
72−
73−function DockLink({ to, label, icon, current, badge, compact = false }: { to: string; label: string; icon: ReactNode; current: boolean; badge?: Item["badge"]; compact?: boolean }) {
74− const link = (
75− <Link
76− to={to}
77− prefetch="intent"
78− aria-current={current ? "page" : undefined}
79− aria-label={badge && badge.count > 0 ? `${label}, ${badge.count} unread` : label}
80− className={ITEM}
81− >
82− {current && <CurrentBar />}
83− <IconSquare current={current}>
84− {icon}
85− {badge && <CountBadge count={badge.count} loud={badge.loud} />}
86− </IconSquare>
87− {!compact && <DockLabel current={current}>{label}</DockLabel>}
88− </Link>
89− );
90− // An icon alone says its name on hover.
91− return compact ? (
92− <Hint label={label} side="right">
93− {link}
94− </Hint>
95− ) : (
96− link
97− );
98−}
99−
100−/** A name under an icon: centred on the dock, whatever its length. */
101−function DockLabel({ current, children }: { current: boolean; children: ReactNode }) {
102− return (
103− <span className={cn("block w-full truncate text-[0.625rem] leading-tight font-medium transition-colors", current ? "text-fg" : "text-faint group-hover:text-muted")}>
104− {children}
105− </span>
106− );
107−}
108−
109−function displayName(membership: Membership): string {
110− return membership.name?.trim() || membership.slug;
111−}
112−
113−/**
114− * The workspace you are in and the way to another: its avatar, its name
115− * and a chevron, opening your workspaces, a new one, and your own account.
116− * `row` heads the sidebar; `compact` starts the page header's trail when
117− * no sidebar is open.
118− */
119−export function WorkspaceSwitcher({ user, workspace, compact = false }: { user: User; workspace: Membership; compact?: boolean }) {
120− return (
121− <DropdownMenu>
122− <DropdownMenuTrigger
123− aria-label={`${displayName(workspace)}: switch workspace`}
124− className={cn(
125− "flex min-w-0 items-center rounded-lg outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised",
126− compact ? "h-8 shrink-0 gap-1.5 px-1 max-md:h-11 max-sm:gap-1" : "h-9 grow gap-2 px-1.5",
127− )}
128− >
129− <Avatar name={workspace.slug} image={workspace.avatar} size={compact ? 22 : 26} square />
130− <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm max-sm:hidden" : "text-[0.875rem]")}>{displayName(workspace)}</span>
131− <ChevronDown size={14} className="shrink-0 text-faint" />
132− </DropdownMenuTrigger>
133− <DropdownMenuContent align="start" className="w-72 p-1.5">
134− <DropdownMenuItem asChild className="gap-3 px-2 py-2">
135− <Link to={`/${workspace.slug}/-/workspace`}>
136− <Avatar name={workspace.slug} image={workspace.avatar} size={36} square />
137− <span className="flex min-w-0 grow flex-col leading-tight">
138− <span className="truncate text-sm font-medium text-fg">{displayName(workspace)}</span>
139− <span className="truncate font-mono text-xs text-muted">g1t.sh/{workspace.slug}</span>
140− <span className="mt-0.5 text-xs text-faint capitalize">{workspace.role}</span>
141− </span>
142− </Link>
143− </DropdownMenuItem>
144− <DropdownMenuSeparator className="my-1.5" />
145− <DropdownMenuLabel>Switch workspace</DropdownMenuLabel>
146− {(user.workspaces ?? []).map((membership) => (
147− <DropdownMenuItem key={membership.slug} asChild>
148− <Link to={homePagePath(membership.slug)}>
149− <Avatar name={membership.slug} image={membership.avatar} size={24} square />
150− <span className="flex min-w-0 grow flex-col leading-tight">
151− <span className="truncate">{displayName(membership)}</span>
152− <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
153− </span>
154− {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />}
155− </Link>
156− </DropdownMenuItem>
157− ))}
158− <DropdownMenuItem asChild>
159− <Link to="/workspaces/new">
160− <Plus />
161− New workspace
162− </Link>
163− </DropdownMenuItem>
164− <DropdownMenuSeparator className="my-1.5" />
165− <DropdownMenuLabel>Personal account</DropdownMenuLabel>
166− <DropdownMenuItem asChild>
167− <Link to={`/u/${user.username}`}>
168− <CircleUserRound />
169− <span className="grow">Your profile</span>
170− <span className="font-mono text-xs text-faint">@{shownUsername(user)}</span>
171− </Link>
172− </DropdownMenuItem>
173− <DropdownMenuItem asChild>
174− <Link to="/settings">
175− <Settings />
176− Your settings
177− </Link>
178− </DropdownMenuItem>
179− </DropdownMenuContent>
180− </DropdownMenu>
181− );
182−}
183−
184−/** Keyboard shortcuts, as the account menu lists them. */
185−function shortcuts(palette: string, sidebar: string): [string, string][] {
186− return [
187− [palette, "Search, jump anywhere, or run a command"],
188− [sidebar, "Show or hide the sidebar"],
189− ["Esc", "Close a menu, dialog or panel"],
190− ["Enter", "Send a message in Chat"],
191− ["Shift Enter", "A new line in a message"],
192− ["@", "Mention a person or an agent"],
193− ["↑ ↓ then Tab", "Choose a suggestion"],
194− ["Alt ↑ ↓", "Move a pinned project in Code's sidebar"],
195− ];
196−}
197−
198−/** The sidebar's shortcut, as this computer writes it. */
199−export function sidebarKeyLabel(platform: string | null): string {
200− return paletteKeyLabel(platform).replace(/K$/, "B");
201−}
202−
203−/** The keyboard's shortcuts, opened from the account menu. */
204−export function ShortcutsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
205− const platform = typeof navigator === "undefined" ? null : navigator.platform;
206− return (
207− <Dialog open={open} onOpenChange={onOpenChange}>
208− <DialogContent className="max-w-md">
209− <DialogHeader>
210− <DialogTitle>Keyboard shortcuts</DialogTitle>
211− <DialogDescription>Everywhere in g1t, and in Chat.</DialogDescription>
212− </DialogHeader>
213− <Card asChild tone="plain" radius="lg" divided>
214− <dl>
215− {shortcuts(paletteKeyLabel(platform), sidebarKeyLabel(platform)).map(([key, what]) => (
216− <div key={key} className="flex items-center justify-between gap-4 px-3.5 py-2.5 text-sm">
217− <dt className="text-fg-soft">{what}</dt>
218− <dd>
219− <kbd className="rounded-md border border-line-strong bg-raised px-1.5 py-0.5 font-sans text-xs whitespace-nowrap text-fg">{key}</kbd>
220− </dd>
221− </div>
222− ))}
223− </dl>
224− </Card>
225− </DialogContent>
226− </Dialog>
227− );
228−}
229−
230−/** The Apps button at the foot of the dock's apps, and the launcher it opens. */
231−function AppsButton({ slug, pins, onToggle, current }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) {
232− const [open, setOpen] = useState(false);
233− return (
234− <Popover open={open} onOpenChange={setOpen}>
235− <PopoverTrigger aria-label="Apps" className={ITEM}>
236− {current && <CurrentBar />}
237− <IconSquare current={current || open}>
238− <LayoutGrid size={19} />
239− </IconSquare>
240− <DockLabel current={current || open}>Apps</DockLabel>
241− </PopoverTrigger>
242− <PopoverContent side="right" align="end" sideOffset={14} aria-label="Apps" className="w-[23rem] bg-dock p-2.5">
243− <AppsLauncher slug={slug} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} />
244− </PopoverContent>
245− </Popover>
246− );
247−}
248−
249−/**
250− * The dock down the left: g1t's mark, which
251− * leads to Home; the built-in apps (Home, Chat, Notifications, Agents,
252− * Code and Artifacts) with their names; the apps you pinned, as icons;
253− * the Apps launcher; and at its foot People, Workspace and your account.
254− * What is lit follows the address. A member without Code access has no
255− * Code. From 768px; a phone has the bottom bar (components/mobile.tsx).
256− */
257−export function Dock({
258− workspace,
259− pins: saved,
260− unread,
261− account,
262−}: {
263− workspace: Membership;
264− /** The apps pinned to this person's dock in this workspace, as saved. */
265− pins: PinnableApp[];
266− unread: { notifications: number; chat: number; mentions: number };
267− account: ReactNode;
268−}) {
269− const { pathname } = useLocation();
270− const going = useNavigation().location?.pathname;
271− const path = going ?? pathname;
272− const here = modeOf(path, workspace.slug);
273− const slug = workspace.slug;
274− const code = hasCodeAccess(workspace);
275− const { pins, toggle } = useAppPins(slug, saved);
276− const builtins: Item[] = [
277− { key: "home", label: "Home" },
278− {
279− key: "chat",
280− label: "Chat",
281− badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
282− },
283− { key: "notifications", label: "Notifications", badge: { count: unread.notifications, loud: true } },
284− { key: "agents", label: "Agents" },
285− ...(code ? [{ key: "code" as const, label: "Code" }] : []),
286− { key: "artifacts", label: "Artifacts" },
287− ];
288− const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
289− // A pinned app is current on its own page and the pages under it.
290− const at = (to: string) => path === to || path.startsWith(`${to}/`);
291− return (
292− <nav
293− aria-label="Apps"
294− className="flex h-full flex-col overflow-hidden rounded-2xl border border-line bg-dock shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.18)]"
295− >
296− <div className="flex h-14 shrink-0 items-center justify-center">
297− <Hint label="Home" side="right">
298− <Link to={homePagePath(slug)} aria-label="g1t: Home" className="flex size-9 items-center justify-center rounded-[11px] transition-colors hover:bg-raised">
299− <Mark tight className="h-[1.125rem] w-auto" />
300− </Link>
301− </Hint>
302− </div>
303− <div className="flex w-full shrink-0 flex-col items-center gap-1 px-1.5 pb-1.5">
304− {builtins.map((item) => (
305− <DockLink
306− key={item.key}
307− to={modeHome(item.key, slug)}
308− label={item.label}
309− icon={appIcon(item.key as BuiltinApp, 19)}
310− current={here === item.key && !shown.some((app) => at(app.to))}
311− badge={item.badge}
312− />
313− ))}
314− </div>
315− {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */}
316− <div className="flex min-h-0 w-full grow flex-col items-center gap-1 overflow-y-auto border-t border-line px-1.5 py-2 [mask-image:linear-gradient(to_bottom,transparent,#000_10px,#000_calc(100%-14px),transparent)] [scrollbar-width:none]">
317− {shown.map((app) => (
318− <DockLink key={app.key} to={app.to} label={app.name} icon={app.icon(22)} current={at(app.to)} compact />
319− ))}
320− </div>
321− <div className="flex w-full shrink-0 flex-col items-center px-1.5 pb-1.5">
322− <AppsButton slug={slug} pins={pins} onToggle={toggle} current={here === "apps"} />
323− </div>
324− <div className="flex w-full shrink-0 flex-col items-center gap-1 border-t border-line px-1.5 pt-2 pb-2.5">
325− <DockLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 19)} current={here === "people"} />
326− <DockLink to={modeHome("workspace", slug)} label="Workspace" icon={appIcon("workspace", 19)} current={here === "workspace"} />
327− <div className="mt-1.5">{account}</div>
328− </div>
329− </nav>
330− );
331−}
+7−7
2323 import { warmOnIntent } from "./list";
2424 import { SpaceIcon } from "./parts";
2525
26−const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
26+const ROW = "group flex h-9 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
2727 const ROW_BUTTON = "flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100 pointer-coarse:size-9 pointer-coarse:opacity-100";
2828
2929 function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
3030 return (
31− <NavLink to={to} end={end} prefetch="intent" className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}>
31+ <NavLink to={to} end={end} prefetch="intent" className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-sidebar-accent font-medium text-fg" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"}`}>
3232 <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span>
3333 <span className="min-w-0 grow truncate">{children}</span>
3434 {trailing}
4141 return (
4242 <section className="mt-4">
4343 <div className="mb-0.5 flex items-center gap-1 pr-1">
44− <button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex grow items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted">
44+ <button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex grow items-center gap-1 rounded px-1 text-[0.6875rem] font-medium tracking-wide text-faint uppercase transition-colors hover:text-muted">
4545 <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
4646 {title}
4747 </button>
110110 setOver(null);
111111 if (drag && drag.id !== item.id) onDrop(item, where);
112112 }}
113− className={`${ROW} relative ${active ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"} ${over === "inside" ? "ring-1 ring-accent/60 ring-inset" : ""}`}
113+ className={`${ROW} relative ${active ? "bg-sidebar-accent font-medium text-fg" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"} ${over === "inside" ? "ring-1 ring-accent/60 ring-inset" : ""}`}
114114 style={{ paddingLeft: 4 + item.depth * 14 }}
115115 >
116116 {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />}
266266 <>
267267 {folder.files.map((f) => (
268268 <li key={f.path}>
269− <NavLink to={repoFilePath(slug, repo, f.path)} prefetch="intent" className={({ isActive }) => `${ROW} ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} style={{ paddingLeft: 26 + depth * 14 }}>
269+ <NavLink to={repoFilePath(slug, repo, f.path)} prefetch="intent" className={({ isActive }) => `${ROW} ${isActive ? "bg-sidebar-accent font-medium text-fg" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"}`} style={{ paddingLeft: 26 + depth * 14 }}>
270270 <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" />
271271 <span className="min-w-0 truncate">{f.title}</span>
272272 </NavLink>
287287 return next;
288288 })
289289 }
290− className={`${ROW} w-full text-muted hover:bg-raised/60 hover:text-fg`}
290+ className={`${ROW} w-full text-muted hover:bg-sidebar-accent/60 hover:text-fg`}
291291 style={{ paddingLeft: 4 + (depth + 1) * 14 }}
292292 >
293293 <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} />
366366 return (
367367 <div className="flex h-full flex-col">
368368 <div className="flex h-9 shrink-0 items-center gap-1 pr-1 pl-3">
369− <h2 className="min-w-0 grow truncate text-xs font-medium text-faint">Artifacts</h2>
369+ <h2 className="min-w-0 grow truncate text-[0.6875rem] font-medium tracking-wide text-faint uppercase">Artifacts</h2>
370370 <NewMenu onMake={make}>
371371 <Button type="button" aria-label="New" variant="ghost" className="h-8 gap-0.5 px-1.5 text-faint">
372372 <Plus size={16} />
+5−5
2222 import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
2323
2424 import { appIcon, pinnedApp } from "./apps";
25−import { CountBadge, ShortcutsDialog } from "./dock";
25+import { CountBadge, ShortcutsDialog } from "./rail";
2626 import { Mark } from "./logo";
2727
2828 import { Avatar } from "./ui/avatar";
4040 /**
4141 * The phone's layout (below 768px): a bottom bar of apps with More for
4242 * the rest, the mode's sidebar as a drawer from the left, and each tab a
43− * list that pushes its detail full screen. A computer has the dock.
43+ * list that pushes its detail full screen. A computer has the rail.
4444 */
4545
4646 /** Whether a path is one conversation, which takes the whole screen on a phone. */
135135 }: {
136136 user: User;
137137 workspace: Membership;
138− /** The apps pinned to this person's dock here, for the More sheet. */
138+ /** The apps pinned to this person's rail here, for the More sheet. */
139139 pins: PinnableApp[];
140140 unread: { notifications: number; chat: number; mentions: number };
141141 /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */
160160 <>
161161 <nav
162162 aria-label="Apps"
163− className="fixed right-2 bottom-[calc(0.5rem+env(safe-area-inset-bottom))] left-2 z-40 h-16 rounded-2xl border border-line bg-dock px-1 shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.35)] md:hidden in-data-[keyboard=open]:hidden"
163+ className="fixed right-2 bottom-[calc(0.5rem+env(safe-area-inset-bottom))] left-2 z-40 h-16 rounded-2xl border border-line bg-rail px-1 shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.35)] md:hidden in-data-[keyboard=open]:hidden"
164164 >
165165 <ul className="grid h-full" style={{ gridTemplateColumns: `repeat(${tabs.length + 1}, minmax(0, 1fr))` }}>
166166 {tabs.map((tab) => {
266266 <Primitive.Content
267267 aria-describedby={undefined}
268268 onOpenAutoFocus={(event) => event.preventDefault()}
269− className="fixed right-2 bottom-[calc(4.875rem+env(safe-area-inset-bottom))] left-2 z-50 max-h-[calc(100dvh-7rem)] overflow-y-auto rounded-[18px] border border-line-strong bg-dock px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden"
269+ className="fixed right-2 bottom-[calc(4.875rem+env(safe-area-inset-bottom))] left-2 z-50 max-h-[calc(100dvh-7rem)] overflow-y-auto rounded-[18px] border border-line-strong bg-rail px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden"
270270 >
271271 <div aria-hidden="true" className="mx-auto mt-0.5 mb-2 h-1 w-9 rounded-full bg-line-strong" />
272272 <div className="mb-2 flex items-center gap-2 px-1">
+382−306
1−import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
1+import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
3−import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
3+import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
55 import { type Abilities, type ChatSidebarEntry, type InboxCounts, type WorkspaceAgent, type Membership, type Spike, type User, hasCodeAccess, mayCreateTeams, shownUsername } from "@g1t/contracts";
66
1010 import { SpendPill } from "./spend";
1111 import { PinButton } from "./pin-button";
1212 import { Hint } from "./ui/hint";
13−import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
13+import {
14+ Sidebar,
15+ SidebarContent,
16+ SidebarGroup,
17+ SidebarGroupAction,
18+ SidebarGroupContent,
19+ SidebarGroupLabel,
20+ SidebarHeader,
21+ SidebarMenu,
22+ SidebarMenuAction,
23+ SidebarMenuBadge,
24+ SidebarMenuButton,
25+ SidebarMenuItem,
26+ SidebarMobile,
27+ SidebarProvider,
28+ SidebarSeparator,
29+ SidebarTrigger,
30+} from "./ui/sidebar";
1431 import { StatusDot, useSiteStatus } from "./footer";
1532 import { Logo, MarkGlyph } from "./logo";
1633 import { SoonPill } from "./ui";
3350 import { AgentsSidebar } from "./agents-mode";
3451 import { ChatSidebar } from "./chat/sidebar";
3552 import { FoliosSidebar } from "./folios/sidebar";
36−import { Dock, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./dock";
53+import { Rail, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./rail";
3754 import { BottomBar, isConversation, useVisualViewport } from "./mobile";
3855 import { useChatSidebar } from "./chat/actions";
3956 import { unreadTotals } from "../lib/chat";
89106 monthUsageMicros: number | null;
90107 /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */
91108 compute?: { paused: string | null; spike: Spike | null; owner: boolean } | null;
92− /** What is unread in their notifications, for the dock and the bell. Absent for a visitor. */
109+ /** What is unread in their notifications, for the rail and the bell. Absent for a visitor. */
93110 inbox?: InboxCounts | null;
94111 /**
95− * Chat, for the dock's badge: what is unread, and the
112+ * Chat, for the rail's badge: what is unread, and the
96113 * starred and latest conversations. Null when chat did not answer in time.
97114 */
98115 chat?: { unread: number; mentions: number; starred?: ChatSidebarEntry[]; recent?: ChatSidebarEntry[] } | null;
99116 /** The workspace's agents, g1t first, for the Agents sidebar; null when not known. */
100117 agents?: ShellAgent[] | null;
101− /** The apps this person pinned to their dock in the workspace (lib/apps.ts). */
118+ /** The apps this person pinned to their rail in the workspace (lib/apps.ts). */
102119 pins?: PinnableApp[];
103120 };
104121
112129 teams?: { slug: string; name: string }[];
113130 };
114131
132+/** Whether `pathname` is `to` or a page under it (`end`: only `to` itself), the query left aside. */
133+function under(pathname: string, to: string, end = false): boolean {
134+ const path = to.split("?")[0]!;
135+ return pathname === path || (!end && pathname.startsWith(path.endsWith("/") ? path : `${path}/`));
136+}
137+
138+/**
139+ * Where a row is current and where it is on its way to. A row's page is
140+ * current under its own address and any `also` prefix, unless the list
141+ * says itself (`current`). Rows that differ by query alone (Notifications'
142+ * views and reasons) are pending only when the page on its way is that
143+ * row's, query and all.
144+ */
145+function useRowState(to: string, { end, also, current: lit }: { end?: boolean; also?: string | string[]; current?: boolean }) {
146+ const { pathname } = useLocation();
147+ const going = useNavigation().location;
148+ const current = lit ?? (under(pathname, to, end) || [also ?? []].flat().some((prefix) => under(pathname, prefix)));
149+ const pending =
150+ going != null && !current && (lit === undefined ? under(going.pathname, to, end) : `${going.pathname}${going.search}` === to);
151+ return { current, pending };
152+}
153+
154+/**
155+ * One row of a mode's sidebar: an icon, its words, how many it lists
156+ * (`count`) and, for a row that opens a list of its own (`drill`), a
157+ * chevron, always or on hover.
158+ */
115159 function SidebarLink({
116160 to,
117161 icon,
120164 also,
121165 drill,
122166 current: lit,
167+ action,
168+ bare = false,
123169 children,
124170 }: {
125171 to: string;
132178 drill?: boolean | "hover";
133179 /** Whether it is the current row, when the list works that out itself. */
134180 current?: boolean;
181+ /** A small button at the row's end, beside the words (a pin). */
182+ action?: ReactNode;
183+ /** The row alone, for a list item that is drawn by the list (a pinned project, which drags). */
184+ bare?: boolean;
135185 children: ReactNode;
136186 }) {
137− const { pathname } = useLocation();
138− const navigation = useNavigation();
139− // NavLink's own pending state compares paths only, so a list whose rows
140− // differ by query (Notifications' views and reasons) would light every
141− // row while one loads. Where the list says which row is current, a row
142− // is pending only when the page on its way is that row's, query and all.
143− const loading = navigation.location;
144− const pendingHere = loading != null && `${loading.pathname}${loading.search}` === to;
187+ const { current, pending } = useRowState(to, { end, also, current: lit });
188+ const Item = bare ? Fragment : SidebarMenuItem;
145189 return (
146− <NavLink
147− to={to}
148− end={end}
149− prefetch="intent"
150− className={({ isActive, isPending }) => {
151− const current =
152− lit ??
153− (isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")));
154− const pending = lit === undefined ? isPending : pendingHere;
155− return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
156− current
157− ? "bg-raised font-medium text-fg"
158− : pending
159− ? "bg-raised/60 text-fg"
160− : "text-muted hover:bg-raised/60 hover:text-fg"
161− }`;
162− }}
163− >
164− <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
165− <span className="min-w-0 grow truncate">{children}</span>
166− {count != null && count > 0 && (
167− <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
168− {count}
169− </span>
170− )}
171− {drill && (
172− <ChevronRight
173− size={14}
174− aria-hidden="true"
175− className={`-mr-0.5 shrink-0 text-faint transition-opacity ${drill === "hover" ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : ""}`}
176− />
177− )}
178− </NavLink>
190+ <Item>
191+ <SidebarMenuButton asChild isActive={current} isPending={pending} className={action ? "pr-7" : undefined}>
192+ <Link to={to} prefetch="intent" aria-current={current ? "page" : undefined}>
193+ {icon}
194+ <span className="min-w-0 grow truncate">{children}</span>
195+ {count != null && count > 0 && <SidebarMenuBadge>{count}</SidebarMenuBadge>}
196+ {drill && (
197+ <ChevronRight
198+ aria-hidden="true"
199+ className={`-mr-0.5 size-3.5! shrink-0 text-faint transition-opacity ${drill === "hover" ? "opacity-0 group-hover/menu-button:opacity-100 group-focus-visible/menu-button:opacity-100" : ""}`}
200+ />
201+ )}
202+ </Link>
203+ </SidebarMenuButton>
204+ {action}
205+ </Item>
179206 );
180207 }
181208
185212 */
186213 function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
187214 return (
188− <Hint label={about} side="right">
189− <div aria-disabled="true" className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint">
190− <span className="shrink-0 opacity-70">{icon}</span>
191− <span className="grow truncate">{children}</span>
192− <span className="sr-only">{about}</span>
193− <SoonPill />
194− </div>
195− </Hint>
215+ <SidebarMenuItem>
216+ <Hint label={about} side="right">
217+ <SidebarMenuButton asChild variant="faint">
218+ <div aria-disabled="true" className="cursor-default">
219+ {icon}
220+ <span className="min-w-0 grow truncate">{children}</span>
221+ <span className="sr-only">{about}</span>
222+ <SoonPill />
223+ </div>
224+ </SidebarMenuButton>
225+ </Hint>
226+ </SidebarMenuItem>
196227 );
197228 }
198229
216247 current?: boolean;
217248 children: ReactNode;
218249 }) {
219− const { pathname } = useLocation();
250+ const { current, pending } = useRowState(to, { also, current: lit });
220251 return (
221− // The hint wraps a plain box: Radix's asChild merges className as a
222− // string, which would break NavLink's className function.
223− <Hint label={about} side="right">
224− <div>
225− <NavLink
226− to={to}
227− prefetch="intent"
228− className={({ isActive }) => {
229− const current = lit ?? (isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")));
230− return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
231− current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"
232− }`;
233− }}
234− >
235− <span className="shrink-0 opacity-80">{icon}</span>
236− <span className="min-w-0 grow truncate">{children}</span>
237− <SoonPill />
238− </NavLink>
239− </div>
240− </Hint>
252+ <SidebarMenuItem>
253+ <Hint label={about} side="right">
254+ <SidebarMenuButton asChild variant="faint" isActive={current} isPending={pending}>
255+ <Link to={to} prefetch="intent" aria-current={current ? "page" : undefined}>
256+ {icon}
257+ <span className="min-w-0 grow truncate">{children}</span>
258+ <SoonPill />
259+ </Link>
260+ </SidebarMenuButton>
261+ </Hint>
262+ </SidebarMenuItem>
241263 );
242264 }
243265
255277 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
256278 }
257279
258−function SidebarGroup({
280+/** A titled group of rows: its name in small capitals, what it makes at the end, and its menu. */
281+function NavGroup({
259282 title,
260283 action,
261− className = "",
284+ className,
262285 children,
263286 }: {
264287 title: string;
288+ /** A small button at the label's end: what the group makes (a `SidebarGroupAction`). */
265289 action?: ReactNode;
266290 className?: string;
267291 children: ReactNode;
268292 }) {
269293 return (
270− <section className={className}>
271− <div className="mb-1 flex h-6 items-center justify-between px-2">
272− <h2 className="text-xs font-medium text-faint">{title}</h2>
273− {action}
274− </div>
275− <div className="space-y-px">{children}</div>
276− </section>
294+ <SidebarGroup className={className}>
295+ <SidebarGroupLabel asChild>
296+ <h2>{title}</h2>
297+ </SidebarGroupLabel>
298+ {action}
299+ <SidebarGroupContent>
300+ <SidebarMenu>{children}</SidebarMenu>
301+ </SidebarGroupContent>
302+ </SidebarGroup>
277303 );
278304 }
279305
306+/** Rows with no name over them: the first of a list, or a run between rules. */
307+function NavRows({ className, children }: { className?: string; children: ReactNode }) {
308+ return (
309+ <SidebarGroup className={className}>
310+ <SidebarGroupContent>
311+ <SidebarMenu>{children}</SidebarMenu>
312+ </SidebarGroupContent>
313+ </SidebarGroup>
314+ );
315+}
316+
280317 /** What a workspace is called where people read it: its display name, else its slug. */
281318 function displayName(membership: Membership): string {
282319 return membership.name?.trim() || membership.slug;
332369 const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]";
333370
334371 /**
335− * Your account, at the foot of the dock (or in the header, before you have
372+ * Your account, at the foot of the rail (or in the header, before you have
336373 * a workspace): who you are and your status, your profile and settings,
337374 * and help: the documentation, support, status and the keyboard's
338375 * shortcuts; then signing out and the fine print.
363400 <>
364401 <DropdownMenu open={open} onOpenChange={setOpen}>
365402 {/* The avatar alone, with a dot that says how others see you (components/presence.tsx). */}
366− <DropdownMenuTrigger
367− aria-label={`Account menu for ${shownUsername(user)}`}
368− onPointerEnter={prefetch}
369− onFocus={prefetch}
370− className="relative flex rounded-full outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong"
371− >
372− <Avatar name={user.username} image={user.avatar} size={side === "right" ? 32 : 28} />
373− <OwnPresenceDot ring={side === "right" ? "var(--color-dock)" : "var(--color-bg)"} className="absolute -right-0.5 -bottom-0.5" />
374− </DropdownMenuTrigger>
403+ <Hint label={side === "right" ? "Your account" : undefined} side="right">
404+ <DropdownMenuTrigger
405+ aria-label={`Account menu for ${shownUsername(user)}`}
406+ onPointerEnter={prefetch}
407+ onFocus={prefetch}
408+ className={
409+ side === "right"
410+ ? // At the foot of the rail: a 40px rounded square, as the rail's icons are.
411+ "relative flex size-10 items-center justify-center rounded-[10px] outline-none transition-colors hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-sidebar-accent"
412+ : "relative flex rounded-full outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong"
413+ }
414+ >
415+ <Avatar name={user.username} image={user.avatar} size={side === "right" ? 40 : 28} square={side === "right"} />
416+ <OwnPresenceDot ring={side === "right" ? "var(--color-rail)" : "var(--color-bg)"} className={side === "right" ? "absolute -right-1 -bottom-1" : "absolute -right-0.5 -bottom-0.5"} />
417+ </DropdownMenuTrigger>
418+ </Hint>
375419 <DropdownMenuContent align="end" side={side} sideOffset={side === "right" ? 14 : 6} collisionPadding={8} className="w-[17.5rem] p-1.5">
376420 {/* Who is signed in, and a way to their profile. */}
377421 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
495539 const LAYER =
496540 "absolute inset-0 [transition:translate_380ms_cubic-bezier(0.32,0.72,0,1),opacity_220ms_ease-out] will-change-[translate,opacity] motion-reduce:transition-none";
497541 /** One list, filling its layer. */
498−const PANEL = "h-full overflow-y-auto px-2 pb-4";
542+const PANEL = "h-full overflow-y-auto px-1 pt-1 pb-4 [scrollbar-width:thin]";
499543
500544 /** One list in the stack: what it is, and its rows. */
501545 type Level = { key: string; node: ReactNode };
563607 */
564608 function BackRow({ to, label, context }: { to: string; label: ReactNode; context?: ReactNode }) {
565609 return (
566− <Link
567− to={to}
568− prefetch="intent"
569− className="group mt-3 flex h-8 items-center gap-1.5 rounded-md pr-2 pl-1 text-[0.8125rem] font-medium text-fg transition-colors hover:bg-raised/60"
570− >
571− <ChevronLeft size={16} className="shrink-0 text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
572− <span className="min-w-0 shrink-0 truncate">{label}</span>
573− {context && (
574− <span className="ml-auto min-w-0 truncate pl-2 font-mono text-[0.6875rem] font-normal text-faint">{context}</span>
575− )}
576− </Link>
610+ <NavRows className="mt-1">
611+ <SidebarMenuItem>
612+ <SidebarMenuButton asChild className="gap-1.5 pl-1 font-medium text-fg">
613+ <Link to={to} prefetch="intent">
614+ <ChevronLeft className="text-faint transition-transform group-hover/menu-button:-translate-x-0.5 group-hover/menu-button:text-muted" />
615+ <span className="min-w-0 shrink-0 truncate">{label}</span>
616+ {context && <span className="ml-auto min-w-0 truncate pl-2 font-mono text-[0.6875rem] font-normal text-faint">{context}</span>}
617+ </Link>
618+ </SidebarMenuButton>
619+ </SidebarMenuItem>
620+ </NavRows>
577621 );
578622 }
579623
580624 /** A thin rule between groups of a list. */
581625 function Rule() {
582− return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />;
626+ return <SidebarSeparator className="my-1.5" />;
583627 }
584628
585629 /** A quiet heading inside a group: Pinned, Recent. */
586630 function SidebarSubhead({ children }: { children: ReactNode }) {
587− return <h3 className="px-2 pt-1.5 pb-0.5 text-[0.6875rem] font-medium tracking-wide text-faint">{children}</h3>;
631+ return (
632+ <SidebarGroupLabel asChild className="h-6 pt-0.5">
633+ <h3>{children}</h3>
634+ </SidebarGroupLabel>
635+ );
588636 }
589637
590638 /**
615663 // Each row pins or unpins in place: the pin shows on hover or focus, and
616664 // stays shown on a pinned row's hover so it reads as "unpin".
617665 const row = (project: ShortcutProject, isPinned: boolean) => (
618− <div className="group/row relative">
619− <SidebarLink to={`/${project.namespace}/${project.name}`} icon={project.isPrivate ? <Lock size={15} /> : <Box size={15} />} drill="hover">
620− <span className="block truncate pr-6">{project.title ?? project.name}</span>
621− </SidebarLink>
622− <PinButton
623− workspace={slug}
624− slug={project.name}
625− name={project.title ?? project.name}
626− pinned={isPinned}
627− small
628− className="absolute top-1/2 right-6 -translate-y-1/2 opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
629− />
630− </div>
631− );
632− return (
633− <SidebarGroup
634− title="Projects"
666+ <SidebarLink
667+ to={`/${project.namespace}/${project.name}`}
668+ icon={project.isPrivate ? <Lock /> : <Box />}
669+ drill="hover"
670+ bare={isPinned}
635671 action={
636− <Link to="/new" aria-label="New project" className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg">
637− <Plus size={13} />
638− </Link>
672+ <PinButton
673+ workspace={slug}
674+ slug={project.name}
675+ name={project.title ?? project.name}
676+ pinned={isPinned}
677+ small
678+ className="absolute top-1/2 right-6 -translate-y-1/2 opacity-0 transition-opacity group-hover/menu-item:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100"
679+ />
639680 }
640681 >
682+ {project.title ?? project.name}
683+ </SidebarLink>
684+ );
685+ return (
686+ <SidebarGroup>
687+ <SidebarGroupLabel asChild>
688+ <h2>Projects</h2>
689+ </SidebarGroupLabel>
690+ <Hint label="New project">
691+ <SidebarGroupAction asChild>
692+ <Link to="/new" aria-label="New project">
693+ <Plus />
694+ </Link>
695+ </SidebarGroupAction>
696+ </Hint>
697+ <SidebarGroupContent>
641698 {/* Only there once something is pinned: each row below has its own pin. */}
642699 {pinned.length > 0 && (
643700 <>
644701 <SidebarSubhead>Pinned</SidebarSubhead>
645− <ul aria-label="Pinned projects" className="space-y-px">
702+ <SidebarMenu aria-label="Pinned projects">
646703 {pinned.map((project, index) => (
647− <li
704+ <SidebarMenuItem
648705 key={project.name}
649706 draggable={pinned.length > 1}
650707 onDragStart={(event) => {
665722 event.preventDefault();
666723 move(index, event.key === "ArrowUp" ? -1 : 1);
667724 }}
668− className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`}
725+ className={`group/pin ${dragging === index ? "opacity-50" : ""}`}
669726 >
670727 {row(project, true)}
671728 {pinned.length > 1 && (
672729 <Hint label="Drag, or Alt and an arrow key, to reorder" side="right">
673− <span className="absolute top-1/2 -left-1.5 -translate-y-1/2 cursor-grab text-faint opacity-0 transition-opacity group-hover/pin:opacity-100">
730+ <span className="absolute top-1/2 -left-2 -translate-y-1/2 cursor-grab text-faint opacity-0 transition-opacity group-hover/pin:opacity-100">
674731 <GripVertical size={12} aria-hidden="true" />
675732 </span>
676733 </Hint>
677734 )}
678− </li>
735+ </SidebarMenuItem>
679736 ))}
680− </ul>
737+ </SidebarMenu>
681738 </>
682739 )}
683740 {recent.length > 0 && (
684741 <>
685742 <SidebarSubhead>Recent</SidebarSubhead>
686− {recent.map((project) => (
687− <div key={`${project.namespace}/${project.name}`}>{row(project, false)}</div>
688− ))}
743+ <SidebarMenu aria-label="Recent projects">
744+ {recent.map((project) => (
745+ <Fragment key={`${project.namespace}/${project.name}`}>{row(project, false)}</Fragment>
746+ ))}
747+ </SidebarMenu>
689748 </>
690749 )}
691− <div className="pt-1">
692− <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length} current={current}>
750+ <SidebarMenu className="pt-1">
751+ <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid />} count={shell.repos.length} current={current}>
693752 All projects
694753 </SidebarLink>
695− </div>
754+ </SidebarMenu>
755+ </SidebarGroupContent>
696756 </SidebarGroup>
697757 );
698758 }
744804 const base = `/${repo.namespace}/${repo.name}`;
745805 // What a member sees, and what everyone who can see the project does.
746806 const shows = new Set(projectPages(repo.member, repo.can));
807+ const { current: atOverview, pending: toOverview } = useRowState(base, { end: true });
747808 return (
748809 <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}>
749810 <BackRow to={back.to} label={back.label} />
750− <Hint label="Overview" side="right">
751− <div>
752− <NavLink
753− to={base}
754− end
755− prefetch="intent"
756− className={({ isActive }) =>
757− `mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
758− }
759− >
760− <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
761− {isPrivate ? <Lock size={13} /> : <Box size={13} />}
762− </span>
763− <span className="min-w-0 truncate font-mono text-[0.8125rem]">
764− <span className="text-faint">{repo.namespace}/</span>
765− <span className="font-semibold text-fg">{repo.name}</span>
766− </span>
767− </NavLink>
768− </div>
769− </Hint>
811+ <NavRows className="mt-1">
812+ <SidebarMenuItem>
813+ <Hint label="Overview" side="right">
814+ <SidebarMenuButton asChild size="lg" isActive={atOverview} isPending={toOverview} className="gap-2 font-normal">
815+ <Link to={base} prefetch="intent" aria-current={atOverview ? "page" : undefined}>
816+ <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line [&>svg]:size-3.5">
817+ {isPrivate ? <Lock /> : <Box />}
818+ </span>
819+ <span className="min-w-0 truncate font-mono text-[0.8125rem]">
820+ <span className="text-faint">{repo.namespace}/</span>
821+ <span className="font-semibold text-fg">{repo.name}</span>
822+ </span>
823+ </Link>
824+ </SidebarMenuButton>
825+ </Hint>
826+ </SidebarMenuItem>
827+ </NavRows>
770828 {/* A project's pages, in the order people use them. A page with more
771829 than one view shows them as tabs across its top. */}
772830 <Rule />
773− <div className="space-y-px">
831+ <NavRows>
774832 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, `${base}/compare`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
775833 Code
776834 </SidebarLink>
786844 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
787845 Workflows
788846 </SidebarLink>
789− </div>
847+ </NavRows>
790848 <Rule />
791− <div className="space-y-px">
849+ <NavRows>
792850 {shows.has("deployments") ? (
793851 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
794852 Deployments
809867 >
810868 Insights
811869 </SidebarLink>
812− </div>
870+ </NavRows>
813871 {(repo.settings ?? shows.has("settings")) && (
814872 <>
815873 <Rule />
816− <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />} drill>
817− Settings
818− </SidebarLink>
874+ <NavRows>
875+ <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />} drill>
876+ Settings
877+ </SidebarLink>
878+ </NavRows>
819879 </>
820880 )}
821881 </nav>
837897 <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}>
838898 <BackRow to={base} label="Settings" context={`${repo.namespace}/${repo.name}`} />
839899 {shows("") && (
840− <div className="mt-2 space-y-px">
900+ <NavRows className="mt-2">
841901 <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />}>
842902 General
843903 </SidebarLink>
844− </div>
904+ </NavRows>
845905 )}
846906 {running && (
847907 <>
848908 {shows("") && <Rule />}
849− <div className={shows("") ? "space-y-px" : "mt-2 space-y-px"}>
909+ <NavRows className={shows("") ? undefined : "mt-2"}>
850910 <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
851911 Deployments
852912 </SidebarLink>
853913 <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}>
854914 Domains
855915 </SidebarLink>
856− </div>
916+ </NavRows>
857917 </>
858918 )}
859919 {agents && (
860920 <>
861921 <Rule />
862− <div className="space-y-px">
922+ <NavRows>
863923 {shows("agents") && (
864924 <SidebarLink to={`${base}/settings/agents`} icon={<Bot size={15} />}>
865925 Agents
870930 Guardrails
871931 </SidebarLink>
872932 )}
873− </div>
933+ </NavRows>
874934 </>
875935 )}
876936 <Rule />
877− <div className="space-y-px">
937+ <NavRows>
878938 {shows("repository") && (
879939 <SidebarLink to={`${base}/settings/repository`} icon={<BookMarked size={15} />}>
880940 Repository
930990 Webhooks
931991 </SidebarLink>
932992 )}
933− </div>
993+ </NavRows>
934994 </nav>
935995 );
936996 }
9611021 return (
9621022 <nav aria-label="Your settings" className={PANEL}>
9631023 <BackRow to="/" label="Your settings" context={username} />
964− <div className="mt-2 space-y-px">
1024+ <NavRows className="mt-2">
9651025 {link("profile", <CircleUserRound size={15} />)}
9661026 {link("emails", <Mail size={15} />)}
9671027 {invites && link("invites", <Ticket size={15} />)}
968− </div>
1028+ </NavRows>
9691029 <Rule />
970− <div className="space-y-px">
1030+ <NavRows>
9711031 {link("keys", <Fingerprint size={15} />)}
9721032 {link("tokens", <KeyRound size={15} />)}
973− </div>
1033+ </NavRows>
9741034 <Rule />
975− <div className="space-y-px">
1035+ <NavRows>
9761036 {link("integrations", <Blocks size={15} />)}
9771037 {link("github", <GithubMark className="size-[15px]" />)}
9781038 {link("applications", <Plug size={15} />)}
979− </div>
1039+ </NavRows>
9801040 <Rule />
981− <div className="space-y-px">
1041+ <NavRows>
9821042 {link("two-factor", <ShieldCheck size={15} />)}
9831043 {link("security-log", <History size={15} />)}
984− </div>
1044+ </NavRows>
9851045 </nav>
9861046 );
9871047 }
10791139 */
10801140 export function ModeHeader({ title, action }: { title: ReactNode; action?: ReactNode }) {
10811141 return (
1082− <div className="flex h-9 shrink-0 items-center gap-1 pr-1 pl-3">
1083− <h2 className="min-w-0 grow truncate text-xs font-medium text-faint">{title}</h2>
1142+ <div className="flex h-8 shrink-0 items-center gap-1 pr-2 pl-3">
1143+ <SidebarGroupLabel asChild className="min-w-0 grow truncate px-1">
1144+ <h2>{title}</h2>
1145+ </SidebarGroupLabel>
10841146 {action}
10851147 </div>
10861148 );
10871149 }
10881150
10891151 /**
1090− * Code's own list (beside the dock): only code. Its Overview (Mission
1152+ * Code's own list (beside the rail): only code. Its Overview (Mission
10911153 * control's code panels), the projects, what spans them, and what is
10921154 * coming. The workspace's people, money and settings are elsewhere;
10931155 * agents, context and memory are Agents'.
11001162 const shared = shell.shared ?? [];
11011163 return (
11021164 <nav aria-label="Code" className={PANEL}>
1103− <div className="space-y-px">
1165+ <NavRows>
11041166 <SidebarLink to={`/${slug}/-/overview`} icon={<LayoutDashboard size={15} />} current={at("overview")}>
11051167 Overview
11061168 </SidebarLink>
1107− </div>
1108− <div className="mt-3">
1109− <SidebarProjects slug={slug} shell={shell} current={at("projects")} />
1110− </div>
1169+ </NavRows>
11111170 <Rule />
1112− <div className="space-y-px">
1171+ <SidebarProjects slug={slug} shell={shell} current={at("projects")} />
1172+ <Rule />
1173+ <NavRows>
11131174 <SidebarLink to={`/${slug}/-/security`} icon={<ShieldCheck size={15} />} current={at("security") && !at("security/settings")}>
11141175 Security
11151176 </SidebarLink>
11281189 {item.title === "Board" ? "Boards" : item.title}
11291190 </SidebarSoonLink>
11301191 ))}
1131− </div>
1192+ </NavRows>
11321193 {shared.length > 0 && (
11331194 <>
11341195 <Rule />
1135− <SidebarGroup title="Shared with you">
1196+ <NavGroup title="Shared with you">
11361197 {shared.map((repo) => (
11371198 <SidebarLink
11381199 key={`${repo.namespace}/${repo.name}`}
11441205 {repo.name}
11451206 </SidebarLink>
11461207 ))}
1147− </SidebarGroup>
1208+ </NavGroup>
11481209 </>
11491210 )}
11501211 </nav>
11691230 const inSettings = WORKSPACE_SETTINGS.includes(top);
11701231 const main = (
11711232 <nav aria-label="Workspace" className={PANEL}>
1172− <div className="space-y-px">
1233+ <NavRows>
11731234 <SidebarLink to={`/${slug}/-/workspace`} icon={<LayoutGrid size={15} />} current={at("workspace")}>
11741235 Overview
11751236 </SidebarLink>
1176− </div>
1177− <SidebarGroup title="Money" className="mt-3">
1237+ </NavRows>
1238+ <Rule />
1239+ <NavGroup title="Money">
11781240 <SidebarLink to={`/${slug}/-/spend`} icon={<Coins size={15} />} current={at("spend")}>
11791241 Spend
11801242 </SidebarLink>
11871249 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />} current={at("billing")}>
11881250 Billing and plans
11891251 </SidebarLink>
1190− </SidebarGroup>
1252+ </NavGroup>
11911253 {owner && (
1192− <SidebarGroup title="Compute" className="mt-3">
1193− <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />} current={at("runners")}>
1194− Runners
1195− </SidebarLink>
1196− </SidebarGroup>
1254+ <>
1255+ <Rule />
1256+ <NavGroup title="Compute">
1257+ <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />} current={at("runners")}>
1258+ Runners
1259+ </SidebarLink>
1260+ </NavGroup>
1261+ </>
11971262 )}
1198− <SidebarGroup title="Connections" className="mt-3">
1263+ <Rule />
1264+ <NavGroup title="Connections">
11991265 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />} current={at("integrations")}>
12001266 Integrations
12011267 </SidebarLink>
1202− </SidebarGroup>
1203− <SidebarGroup title="Security policies" className="mt-3">
1268+ </NavGroup>
1269+ <Rule />
1270+ <NavGroup title="Security policies">
12041271 <SidebarLink to={`/${slug}/-/security/settings`} icon={<ShieldCheck size={15} />} current={at("security/settings")}>
12051272 Security settings
12061273 </SidebarLink>
12101277 <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />} current={at("rules")}>
12111278 Rules
12121279 </SidebarLink>
1213− </SidebarGroup>
1280+ </NavGroup>
12141281 <Rule />
1215− <div className="space-y-px">
1282+ <NavRows>
12161283 <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />} current={at("audit")}>
12171284 Audit log
12181285 </SidebarLink>
12191286 <SidebarLink to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings size={15} />} drill current={inSettings}>
12201287 Settings
12211288 </SidebarLink>
1222− </div>
1289+ </NavRows>
12231290 </nav>
12241291 );
12251292 const settings = (
12261293 <nav aria-label="Workspace settings" className={PANEL}>
12271294 <BackRow to={`/${slug}/-/workspace`} label="Settings" context={slug} />
1228− <div className="mt-2 space-y-px">
1295+ <NavRows className="mt-2">
12291296 {owner && (
12301297 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />} end>
12311298 General
12511318 <SidebarLink to={`/${slug}/-/emoji`} icon={<Smile size={15} />}>
12521319 Emoji
12531320 </SidebarLink>
1254− </div>
1255− <SidebarGroup title="Runs" className="mt-3">
1321+ </NavRows>
1322+ <Rule />
1323+ <NavGroup title="Runs">
12561324 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
12571325 Secrets and variables
12581326 </SidebarLink>
12591327 <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}>
12601328 Actions
12611329 </SidebarLink>
1262− </SidebarGroup>
1330+ </NavGroup>
12631331 </nav>
12641332 );
12651333 const trail: Level[] = [{ key: "workspace", node: main }];
12851353 <div className="flex h-full flex-col">
12861354 <ModeHeader title="People" />
12871355 <nav aria-label="People" className={PANEL}>
1288− <div className="space-y-px">
1356+ <NavRows>
12891357 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />} current={at("people")}>
12901358 Everyone
12911359 </SidebarLink>
12951363 <SidebarLink to={`/${slug}/-/org-chart`} icon={<Network size={15} />} current={at("org-chart")}>
12961364 Org chart
12971365 </SidebarLink>
1298− </div>
1299− <SidebarGroup title="Membership" className="mt-3">
1366+ </NavRows>
1367+ <Rule />
1368+ <NavGroup title="Membership">
13001369 <SidebarLink to={`/${slug}/-/members`} icon={<UserPlus size={15} />} current={at("members")}>
13011370 Members and invites
13021371 </SidebarLink>
1303− </SidebarGroup>
1372+ </NavGroup>
13041373 </nav>
13051374 </div>
13061375 );
13321401 <div className="flex h-full flex-col">
13331402 <ModeHeader title="Notifications" />
13341403 <nav aria-label="Notifications" className={PANEL}>
1335− <div className="space-y-px">
1404+ <NavRows>
13361405 {views.map((entry) => (
13371406 <SidebarLink
13381407 key={entry.view}
13441413 {entry.label}
13451414 </SidebarLink>
13461415 ))}
1347− </div>
1348− <SidebarGroup title="Why you were told" className="mt-4">
1416+ </NavRows>
1417+ <Rule />
1418+ <NavGroup title="Why you were told">
13491419 {REASON_FILTERS.map((entry) => (
13501420 <SidebarLink
13511421 key={entry.reason ?? "any"}
13561426 {entry.reason == null ? "Any reason" : entry.label}
13571427 </SidebarLink>
13581428 ))}
1359− </SidebarGroup>
1429+ </NavGroup>
13601430 </nav>
13611431 </div>
13621432 );
18301900 * Search or jump to: the command palette. On a computer it looks like the
18311901 * field it opens, with its shortcut; on a phone, a magnifier.
18321902 */
1833−function SearchButton({ onClick }: { onClick: () => void }) {
1903+function SearchButton({ onClick, place }: { onClick: () => void; place: "bar" | "sidebar" }) {
1904+ const field = (
1905+ <Button
1906+ variant="outline"
1907+ size="sm"
1908+ aria-label="Search or jump to"
1909+ onClick={onClick}
1910+ className={
1911+ place === "sidebar"
1912+ ? "w-full justify-start bg-bg font-normal text-faint hover:bg-bg hover:text-muted"
1913+ : "mr-1 w-[min(18rem,26vw)] justify-start bg-surface font-normal text-faint hover:bg-surface hover:text-muted max-md:hidden"
1914+ }
1915+ >
1916+ <Search size={15} />
1917+ <span className="grow truncate text-left">Search or jump to</span>
1918+ <PaletteKey className="px-1.5 text-[0.625rem] leading-4 text-muted" />
1919+ </Button>
1920+ );
1921+ if (place === "sidebar") return field;
18341922 return (
18351923 <>
18361924 <Button variant="ghost" size="icon-bar" aria-label="Search or jump to" onClick={onClick} className="md:hidden">
18371925 <Search size={18} />
18381926 </Button>
1839− <Button
1840− variant="outline"
1841− size="sm"
1842− aria-label="Search or jump to"
1843− onClick={onClick}
1844− className="mr-1 w-[min(18rem,26vw)] justify-start bg-surface font-normal text-faint hover:bg-surface hover:text-muted max-md:hidden"
1845− >
1846− <Search size={15} />
1847− <span className="grow truncate text-left">Search or jump to</span>
1848− <PaletteKey className="rounded border border-line bg-bg px-1.5 font-mono text-[0.625rem] leading-4 text-muted" />
1849− </Button>
1927+ {field}
18501928 </>
18511929 );
18521930 }
18531931
1854−/** The button that shows or hides the sidebar. */
1855−function SidebarToggle({ onClick, open, className = "" }: { onClick: () => void; open: boolean; className?: string }) {
1856− const key = sidebarKeyLabel(typeof navigator === "undefined" ? null : navigator.platform);
1857− return (
1858− <Hint label={`Toggle sidebar (${key})`}>
1859− <Button variant="ghost" size="icon-sm" aria-label={open ? "Hide the sidebar" : "Show the sidebar"} aria-expanded={open} onClick={onClick} className={className}>
1860− <PanelLeft size={17} />
1861− </Button>
1862− </Hint>
1863− );
1932+/** The sidebar's shortcut, for the hint on its toggle. */
1933+function useSidebarKey(): string {
1934+ return sidebarKeyLabel(typeof navigator === "undefined" ? null : navigator.platform);
18641935 }
18651936
18661937 /**
1867− * The app: three surfaces that never blur
1868− * together. The dock, a floating bar of apps down the left; the mode's
1869− * sidebar, flat on the background beside it, which folds away with Ctrl B
1870− * and is a drawer below 1024px; and the page, a rounded panel inset from
1871− * the window, with its header across the top: the sidebar's toggle, where
1872− * you are, search, Ask g1t and Create new. On a phone the dock is a bar
1873− * along the bottom. Only for someone signed in; without a workspace yet,
1874− * there is no dock.
1938+ * The app: three surfaces that never blur together. The rail, a narrow
1939+ * column of icons down the window's left edge; the mode's sidebar, flat
1940+ * against it with a hairline between, which folds away with Ctrl B and is
1941+ * a drawer below 1024px; and the page, a rounded panel inset from the
1942+ * window, with its header across the top: the sidebar's toggle, where you
1943+ * are, search, Ask g1t and Create new. On a phone the rail is a bar along
1944+ * the bottom. Only for someone signed in; without a workspace yet, there
1945+ * is no rail. The rail and the sidebar are drawn from the sidebar kit
1946+ * (components/ui/sidebar.tsx): one provider holds whether the sidebar is
1947+ * open on a computer and whether the drawer is.
18751948 */
18761949 export function AppShell({
18771950 user,
19642037 return <AccountSidebar username={user.username} />;
19652038 }
19662039 };
1967− // The sidebar's top row: the workspace and its switcher, and the way to fold the sidebar or close the drawer.
2040+ const sidebarKey = useSidebarKey();
2041+ // Search or jump to sits at the top of the sidebar, except in Chat and
2042+ // Artifacts, whose sidebars search their own lists: there it stays in the bar.
2043+ const sidebarSearch = panel != null && panel !== "chat" && panel !== "artifacts";
2044+ // The sidebar's head: the workspace and its switcher, the way to fold the
2045+ // sidebar or close the drawer, and search, which opens the palette.
19682046 const sidebarTop = (inDrawer: boolean) => (
1969− <div className="flex h-14 shrink-0 items-center gap-1 pr-1 pl-1">
1970− {ws ? (
1971− <WorkspaceSwitcher user={user} workspace={ws} />
1972− ) : (
1973− <Link to="/" aria-label="g1t" className="mr-auto flex h-9 items-center rounded-md px-2">
1974− <Logo className="text-[1.125rem]" />
1975− </Link>
1976− )}
1977− {inDrawer ? (
1978− <Button type="button" aria-label="Close menu" onClick={() => setDrawer(false)} variant="ghost" size="icon-sm" className="text-faint">
1979− <X size={16} />
1980− </Button>
1981− ) : (
1982− <SidebarToggle open onClick={toggleSidebar} />
1983− )}
1984− </div>
2047+ <SidebarHeader className="gap-1 px-2 pt-0 pb-2">
2048+ <div className="flex h-14 shrink-0 items-center gap-1">
2049+ {ws ? (
2050+ <WorkspaceSwitcher user={user} workspace={ws} />
2051+ ) : (
2052+ <Link to="/" aria-label="g1t" className="mr-auto flex h-9 items-center rounded-md px-2">
2053+ <Logo className="text-[1.125rem]" />
2054+ </Link>
2055+ )}
2056+ {inDrawer ? (
2057+ <Button type="button" aria-label="Close menu" onClick={() => setDrawer(false)} variant="ghost" size="icon-sm" className="text-faint">
2058+ <X size={16} />
2059+ </Button>
2060+ ) : (
2061+ <SidebarTrigger hint={`Hide the sidebar (${sidebarKey})`} className="text-faint" />
2062+ )}
2063+ </div>
2064+ {sidebarSearch && <SearchButton place="sidebar" onClick={() => setPalette(true)} />}
2065+ </SidebarHeader>
19852066 );
19862067
19872068 // On a phone (below 768px): the bottom bar, and a conversation full screen.
19882069 const conversation = isConversation(going ?? pathname);
1989− // Where the page panel starts: 8px past the dock (88px), or right after the sidebar (260px) when it is open.
1990− const left = ws ? (inline ? "md:left-24 lg:left-[21.75rem]" : "md:left-24") : inline ? "md:left-2 lg:left-[16.75rem]" : "md:left-2";
1991− const pad = ws ? (inline ? "md:pl-24 lg:pl-[21.75rem]" : "md:pl-24") : inline ? "md:pl-2 lg:pl-[16.75rem]" : "md:pl-2";
2070+ // Where the page panel starts: 8px past the rail (56px), or 8px past the sidebar (56 + 240px) when it is open.
2071+ const left = ws ? (inline ? "md:left-16 lg:left-[19rem]" : "md:left-16") : inline ? "md:left-2 lg:left-[15.5rem]" : "md:left-2";
2072+ const pad = ws ? (inline ? "md:pl-16 lg:pl-[19rem]" : "md:pl-16") : inline ? "md:pl-2 lg:pl-[15.5rem]" : "md:pl-2";
19922073
19932074 return (
1994− // The drawer is a sheet: a dialog that holds focus, closes on Escape or
1995− // a tap outside, and gives focus back to the button that opened it.
1996− <Sheet open={drawer} onOpenChange={setDrawer}>
2075+ <SidebarProvider open={!closed} onOpenChange={(open) => setClosed(!open)} openMobile={drawer} onOpenMobileChange={setDrawer} toggleSidebar={toggleSidebar}>
19972076 <Progress />
19982077 {ws && (
1999− <div className="fixed top-2 bottom-2 left-2 z-40 hidden w-20 md:block">
2000− <Dock
2001− workspace={ws}
2002− pins={shell.pins ?? []}
2003− unread={unread}
2004− account={<AccountMenu user={user} />}
2005− />
2078+ <div className="fixed inset-y-0 left-0 z-40 hidden md:block">
2079+ <Rail workspace={ws} pins={shell.pins ?? []} unread={unread} account={<AccountMenu user={user} />} />
20062080 </div>
20072081 )}
20082082 {panel && (
2009− <aside
2083+ <Sidebar
2084+ role="complementary"
20102085 aria-label={`${MODE_MENU[panel]} sidebar`}
2011− className={`fixed inset-y-0 z-40 hidden w-[16.25rem] flex-col pr-0.5 pl-1.5 ${ws ? "left-[5.5rem]" : "left-0"} ${inline ? "lg:flex" : ""}`}
2086+ className={`fixed inset-y-0 z-40 max-lg:hidden ${ws ? "left-14" : "left-0"}`}
20122087 >
20132088 {sidebarTop(false)}
20142089 <div className="min-h-0 grow">{sidebarNode()}</div>
2015− </aside>
2090+ </Sidebar>
20162091 )}
2017− <SheetContent
2018− side="left"
2019− showClose={false}
2020− aria-describedby={undefined}
2021− // Focus would land on the workspace's switcher and open its menu's hint; the sheet itself takes it instead.
2022− onOpenAutoFocus={(event) => {
2023− event.preventDefault();
2024− (event.currentTarget as HTMLElement | null)?.focus();
2025− }}
2026− className="flex w-[min(18.5rem,86vw)] flex-col border-line bg-shell px-1.5 pb-[env(safe-area-inset-bottom)] max-md:[&_nav_a]:min-h-10"
2027− >
2028− <SheetTitle className="sr-only">{panel ? `${MODE_MENU[panel]} menu` : "Menu"}</SheetTitle>
2029− {sidebarTop(true)}
2030− <div className="min-h-0 grow">{sidebarNode()}</div>
2031− </SheetContent>
2092+ {/* The drawer is a sheet: a dialog that holds focus, closes on Escape or
2093+ a tap outside, and gives focus back to the button that opened it. */}
2094+ {panel && (
2095+ <SidebarMobile title={`${MODE_MENU[panel]} menu`} className="pb-[env(safe-area-inset-bottom)] max-md:[&_nav_a]:min-h-10">
2096+ {sidebarTop(true)}
2097+ <div className="min-h-0 grow">{sidebarNode()}</div>
2098+ </SidebarMobile>
2099+ )}
20322100
20332101 <div className={`min-h-dvh px-1.5 pt-(--frame-top) pb-(--tabbar-h) md:pr-2 ${pad}`}>
20342102 {/* A page whose own header sits right under this bar (data-page-head: a conversation's, a project's) reads as one
20402108 className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 rounded-t-[14px] border-b border-line bg-bg pr-2 pl-2 transition-[border-color] duration-150 sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`}
20412109 >
20422110 {panel && (
2043− <SidebarToggle open={drawer} onClick={toggleSidebar} className={`max-md:size-11 ${inline ? "lg:hidden" : ""}`} />
2111+ <SidebarTrigger
2112+ hint={`Show the sidebar (${sidebarKey})`}
2113+ aria-label={drawer ? "Hide the sidebar" : "Show the sidebar"}
2114+ aria-expanded={drawer}
2115+ className={`max-md:size-11 ${inline ? "lg:hidden" : ""}`}
2116+ />
20442117 )}
20452118 {ws && (
20462119 <span className={`flex min-w-0 shrink items-center gap-1.5 ${inline ? "lg:hidden" : ""}`}>
20532126 <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} workspace={ws?.slug ?? null} />
20542127 {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */}
20552128 <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5">
2056− <SearchButton onClick={() => setPalette(true)} />
2129+ {/* With the sidebar open, search is at its top. */}
2130+ <span className={`flex ${inline && sidebarSearch ? "lg:hidden" : ""}`}>
2131+ <SearchButton place="bar" onClick={() => setPalette(true)} />
2132+ </span>
20572133 {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */}
20582134 {ws && (
20592135 <span className="flex max-md:hidden">
20672143 <NotificationsBell counts={shell.inbox ? { ...shell.inbox, unread: notificationsUnread } : null} />
20682144 </span>
20692145 <CreateMenu shell={shell} />
2070− {/* Without a workspace there is no dock: the account is here. */}
2146+ {/* Without a workspace there is no rail: the account is here. */}
20712147 {!ws && <AccountMenu user={user} side="bottom" />}
20722148 </div>
20732149 </header>
20852161 />
20862162 {ws && <BottomBar user={user} workspace={ws} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />}
20872163 <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} />
2088− </Sheet>
2164+ </SidebarProvider>
20892165 );
20902166 }
20912167
+1−1
1010
1111 /**
1212 * The frame for signing in, signing up and choosing a workspace
13− * (lib/chrome.ts, `standalone`): no header, no sidebar, no dock. The
13+ * (lib/chrome.ts, `standalone`): no header, no sidebar, no rail. The
1414 * logo, then the page in a narrow column at the centre, and a quiet row
1515 * of links at the foot of the window.
1616 */
+465−0
1+import { type VariantProps, cva } from "class-variance-authority";
2+import { PanelLeft } from "lucide-react";
3+import { Slot } from "radix-ui";
4+import { type ComponentProps, type CSSProperties, type ReactNode, createContext, useCallback, useContext, useMemo } from "react";
5+
6+import { cn } from "../../lib/cn";
7+import { Button } from "./button";
8+import { Hint } from "./hint";
9+import { Input } from "./input";
10+import { Separator } from "./separator";
11+import { 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+
29+export const SIDEBAR_WIDTH = "15rem";
30+export const SIDEBAR_WIDTH_ICON = "3.5rem";
31+
32+type 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+
44+const SidebarContext = createContext<SidebarContextValue | null>(null);
45+
46+export 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+
52+export 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+ */
101+export 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+ */
142+export 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. */
174+export 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+ */
200+export 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. */
220+export 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. */
225+export 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+
229+export 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+
233+export 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+
237+export 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. */
242+export 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+
252+export 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. */
257+export 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. */
274+export 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+
291+export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) {
292+ return <div data-slot="sidebar-group-content" className={cn("w-full text-sm", className)} {...props} />;
293+}
294+
295+export 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+
299+export 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+ */
309+export 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+
338+export 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. */
377+export 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. */
401+export 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. */
417+export 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. */
428+export 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+
438+export 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+
442+export 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+}
+7−7
11 /**
2− * The dock's built-in apps, the apps a workspace installed from the
3− * Marketplace, and the ones each person pins to their dock.
2+ * The rail's built-in apps, the apps a workspace installed from the
3+ * Marketplace, and the ones each person pins to their rail.
44 *
55 * Built-in apps (Home, Chat, Notifications, Agents, Code, Artifacts,
6− * People and Workspace) are always in the dock; their own pages (Projects,
6+ * People and Workspace) are always in the rail; their own pages (Projects,
77 * Usage, Teams and the rest) are in their sidebars. Apps are what the
88 * workspace added from the Marketplace: today, the integrations it
99 * connected, each opening its page. Each person pins the ones they want.
1616 * Pins are each person's own, per workspace, in the order they set, and
1717 * kept with their account by the identity service (`dock_pins`), so the
1818 * dock is the same on every device (lib/dock.server.ts). A cookie
19− * (`g1t_dock`) keeps a copy on each device: the dock still draws from it
19+ * (`g1t_dock`) keeps a copy on each device: the rail still draws from it
2020 * when identity cannot be reached, and pins from before they were kept
2121 * with the account carry over from it until the first change.
2222 * No Workers or React imports, so it can be tested under Node.
2525 import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors";
2626 import { CONNECTOR_PUBLISHER, extensionById } from "@g1t/contracts/marketplace";
2727
28−/** The apps that are always in the dock. */
28+/** The apps that are always in the rail. */
2929 export type BuiltinApp = "home" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace";
3030
3131 export type BuiltinInfo = {
3939
4040 const under = (page: string) => (slug: string) => `/${slug}/-/${page}`;
4141
42−/** The dock's built-in apps, in its order. */
42+/** The rail's built-in apps, in its order. */
4343 export const BUILTINS: BuiltinInfo[] = [
4444 { key: "home", name: "Home", path: under("home") },
4545 { key: "chat", name: "Chat", path: under("chat") },
238238 return pinned ? [...rest, app] : rest;
239239 }
240240
241−/** The Set-Cookie header that keeps the dock, for a year. */
241+/** The Set-Cookie header that keeps the rail, for a year. */
242242 export function dockCookie(value: string, secure: boolean): string {
243243 return `${DOCK_COOKIE}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`;
244244 }
+2−2
33 *
44 * - `standalone`: signing in, signing up and choosing a workspace. The
55 * page alone, centred, with the logo and a quiet row of links at the
6− * foot: no header, no sidebar, no dock.
7− * - `app`: someone signed in, everywhere else. The dock, the mode's
6+ * foot: no header, no sidebar, no rail.
7+ * - `app`: someone signed in, everywhere else. The rail, the mode's
88 * sidebar and the page in its panel, always about their current
99 * workspace, even on public pages such as Explore, a profile or another
1010 * workspace's public project.
+1−1
7474 );
7575 });
7676
77−test("the dock's mode follows the address", async () => {
77+test("the rail's mode follows the address", async () => {
7878 const { modeOf, modeHome, homePath } = await import("./workspace-nav.ts");
7979 assert.equal(modeOf("/", "acme"), "home");
8080 assert.equal(modeOf("/acme", "acme"), "home");
+2−2
123123 }
124124
125125 /**
126− * The dock's modes. Home is the front page;
126+ * The rail's modes. Home is the front page;
127127 * Chat, Agents, Code and Artifacts are where work happens; Notifications
128128 * spans them; People is who belongs; Workspace is the workspace itself:
129129 * its money, policies and settings; Apps is everything installed that you
205205 return "code";
206206 }
207207
208−/** Where each mode's button in the dock goes, in the workspace `slug`. */
208+/** Where each mode's button in the rail goes, in the workspace `slug`. */
209209 export function modeHome(mode: ModeKey, slug: string): string {
210210 switch (mode) {
211211 case "home":
+3−3
9595 : visitorShell(params, context),
9696 registrationMode(),
9797 ]);
98− // The apps this person pinned to their dock here: as their account keeps
98+ // The apps this person pinned to their rail here: as their account keeps
9999 // them (read with the rest of the shell), else as this device's cookie
100100 // remembers them (lib/apps.ts).
101101 if (shell.workspace) shell.pins = pinsToShow(shell.pins, dock, shell.workspace.slug);
200200 workspace ? chatUnreadFor(workspace.slug, user) : Promise.resolve(null),
201201 // Home's Recent and the Agents sidebar, on every page: never waited on for long.
202202 workspace ? agentsFor(workspace.slug, user) : Promise.resolve(null),
203− // The dock's pins, kept with the account so every device shows the same
204− // dock; never kept here, so a pin shows the moment it is made. Null
203+ // The rail's pins, kept with the account so every device shows the same
204+ // rail; never kept here, so a pin shows the moment it is made. Null
205205 // falls back to the cookie (lib/dock.server.ts).
206206 workspace ? savedPins(user, workspace.slug) : Promise.resolve(null),
207207 ]);
+1−1
214214 ]),
215215 // Home, the workspace's front page (`-/today` leads there), where it
216216 // marks your visit, and every app in it you can use, with pinning them
217− // to your dock.
217+ // to your rail.
218218 route("-/home", "routes/workspace/home.tsx"),
219219 route("-/home/seen", "routes/workspace/home-seen.ts"),
220220 route("-/today", "routes/workspace/moved.ts", { id: "routes/workspace/moved-today" }),
+5−5
11 /**
22 * Apps: what the workspace installed from the Marketplace that you can
3− * use, the ones pinned to your dock first. Built-in apps are always in the
4− * dock, and their own pages are in their sidebars; Apps are only what was
3+ * use, the ones pinned to your rail first. Built-in apps are always in the
4+ * rail, and their own pages are in their sidebars; Apps are only what was
55 * added. Pins are yours alone, per workspace, kept with your account so
66 * every device shows the same dock (lib/dock.server.ts), with a copy in a
77 * cookie for when the account's cannot be read (lib/apps.ts). The pin
7676 <div>
7777 <h1 className="text-2xl font-semibold tracking-tight">Apps</h1>
7878 <p className="mt-1 max-w-xl text-sm text-muted">
79− What {name} added from the Marketplace. Pin the ones you want in your dock: your dock is yours, and nobody else sees your pins.
79+ What {name} added from the Marketplace. Pin the ones you want in your rail: your rail is yours, and nobody else sees your pins.
8080 </p>
8181 </div>
8282 <Link
102102 <section aria-labelledby="pinned" className="mt-8">
103103 <div className="flex items-baseline justify-between gap-3">
104104 <h2 id="pinned" className="text-sm font-semibold">
105− Pinned to your dock
105+ Pinned to your rail
106106 </h2>
107107 <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span>
108108 </div>
109109 {pinned.length === 0 ? (
110110 <Card asChild tone="plain" className="mt-3 border-dashed px-4 py-6 text-center text-sm text-muted">
111111 <p>
112− Nothing pinned yet. Pin an app below and it shows in your dock, under the built-in ones.
112+ Nothing pinned yet. Pin an app below and it shows in your rail, under the built-in ones.
113113 </p>
114114 </Card>
115115 ) : (
+1−1
44 * what the team already uses so agents can work with it. Owners add them;
55 * everyone else asks an owner, and the agents service keeps the request
66 * (lib/marketplace.ts). Agents aren't here: they start from templates in
7− * Agents mode. It is not in the dock: the Apps page and the launcher lead
7+ * Agents mode. It is not in the rail: the Apps page and the launcher lead
88 * here, as does ⌘K.
99 *
1010 * This layout holds the heading, the tabs and the viewer's requests, which