AkurAI Build
Menu

popagent

public

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