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.
32 files+958−7350/32 viewed
| 182 | 182 | ||
| 183 | 183 | | | | | |
| 184 | 184 | | --- | --- | | |
| 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. | | |
| 186 | 186 | | **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. | | |
| 187 | 187 | | **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. | | |
| 188 | 188 | | **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. | |
| 41 | 41 | ||
| 42 | 42 | ## See an agent's skills | |
| 43 | 43 | ||
| 44 | − | 1. Open **Agents** in the dock and choose an agent. | |
| 44 | + | 1. Open **Agents** in the rail and choose an agent. | |
| 45 | 45 | 2. Open its **Skills** tab. | |
| 46 | 46 | ||
| 47 | 47 | **Foundational, from g1t** shows each foundational skill: a check on each |
| 5 | 5 | ||
| 6 | 6 | Every project has an **Agents** section, beside its pull requests: who is | |
| 7 | 7 | 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 | |
| 9 | 9 | shows every agent, its [sessions](/guides/agent-sessions/) and what it | |
| 10 | 10 | costs, and **Memory** holds what agents know in every project. | |
| 11 | 11 | ||
| ⋯ | |||
| 17 | 17 | | Teach every agent something about this code | **Agents → Memory** | | |
| 18 | 18 | | Teach every agent something true in every project | **Memory**, in the workspace's sidebar | | |
| 19 | 19 | | 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** | | |
| 21 | 21 | ||
| 22 | 22 | ## Runs | |
| 23 | 23 | ||
| 68 | 68 | ||
| 69 | 69 | <Steps> | |
| 70 | 70 | ||
| 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 | |
| 72 | 72 | `g1t.sh/<workspace>/-/agents/new`. | |
| 73 | 73 | 2. In **What should this agent do?**, describe the job in a sentence or a | |
| 74 | 74 | paragraph: what it does, for whom, and what good looks like. Owners | |
| ⋯ | |||
| 188 | 188 | ||
| 189 | 189 | <Steps> | |
| 190 | 190 | ||
| 191 | − | 1. Open **Agents** in the dock, then **Overview**. | |
| 191 | + | 1. Open **Agents** in the rail, then **Overview**. | |
| 192 | 192 | 2. Under **Personal agents**, switch off **Members can create personal | |
| 193 | 193 | agents**. | |
| 194 | 194 | ||
| 12 | 12 | private until you share it, and agents read only what the people they work | |
| 13 | 13 | for can read. | |
| 14 | 14 | ||
| 15 | − | Open **Artifacts** in the dock on the left, or go to | |
| 15 | + | Open **Artifacts** in the rail on the left, or go to | |
| 16 | 16 | `g1t.sh/<workspace>/-/artifacts`. On a phone, it's under **More** in the bar | |
| 17 | 17 | at the bottom. Artifacts is open to every member of a workspace, on every | |
| 18 | 18 | plan. |
| 22 | 22 | no history cutoff. Agents' replies are charged to the agent that wrote | |
| 23 | 23 | them; see [what an agent costs](/guides/agents/#what-an-agent-costs). | |
| 24 | 24 | ||
| 25 | − | Open **Chat** in the dock on the left, or go to | |
| 25 | + | Open **Chat** in the rail on the left, or go to | |
| 26 | 26 | `g1t.sh/<workspace>/-/chat`. | |
| 27 | 27 | ||
| 28 | 28 | ## A conversation | |
| ⋯ | |||
| 565 | 565 | ||
| 566 | 566 | ### Set a status | |
| 567 | 567 | ||
| 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**, | |
| 569 | 569 | then **You and help**) and choose **Set a status**. Give it an | |
| 570 | 570 | emoji and a few words, or pick one: | |
| 571 | 571 | ||
| 16 | 16 | Agents aren't in the Marketplace. To add one, start from a | |
| 17 | 17 | [template](/guides/agents/#role-templates) in Agents. | |
| 18 | 18 | ||
| 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 | |
| 20 | 20 | **Marketplace** link, or **Browse the Marketplace** on the Apps page), from | |
| 21 | 21 | **More** on a phone, or with <kbd>Ctrl</kbd> <kbd>K</kbd> and | |
| 22 | 22 | *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. | |
| 24 | 24 | ||
| 25 | 25 | ## Who builds what, and whether you can add it | |
| 26 | 26 | ||
| ⋯ | |||
| 220 | 220 | ||
| 221 | 221 | What the workspace added from the Marketplace that you can use is under | |
| 222 | 222 | **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). | |
| 9 | 9 | request, a deployment failed, someone mentioned you. You are never told | |
| 10 | 10 | about what you did yourself. | |
| 11 | 11 | ||
| 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 | |
| 13 | 13 | phone. The number on it is what is unread. The bell at the right of the | |
| 14 | 14 | page's header opens the same notifications in a panel beside the page; its | |
| 15 | 15 | number is amber while something is waiting on you, red while a failure is | |
| ⋯ | |||
| 205 | 205 | ||
| 206 | 206 | ## The Notifications page | |
| 207 | 207 | ||
| 208 | − | **Notifications** in the dock opens | |
| 208 | + | **Notifications** in the rail opens | |
| 209 | 209 | [g1t.sh/notifications](https://g1t.sh/notifications): every thread, a page | |
| 210 | 210 | at a time, under the same tabs. Its sidebar chooses what you look at: | |
| 211 | 211 | ||
| 5 | 5 | ||
| 6 | 6 | **People** is where a workspace's people and agents are listed together: | |
| 7 | 7 | 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: | |
| 9 | 9 | ||
| 10 | 10 | | Page | Address | What it is | | |
| 11 | 11 | | --- | --- | --- | |
| 18 | 18 | ||
| 19 | 19 | | Where | Page | Who manages it | | |
| 20 | 20 | | --- | --- | --- | | |
| 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. | | |
| 22 | 22 | | A project | **Settings → Runners**, `g1t.sh/<workspace>/<project>/settings/runners` | People with the Admin [role](/guides/access-and-roles/) on its repository | | |
| 23 | 23 | ||
| 24 | 24 | A workspace's runners serve the repositories their [group](#groups) allows. |
| 525 | 525 | Everything you choose is in the address, so a filtered list can be | |
| 526 | 526 | bookmarked or shared. | |
| 527 | 527 | ||
| 528 | − | ## The dock, the sidebar and the page | |
| 528 | + | ## The rail, the sidebar and the page | |
| 529 | 529 | ||
| 530 | 530 | Signed in, g1t is always about one workspace: the one you are in. On a | |
| 531 | 531 | workspace's pages, and on a project in one of your workspaces, that is the | |
| ⋯ | |||
| 537 | 537 | ||
| 538 | 538 | | Part | Where | What it holds | | |
| 539 | 539 | | --- | --- | --- | | |
| 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. | | |
| 543 | 543 | ||
| 544 | − | ### The dock | |
| 544 | + | ### The rail | |
| 545 | 545 | ||
| 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: | |
| 548 | 548 | ||
| 549 | 549 | | App | Opens | | |
| 550 | 550 | | --- | --- | | |
| ⋯ | |||
| 557 | 557 | | **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/). | | |
| 558 | 558 | | **Workspace** | Usage, billing, integrations, policies and settings. | | |
| 559 | 559 | ||
| 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 | |
| 561 | 561 | avatar at the foot: your status, your profile and settings, | |
| 562 | 562 | [appearance](#appearance), the documentation, support, status, the | |
| 563 | 563 | keyboard's shortcuts, and signing out. | |
| ⋯ | |||
| 575 | 575 | ||
| 576 | 576 | To change it: | |
| 577 | 577 | ||
| 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**, | |
| 579 | 579 | then **You and help**. | |
| 580 | 580 | 2. Under **Appearance**, select **Auto** (the screen icon), **Light** (the | |
| 581 | 581 | sun) or **Dark** (the moon). | |
| ⋯ | |||
| 611 | 611 | A pinned app shows as its mark under the built-in ones, in the order you | |
| 612 | 612 | pinned them. To pin one: | |
| 613 | 613 | ||
| 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 | |
| 615 | 615 | `g1t.sh/<workspace>/-/apps`. | |
| 616 | 616 | 2. Find the app, by name if you like. | |
| 617 | 617 | 3. Select the pin on its tile. Select it again to unpin it. | |
| ⋯ | |||
| 621 | 621 | link always does. | |
| 622 | 622 | ||
| 623 | 623 | 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 | |
| 625 | 625 | sign in on, in the order you pinned. | |
| 626 | 626 | ||
| 627 | 627 | ### The sidebar | |
| 628 | 628 | ||
| 629 | 629 | The sidebar's top row names the workspace you are in. Select it to switch | |
| 630 | 630 | 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. | |
| 636 | 639 | ||
| 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 | |
| 638 | 642 | [projects](#pinned-and-recent-projects), and drills into a project's own | |
| 639 | 643 | list when you open one, with **‹ All projects** at the top to go back. Its | |
| 640 | 644 | **Settings** opens one level further: **General**, **Deployments**, | |
| ⋯ | |||
| 894 | 898 | ||
| 895 | 899 | **How it is laid out.** Signed out, a profile, Explore and Search are | |
| 896 | 900 | 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 | |
| 898 | 902 | no mode is lit and no mode's sidebar opens beside these pages: they are | |
| 899 | 903 | nobody's workspace, and the profile's own left column says whose it is. | |
| 900 | 904 | ||
| 55 | 55 | ## The workspace | |
| 56 | 56 | ||
| 57 | 57 | 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 | |
| 59 | 59 | Artifacts, the apps you pin, and People, Workspace and your account at its | |
| 60 | 60 | foot. On a phone it is a bar along the bottom. | |
| 61 | 61 |
| 32 | 32 | ||
| 33 | 33 | 2. **Open Chat** | |
| 34 | 34 | ||
| 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 | |
| 36 | 36 | workspace starts with `#general`. Say hello; people chatting is free on | |
| 37 | 37 | every plan. See [Chat](/guides/chat/). | |
| 38 | 38 | ||
| ⋯ | |||
| 47 | 47 | ||
| 48 | 48 | 4. **Create an agent** | |
| 49 | 49 | ||
| 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 | |
| 51 | 51 | a role, such as **QA Engineer**, and keep its | |
| 52 | 52 | defaults: you hire Margo, with a title, responsibilities, a personality | |
| 53 | 53 | and sensible model limits. Choose | |
| 19 | 19 | * `scheme-dark` and its tokens stay dark. | |
| 20 | 20 | */ | |
| 21 | 21 | --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); | |
| 26 | 34 | --color-surface: light-dark(var(--g1t-light-surface), var(--g1t-surface)); | |
| 27 | 35 | --color-raised: light-dark(var(--g1t-light-raised), var(--g1t-raised)); | |
| 28 | 36 | --color-line: light-dark(var(--g1t-light-line), var(--g1t-line)); | |
| ⋯ | |||
| 185 | 193 | ||
| 186 | 194 | /* | |
| 187 | 195 | * 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 | |
| 190 | 198 | * the bottom of a page reads these: | |
| 191 | 199 | * --frame-top the gap above the panel | |
| 192 | 200 | * --topbar-h the panel's top edge plus its header: where sticky things sit | |
| 39 | 39 | prefetch="intent" | |
| 40 | 40 | className={({ isActive }) => | |
| 41 | 41 | `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" | |
| 43 | 43 | }` | |
| 44 | 44 | } | |
| 45 | 45 | > | |
| ⋯ | |||
| 111 | 111 | to={`/${slug}/-/agents/${agent.handle}`} | |
| 112 | 112 | prefetch="intent" | |
| 113 | 113 | 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"}` | |
| 115 | 115 | } | |
| 116 | 116 | > | |
| 117 | 117 | <span className="relative shrink-0"> | |
| 118 | 118 | <AgentFace agent={{ ...agent, builtin: isOrchestrator(agent) }} size={26} /> | |
| 119 | 119 | {!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" /> | |
| 121 | 121 | )} | |
| 122 | 122 | </span> | |
| 123 | 123 | <span className="min-w-0 grow leading-tight"> | |
| ⋯ | |||
| 147 | 147 | return ( | |
| 148 | 148 | <div className="flex h-full flex-col"> | |
| 149 | 149 | <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> | |
| 151 | 151 | {canCreate && ( | |
| 152 | 152 | <NavLink | |
| 153 | 153 | to={`/${slug}/-/agents/new`} | |
| ⋯ | |||
| 246 | 246 | type="button" | |
| 247 | 247 | onClick={() => setOpen(!open)} | |
| 248 | 248 | 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" | |
| 250 | 250 | > | |
| 251 | 251 | <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} /> | |
| 252 | 252 | {title} | |
| ⋯ | |||
| 257 | 257 | } | |
| 258 | 258 | return ( | |
| 259 | 259 | <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> | |
| 261 | 261 | <ul className="space-y-px">{children}</ul> | |
| 262 | 262 | </section> | |
| 263 | 263 | ); | |
| 30 | 30 | return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />; | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 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. */ | |
| 34 | 34 | export function pinnedApp(key: PinnableApp, slug: string): { key: string; name: string; to: string; icon: (size: number) => ReactNode } | null { | |
| 35 | 35 | const app = installedAppOf(key); | |
| 36 | 36 | if (!app) return null; | |
| 37 | 37 | return { key, name: app.name, to: app.path(slug), icon: (size) => <InstalledMark app={app} size={size} /> }; | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 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. */ | |
| 41 | 41 | const PIN_FETCHER = "app-pin"; | |
| 42 | 42 | ||
| 43 | 43 | /** | |
| 44 | 44 | * Someone's pins in the workspace `slug`, as saved (`saved`, from the | |
| 45 | 45 | * 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. | |
| 47 | 47 | */ | |
| 48 | 48 | export function useAppPins(slug: string, saved: PinnableApp[]) { | |
| 49 | 49 | const fetcher = useFetcher({ key: PIN_FETCHER }); | |
| ⋯ | |||
| 127 | 127 | {body} | |
| 128 | 128 | </Link> | |
| 129 | 129 | {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"}> | |
| 131 | 131 | <button | |
| 132 | 132 | 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`} | |
| 134 | 134 | aria-pressed={pinned} | |
| 135 | 135 | onClick={() => onToggle(app.key)} | |
| 136 | 136 | className={cn( | |
| ⋯ | |||
| 155 | 155 | <Store size={18} /> | |
| 156 | 156 | </span> | |
| 157 | 157 | <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> | |
| 159 | 159 | <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"> | |
| 160 | 160 | <Store size={14} /> | |
| 161 | 161 | Browse the Marketplace | |
| 22 | 22 | const REFRESH_MS = FALLBACK_REFRESH_MS; | |
| 23 | 23 | ||
| 24 | 24 | 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"; | |
| 26 | 26 | ||
| 27 | 27 | /** Unread on a row: lavender for a mention, quiet for the rest. */ | |
| 28 | 28 | function Count({ entry }: { entry: ChatSidebarEntry | undefined }) { | |
| ⋯ | |||
| 140 | 140 | ||
| 141 | 141 | return ( | |
| 142 | 142 | // 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)]"> | |
| 144 | 144 | {heading && ( | |
| 145 | 145 | <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> | |
| 147 | 147 | <NewMessageButton slug={slug} /> | |
| 148 | 148 | </div> | |
| 149 | 149 | )} | |
| ⋯ | |||
| 327 | 327 | onClick={() => onFold(id)} | |
| 328 | 328 | aria-expanded={open} | |
| 329 | 329 | 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]" | |
| 331 | 331 | > | |
| 332 | 332 | <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} /> | |
| 333 | 333 | {title} | |
| ⋯ | |||
| 350 | 350 | <NavLink | |
| 351 | 351 | to={to} | |
| 352 | 352 | 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"}`} | |
| 354 | 354 | > | |
| 355 | 355 | <span className="flex w-4.5 shrink-0 justify-center">{icon}</span> | |
| 356 | 356 | <span className="min-w-0 grow truncate">{children}</span> | |
| ⋯ | |||
| 468 | 468 | {...prefetch} | |
| 469 | 469 | aria-current={current ? "page" : undefined} | |
| 470 | 470 | 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" | |
| 472 | 472 | }`} | |
| 473 | 473 | > | |
| 474 | 474 | <span className={`flex w-4.5 shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span> | |
| ⋯ | |||
| 513 | 513 | onClick={onOpen} | |
| 514 | 514 | aria-busy={busy || undefined} | |
| 515 | 515 | 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" : ""}`} | |
| 517 | 517 | > | |
| 518 | 518 | <span className="flex w-4.5 shrink-0 justify-center"> | |
| 519 | 519 | <AgentFace agent={agent} g1t={g1t} /> | |
| 1 | 1 | import { type ReactNode, Suspense, lazy, useEffect, useRef, useState, useSyncExternalStore } from "react"; | |
| 2 | 2 | ||
| 3 | 3 | import { cn } from "../lib/cn"; | |
| 4 | + | import { Kbd } from "./ui/kbd"; | |
| 4 | 5 | import { paletteKeyLabel } from "../lib/shortcut"; | |
| 5 | 6 | ||
| 6 | 7 | /* | |
| ⋯ | |||
| 46 | 47 | export function PaletteKey({ className }: { className?: string }) { | |
| 47 | 48 | // The server cannot know the computer: it says ⌘K, and the browser corrects it. | |
| 48 | 49 | 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>; | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | /** | |
| 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 | − | } |
| 23 | 23 | import { warmOnIntent } from "./list"; | |
| 24 | 24 | import { SpaceIcon } from "./parts"; | |
| 25 | 25 | ||
| 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"; | |
| 27 | 27 | 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"; | |
| 28 | 28 | ||
| 29 | 29 | function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) { | |
| 30 | 30 | 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"}`}> | |
| 32 | 32 | <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span> | |
| 33 | 33 | <span className="min-w-0 grow truncate">{children}</span> | |
| 34 | 34 | {trailing} | |
| ⋯ | |||
| 41 | 41 | return ( | |
| 42 | 42 | <section className="mt-4"> | |
| 43 | 43 | <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"> | |
| 45 | 45 | <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} /> | |
| 46 | 46 | {title} | |
| 47 | 47 | </button> | |
| ⋯ | |||
| 110 | 110 | setOver(null); | |
| 111 | 111 | if (drag && drag.id !== item.id) onDrop(item, where); | |
| 112 | 112 | }} | |
| 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" : ""}`} | |
| 114 | 114 | style={{ paddingLeft: 4 + item.depth * 14 }} | |
| 115 | 115 | > | |
| 116 | 116 | {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />} | |
| ⋯ | |||
| 266 | 266 | <> | |
| 267 | 267 | {folder.files.map((f) => ( | |
| 268 | 268 | <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 }}> | |
| 270 | 270 | <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" /> | |
| 271 | 271 | <span className="min-w-0 truncate">{f.title}</span> | |
| 272 | 272 | </NavLink> | |
| ⋯ | |||
| 287 | 287 | return next; | |
| 288 | 288 | }) | |
| 289 | 289 | } | |
| 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`} | |
| 291 | 291 | style={{ paddingLeft: 4 + (depth + 1) * 14 }} | |
| 292 | 292 | > | |
| 293 | 293 | <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} /> | |
| ⋯ | |||
| 366 | 366 | return ( | |
| 367 | 367 | <div className="flex h-full flex-col"> | |
| 368 | 368 | <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> | |
| 370 | 370 | <NewMenu onMake={make}> | |
| 371 | 371 | <Button type="button" aria-label="New" variant="ghost" className="h-8 gap-0.5 px-1.5 text-faint"> | |
| 372 | 372 | <Plus size={16} /> | |
| 22 | 22 | import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| 23 | 23 | ||
| 24 | 24 | import { appIcon, pinnedApp } from "./apps"; | |
| 25 | − | import { CountBadge, ShortcutsDialog } from "./dock"; | |
| 25 | + | import { CountBadge, ShortcutsDialog } from "./rail"; | |
| 26 | 26 | import { Mark } from "./logo"; | |
| 27 | 27 | ||
| 28 | 28 | import { Avatar } from "./ui/avatar"; | |
| ⋯ | |||
| 40 | 40 | /** | |
| 41 | 41 | * The phone's layout (below 768px): a bottom bar of apps with More for | |
| 42 | 42 | * 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. | |
| 44 | 44 | */ | |
| 45 | 45 | ||
| 46 | 46 | /** Whether a path is one conversation, which takes the whole screen on a phone. */ | |
| ⋯ | |||
| 135 | 135 | }: { | |
| 136 | 136 | user: User; | |
| 137 | 137 | 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. */ | |
| 139 | 139 | pins: PinnableApp[]; | |
| 140 | 140 | unread: { notifications: number; chat: number; mentions: number }; | |
| 141 | 141 | /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */ | |
| ⋯ | |||
| 160 | 160 | <> | |
| 161 | 161 | <nav | |
| 162 | 162 | 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" | |
| 164 | 164 | > | |
| 165 | 165 | <ul className="grid h-full" style={{ gridTemplateColumns: `repeat(${tabs.length + 1}, minmax(0, 1fr))` }}> | |
| 166 | 166 | {tabs.map((tab) => { | |
| ⋯ | |||
| 266 | 266 | <Primitive.Content | |
| 267 | 267 | aria-describedby={undefined} | |
| 268 | 268 | 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" | |
| 270 | 270 | > | |
| 271 | 271 | <div aria-hidden="true" className="mx-auto mt-0.5 mb-2 h-1 w-9 rounded-full bg-line-strong" /> | |
| 272 | 272 | <div className="mb-2 flex items-center gap-2 px-1"> | |
| 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"; | |
| 2 | 2 | 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"; | |
| 4 | 4 | ||
| 5 | 5 | import { type Abilities, type ChatSidebarEntry, type InboxCounts, type WorkspaceAgent, type Membership, type Spike, type User, hasCodeAccess, mayCreateTeams, shownUsername } from "@g1t/contracts"; | |
| 6 | 6 | ||
| ⋯ | |||
| 10 | 10 | import { SpendPill } from "./spend"; | |
| 11 | 11 | import { PinButton } from "./pin-button"; | |
| 12 | 12 | 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"; | |
| 14 | 31 | import { StatusDot, useSiteStatus } from "./footer"; | |
| 15 | 32 | import { Logo, MarkGlyph } from "./logo"; | |
| 16 | 33 | import { SoonPill } from "./ui"; | |
| ⋯ | |||
| 33 | 50 | import { AgentsSidebar } from "./agents-mode"; | |
| 34 | 51 | import { ChatSidebar } from "./chat/sidebar"; | |
| 35 | 52 | import { FoliosSidebar } from "./folios/sidebar"; | |
| 36 | − | import { Dock, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./dock"; | |
| 53 | + | import { Rail, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./rail"; | |
| 37 | 54 | import { BottomBar, isConversation, useVisualViewport } from "./mobile"; | |
| 38 | 55 | import { useChatSidebar } from "./chat/actions"; | |
| 39 | 56 | import { unreadTotals } from "../lib/chat"; | |
| ⋯ | |||
| 89 | 106 | monthUsageMicros: number | null; | |
| 90 | 107 | /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */ | |
| 91 | 108 | 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. */ | |
| 93 | 110 | inbox?: InboxCounts | null; | |
| 94 | 111 | /** | |
| 95 | − | * Chat, for the dock's badge: what is unread, and the | |
| 112 | + | * Chat, for the rail's badge: what is unread, and the | |
| 96 | 113 | * starred and latest conversations. Null when chat did not answer in time. | |
| 97 | 114 | */ | |
| 98 | 115 | chat?: { unread: number; mentions: number; starred?: ChatSidebarEntry[]; recent?: ChatSidebarEntry[] } | null; | |
| 99 | 116 | /** The workspace's agents, g1t first, for the Agents sidebar; null when not known. */ | |
| 100 | 117 | 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). */ | |
| 102 | 119 | pins?: PinnableApp[]; | |
| 103 | 120 | }; | |
| 104 | 121 | ||
| ⋯ | |||
| 112 | 129 | teams?: { slug: string; name: string }[]; | |
| 113 | 130 | }; | |
| 114 | 131 | ||
| 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 | + | */ | |
| 115 | 159 | function SidebarLink({ | |
| 116 | 160 | to, | |
| 117 | 161 | icon, | |
| ⋯ | |||
| 120 | 164 | also, | |
| 121 | 165 | drill, | |
| 122 | 166 | current: lit, | |
| 167 | + | action, | |
| 168 | + | bare = false, | |
| 123 | 169 | children, | |
| 124 | 170 | }: { | |
| 125 | 171 | to: string; | |
| ⋯ | |||
| 132 | 178 | drill?: boolean | "hover"; | |
| 133 | 179 | /** Whether it is the current row, when the list works that out itself. */ | |
| 134 | 180 | 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; | |
| 135 | 185 | children: ReactNode; | |
| 136 | 186 | }) { | |
| 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; | |
| 145 | 189 | 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> | |
| 179 | 206 | ); | |
| 180 | 207 | } | |
| 181 | 208 | ||
| ⋯ | |||
| 185 | 212 | */ | |
| 186 | 213 | function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) { | |
| 187 | 214 | 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> | |
| 196 | 227 | ); | |
| 197 | 228 | } | |
| 198 | 229 | ||
| ⋯ | |||
| 216 | 247 | current?: boolean; | |
| 217 | 248 | children: ReactNode; | |
| 218 | 249 | }) { | |
| 219 | − | const { pathname } = useLocation(); | |
| 250 | + | const { current, pending } = useRowState(to, { also, current: lit }); | |
| 220 | 251 | 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> | |
| 241 | 263 | ); | |
| 242 | 264 | } | |
| 243 | 265 | ||
| ⋯ | |||
| 255 | 277 | return roadmapIn(section).map((item) => `${base}/soon/${item.key}`); | |
| 256 | 278 | } | |
| 257 | 279 | ||
| 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({ | |
| 259 | 282 | title, | |
| 260 | 283 | action, | |
| 261 | − | className = "", | |
| 284 | + | className, | |
| 262 | 285 | children, | |
| 263 | 286 | }: { | |
| 264 | 287 | title: string; | |
| 288 | + | /** A small button at the label's end: what the group makes (a `SidebarGroupAction`). */ | |
| 265 | 289 | action?: ReactNode; | |
| 266 | 290 | className?: string; | |
| 267 | 291 | children: ReactNode; | |
| 268 | 292 | }) { | |
| 269 | 293 | 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> | |
| 277 | 303 | ); | |
| 278 | 304 | } | |
| 279 | 305 | ||
| 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 | + | ||
| 280 | 317 | /** What a workspace is called where people read it: its display name, else its slug. */ | |
| 281 | 318 | function displayName(membership: Membership): string { | |
| 282 | 319 | return membership.name?.trim() || membership.slug; | |
| ⋯ | |||
| 332 | 369 | const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]"; | |
| 333 | 370 | ||
| 334 | 371 | /** | |
| 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 | |
| 336 | 373 | * a workspace): who you are and your status, your profile and settings, | |
| 337 | 374 | * and help: the documentation, support, status and the keyboard's | |
| 338 | 375 | * shortcuts; then signing out and the fine print. | |
| ⋯ | |||
| 363 | 400 | <> | |
| 364 | 401 | <DropdownMenu open={open} onOpenChange={setOpen}> | |
| 365 | 402 | {/* 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> | |
| 375 | 419 | <DropdownMenuContent align="end" side={side} sideOffset={side === "right" ? 14 : 6} collisionPadding={8} className="w-[17.5rem] p-1.5"> | |
| 376 | 420 | {/* Who is signed in, and a way to their profile. */} | |
| 377 | 421 | <DropdownMenuItem asChild className="gap-3 px-2 py-2"> | |
| ⋯ | |||
| 495 | 539 | const LAYER = | |
| 496 | 540 | "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"; | |
| 497 | 541 | /** 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]"; | |
| 499 | 543 | ||
| 500 | 544 | /** One list in the stack: what it is, and its rows. */ | |
| 501 | 545 | type Level = { key: string; node: ReactNode }; | |
| ⋯ | |||
| 563 | 607 | */ | |
| 564 | 608 | function BackRow({ to, label, context }: { to: string; label: ReactNode; context?: ReactNode }) { | |
| 565 | 609 | 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> | |
| 577 | 621 | ); | |
| 578 | 622 | } | |
| 579 | 623 | ||
| 580 | 624 | /** A thin rule between groups of a list. */ | |
| 581 | 625 | function Rule() { | |
| 582 | − | return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />; | |
| 626 | + | return <SidebarSeparator className="my-1.5" />; | |
| 583 | 627 | } | |
| 584 | 628 | ||
| 585 | 629 | /** A quiet heading inside a group: Pinned, Recent. */ | |
| 586 | 630 | 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 | + | ); | |
| 588 | 636 | } | |
| 589 | 637 | ||
| 590 | 638 | /** | |
| ⋯ | |||
| 615 | 663 | // Each row pins or unpins in place: the pin shows on hover or focus, and | |
| 616 | 664 | // stays shown on a pinned row's hover so it reads as "unpin". | |
| 617 | 665 | 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} | |
| 635 | 671 | 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 | + | /> | |
| 639 | 680 | } | |
| 640 | 681 | > | |
| 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> | |
| 641 | 698 | {/* Only there once something is pinned: each row below has its own pin. */} | |
| 642 | 699 | {pinned.length > 0 && ( | |
| 643 | 700 | <> | |
| 644 | 701 | <SidebarSubhead>Pinned</SidebarSubhead> | |
| 645 | − | <ul aria-label="Pinned projects" className="space-y-px"> | |
| 702 | + | <SidebarMenu aria-label="Pinned projects"> | |
| 646 | 703 | {pinned.map((project, index) => ( | |
| 647 | − | <li | |
| 704 | + | <SidebarMenuItem | |
| 648 | 705 | key={project.name} | |
| 649 | 706 | draggable={pinned.length > 1} | |
| 650 | 707 | onDragStart={(event) => { | |
| ⋯ | |||
| 665 | 722 | event.preventDefault(); | |
| 666 | 723 | move(index, event.key === "ArrowUp" ? -1 : 1); | |
| 667 | 724 | }} | |
| 668 | − | className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`} | |
| 725 | + | className={`group/pin ${dragging === index ? "opacity-50" : ""}`} | |
| 669 | 726 | > | |
| 670 | 727 | {row(project, true)} | |
| 671 | 728 | {pinned.length > 1 && ( | |
| 672 | 729 | <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"> | |
| 674 | 731 | <GripVertical size={12} aria-hidden="true" /> | |
| 675 | 732 | </span> | |
| 676 | 733 | </Hint> | |
| 677 | 734 | )} | |
| 678 | − | </li> | |
| 735 | + | </SidebarMenuItem> | |
| 679 | 736 | ))} | |
| 680 | − | </ul> | |
| 737 | + | </SidebarMenu> | |
| 681 | 738 | </> | |
| 682 | 739 | )} | |
| 683 | 740 | {recent.length > 0 && ( | |
| 684 | 741 | <> | |
| 685 | 742 | <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> | |
| 689 | 748 | </> | |
| 690 | 749 | )} | |
| 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}> | |
| 693 | 752 | All projects | |
| 694 | 753 | </SidebarLink> | |
| 695 | − | </div> | |
| 754 | + | </SidebarMenu> | |
| 755 | + | </SidebarGroupContent> | |
| 696 | 756 | </SidebarGroup> | |
| 697 | 757 | ); | |
| 698 | 758 | } | |
| ⋯ | |||
| 744 | 804 | const base = `/${repo.namespace}/${repo.name}`; | |
| 745 | 805 | // What a member sees, and what everyone who can see the project does. | |
| 746 | 806 | const shows = new Set(projectPages(repo.member, repo.can)); | |
| 807 | + | const { current: atOverview, pending: toOverview } = useRowState(base, { end: true }); | |
| 747 | 808 | return ( | |
| 748 | 809 | <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}> | |
| 749 | 810 | <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> | |
| 770 | 828 | {/* A project's pages, in the order people use them. A page with more | |
| 771 | 829 | than one view shows them as tabs across its top. */} | |
| 772 | 830 | <Rule /> | |
| 773 | − | <div className="space-y-px"> | |
| 831 | + | <NavRows> | |
| 774 | 832 | <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} />}> | |
| 775 | 833 | Code | |
| 776 | 834 | </SidebarLink> | |
| ⋯ | |||
| 786 | 844 | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 787 | 845 | Workflows | |
| 788 | 846 | </SidebarLink> | |
| 789 | − | </div> | |
| 847 | + | </NavRows> | |
| 790 | 848 | <Rule /> | |
| 791 | − | <div className="space-y-px"> | |
| 849 | + | <NavRows> | |
| 792 | 850 | {shows.has("deployments") ? ( | |
| 793 | 851 | <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}> | |
| 794 | 852 | Deployments | |
| ⋯ | |||
| 809 | 867 | > | |
| 810 | 868 | Insights | |
| 811 | 869 | </SidebarLink> | |
| 812 | − | </div> | |
| 870 | + | </NavRows> | |
| 813 | 871 | {(repo.settings ?? shows.has("settings")) && ( | |
| 814 | 872 | <> | |
| 815 | 873 | <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> | |
| 819 | 879 | </> | |
| 820 | 880 | )} | |
| 821 | 881 | </nav> | |
| ⋯ | |||
| 837 | 897 | <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}> | |
| 838 | 898 | <BackRow to={base} label="Settings" context={`${repo.namespace}/${repo.name}`} /> | |
| 839 | 899 | {shows("") && ( | |
| 840 | − | <div className="mt-2 space-y-px"> | |
| 900 | + | <NavRows className="mt-2"> | |
| 841 | 901 | <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />}> | |
| 842 | 902 | General | |
| 843 | 903 | </SidebarLink> | |
| 844 | − | </div> | |
| 904 | + | </NavRows> | |
| 845 | 905 | )} | |
| 846 | 906 | {running && ( | |
| 847 | 907 | <> | |
| 848 | 908 | {shows("") && <Rule />} | |
| 849 | − | <div className={shows("") ? "space-y-px" : "mt-2 space-y-px"}> | |
| 909 | + | <NavRows className={shows("") ? undefined : "mt-2"}> | |
| 850 | 910 | <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}> | |
| 851 | 911 | Deployments | |
| 852 | 912 | </SidebarLink> | |
| 853 | 913 | <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}> | |
| 854 | 914 | Domains | |
| 855 | 915 | </SidebarLink> | |
| 856 | − | </div> | |
| 916 | + | </NavRows> | |
| 857 | 917 | </> | |
| 858 | 918 | )} | |
| 859 | 919 | {agents && ( | |
| 860 | 920 | <> | |
| 861 | 921 | <Rule /> | |
| 862 | − | <div className="space-y-px"> | |
| 922 | + | <NavRows> | |
| 863 | 923 | {shows("agents") && ( | |
| 864 | 924 | <SidebarLink to={`${base}/settings/agents`} icon={<Bot size={15} />}> | |
| 865 | 925 | Agents | |
| ⋯ | |||
| 870 | 930 | Guardrails | |
| 871 | 931 | </SidebarLink> | |
| 872 | 932 | )} | |
| 873 | − | </div> | |
| 933 | + | </NavRows> | |
| 874 | 934 | </> | |
| 875 | 935 | )} | |
| 876 | 936 | <Rule /> | |
| 877 | − | <div className="space-y-px"> | |
| 937 | + | <NavRows> | |
| 878 | 938 | {shows("repository") && ( | |
| 879 | 939 | <SidebarLink to={`${base}/settings/repository`} icon={<BookMarked size={15} />}> | |
| 880 | 940 | Repository | |
| ⋯ | |||
| 930 | 990 | Webhooks | |
| 931 | 991 | </SidebarLink> | |
| 932 | 992 | )} | |
| 933 | − | </div> | |
| 993 | + | </NavRows> | |
| 934 | 994 | </nav> | |
| 935 | 995 | ); | |
| 936 | 996 | } | |
| ⋯ | |||
| 961 | 1021 | return ( | |
| 962 | 1022 | <nav aria-label="Your settings" className={PANEL}> | |
| 963 | 1023 | <BackRow to="/" label="Your settings" context={username} /> | |
| 964 | − | <div className="mt-2 space-y-px"> | |
| 1024 | + | <NavRows className="mt-2"> | |
| 965 | 1025 | {link("profile", <CircleUserRound size={15} />)} | |
| 966 | 1026 | {link("emails", <Mail size={15} />)} | |
| 967 | 1027 | {invites && link("invites", <Ticket size={15} />)} | |
| 968 | − | </div> | |
| 1028 | + | </NavRows> | |
| 969 | 1029 | <Rule /> | |
| 970 | − | <div className="space-y-px"> | |
| 1030 | + | <NavRows> | |
| 971 | 1031 | {link("keys", <Fingerprint size={15} />)} | |
| 972 | 1032 | {link("tokens", <KeyRound size={15} />)} | |
| 973 | − | </div> | |
| 1033 | + | </NavRows> | |
| 974 | 1034 | <Rule /> | |
| 975 | − | <div className="space-y-px"> | |
| 1035 | + | <NavRows> | |
| 976 | 1036 | {link("integrations", <Blocks size={15} />)} | |
| 977 | 1037 | {link("github", <GithubMark className="size-[15px]" />)} | |
| 978 | 1038 | {link("applications", <Plug size={15} />)} | |
| 979 | − | </div> | |
| 1039 | + | </NavRows> | |
| 980 | 1040 | <Rule /> | |
| 981 | − | <div className="space-y-px"> | |
| 1041 | + | <NavRows> | |
| 982 | 1042 | {link("two-factor", <ShieldCheck size={15} />)} | |
| 983 | 1043 | {link("security-log", <History size={15} />)} | |
| 984 | − | </div> | |
| 1044 | + | </NavRows> | |
| 985 | 1045 | </nav> | |
| 986 | 1046 | ); | |
| 987 | 1047 | } | |
| ⋯ | |||
| 1079 | 1139 | */ | |
| 1080 | 1140 | export function ModeHeader({ title, action }: { title: ReactNode; action?: ReactNode }) { | |
| 1081 | 1141 | 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> | |
| 1084 | 1146 | {action} | |
| 1085 | 1147 | </div> | |
| 1086 | 1148 | ); | |
| 1087 | 1149 | } | |
| 1088 | 1150 | ||
| 1089 | 1151 | /** | |
| 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 | |
| 1091 | 1153 | * control's code panels), the projects, what spans them, and what is | |
| 1092 | 1154 | * coming. The workspace's people, money and settings are elsewhere; | |
| 1093 | 1155 | * agents, context and memory are Agents'. | |
| ⋯ | |||
| 1100 | 1162 | const shared = shell.shared ?? []; | |
| 1101 | 1163 | return ( | |
| 1102 | 1164 | <nav aria-label="Code" className={PANEL}> | |
| 1103 | − | <div className="space-y-px"> | |
| 1165 | + | <NavRows> | |
| 1104 | 1166 | <SidebarLink to={`/${slug}/-/overview`} icon={<LayoutDashboard size={15} />} current={at("overview")}> | |
| 1105 | 1167 | Overview | |
| 1106 | 1168 | </SidebarLink> | |
| 1107 | − | </div> | |
| 1108 | − | <div className="mt-3"> | |
| 1109 | − | <SidebarProjects slug={slug} shell={shell} current={at("projects")} /> | |
| 1110 | − | </div> | |
| 1169 | + | </NavRows> | |
| 1111 | 1170 | <Rule /> | |
| 1112 | − | <div className="space-y-px"> | |
| 1171 | + | <SidebarProjects slug={slug} shell={shell} current={at("projects")} /> | |
| 1172 | + | <Rule /> | |
| 1173 | + | <NavRows> | |
| 1113 | 1174 | <SidebarLink to={`/${slug}/-/security`} icon={<ShieldCheck size={15} />} current={at("security") && !at("security/settings")}> | |
| 1114 | 1175 | Security | |
| 1115 | 1176 | </SidebarLink> | |
| ⋯ | |||
| 1128 | 1189 | {item.title === "Board" ? "Boards" : item.title} | |
| 1129 | 1190 | </SidebarSoonLink> | |
| 1130 | 1191 | ))} | |
| 1131 | − | </div> | |
| 1192 | + | </NavRows> | |
| 1132 | 1193 | {shared.length > 0 && ( | |
| 1133 | 1194 | <> | |
| 1134 | 1195 | <Rule /> | |
| 1135 | − | <SidebarGroup title="Shared with you"> | |
| 1196 | + | <NavGroup title="Shared with you"> | |
| 1136 | 1197 | {shared.map((repo) => ( | |
| 1137 | 1198 | <SidebarLink | |
| 1138 | 1199 | key={`${repo.namespace}/${repo.name}`} | |
| ⋯ | |||
| 1144 | 1205 | {repo.name} | |
| 1145 | 1206 | </SidebarLink> | |
| 1146 | 1207 | ))} | |
| 1147 | − | </SidebarGroup> | |
| 1208 | + | </NavGroup> | |
| 1148 | 1209 | </> | |
| 1149 | 1210 | )} | |
| 1150 | 1211 | </nav> | |
| ⋯ | |||
| 1169 | 1230 | const inSettings = WORKSPACE_SETTINGS.includes(top); | |
| 1170 | 1231 | const main = ( | |
| 1171 | 1232 | <nav aria-label="Workspace" className={PANEL}> | |
| 1172 | − | <div className="space-y-px"> | |
| 1233 | + | <NavRows> | |
| 1173 | 1234 | <SidebarLink to={`/${slug}/-/workspace`} icon={<LayoutGrid size={15} />} current={at("workspace")}> | |
| 1174 | 1235 | Overview | |
| 1175 | 1236 | </SidebarLink> | |
| 1176 | − | </div> | |
| 1177 | − | <SidebarGroup title="Money" className="mt-3"> | |
| 1237 | + | </NavRows> | |
| 1238 | + | <Rule /> | |
| 1239 | + | <NavGroup title="Money"> | |
| 1178 | 1240 | <SidebarLink to={`/${slug}/-/spend`} icon={<Coins size={15} />} current={at("spend")}> | |
| 1179 | 1241 | Spend | |
| 1180 | 1242 | </SidebarLink> | |
| ⋯ | |||
| 1187 | 1249 | <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />} current={at("billing")}> | |
| 1188 | 1250 | Billing and plans | |
| 1189 | 1251 | </SidebarLink> | |
| 1190 | − | </SidebarGroup> | |
| 1252 | + | </NavGroup> | |
| 1191 | 1253 | {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 | + | </> | |
| 1197 | 1262 | )} | |
| 1198 | − | <SidebarGroup title="Connections" className="mt-3"> | |
| 1263 | + | <Rule /> | |
| 1264 | + | <NavGroup title="Connections"> | |
| 1199 | 1265 | <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />} current={at("integrations")}> | |
| 1200 | 1266 | Integrations | |
| 1201 | 1267 | </SidebarLink> | |
| 1202 | − | </SidebarGroup> | |
| 1203 | − | <SidebarGroup title="Security policies" className="mt-3"> | |
| 1268 | + | </NavGroup> | |
| 1269 | + | <Rule /> | |
| 1270 | + | <NavGroup title="Security policies"> | |
| 1204 | 1271 | <SidebarLink to={`/${slug}/-/security/settings`} icon={<ShieldCheck size={15} />} current={at("security/settings")}> | |
| 1205 | 1272 | Security settings | |
| 1206 | 1273 | </SidebarLink> | |
| ⋯ | |||
| 1210 | 1277 | <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />} current={at("rules")}> | |
| 1211 | 1278 | Rules | |
| 1212 | 1279 | </SidebarLink> | |
| 1213 | − | </SidebarGroup> | |
| 1280 | + | </NavGroup> | |
| 1214 | 1281 | <Rule /> | |
| 1215 | − | <div className="space-y-px"> | |
| 1282 | + | <NavRows> | |
| 1216 | 1283 | <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />} current={at("audit")}> | |
| 1217 | 1284 | Audit log | |
| 1218 | 1285 | </SidebarLink> | |
| 1219 | 1286 | <SidebarLink to={owner ? `/${slug}/-/settings` : `/${slug}/-/repositories`} icon={<Settings size={15} />} drill current={inSettings}> | |
| 1220 | 1287 | Settings | |
| 1221 | 1288 | </SidebarLink> | |
| 1222 | − | </div> | |
| 1289 | + | </NavRows> | |
| 1223 | 1290 | </nav> | |
| 1224 | 1291 | ); | |
| 1225 | 1292 | const settings = ( | |
| 1226 | 1293 | <nav aria-label="Workspace settings" className={PANEL}> | |
| 1227 | 1294 | <BackRow to={`/${slug}/-/workspace`} label="Settings" context={slug} /> | |
| 1228 | − | <div className="mt-2 space-y-px"> | |
| 1295 | + | <NavRows className="mt-2"> | |
| 1229 | 1296 | {owner && ( | |
| 1230 | 1297 | <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />} end> | |
| 1231 | 1298 | General | |
| ⋯ | |||
| 1251 | 1318 | <SidebarLink to={`/${slug}/-/emoji`} icon={<Smile size={15} />}> | |
| 1252 | 1319 | Emoji | |
| 1253 | 1320 | </SidebarLink> | |
| 1254 | − | </div> | |
| 1255 | − | <SidebarGroup title="Runs" className="mt-3"> | |
| 1321 | + | </NavRows> | |
| 1322 | + | <Rule /> | |
| 1323 | + | <NavGroup title="Runs"> | |
| 1256 | 1324 | <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}> | |
| 1257 | 1325 | Secrets and variables | |
| 1258 | 1326 | </SidebarLink> | |
| 1259 | 1327 | <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}> | |
| 1260 | 1328 | Actions | |
| 1261 | 1329 | </SidebarLink> | |
| 1262 | − | </SidebarGroup> | |
| 1330 | + | </NavGroup> | |
| 1263 | 1331 | </nav> | |
| 1264 | 1332 | ); | |
| 1265 | 1333 | const trail: Level[] = [{ key: "workspace", node: main }]; | |
| ⋯ | |||
| 1285 | 1353 | <div className="flex h-full flex-col"> | |
| 1286 | 1354 | <ModeHeader title="People" /> | |
| 1287 | 1355 | <nav aria-label="People" className={PANEL}> | |
| 1288 | − | <div className="space-y-px"> | |
| 1356 | + | <NavRows> | |
| 1289 | 1357 | <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />} current={at("people")}> | |
| 1290 | 1358 | Everyone | |
| 1291 | 1359 | </SidebarLink> | |
| ⋯ | |||
| 1295 | 1363 | <SidebarLink to={`/${slug}/-/org-chart`} icon={<Network size={15} />} current={at("org-chart")}> | |
| 1296 | 1364 | Org chart | |
| 1297 | 1365 | </SidebarLink> | |
| 1298 | − | </div> | |
| 1299 | − | <SidebarGroup title="Membership" className="mt-3"> | |
| 1366 | + | </NavRows> | |
| 1367 | + | <Rule /> | |
| 1368 | + | <NavGroup title="Membership"> | |
| 1300 | 1369 | <SidebarLink to={`/${slug}/-/members`} icon={<UserPlus size={15} />} current={at("members")}> | |
| 1301 | 1370 | Members and invites | |
| 1302 | 1371 | </SidebarLink> | |
| 1303 | − | </SidebarGroup> | |
| 1372 | + | </NavGroup> | |
| 1304 | 1373 | </nav> | |
| 1305 | 1374 | </div> | |
| 1306 | 1375 | ); | |
| ⋯ | |||
| 1332 | 1401 | <div className="flex h-full flex-col"> | |
| 1333 | 1402 | <ModeHeader title="Notifications" /> | |
| 1334 | 1403 | <nav aria-label="Notifications" className={PANEL}> | |
| 1335 | − | <div className="space-y-px"> | |
| 1404 | + | <NavRows> | |
| 1336 | 1405 | {views.map((entry) => ( | |
| 1337 | 1406 | <SidebarLink | |
| 1338 | 1407 | key={entry.view} | |
| ⋯ | |||
| 1344 | 1413 | {entry.label} | |
| 1345 | 1414 | </SidebarLink> | |
| 1346 | 1415 | ))} | |
| 1347 | − | </div> | |
| 1348 | − | <SidebarGroup title="Why you were told" className="mt-4"> | |
| 1416 | + | </NavRows> | |
| 1417 | + | <Rule /> | |
| 1418 | + | <NavGroup title="Why you were told"> | |
| 1349 | 1419 | {REASON_FILTERS.map((entry) => ( | |
| 1350 | 1420 | <SidebarLink | |
| 1351 | 1421 | key={entry.reason ?? "any"} | |
| ⋯ | |||
| 1356 | 1426 | {entry.reason == null ? "Any reason" : entry.label} | |
| 1357 | 1427 | </SidebarLink> | |
| 1358 | 1428 | ))} | |
| 1359 | − | </SidebarGroup> | |
| 1429 | + | </NavGroup> | |
| 1360 | 1430 | </nav> | |
| 1361 | 1431 | </div> | |
| 1362 | 1432 | ); | |
| ⋯ | |||
| 1830 | 1900 | * Search or jump to: the command palette. On a computer it looks like the | |
| 1831 | 1901 | * field it opens, with its shortcut; on a phone, a magnifier. | |
| 1832 | 1902 | */ | |
| 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; | |
| 1834 | 1922 | return ( | |
| 1835 | 1923 | <> | |
| 1836 | 1924 | <Button variant="ghost" size="icon-bar" aria-label="Search or jump to" onClick={onClick} className="md:hidden"> | |
| 1837 | 1925 | <Search size={18} /> | |
| 1838 | 1926 | </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} | |
| 1850 | 1928 | </> | |
| 1851 | 1929 | ); | |
| 1852 | 1930 | } | |
| 1853 | 1931 | ||
| 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); | |
| 1864 | 1935 | } | |
| 1865 | 1936 | ||
| 1866 | 1937 | /** | |
| 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. | |
| 1875 | 1948 | */ | |
| 1876 | 1949 | export function AppShell({ | |
| 1877 | 1950 | user, | |
| ⋯ | |||
| 1964 | 2037 | return <AccountSidebar username={user.username} />; | |
| 1965 | 2038 | } | |
| 1966 | 2039 | }; | |
| 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. | |
| 1968 | 2046 | 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> | |
| 1985 | 2066 | ); | |
| 1986 | 2067 | ||
| 1987 | 2068 | // On a phone (below 768px): the bottom bar, and a conversation full screen. | |
| 1988 | 2069 | 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"; | |
| 1992 | 2073 | ||
| 1993 | 2074 | 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}> | |
| 1997 | 2076 | <Progress /> | |
| 1998 | 2077 | {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} />} /> | |
| 2006 | 2080 | </div> | |
| 2007 | 2081 | )} | |
| 2008 | 2082 | {panel && ( | |
| 2009 | − | <aside | |
| 2083 | + | <Sidebar | |
| 2084 | + | role="complementary" | |
| 2010 | 2085 | 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"}`} | |
| 2012 | 2087 | > | |
| 2013 | 2088 | {sidebarTop(false)} | |
| 2014 | 2089 | <div className="min-h-0 grow">{sidebarNode()}</div> | |
| 2015 | − | </aside> | |
| 2090 | + | </Sidebar> | |
| 2016 | 2091 | )} | |
| 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 | + | )} | |
| 2032 | 2100 | ||
| 2033 | 2101 | <div className={`min-h-dvh px-1.5 pt-(--frame-top) pb-(--tabbar-h) md:pr-2 ${pad}`}> | |
| 2034 | 2102 | {/* A page whose own header sits right under this bar (data-page-head: a conversation's, a project's) reads as one | |
| ⋯ | |||
| 2040 | 2108 | 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" : ""}`} | |
| 2041 | 2109 | > | |
| 2042 | 2110 | {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 | + | /> | |
| 2044 | 2117 | )} | |
| 2045 | 2118 | {ws && ( | |
| 2046 | 2119 | <span className={`flex min-w-0 shrink items-center gap-1.5 ${inline ? "lg:hidden" : ""}`}> | |
| ⋯ | |||
| 2053 | 2126 | <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} workspace={ws?.slug ?? null} /> | |
| 2054 | 2127 | {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */} | |
| 2055 | 2128 | <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> | |
| 2057 | 2133 | {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */} | |
| 2058 | 2134 | {ws && ( | |
| 2059 | 2135 | <span className="flex max-md:hidden"> | |
| ⋯ | |||
| 2067 | 2143 | <NotificationsBell counts={shell.inbox ? { ...shell.inbox, unread: notificationsUnread } : null} /> | |
| 2068 | 2144 | </span> | |
| 2069 | 2145 | <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. */} | |
| 2071 | 2147 | {!ws && <AccountMenu user={user} side="bottom" />} | |
| 2072 | 2148 | </div> | |
| 2073 | 2149 | </header> | |
| ⋯ | |||
| 2085 | 2161 | /> | |
| 2086 | 2162 | {ws && <BottomBar user={user} workspace={ws} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />} | |
| 2087 | 2163 | <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} /> | |
| 2088 | − | </Sheet> | |
| 2164 | + | </SidebarProvider> | |
| 2089 | 2165 | ); | |
| 2090 | 2166 | } | |
| 2091 | 2167 | ||
| 10 | 10 | ||
| 11 | 11 | /** | |
| 12 | 12 | * 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 | |
| 14 | 14 | * logo, then the page in a narrow column at the centre, and a quiet row | |
| 15 | 15 | * of links at the foot of the window. | |
| 16 | 16 | */ |
| 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 | + | } |
| 1 | 1 | /** | |
| 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. | |
| 4 | 4 | * | |
| 5 | 5 | * 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, | |
| 7 | 7 | * Usage, Teams and the rest) are in their sidebars. Apps are what the | |
| 8 | 8 | * workspace added from the Marketplace: today, the integrations it | |
| 9 | 9 | * connected, each opening its page. Each person pins the ones they want. | |
| ⋯ | |||
| 16 | 16 | * Pins are each person's own, per workspace, in the order they set, and | |
| 17 | 17 | * kept with their account by the identity service (`dock_pins`), so the | |
| 18 | 18 | * 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 | |
| 20 | 20 | * when identity cannot be reached, and pins from before they were kept | |
| 21 | 21 | * with the account carry over from it until the first change. | |
| 22 | 22 | * No Workers or React imports, so it can be tested under Node. | |
| ⋯ | |||
| 25 | 25 | import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors"; | |
| 26 | 26 | import { CONNECTOR_PUBLISHER, extensionById } from "@g1t/contracts/marketplace"; | |
| 27 | 27 | ||
| 28 | − | /** The apps that are always in the dock. */ | |
| 28 | + | /** The apps that are always in the rail. */ | |
| 29 | 29 | export type BuiltinApp = "home" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace"; | |
| 30 | 30 | ||
| 31 | 31 | export type BuiltinInfo = { | |
| ⋯ | |||
| 39 | 39 | ||
| 40 | 40 | const under = (page: string) => (slug: string) => `/${slug}/-/${page}`; | |
| 41 | 41 | ||
| 42 | − | /** The dock's built-in apps, in its order. */ | |
| 42 | + | /** The rail's built-in apps, in its order. */ | |
| 43 | 43 | export const BUILTINS: BuiltinInfo[] = [ | |
| 44 | 44 | { key: "home", name: "Home", path: under("home") }, | |
| 45 | 45 | { key: "chat", name: "Chat", path: under("chat") }, | |
| ⋯ | |||
| 238 | 238 | return pinned ? [...rest, app] : rest; | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | − | /** The Set-Cookie header that keeps the dock, for a year. */ | |
| 241 | + | /** The Set-Cookie header that keeps the rail, for a year. */ | |
| 242 | 242 | export function dockCookie(value: string, secure: boolean): string { | |
| 243 | 243 | return `${DOCK_COOKIE}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 244 | 244 | } | |
| 3 | 3 | * | |
| 4 | 4 | * - `standalone`: signing in, signing up and choosing a workspace. The | |
| 5 | 5 | * 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 | |
| 8 | 8 | * sidebar and the page in its panel, always about their current | |
| 9 | 9 | * workspace, even on public pages such as Explore, a profile or another | |
| 10 | 10 | * workspace's public project. |
| 74 | 74 | ); | |
| 75 | 75 | }); | |
| 76 | 76 | ||
| 77 | − | test("the dock's mode follows the address", async () => { | |
| 77 | + | test("the rail's mode follows the address", async () => { | |
| 78 | 78 | const { modeOf, modeHome, homePath } = await import("./workspace-nav.ts"); | |
| 79 | 79 | assert.equal(modeOf("/", "acme"), "home"); | |
| 80 | 80 | assert.equal(modeOf("/acme", "acme"), "home"); |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | 125 | /** | |
| 126 | − | * The dock's modes. Home is the front page; | |
| 126 | + | * The rail's modes. Home is the front page; | |
| 127 | 127 | * Chat, Agents, Code and Artifacts are where work happens; Notifications | |
| 128 | 128 | * spans them; People is who belongs; Workspace is the workspace itself: | |
| 129 | 129 | * its money, policies and settings; Apps is everything installed that you | |
| ⋯ | |||
| 205 | 205 | return "code"; | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 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`. */ | |
| 209 | 209 | export function modeHome(mode: ModeKey, slug: string): string { | |
| 210 | 210 | switch (mode) { | |
| 211 | 211 | case "home": | |
| 95 | 95 | : visitorShell(params, context), | |
| 96 | 96 | registrationMode(), | |
| 97 | 97 | ]); | |
| 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 | |
| 99 | 99 | // them (read with the rest of the shell), else as this device's cookie | |
| 100 | 100 | // remembers them (lib/apps.ts). | |
| 101 | 101 | if (shell.workspace) shell.pins = pinsToShow(shell.pins, dock, shell.workspace.slug); | |
| ⋯ | |||
| 200 | 200 | workspace ? chatUnreadFor(workspace.slug, user) : Promise.resolve(null), | |
| 201 | 201 | // Home's Recent and the Agents sidebar, on every page: never waited on for long. | |
| 202 | 202 | 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 | |
| 205 | 205 | // falls back to the cookie (lib/dock.server.ts). | |
| 206 | 206 | workspace ? savedPins(user, workspace.slug) : Promise.resolve(null), | |
| 207 | 207 | ]); | |
| 214 | 214 | ]), | |
| 215 | 215 | // Home, the workspace's front page (`-/today` leads there), where it | |
| 216 | 216 | // marks your visit, and every app in it you can use, with pinning them | |
| 217 | − | // to your dock. | |
| 217 | + | // to your rail. | |
| 218 | 218 | route("-/home", "routes/workspace/home.tsx"), | |
| 219 | 219 | route("-/home/seen", "routes/workspace/home-seen.ts"), | |
| 220 | 220 | route("-/today", "routes/workspace/moved.ts", { id: "routes/workspace/moved-today" }), |
| 1 | 1 | /** | |
| 2 | 2 | * 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 | |
| 5 | 5 | * added. Pins are yours alone, per workspace, kept with your account so | |
| 6 | 6 | * every device shows the same dock (lib/dock.server.ts), with a copy in a | |
| 7 | 7 | * cookie for when the account's cannot be read (lib/apps.ts). The pin | |
| ⋯ | |||
| 76 | 76 | <div> | |
| 77 | 77 | <h1 className="text-2xl font-semibold tracking-tight">Apps</h1> | |
| 78 | 78 | <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. | |
| 80 | 80 | </p> | |
| 81 | 81 | </div> | |
| 82 | 82 | <Link | |
| ⋯ | |||
| 102 | 102 | <section aria-labelledby="pinned" className="mt-8"> | |
| 103 | 103 | <div className="flex items-baseline justify-between gap-3"> | |
| 104 | 104 | <h2 id="pinned" className="text-sm font-semibold"> | |
| 105 | − | Pinned to your dock | |
| 105 | + | Pinned to your rail | |
| 106 | 106 | </h2> | |
| 107 | 107 | <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span> | |
| 108 | 108 | </div> | |
| 109 | 109 | {pinned.length === 0 ? ( | |
| 110 | 110 | <Card asChild tone="plain" className="mt-3 border-dashed px-4 py-6 text-center text-sm text-muted"> | |
| 111 | 111 | <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. | |
| 113 | 113 | </p> | |
| 114 | 114 | </Card> | |
| 115 | 115 | ) : ( | |
| 4 | 4 | * what the team already uses so agents can work with it. Owners add them; | |
| 5 | 5 | * everyone else asks an owner, and the agents service keeps the request | |
| 6 | 6 | * (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 | |
| 8 | 8 | * here, as does ⌘K. | |
| 9 | 9 | * | |
| 10 | 10 | * This layout holds the heading, the tabs and the viewer's requests, which |