Menu
popagent
publicLatest change fadf21d1cd584745f6f92eaa60509e0bef19d242 - fix task orchestration and compact overview cards by AkurAI Build
import { useCallback, useEffect, useMemo, useState } from "react";
import { BookOpen, ChevronDown, ChevronRight, FilePlus2, FileText, Folder, FolderPlus, Loader2, Menu, Pencil, RefreshCw, Save, Search, Trash2 } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type {
AgentWorkspace,
DocumentationIndexStatus,
DocumentationFolder,
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 [folders, setFolders] = useState<DocumentationFolder[]>([]);
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;
setFolders(body.folders);
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 createFolder = async () => {
const requested = prompt("Project folder", "Architecture")?.trim();
if (!requested) return;
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs/folders`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: requested }),
});
if (!response.ok) return toast("Unable to create folder");
await loadPages();
};
const removeFolder = async (folderPath: string) => {
if (!confirm(`Delete “${folderPath}” and every file inside it?`)) return;
const response = await apiFetch(`/api/workspaces/${encodeURIComponent(workspace.id)}/docs/folders`, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: folderPath }),
});
if (!response.ok) return toast("Unable to delete folder");
if (page?.path === folderPath || page?.path.startsWith(`${folderPath}/`)) onOpen();
await loadPages();
};
const createPage = async () => {
const requested = prompt("Markdown path", folders[0] ? `${folders[0].path}/new-page.md` : "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 tree = useMemo(() => {
type Node = { path: string; name: string; folders: Node[]; pages: DocumentationPageSummary[] };
const root: Node = { path: "", name: workspace.name, folders: [], pages: [] };
const nodes = new Map([["", root]]);
for (const folder of folders) {
const parentPath = folder.path.includes("/") ? folder.path.slice(0, folder.path.lastIndexOf("/")) : "";
const node = { path: folder.path, name: folder.name, folders: [], pages: [] };
nodes.set(folder.path, node);
nodes.get(parentPath)?.folders.push(node);
}
for (const item of pages) {
const parentPath = item.path.includes("/") ? item.path.slice(0, item.path.lastIndexOf("/")) : "";
nodes.get(parentPath)?.pages.push(item);
}
return root;
}, [folders, pages, workspace.name]);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const renderFolder = (folder: typeof tree, depth = 0): React.ReactNode => {
const closed = collapsed.has(folder.path);
return <div key={folder.path || "root"} className={folder.path ? "flex flex-wrap items-center" : undefined}>
{folder.path ? <button type="button" onClick={() => setCollapsed((current) => {
const next = new Set(current);
if (next.has(folder.path)) next.delete(folder.path); else next.add(folder.path);
return next;
})} className="flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded px-2 text-left text-xs font-medium text-ink-muted hover:bg-panel-2" style={{ paddingLeft: `${8 + depth * 14}px` }}>
{closed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}<Folder className="size-3.5 text-accent" /><span className="truncate">{folder.name}</span>
</button> : null}{folder.path ? <button type="button" aria-label={`Delete folder ${folder.name}`} onClick={() => void removeFolder(folder.path)} className="flex size-10 shrink-0 items-center justify-center rounded text-ink-dim hover:bg-panel-2 hover:text-red-300"><Trash2 className="size-3.5" /></button> : null}
{!closed ? <div className="w-full">
{folder.folders.map((child) => renderFolder(child, depth + 1))}
{folder.pages.map((item) => <button key={item.path} type="button" onClick={() => onOpen(item.path)} className={`flex min-h-10 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"}`} style={{ paddingLeft: `${22 + depth * 14}px` }}><FileText className="size-3.5 shrink-0" /><span className="truncate">{item.title}</span></button>)}
</div> : null}
</div>;
};
return (
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<header className="page-gutter liquid-header flex items-center gap-2">
<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>
<div className="mt-2 grid grid-cols-2 gap-2"><button type="button" onClick={() => void createFolder()} className="flex min-h-11 items-center gap-2 rounded border border-line px-3 text-sm"><FolderPlus className="size-4" />Folder</button><button type="button" onClick={() => void createPage()} className="flex min-h-11 items-center gap-2 rounded bg-accent-soft px-3 text-sm"><FilePlus2 className="size-4" />File</button></div>
{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 folders and files">{renderFolder(tree)}</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="page-content">
<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="page-content flex h-full items-center justify-center 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 file or create a project folder.</p></div></div>}
</section>
</div>
</main>
);
}