Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Docs: a workspace knowledge base people and agents write together | 1 | /** |
| 2 | * Docs mode's sidebar (beside the rail, docs/WORKSPACE.md "Shell"): | |
| 3 | * search, Home, Templates and Trash; Favorites and Recent; then each | |
| 4 | * space with its page tree, which opens to the page being read. Pages are | |
| 5 | * dragged to reorder them or to put one inside another; the ⋯ menu moves | |
| 6 | * them too, for keyboards and phones. | |
| 7 | */ | |
| 8 | import type { DocsSidebarSpace } from "@g1t/contracts"; | |
| 9 | import { BookOpen, ChevronRight, Clock, FileText, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, Users } from "lucide-react"; | |
| 10 | import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 11 | import { NavLink, useLocation, useNavigate, useParams } from "react-router"; | |
| 12 | ||
| 13 | import { buildTree, canDo, pageIdOf, pagePath, pathTo, type TreeItem } from "../../lib/docs"; | |
| 14 | import { Hint } from "../ui/hint"; | |
| 15 | import { Skeleton } from "../ui/skeleton"; | |
| 16 | import { docsRequest, useDocsAction, useDocsData } from "./actions"; | |
| 17 | ||
| 18 | const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors"; | |
| 19 | ||
| 20 | function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) { | |
| 21 | return ( | |
| 22 | <NavLink | |
| 23 | to={to} | |
| 24 | end={end} | |
| 25 | prefetch="intent" | |
| 26 | className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} | |
| 27 | > | |
| 28 | <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span> | |
| 29 | <span className="min-w-0 grow truncate">{children}</span> | |
| 30 | {trailing} | |
| 31 | </NavLink> | |
| 32 | ); | |
| 33 | } | |
| 34 | ||
| 35 | function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) { | |
| 36 | const [open, setOpen] = useState(initially); | |
| 37 | return ( | |
| 38 | <section className="mt-4"> | |
| 39 | <div className="mb-0.5 flex items-center gap-1 pr-1"> | |
| 40 | <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"> | |
| 41 | <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} /> | |
| 42 | {title} | |
| 43 | </button> | |
| 44 | {action} | |
| 45 | </div> | |
| 46 | {open && children} | |
| 47 | </section> | |
| 48 | ); | |
| 49 | } | |
| 50 | ||
| 51 | /** A page's icon: its emoji, or the plain page. */ | |
| 52 | export function PageIcon({ icon, size = 15 }: { icon: string | null | undefined; size?: number }) { | |
| 53 | if (icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{icon}</span>; | |
| 54 | return <FileText size={size} className="text-faint" aria-hidden="true" />; | |
| 55 | } | |
| 56 | ||
| 57 | /** A space's icon: its emoji, or the book. */ | |
| 58 | export function SpaceIcon({ space, size = 15 }: { space: Pick<DocsSidebarSpace, "icon" | "kind">; size?: number }) { | |
| 59 | if (space.icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{space.icon}</span>; | |
| 60 | if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />; | |
| 61 | if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />; | |
| 62 | return <BookOpen size={size} className="text-faint" aria-hidden="true" />; | |
| 63 | } | |
| 64 | ||
| 65 | type Drag = { id: string; space: string } | null; | |
| 66 | ||
| 67 | function TreeRow({ | |
| 68 | slug, | |
| 69 | space, | |
| 70 | item, | |
| 71 | current, | |
| 72 | open, | |
| 73 | toggle, | |
| 74 | drag, | |
| 75 | setDrag, | |
| 76 | onDrop, | |
| 77 | onAdd, | |
| 78 | }: { | |
| 79 | slug: string; | |
| 80 | space: DocsSidebarSpace; | |
| 81 | item: TreeItem; | |
| 82 | current: string | null; | |
| 83 | open: Set<string>; | |
| 84 | toggle: (id: string) => void; | |
| 85 | drag: Drag; | |
| 86 | setDrag: (d: Drag) => void; | |
| 87 | onDrop: (target: TreeItem, where: "before" | "inside") => void; | |
| 88 | onAdd: (parent: string) => void; | |
| 89 | }) { | |
| 90 | const [over, setOver] = useState<"before" | "inside" | null>(null); | |
| 91 | const expanded = open.has(item.id); | |
| 92 | const active = current === item.id; | |
| 93 | const editable = canDo(space.viewer_role, "edit"); | |
| 94 | const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => { | |
| 95 | const box = e.currentTarget.getBoundingClientRect(); | |
| 96 | return e.clientY - box.top < box.height * 0.35 ? "before" : "inside"; | |
| 97 | }; | |
| 98 | return ( | |
| 99 | <li> | |
| 100 | <div | |
| 101 | draggable={editable} | |
| 102 | onDragStart={(e) => { | |
| 103 | e.dataTransfer.effectAllowed = "move"; | |
| 104 | e.dataTransfer.setData("text/plain", item.id); | |
| 105 | setDrag({ id: item.id, space: space.id }); | |
| 106 | }} | |
| 107 | onDragEnd={() => setDrag(null)} | |
| 108 | onDragOver={(e) => { | |
| 109 | if (!drag || drag.id === item.id || !editable) return; | |
| 110 | e.preventDefault(); | |
| 111 | setOver(zone(e)); | |
| 112 | }} | |
| 113 | onDragLeave={() => setOver(null)} | |
| 114 | onDrop={(e) => { | |
| 115 | e.preventDefault(); | |
| 116 | const where = zone(e); | |
| 117 | setOver(null); | |
| 118 | if (drag && drag.id !== item.id) onDrop(item, where); | |
| 119 | }} | |
| 120 | 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" : ""}`} | |
| 121 | style={{ paddingLeft: 4 + item.depth * 14 }} | |
| 122 | > | |
| 123 | {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />} | |
| 124 | <button | |
| 125 | type="button" | |
| 126 | onClick={() => toggle(item.id)} | |
| 127 | aria-label={expanded ? "Collapse" : "Expand"} | |
| 128 | aria-expanded={expanded} | |
| 129 | className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`} | |
| 130 | > | |
| 131 | <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} /> | |
| 132 | </button> | |
| 133 | <NavLink to={pagePath(slug, space.slug, item.title, item.id)} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5"> | |
| 134 | <span className="flex w-4 shrink-0 justify-center"> | |
| 135 | <PageIcon icon={item.icon} size={14} /> | |
| 136 | </span> | |
| 137 | <span className="min-w-0 truncate">{item.title || "Untitled"}</span> | |
| 138 | </NavLink> | |
| 139 | {editable && ( | |
| 140 | <Hint label="Add a page inside"> | |
| 141 | <button | |
| 142 | type="button" | |
| 143 | onClick={() => onAdd(item.id)} | |
| 144 | aria-label={`Add a page inside ${item.title || "Untitled"}`} | |
| 145 | className="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" | |
| 146 | > | |
| 147 | <Plus size={13} /> | |
| 148 | </button> | |
| 149 | </Hint> | |
| 150 | )} | |
| 151 | </div> | |
| 152 | {expanded && item.children.length > 0 && ( | |
| 153 | <ul> | |
| 154 | {item.children.map((child) => ( | |
| 155 | <TreeRow key={child.id} slug={slug} space={space} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} /> | |
| 156 | ))} | |
| 157 | </ul> | |
| 158 | )} | |
| 159 | </li> | |
| 160 | ); | |
| 161 | } | |
| 162 | ||
| 163 | function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: DocsSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) { | |
| 164 | const navigate = useNavigate(); | |
| 165 | const { send } = useDocsAction(slug); | |
| 166 | const tree = useMemo(() => buildTree(space.pages), [space.pages]); | |
| 167 | const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(space.pages, current))); | |
| 168 | const [collapsed, setCollapsed] = useState(false); | |
| 169 | // Opening a page opens the way to it. | |
| 170 | useEffect(() => { | |
| 171 | if (!current) return; | |
| 172 | const path = pathTo(space.pages, current); | |
| 173 | if (path.length) setOpen((was) => new Set([...was, ...path])); | |
| 174 | }, [current, space.pages]); | |
| 175 | const toggle = (id: string) => | |
| 176 | setOpen((was) => { | |
| 177 | const next = new Set(was); | |
| 178 | if (next.has(id)) next.delete(id); | |
| 179 | else next.add(id); | |
| 180 | return next; | |
| 181 | }); | |
| 182 | const add = async (parent: string | null) => { | |
| 183 | const made = await send<{ path: string }>("create_page", { page: { space_id: space.id, parent_id: parent } }); | |
| 184 | if (made.ok) { | |
| 185 | if (parent) setOpen((was) => new Set([...was, parent])); | |
| 186 | navigate(made.value.path); | |
| 187 | } | |
| 188 | }; | |
| 189 | const drop = async (target: TreeItem, where: "before" | "inside") => { | |
| 190 | if (!drag) return; | |
| 191 | const move = where === "inside" ? { space_id: space.id, parent_id: target.id, before_id: null } : { space_id: space.id, parent_id: target.parent_id, before_id: target.id }; | |
| 192 | setDrag(null); | |
| 193 | await send("move_page", { page_id: drag.id, move }); | |
| 194 | if (where === "inside") setOpen((was) => new Set([...was, target.id])); | |
| 195 | }; | |
| 196 | const editable = canDo(space.viewer_role, "edit"); | |
| 197 | return ( | |
| 198 | <li className="mt-1"> | |
| 199 | <div | |
| 200 | className={`${ROW} text-fg-soft`} | |
| 201 | onDragOver={(e) => { | |
| 202 | if (drag && editable) e.preventDefault(); | |
| 203 | }} | |
| 204 | onDrop={async (e) => { | |
| 205 | // Dropped on the space itself: to the top of it, at the end. | |
| 206 | e.preventDefault(); | |
| 207 | if (!drag) return; | |
| 208 | const id = drag.id; | |
| 209 | setDrag(null); | |
| 210 | await send("move_page", { page_id: id, move: { space_id: space.id, parent_id: null, before_id: null } }); | |
| 211 | }} | |
| 212 | > | |
| 213 | <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.name}` : `Hide ${space.name}`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg"> | |
| 214 | <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} /> | |
| 215 | </button> | |
| 216 | <NavLink to={`/${slug}/-/docs/${space.slug}`} end prefetch="intent" className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`}> | |
| 217 | <span className="flex w-4 shrink-0 justify-center"> | |
| 218 | <SpaceIcon space={space} size={14} /> | |
| 219 | </span> | |
| 220 | <span className="min-w-0 truncate">{space.name}</span> | |
| 221 | </NavLink> | |
| 222 | {canDo(space.viewer_role, "manage") && ( | |
| 223 | <Hint label="Space settings"> | |
| 224 | <NavLink to={`/${slug}/-/docs/${space.slug}/settings`} aria-label={`${space.name} settings`} className="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"> | |
| 225 | <Settings size={13} /> | |
| 226 | </NavLink> | |
| 227 | </Hint> | |
| 228 | )} | |
| 229 | {editable && ( | |
| 230 | <Hint label={`New page in ${space.name}`}> | |
| 231 | <button type="button" onClick={() => add(null)} aria-label={`New page in ${space.name}`} className="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"> | |
| 232 | <Plus size={13} /> | |
| 233 | </button> | |
| 234 | </Hint> | |
| 235 | )} | |
| 236 | </div> | |
| 237 | {!collapsed && ( | |
| 238 | <ul> | |
| 239 | {tree.map((item) => ( | |
| 240 | <TreeRow key={item.id} slug={slug} space={space} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} /> | |
| 241 | ))} | |
| 242 | {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">No pages yet.</li>} | |
| 243 | </ul> | |
| 244 | )} | |
| 245 | </li> | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | export function DocsSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) { | |
| 250 | const data = useDocsData(); | |
| 251 | const navigate = useNavigate(); | |
| 252 | const params = useParams(); | |
| 253 | const { pathname } = useLocation(); | |
| 254 | const current = pageIdOf(params.page); | |
| 255 | const [drag, setDrag] = useState<Drag>(null); | |
| 256 | const [query, setQuery] = useState(""); | |
| 257 | const sidebar = data?.sidebar; | |
| 258 | const general = sidebar?.spaces.find((s) => s.is_default) ?? sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit")); | |
| 259 | const newPage = async () => { | |
| 260 | if (!general) return; | |
| 261 | const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id } }); | |
| 262 | if (made.ok) navigate(made.value.path); | |
| 263 | }; | |
| 264 | return ( | |
| 265 | <div className="flex h-full flex-col"> | |
| 266 | <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4"> | |
| 267 | <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Docs</h2> | |
| 268 | {general && ( | |
| 269 | <Hint label="New page"> | |
| 270 | <button type="button" onClick={newPage} aria-label="New page" className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"> | |
| 271 | <Plus size={16} /> | |
| 272 | </button> | |
| 273 | </Hint> | |
| 274 | )} | |
| 275 | {onClose && ( | |
| 276 | <button type="button" aria-label="Close menu" onClick={onClose} className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"> | |
| 277 | <ChevronRight size={16} className="rotate-180" /> | |
| 278 | </button> | |
| 279 | )} | |
| 280 | </div> | |
| 281 | <nav aria-label="Docs" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]"> | |
| 282 | <form | |
| 283 | role="search" | |
| 284 | onSubmit={(e) => { | |
| 285 | e.preventDefault(); | |
| 286 | navigate(`/${slug}/-/docs/search?q=${encodeURIComponent(query.trim())}`); | |
| 287 | }} | |
| 288 | className="mb-2" | |
| 289 | > | |
| 290 | <label className="flex h-8 items-center gap-2 rounded-md border border-line bg-bg px-2 text-[0.8125rem] text-faint focus-within:border-accent/60"> | |
| 291 | <Search size={14} aria-hidden="true" /> | |
| 292 | <span className="sr-only">Search docs</span> | |
| 293 | <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search docs" className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" /> | |
| 294 | </label> | |
| 295 | </form> | |
| 296 | <div className="space-y-px"> | |
| 297 | <SideLink to={`/${slug}/-/docs`} end icon={<Home size={15} />}> | |
| 298 | Home | |
| 299 | </SideLink> | |
| 300 | <SideLink to={`/${slug}/-/docs/templates`} icon={<LayoutTemplate size={15} />}> | |
| 301 | Templates | |
| 302 | </SideLink> | |
| 303 | <SideLink to={`/${slug}/-/docs/trash`} icon={<Trash2 size={15} />} trailing={sidebar?.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined}> | |
| 304 | Trash | |
| 305 | </SideLink> | |
| 306 | </div> | |
| 307 | {sidebar === undefined || sidebar === null ? ( | |
| 308 | sidebar === null ? ( | |
| 309 | <p className="mt-4 px-2 text-xs leading-relaxed text-faint">Docs didn't answer. Your spaces will show here once it does.</p> | |
| 310 | ) : ( | |
| 311 | <div className="mt-5 space-y-3 px-2" aria-busy="true"> | |
| 312 | {[0, 1, 2, 3].map((i) => ( | |
| 313 | <Skeleton key={i} className="h-3 w-36" /> | |
| 314 | ))} | |
| 315 | </div> | |
| 316 | ) | |
| 317 | ) : ( | |
| 318 | <> | |
| 319 | {sidebar.favorites.length > 0 && ( | |
| 320 | <Section title="Favorites"> | |
| 321 | <ul className="space-y-px"> | |
| 322 | {sidebar.favorites.map((p) => ( | |
| 323 | <li key={p.id}> | |
| 324 | <SideLink to={p.path} icon={<PageIcon icon={p.icon} size={14} />} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />}> | |
| 325 | {p.title || "Untitled"} | |
| 326 | </SideLink> | |
| 327 | </li> | |
| 328 | ))} | |
| 329 | </ul> | |
| 330 | </Section> | |
| 331 | )} | |
| 332 | {sidebar.recent.length > 0 && ( | |
| 333 | <Section title="Recent" open={false}> | |
| 334 | <ul className="space-y-px"> | |
| 335 | {sidebar.recent.slice(0, 6).map((p) => ( | |
| 336 | <li key={p.id}> | |
| 337 | <SideLink to={p.path} icon={<Clock size={14} />}> | |
| 338 | {p.title || "Untitled"} | |
| 339 | </SideLink> | |
| 340 | </li> | |
| 341 | ))} | |
| 342 | </ul> | |
| 343 | </Section> | |
| 344 | )} | |
| 345 | <Section | |
| 346 | title="Spaces" | |
| 347 | action={ | |
| 348 | sidebar.can_create_space ? ( | |
| 349 | <Hint label="New space"> | |
| 350 | <NavLink to={`/${slug}/-/docs/new`} aria-label="New space" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg"> | |
| 351 | <Plus size={13} /> | |
| 352 | </NavLink> | |
| 353 | </Hint> | |
| 354 | ) : undefined | |
| 355 | } | |
| 356 | > | |
| 357 | <ul> | |
| 358 | {sidebar.spaces.map((space) => ( | |
| 359 | <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} /> | |
| 360 | ))} | |
| 361 | </ul> | |
| 362 | </Section> | |
| 363 | {pathname === `/${slug}/-/docs` && sidebar.spaces.length === 0 && <p className="mt-3 px-2 text-xs text-faint">No spaces you can read yet.</p>} | |
| 364 | </> | |
| 365 | )} | |
| 366 | </nav> | |
| 367 | </div> | |
| 368 | ); | |
| 369 | } | |
| 370 |