Skip to content
151 linesCodeBlameRaw
1import { BookOpen, Check, Code2, House, Inbox, MessagesSquare, Plus, Sparkles } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link, useLocation, useNavigation } from "react-router";
4
5import { type Membership, type User, hasCodeAccess } from "@g1t/contracts";
6
7import { Avatar } from "./ui";
8import { Hint } from "./ui/hint";
9import {
10 DropdownMenu,
11 DropdownMenuContent,
12 DropdownMenuItem,
13 DropdownMenuLabel,
14 DropdownMenuSeparator,
15 DropdownMenuTrigger,
16} from "./ui/dropdown-menu";
17import { type ModeKey, modeHome, modeOf } from "../lib/workspace-nav";
18
19/** The rail's width; the shell lays the mode's sidebar and the page out beside it. */
20export const RAIL_WIDTH = "5rem";
21
22type Mode = { key: ModeKey; label: string; icon: ReactNode; badge?: { count: number; loud: boolean } | null };
23
24function Badge({ count, loud }: { count: number; loud: boolean }) {
25 if (count <= 0) return null;
26 return (
27 <span
28 className={`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-[var(--rail-bg)] ${
29 loud ? "bg-accent text-bg" : "bg-[#3d3d45] text-fg"
30 }`}
31 >
32 {count > 99 ? "99+" : count}
33 </span>
34 );
35}
36
37function RailButton({ mode, to, current }: { mode: Mode; to: string; current: boolean }) {
38 return (
39 <Link
40 to={to}
41 prefetch="intent"
42 aria-current={current ? "page" : undefined}
43 aria-label={mode.badge && mode.badge.count > 0 ? `${mode.label}, ${mode.badge.count} unread` : mode.label}
44 className="group flex w-full flex-col items-center gap-1 rounded-lg py-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
45 >
46 <span
47 className={`relative flex size-9 items-center justify-center rounded-[10px] transition-colors ${
48 current ? "bg-[#2c2c33] text-fg shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" : "text-muted group-hover:bg-raised group-hover:text-fg"
49 }`}
50 >
51 {mode.icon}
52 {mode.badge && <Badge count={mode.badge.count} loud={mode.badge.loud} />}
53 </span>
54 <span className={`text-[0.6875rem] leading-none font-medium transition-colors ${current ? "text-fg" : "text-faint group-hover:text-muted"}`}>
55 {mode.label}
56 </span>
57 </Link>
58 );
59}
60
61function displayName(membership: Membership): string {
62 return membership.name?.trim() || membership.slug;
63}
64
65/** The workspace at the top of the rail: its avatar, which switches between yours. */
66function WorkspaceButton({ user, workspace }: { user: User; workspace: Membership }) {
67 return (
68 <DropdownMenu>
69 <Hint label={displayName(workspace)} side="right">
70 <DropdownMenuTrigger
71 aria-label={`${displayName(workspace)}: switch workspace`}
72 className="rounded-[11px] 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"
73 >
74 <Avatar name={workspace.slug} image={workspace.avatar} size={40} square />
75 </DropdownMenuTrigger>
76 </Hint>
77 <DropdownMenuContent side="right" align="start" className="w-64">
78 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
79 {(user.workspaces ?? []).map((membership) => (
80 <DropdownMenuItem key={membership.slug} asChild>
81 <Link to={hasCodeAccess(membership) ? `/${membership.slug}` : `/${membership.slug}/-/home`}>
82 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
83 <span className="flex min-w-0 grow flex-col leading-tight">
84 <span className="truncate">{displayName(membership)}</span>
85 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
86 </span>
87 {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />}
88 </Link>
89 </DropdownMenuItem>
90 ))}
91 <DropdownMenuSeparator />
92 <DropdownMenuItem asChild>
93 <Link to="/workspaces/new">
94 <Plus />
95 New workspace
96 </Link>
97 </DropdownMenuItem>
98 </DropdownMenuContent>
99 </DropdownMenu>
100 );
101}
102
103/**
104 * The rail down the left (docs/WORKSPACE.md, "Shell"): the workspace, then
105 * Home, Code, Chat, Docs, Agents and the Inbox, then the account. The mode
106 * lit follows the address. A member without Code access has no Code.
107 */
108export function Rail({
109 user,
110 workspace,
111 unread,
112 account,
113}: {
114 user: User;
115 workspace: Membership;
116 unread: { inbox: number; chat: number; mentions: number };
117 account: ReactNode;
118}) {
119 const { pathname } = useLocation();
120 const going = useNavigation().location?.pathname;
121 const here = modeOf(going ?? pathname, workspace.slug);
122 const code = hasCodeAccess(workspace);
123 const modes: Mode[] = [
124 { key: "home", label: "Home", icon: <House size={19} /> },
125 ...(code ? [{ key: "code" as const, label: "Code", icon: <Code2 size={19} /> }] : []),
126 {
127 key: "chat",
128 label: "Chat",
129 icon: <MessagesSquare size={19} />,
130 badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
131 },
132 { key: "docs", label: "Docs", icon: <BookOpen size={19} /> },
133 { key: "agents", label: "Agents", icon: <Sparkles size={19} /> },
134 { key: "inbox", label: "Inbox", icon: <Inbox size={19} />, badge: { count: unread.inbox, loud: true } },
135 ];
136 return (
137 <nav
138 aria-label="Modes"
139 style={{ width: RAIL_WIDTH, ["--rail-bg" as string]: "#0b0b0d" }}
140 className="flex h-full shrink-0 flex-col items-center border-r border-line bg-[var(--rail-bg)] pt-3 pb-3"
141 >
142 <WorkspaceButton user={user} workspace={workspace} />
143 <div className="mt-4 flex w-full flex-col items-center gap-2.5 px-2">
144 {modes.map((mode) => (
145 <RailButton key={mode.key} mode={mode} to={modeHome(mode.key, workspace.slug, code)} current={here === mode.key} />
146 ))}
147 </div>
148 <div className="mt-auto">{account}</div>
149 </nav>
150 );
151}