Menu
popagent
publicLatest change ac1b32888da9db0a129d0e91ff5cb4dac22c12f0 - Add workspace documentation RAG by AkurAI Build
import { useCallback, useEffect, useMemo, useState } from "react";
import { BookOpen, FilePlus2, FileText, Loader2, Menu, Pencil, RefreshCw, Save, Search, Trash2 } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type {
AgentWorkspace,
DocumentationIndexStatus,
DocumentationListResponse,
DocumentationPage,
DocumentationPageSummary,
DocumentationSearchResponse,
DocumentationSearchResult,
} from "../api-types";
import { apiFetch } from "./api";
import { toast } from "./toast";
export function DocumentationWorkspace({
workspace,
path,
onOpen,
onOpenNavigation,
}: {
workspace: AgentWorkspace;
path?: string;
onOpen: (path?: string, workspaceId?: string) => void;
onOpenNavigation: () => void;
}) {
const [pages, setPages] = useState<DocumentationPageSummary[]>([]);
const [page, setPage] = useState<DocumentationPage>();
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState("");
const [allWorkspaces, setAllWorkspaces] = useState(false);
const [results, setResults] = useState<DocumentationSearchResult[]>([]);
const [status, setStatus] = useState<DocumentationIndexStatus>();
const loadPages = useCallback(async () => {
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs`);
if (!response.ok) throw new Error(`Unable to load documentation: HTTP ${response.status}`);
const body = await response.json() as DocumentationListResponse;
setPages(body.pages);
return body.pages;
}, [workspace.id]);
useEffect(() => {
setLoading(true);
void Promise.all([
loadPages(),
apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs/index-status`)
.then((response) => response.ok ? response.json() as Promise<DocumentationIndexStatus> : undefined)
.then(setStatus),
]).catch((error) => toast(error instanceof Error ? error.message : "Unable to load documentation"))
.finally(() => setLoading(false));
}, [loadPages, workspace.id]);
useEffect(() => {
if (!path) {
setPage(undefined);
setContent("");
return;
}
setLoading(true);
void apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs/content?path=${encodeURIComponent(path)}`)
.then(async (response) => {
if (!response.ok) throw new Error(`Unable to load page: HTTP ${response.status}`);
const loaded = await response.json() as DocumentationPage;
setPage(loaded);
setContent(loaded.content);
})
.catch((error) => toast(error instanceof Error ? error.message : "Unable to load page"))
.finally(() => setLoading(false));
}, [path, workspace.id]);
const dirty = Boolean(page && content !== page.content);
useEffect(() => {
if (!dirty) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
const createPage = async () => {
const requested = prompt("Markdown path", "docs/new-page.md")?.trim();
if (!requested) return;
const nextPath = requested.endsWith(".md") ? requested : `${requested}.md`;
setSaving(true);
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: nextPath, content: `# ${nextPath.split("/").at(-1)!.replace(/\.md$/, "").replaceAll(/[-_]+/g, " ")}\n` }),
});
setSaving(false);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { error?: string };
toast(body.error ?? "Unable to create page");
return;
}
await loadPages();
onOpen(nextPath);
};
const save = async () => {
if (!page) return;
setSaving(true);
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: page.path, content, revision: page.revision }),
});
setSaving(false);
if (response.status === 409) {
const body = await response.json() as { page: DocumentationPage };
setPage(body.page);
setContent(body.page.content);
toast("This page changed elsewhere; the latest version was loaded");
return;
}
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { error?: string };
toast(body.error ?? "Unable to save page");
return;
}
const saved = await response.json() as DocumentationPage;
setPage(saved);
setContent(saved.content);
await loadPages();
toast(response.headers.get("x-popagent-indexed") === "false"
? "Documentation saved; indexing is currently unavailable"
: "Documentation saved and indexed");
};
const move = async () => {
if (!page || dirty) return;
const requested = prompt("New Markdown path", page.path)?.trim();
if (!requested || requested === page.path) return;
const nextPath = requested.endsWith(".md") ? requested : `${requested}.md`;
setSaving(true);
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: page.path, nextPath, revision: page.revision }),
});
setSaving(false);
if (response.status === 409) {
const body = await response.json() as { page: DocumentationPage };
setPage(body.page);
setContent(body.page.content);
return toast("This page changed elsewhere; the latest version was loaded");
}
if (!response.ok) {
const body = await response.json().catch(() => ({})) as { error?: string };
return toast(body.error ?? "Unable to rename page");
}
await loadPages();
onOpen(nextPath);
};
const remove = async () => {
if (!page || !confirm(`Delete “${page.title}”?`)) return;
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs`, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: page.path, revision: page.revision }),
});
if (!response.ok) return toast("Unable to delete page");
await loadPages();
onOpen();
};
const reindex = async () => {
setStatus((current) => current ? { ...current, running: true } : current);
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs/reindex`, { method: "POST" });
if (response.ok) setStatus(await response.json() as DocumentationIndexStatus);
else toast("Unable to index documentation");
};
const search = async (event: React.FormEvent) => {
event.preventDefault();
if (!query.trim()) return setResults([]);
setSearching(true);
const scope = allWorkspaces ? "" : `&workspaceId=${encodeURIComponent(workspace.id)}`;
const response = await apiFetch(`/api/docs/search?q=${encodeURIComponent(query)}${scope}`);
if (response.ok) setResults((await response.json() as DocumentationSearchResponse).results);
else toast("Documentation search failed");
setSearching(false);
};
const grouped = useMemo(() => pages.map((item) => ({ ...item, directory: item.path.includes("/") ? item.path.slice(0, item.path.lastIndexOf("/")) : "Project" })), [pages]);
return (
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<header className="liquid-header flex items-center gap-2 px-2 md:px-3">
<button type="button" aria-label="Open workspace navigation" onClick={onOpenNavigation} className="liquid-control flex size-11 items-center justify-center rounded lg:hidden"><Menu className="size-4" /></button>
<BookOpen className="size-4 text-accent" /><span className="min-w-0 flex-1 truncate text-sm font-medium">{workspace.name} · Documentation</span>
<span className="hidden text-xs text-ink-dim sm:block">{status ? `${status.files} pages · ${status.chunks} chunks` : "Not indexed"}</span>
<button type="button" disabled={status?.running} onClick={() => void reindex()} className="liquid-control flex min-h-11 items-center gap-2 rounded px-3 text-xs disabled:opacity-50"><RefreshCw className={`size-3.5 ${status?.running ? "animate-spin" : ""}`} />Reindex</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-b border-line bg-panel/50 p-3 md:border-b-0 md:border-r">
<form onSubmit={(event) => void search(event)} className="flex gap-2">
<label className="liquid-control flex min-h-11 min-w-0 flex-1 items-center gap-2 rounded px-3"><Search className="size-3.5 text-ink-dim" /><input aria-label="Search documentation" value={query} onChange={(event) => setQuery(event.currentTarget.value)} className="min-w-0 flex-1 bg-transparent text-sm outline-none" /></label>
<button type="submit" aria-label="Run documentation search" className="liquid-control flex size-11 items-center justify-center rounded">{searching ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}</button>
</form>
<label className="mt-2 flex min-h-8 items-center gap-2 px-1 text-xs text-ink-dim"><input type="checkbox" checked={allWorkspaces} onChange={(event) => setAllWorkspaces(event.currentTarget.checked)} />Search all workspaces</label>
<button type="button" onClick={() => void createPage()} className="mt-2 flex min-h-11 w-full items-center gap-2 rounded bg-accent-soft px-3 text-sm"><FilePlus2 className="size-4" />New page</button>
{results.length ? <div className="mt-4 border-b border-line pb-4"><div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-ink-dim">Search results</div>{results.map((result, index) => <button key={`${result.path}-${index}`} type="button" onClick={() => { onOpen(result.path, result.workspaceId); setResults([]); }} className="mb-1 w-full rounded px-2 py-2 text-left hover:bg-panel-2"><div className="truncate text-xs font-medium">{result.heading || result.title}</div><div className="truncate text-[10px] text-ink-dim">{result.path} · {Math.round(result.score * 100)}%</div></button>)}</div> : null}
<nav className="mt-4" aria-label="Documentation pages">{grouped.map((item) => <button key={item.path} type="button" onClick={() => onOpen(item.path)} className={`mb-1 flex min-h-11 w-full items-center gap-2 rounded px-2 text-left text-sm ${page?.path === item.path ? "active-session text-ink" : "text-ink-muted hover:bg-panel-2"}`}><FileText className="size-3.5 shrink-0" /><span className="min-w-0 flex-1"><span className="block truncate">{item.title}</span><span className="block truncate text-[10px] text-ink-dim">{item.directory}</span></span></button>)}</nav>
</aside>
<section className="min-h-0 overflow-y-auto">
{loading ? <div className="flex h-full items-center justify-center"><Loader2 className="size-5 animate-spin text-accent" /></div> : page ? <div className="mx-auto max-w-6xl p-4 md:p-8">
<div className="mb-4 flex flex-wrap items-center gap-2"><code className="min-w-0 flex-1 truncate text-xs text-ink-dim">{page.path}</code><button type="button" disabled={dirty || saving} onClick={() => void move()} className="liquid-control flex min-h-11 items-center gap-2 rounded px-3 text-xs disabled:opacity-40"><Pencil className="size-3.5" />Rename</button><button type="button" onClick={() => void remove()} className="liquid-control flex min-h-11 items-center gap-2 rounded px-3 text-xs text-red-300"><Trash2 className="size-3.5" />Delete</button><button type="button" disabled={!dirty || saving} onClick={() => void save()} className="brand-orb flex min-h-11 items-center gap-2 rounded px-4 text-xs font-medium disabled:opacity-40">{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}Save</button></div>
<div className="grid min-h-[60vh] gap-4 lg:grid-cols-2"><textarea aria-label="Markdown editor" value={content} onChange={(event) => setContent(event.currentTarget.value)} spellCheck className="min-h-[60vh] resize-y rounded border border-line bg-bg p-4 font-mono text-sm leading-6 text-ink outline-none focus:border-accent/40" /><article className="md-content min-h-[60vh] overflow-x-auto rounded border border-line bg-panel p-5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown></article></div>
</div> : <div className="flex h-full items-center justify-center p-8 text-center"><div><BookOpen className="mx-auto size-10 text-accent" /><h1 className="mt-3 text-xl font-semibold">Project documentation</h1><p className="mt-2 text-sm text-ink-muted">Select a Markdown page or create one under docs/.</p></div></div>}
</section>
</div>
</main>
);
}