AkurAI Build
Menu

popagent

public

Latest 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>
  );
}