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"): | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 3 | * search, Home, Possibly stale, Templates and Trash; Favorites and |
| 4 | * Recent; each space with its page tree, which opens to the page being | |
| 5 | * read; then projects' docs folders, read-only. Pages are | |
| Docs: a workspace knowledge base people and agents write together | 6 | * dragged to reorder them or to put one inside another; the ⋯ menu moves |
| 7 | * them too, for keyboards and phones. | |
| 8 | */ | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 9 | import type { DocRepoSpace, DocsSidebarSpace } from "@g1t/contracts"; |
| 10 | import { AlertTriangle, BookOpen, ChevronRight, Clock, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, Users } from "lucide-react"; | |
| Docs: a workspace knowledge base people and agents write together | 11 | import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react"; |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 12 | import { NavLink, useLocation, useNavigate, useParams, useRevalidator } from "react-router"; |
| Docs: a workspace knowledge base people and agents write together | 13 | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 14 | import { buildTree, canDo, pageIdOf, pagePath, pathTo, repoFilePath, repoFolders, type RepoFolder, type TreeItem } from "../../lib/docs"; |
| Docs: a workspace knowledge base people and agents write together | 15 | import { Hint } from "../ui/hint"; |
| 16 | import { Skeleton } from "../ui/skeleton"; | |
| 17 | import { docsRequest, useDocsAction, useDocsData } from "./actions"; | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 18 | import { RepoDocsDialog } from "./code"; |
| Docs: a workspace knowledge base people and agents write together | 19 | |
| 20 | const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors"; | |
| 21 | ||
| 22 | function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) { | |
| 23 | return ( | |
| 24 | <NavLink | |
| 25 | to={to} | |
| 26 | end={end} | |
| 27 | prefetch="intent" | |
| 28 | className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} | |
| 29 | > | |
| 30 | <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span> | |
| 31 | <span className="min-w-0 grow truncate">{children}</span> | |
| 32 | {trailing} | |
| 33 | </NavLink> | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) { | |
| 38 | const [open, setOpen] = useState(initially); | |
| 39 | return ( | |
| 40 | <section className="mt-4"> | |
| 41 | <div className="mb-0.5 flex items-center gap-1 pr-1"> | |
| 42 | <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"> | |
| 43 | <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} /> | |
| 44 | {title} | |
| 45 | </button> | |
| 46 | {action} | |
| 47 | </div> | |
| 48 | {open && children} | |
| 49 | </section> | |
| 50 | ); | |
| 51 | } | |
| 52 | ||
| 53 | /** A page's icon: its emoji, or the plain page. */ | |
| 54 | export function PageIcon({ icon, size = 15 }: { icon: string | null | undefined; size?: number }) { | |
| 55 | if (icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{icon}</span>; | |
| 56 | return <FileText size={size} className="text-faint" aria-hidden="true" />; | |
| 57 | } | |
| 58 | ||
| 59 | /** A space's icon: its emoji, or the book. */ | |
| 60 | export function SpaceIcon({ space, size = 15 }: { space: Pick<DocsSidebarSpace, "icon" | "kind">; size?: number }) { | |
| 61 | if (space.icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{space.icon}</span>; | |
| 62 | if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />; | |
| 63 | if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />; | |
| 64 | return <BookOpen size={size} className="text-faint" aria-hidden="true" />; | |
| 65 | } | |
| 66 | ||
| 67 | type Drag = { id: string; space: string } | null; | |
| 68 | ||
| 69 | function TreeRow({ | |
| 70 | slug, | |
| 71 | space, | |
| 72 | item, | |
| 73 | current, | |
| 74 | open, | |
| 75 | toggle, | |
| 76 | drag, | |
| 77 | setDrag, | |
| 78 | onDrop, | |
| 79 | onAdd, | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 80 | stale, |
| Docs: a workspace knowledge base people and agents write together | 81 | }: { |
| 82 | slug: string; | |
| 83 | space: DocsSidebarSpace; | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 84 | stale: Set<string>; |
| Docs: a workspace knowledge base people and agents write together | 85 | item: TreeItem; |
| 86 | current: string | null; | |
| 87 | open: Set<string>; | |
| 88 | toggle: (id: string) => void; | |
| 89 | drag: Drag; | |
| 90 | setDrag: (d: Drag) => void; | |
| 91 | onDrop: (target: TreeItem, where: "before" | "inside") => void; | |
| 92 | onAdd: (parent: string) => void; | |
| 93 | }) { | |
| 94 | const [over, setOver] = useState<"before" | "inside" | null>(null); | |
| 95 | const expanded = open.has(item.id); | |
| 96 | const active = current === item.id; | |
| 97 | const editable = canDo(space.viewer_role, "edit"); | |
| 98 | const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => { | |
| 99 | const box = e.currentTarget.getBoundingClientRect(); | |
| 100 | return e.clientY - box.top < box.height * 0.35 ? "before" : "inside"; | |
| 101 | }; | |
| 102 | return ( | |
| 103 | <li> | |
| 104 | <div | |
| 105 | draggable={editable} | |
| 106 | onDragStart={(e) => { | |
| 107 | e.dataTransfer.effectAllowed = "move"; | |
| 108 | e.dataTransfer.setData("text/plain", item.id); | |
| 109 | setDrag({ id: item.id, space: space.id }); | |
| 110 | }} | |
| 111 | onDragEnd={() => setDrag(null)} | |
| 112 | onDragOver={(e) => { | |
| 113 | if (!drag || drag.id === item.id || !editable) return; | |
| 114 | e.preventDefault(); | |
| 115 | setOver(zone(e)); | |
| 116 | }} | |
| 117 | onDragLeave={() => setOver(null)} | |
| 118 | onDrop={(e) => { | |
| 119 | e.preventDefault(); | |
| 120 | const where = zone(e); | |
| 121 | setOver(null); | |
| 122 | if (drag && drag.id !== item.id) onDrop(item, where); | |
| 123 | }} | |
| 124 | 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" : ""}`} | |
| 125 | style={{ paddingLeft: 4 + item.depth * 14 }} | |
| 126 | > | |
| 127 | {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />} | |
| 128 | <button | |
| 129 | type="button" | |
| 130 | onClick={() => toggle(item.id)} | |
| 131 | aria-label={expanded ? "Collapse" : "Expand"} | |
| 132 | aria-expanded={expanded} | |
| 133 | className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`} | |
| 134 | > | |
| 135 | <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} /> | |
| 136 | </button> | |
| 137 | <NavLink to={pagePath(slug, space.slug, item.title, item.id)} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5"> | |
| 138 | <span className="flex w-4 shrink-0 justify-center"> | |
| 139 | <PageIcon icon={item.icon} size={14} /> | |
| 140 | </span> | |
| 141 | <span className="min-w-0 truncate">{item.title || "Untitled"}</span> | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 142 | {stale.has(item.id) && ( |
| 143 | <Hint label="Possibly out of date: code it cites changed"> | |
| 144 | <span className="ml-auto size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" /> | |
| 145 | </Hint> | |
| 146 | )} | |
| Docs: a workspace knowledge base people and agents write together | 147 | </NavLink> |
| 148 | {editable && ( | |
| 149 | <Hint label="Add a page inside"> | |
| 150 | <button | |
| 151 | type="button" | |
| 152 | onClick={() => onAdd(item.id)} | |
| 153 | aria-label={`Add a page inside ${item.title || "Untitled"}`} | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 154 | 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 pointer-coarse:size-9 pointer-coarse:opacity-100" |
| Docs: a workspace knowledge base people and agents write together | 155 | > |
| 156 | <Plus size={13} /> | |
| 157 | </button> | |
| 158 | </Hint> | |
| 159 | )} | |
| 160 | </div> | |
| 161 | {expanded && item.children.length > 0 && ( | |
| 162 | <ul> | |
| 163 | {item.children.map((child) => ( | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 164 | <TreeRow key={child.id} slug={slug} space={space} stale={stale} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} /> |
| Docs: a workspace knowledge base people and agents write together | 165 | ))} |
| 166 | </ul> | |
| 167 | )} | |
| 168 | </li> | |
| 169 | ); | |
| 170 | } | |
| 171 | ||
| 172 | function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: DocsSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) { | |
| 173 | const navigate = useNavigate(); | |
| 174 | const { send } = useDocsAction(slug); | |
| 175 | const tree = useMemo(() => buildTree(space.pages), [space.pages]); | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 176 | const stale = useMemo(() => new Set(space.pages.filter((p) => p.stale).map((p) => p.id)), [space.pages]); |
| Docs: a workspace knowledge base people and agents write together | 177 | const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(space.pages, current))); |
| 178 | const [collapsed, setCollapsed] = useState(false); | |
| 179 | // Opening a page opens the way to it. | |
| 180 | useEffect(() => { | |
| 181 | if (!current) return; | |
| 182 | const path = pathTo(space.pages, current); | |
| 183 | if (path.length) setOpen((was) => new Set([...was, ...path])); | |
| 184 | }, [current, space.pages]); | |
| 185 | const toggle = (id: string) => | |
| 186 | setOpen((was) => { | |
| 187 | const next = new Set(was); | |
| 188 | if (next.has(id)) next.delete(id); | |
| 189 | else next.add(id); | |
| 190 | return next; | |
| 191 | }); | |
| 192 | const add = async (parent: string | null) => { | |
| 193 | const made = await send<{ path: string }>("create_page", { page: { space_id: space.id, parent_id: parent } }); | |
| 194 | if (made.ok) { | |
| 195 | if (parent) setOpen((was) => new Set([...was, parent])); | |
| 196 | navigate(made.value.path); | |
| 197 | } | |
| 198 | }; | |
| 199 | const drop = async (target: TreeItem, where: "before" | "inside") => { | |
| 200 | if (!drag) return; | |
| 201 | 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 }; | |
| 202 | setDrag(null); | |
| 203 | await send("move_page", { page_id: drag.id, move }); | |
| 204 | if (where === "inside") setOpen((was) => new Set([...was, target.id])); | |
| 205 | }; | |
| 206 | const editable = canDo(space.viewer_role, "edit"); | |
| 207 | return ( | |
| 208 | <li className="mt-1"> | |
| 209 | <div | |
| 210 | className={`${ROW} text-fg-soft`} | |
| 211 | onDragOver={(e) => { | |
| 212 | if (drag && editable) e.preventDefault(); | |
| 213 | }} | |
| 214 | onDrop={async (e) => { | |
| 215 | // Dropped on the space itself: to the top of it, at the end. | |
| 216 | e.preventDefault(); | |
| 217 | if (!drag) return; | |
| 218 | const id = drag.id; | |
| 219 | setDrag(null); | |
| 220 | await send("move_page", { page_id: id, move: { space_id: space.id, parent_id: null, before_id: null } }); | |
| 221 | }} | |
| 222 | > | |
| 223 | <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"> | |
| 224 | <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} /> | |
| 225 | </button> | |
| 226 | <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" : ""}`}> | |
| 227 | <span className="flex w-4 shrink-0 justify-center"> | |
| 228 | <SpaceIcon space={space} size={14} /> | |
| 229 | </span> | |
| 230 | <span className="min-w-0 truncate">{space.name}</span> | |
| 231 | </NavLink> | |
| 232 | {canDo(space.viewer_role, "manage") && ( | |
| 233 | <Hint label="Space settings"> | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 234 | <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 pointer-coarse:size-9 pointer-coarse:opacity-100"> |
| Docs: a workspace knowledge base people and agents write together | 235 | <Settings size={13} /> |
| 236 | </NavLink> | |
| 237 | </Hint> | |
| 238 | )} | |
| 239 | {editable && ( | |
| 240 | <Hint label={`New page in ${space.name}`}> | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 241 | <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 pointer-coarse:size-9 pointer-coarse:opacity-100"> |
| Docs: a workspace knowledge base people and agents write together | 242 | <Plus size={13} /> |
| 243 | </button> | |
| 244 | </Hint> | |
| 245 | )} | |
| 246 | </div> | |
| 247 | {!collapsed && ( | |
| 248 | <ul> | |
| 249 | {tree.map((item) => ( | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 250 | <TreeRow key={item.id} slug={slug} space={space} stale={stale} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} /> |
| Docs: a workspace knowledge base people and agents write together | 251 | ))} |
| 252 | {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">No pages yet.</li>} | |
| 253 | </ul> | |
| 254 | )} | |
| 255 | </li> | |
| 256 | ); | |
| 257 | } | |
| 258 | ||
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 259 | function RepoFolderRows({ slug, repo, folder, depth, current }: { slug: string; repo: string; folder: RepoFolder; depth: number; current: string }) { |
| 260 | const [open, setOpen] = useState<Set<string>>(() => new Set(folder.folders.filter((f) => current.includes(`/${f.path}/`)).map((f) => f.path))); | |
| 261 | return ( | |
| 262 | <> | |
| 263 | {folder.files.map((f) => { | |
| 264 | const to = repoFilePath(slug, repo, f.path); | |
| 265 | return ( | |
| 266 | <li key={f.path}> | |
| 267 | <NavLink to={to} 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 }}> | |
| 268 | <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" /> | |
| 269 | <span className="min-w-0 truncate">{f.title}</span> | |
| 270 | </NavLink> | |
| 271 | </li> | |
| 272 | ); | |
| 273 | })} | |
| 274 | {folder.folders.map((sub) => { | |
| 275 | const expanded = open.has(sub.path); | |
| 276 | return ( | |
| 277 | <li key={sub.path}> | |
| 278 | <button | |
| 279 | type="button" | |
| 280 | aria-expanded={expanded} | |
| 281 | onClick={() => | |
| 282 | setOpen((was) => { | |
| 283 | const next = new Set(was); | |
| 284 | if (next.has(sub.path)) next.delete(sub.path); | |
| 285 | else next.add(sub.path); | |
| 286 | return next; | |
| 287 | }) | |
| 288 | } | |
| 289 | className={`${ROW} w-full text-muted hover:bg-raised/60 hover:text-fg`} | |
| 290 | style={{ paddingLeft: 4 + (depth + 1) * 14 }} | |
| 291 | > | |
| 292 | <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} /> | |
| 293 | <Folder size={14} className="shrink-0 text-faint" aria-hidden="true" /> | |
| 294 | <span className="min-w-0 truncate">{sub.name}</span> | |
| 295 | </button> | |
| 296 | {expanded && ( | |
| 297 | <ul> | |
| 298 | <RepoFolderRows slug={slug} repo={repo} folder={sub} depth={depth + 1} current={current} /> | |
| 299 | </ul> | |
| 300 | )} | |
| 301 | </li> | |
| 302 | ); | |
| 303 | })} | |
| 304 | </> | |
| 305 | ); | |
| 306 | } | |
| 307 | ||
| 308 | /** A project's docs folder: read-only, its files as folders, from the repository's default branch. */ | |
| 309 | function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) { | |
| 310 | const { pathname } = useLocation(); | |
| 311 | const base = `/${slug}/-/docs/repo/${space.repo}/`; | |
| 312 | const here = decodeURIComponent(pathname).startsWith(base); | |
| 313 | const [collapsed, setCollapsed] = useState(!here); | |
| 314 | const root = useMemo(() => repoFolders(space.files), [space.files]); | |
| 315 | return ( | |
| 316 | <li className="mt-1"> | |
| 317 | <div className={`${ROW} text-fg-soft`}> | |
| 318 | <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.repo}'s docs` : `Hide ${space.repo}'s docs`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg"> | |
| 319 | <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} /> | |
| 320 | </button> | |
| 321 | <span className="flex w-4 shrink-0 justify-center"> | |
| 322 | <FolderGit2 size={14} className="text-faint" aria-hidden="true" /> | |
| 323 | </span> | |
| 324 | <span className="min-w-0 grow truncate font-medium">{space.repo}</span> | |
| 325 | </div> | |
| 326 | {!collapsed && ( | |
| 327 | <ul> | |
| 328 | <RepoFolderRows slug={slug} repo={space.repo} folder={root} depth={0} current={decodeURIComponent(pathname)} /> | |
| 329 | {space.files.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{space.indexed_at ? "No docs folder or README." : "Reading its docs…"}</li>} | |
| 330 | </ul> | |
| 331 | )} | |
| 332 | </li> | |
| 333 | ); | |
| 334 | } | |
| 335 | ||
| Docs: a workspace knowledge base people and agents write together | 336 | export function DocsSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) { |
| 337 | const data = useDocsData(); | |
| 338 | const navigate = useNavigate(); | |
| 339 | const params = useParams(); | |
| 340 | const { pathname } = useLocation(); | |
| 341 | const current = pageIdOf(params.page); | |
| 342 | const [drag, setDrag] = useState<Drag>(null); | |
| 343 | const [query, setQuery] = useState(""); | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 344 | const [addingRepo, setAddingRepo] = useState(false); |
| 345 | const { revalidate } = useRevalidator(); | |
| Docs: a workspace knowledge base people and agents write together | 346 | const sidebar = data?.sidebar; |
| 347 | const general = sidebar?.spaces.find((s) => s.is_default) ?? sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit")); | |
| 348 | const newPage = async () => { | |
| 349 | if (!general) return; | |
| 350 | const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id } }); | |
| 351 | if (made.ok) navigate(made.value.path); | |
| 352 | }; | |
| 353 | return ( | |
| 354 | <div className="flex h-full flex-col"> | |
| 355 | <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4"> | |
| 356 | <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Docs</h2> | |
| 357 | {general && ( | |
| 358 | <Hint label="New page"> | |
| 359 | <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"> | |
| 360 | <Plus size={16} /> | |
| 361 | </button> | |
| 362 | </Hint> | |
| 363 | )} | |
| 364 | {onClose && ( | |
| 365 | <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"> | |
| 366 | <ChevronRight size={16} className="rotate-180" /> | |
| 367 | </button> | |
| 368 | )} | |
| 369 | </div> | |
| 370 | <nav aria-label="Docs" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]"> | |
| 371 | <form | |
| 372 | role="search" | |
| 373 | onSubmit={(e) => { | |
| 374 | e.preventDefault(); | |
| 375 | navigate(`/${slug}/-/docs/search?q=${encodeURIComponent(query.trim())}`); | |
| 376 | }} | |
| 377 | className="mb-2" | |
| 378 | > | |
| 379 | <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"> | |
| 380 | <Search size={14} aria-hidden="true" /> | |
| 381 | <span className="sr-only">Search docs</span> | |
| 382 | <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" /> | |
| 383 | </label> | |
| 384 | </form> | |
| 385 | <div className="space-y-px"> | |
| 386 | <SideLink to={`/${slug}/-/docs`} end icon={<Home size={15} />}> | |
| 387 | Home | |
| 388 | </SideLink> | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 389 | {!!sidebar?.stale_count && ( |
| 390 | <SideLink to={`/${slug}/-/docs/stale`} icon={<AlertTriangle size={15} className="text-warn" />} trailing={<span className="text-[0.6875rem] text-warn tabular-nums">{sidebar.stale_count}</span>}> | |
| 391 | Possibly stale | |
| 392 | </SideLink> | |
| 393 | )} | |
| Docs: a workspace knowledge base people and agents write together | 394 | <SideLink to={`/${slug}/-/docs/templates`} icon={<LayoutTemplate size={15} />}> |
| 395 | Templates | |
| 396 | </SideLink> | |
| 397 | <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}> | |
| 398 | Trash | |
| 399 | </SideLink> | |
| 400 | </div> | |
| 401 | {sidebar === undefined || sidebar === null ? ( | |
| 402 | sidebar === null ? ( | |
| 403 | <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> | |
| 404 | ) : ( | |
| 405 | <div className="mt-5 space-y-3 px-2" aria-busy="true"> | |
| 406 | {[0, 1, 2, 3].map((i) => ( | |
| 407 | <Skeleton key={i} className="h-3 w-36" /> | |
| 408 | ))} | |
| 409 | </div> | |
| 410 | ) | |
| 411 | ) : ( | |
| 412 | <> | |
| 413 | {sidebar.favorites.length > 0 && ( | |
| 414 | <Section title="Favorites"> | |
| 415 | <ul className="space-y-px"> | |
| 416 | {sidebar.favorites.map((p) => ( | |
| 417 | <li key={p.id}> | |
| 418 | <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" />}> | |
| 419 | {p.title || "Untitled"} | |
| 420 | </SideLink> | |
| 421 | </li> | |
| 422 | ))} | |
| 423 | </ul> | |
| 424 | </Section> | |
| 425 | )} | |
| 426 | {sidebar.recent.length > 0 && ( | |
| 427 | <Section title="Recent" open={false}> | |
| 428 | <ul className="space-y-px"> | |
| 429 | {sidebar.recent.slice(0, 6).map((p) => ( | |
| 430 | <li key={p.id}> | |
| 431 | <SideLink to={p.path} icon={<Clock size={14} />}> | |
| 432 | {p.title || "Untitled"} | |
| 433 | </SideLink> | |
| 434 | </li> | |
| 435 | ))} | |
| 436 | </ul> | |
| 437 | </Section> | |
| 438 | )} | |
| 439 | <Section | |
| 440 | title="Spaces" | |
| 441 | action={ | |
| 442 | sidebar.can_create_space ? ( | |
| 443 | <Hint label="New space"> | |
| 444 | <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"> | |
| 445 | <Plus size={13} /> | |
| 446 | </NavLink> | |
| 447 | </Hint> | |
| 448 | ) : undefined | |
| 449 | } | |
| 450 | > | |
| 451 | <ul> | |
| 452 | {sidebar.spaces.map((space) => ( | |
| 453 | <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} /> | |
| 454 | ))} | |
| 455 | </ul> | |
| 456 | </Section> | |
| 457 | {pathname === `/${slug}/-/docs` && sidebar.spaces.length === 0 && <p className="mt-3 px-2 text-xs text-faint">No spaces you can read yet.</p>} | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 458 | <Section |
| 459 | title="Projects' docs" | |
| 460 | open={(sidebar.repos ?? []).length > 0} | |
| 461 | action={ | |
| 462 | <Hint label="Show a project's docs"> | |
| 463 | <button type="button" onClick={() => setAddingRepo(true)} aria-label="Show a project's docs" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg"> | |
| 464 | <Plus size={13} /> | |
| 465 | </button> | |
| 466 | </Hint> | |
| 467 | } | |
| 468 | > | |
| 469 | <ul> | |
| 470 | {(sidebar.repos ?? []).map((space) => ( | |
| 471 | <RepoTree key={space.id} slug={slug} space={space} /> | |
| 472 | ))} | |
| 473 | {(sidebar.repos ?? []).length === 0 && ( | |
| 474 | <li> | |
| 475 | <button type="button" onClick={() => setAddingRepo(true)} className="px-2 py-1 text-left text-xs text-faint hover:text-fg"> | |
| 476 | Show a repository's docs folder here, read-only. | |
| 477 | </button> | |
| 478 | </li> | |
| 479 | )} | |
| 480 | </ul> | |
| 481 | </Section> | |
| 482 | <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(sidebar.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} /> | |
| Docs: a workspace knowledge base people and agents write together | 483 | </> |
| 484 | )} | |
| 485 | </nav> | |
| 486 | </div> | |
| 487 | ); | |
| 488 | } | |
| 489 |