diff options
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 520 |
1 files changed, 380 insertions, 140 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 42e6a69..687d86f 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -1,10 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router"; import { Masthead } from "../components/Masthead"; import { useDirective, useDirectives } from "../hooks/useDirectives"; import { useAuth } from "../contexts/AuthContext"; import { DocumentEditor } from "../components/directives/DocumentEditor"; -import type { DirectiveSummary, DirectiveStatus } from "../lib/api"; +import { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; +import type { + DirectiveStatus, + DirectiveSummary, + DirectiveWithSteps, +} from "../lib/api"; // Status dot color, matching the existing tabular UI's badge palette so the // document mode feels like a sibling of the existing list, not a foreign UI. @@ -16,27 +21,17 @@ const STATUS_DOT: Record<DirectiveStatus, string> = { archived: "bg-[#3a4a6a]", }; -// ============================================================================= -// Sidebar grouping — group directives by lifecycle stage so the file tree -// reads like a folder per status. We collapse the noisy ones (Archived) by -// default and keep Active / Idle expanded. -// ============================================================================= - -type SidebarGroup = "active" | "idle" | "archived"; - -const GROUP_LABEL: Record<SidebarGroup, string> = { - active: "active", - idle: "idle", - archived: "archived", +// Per-task dot color for the sidebar entries inside a directive folder. +// Matches the StepsBlockNode palette. +const STEP_STATUS_DOT: Record<string, string> = { + pending: "bg-[#556677]", + ready: "bg-[#9bc3ff]", + running: "bg-yellow-400", + done: "bg-green-400", + failed: "bg-red-400", + skipped: "bg-[#3a4a6a]", }; -function bucketOf(status: DirectiveStatus): SidebarGroup { - if (status === "active" || status === "paused") return "active"; - if (status === "archived") return "archived"; - // draft + idle land in the idle bucket (i.e. "not currently running"). - return "idle"; -} - // ============================================================================= // Sidebar icons (inline SVG, no new deps) // ============================================================================= @@ -92,6 +87,24 @@ function FileIcon() { ); } +function PinIcon() { + return ( + <svg + viewBox="0 0 16 16" + width={10} + height={10} + className="shrink-0" + aria-hidden + > + <path + d="M8 1.5l1.6 3.6 3.9.4-2.95 2.7.85 3.9L8 10.2 4.6 12.1l.85-3.9L2.5 5.5l3.9-.4z" + fill="#75aafc" + opacity="0.7" + /> + </svg> + ); +} + function Caret({ open }: { open: boolean }) { return ( <svg @@ -110,42 +123,266 @@ function Caret({ open }: { open: boolean }) { // Sidebar // ============================================================================= +function slugify(title: string, fallback: string): string { + const slug = title + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-zA-Z0-9._-]/g, "") + .toLowerCase(); + return slug.length > 0 ? slug : fallback; +} + +interface SidebarSelection { + directiveId: string; + /** null = the directive's document; otherwise a task id (orchestrator/step). */ + taskId: string | null; +} + interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; - selectedId: string | null; - onSelect: (id: string) => void; + selection: SidebarSelection | null; + onSelect: (sel: SidebarSelection) => void; } -function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) { - const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => { - const out: Record<SidebarGroup, DirectiveSummary[]> = { - active: [], - idle: [], - archived: [], - }; - for (const d of directives) { - out[bucketOf(d.status)].push(d); +/** + * Per-directive folder. Renders the directive as a collapsible folder whose + * children are the pinned document entry (always first) and the live task list + * — orchestrator, completion, and any step tasks. We fetch the directive's + * full step list lazily, only when the folder is expanded, to avoid a thundering + * herd of GETs at page load. + */ +function DirectiveFolder({ + directive, + open, + onToggle, + selection, + onSelect, +}: { + directive: DirectiveSummary; + open: boolean; + onToggle: () => void; + selection: SidebarSelection | null; + onSelect: (sel: SidebarSelection) => void; +}) { + const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; + const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`; + + // Lazy fetch full directive (with steps) only when folder is open. + const { directive: detailed } = useDirective(open ? directive.id : undefined); + + const docSelected = + selection?.directiveId === directive.id && selection.taskId === null; + + // Collect the tasks to surface in the folder body. + const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]); + + return ( + <div className="select-none"> + <button + type="button" + onClick={onToggle} + title={directive.title} + className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" + > + <Caret open={open} /> + {/* Color icon LEFT — the user explicitly asked for an icon, not a /status text label. */} + <span + className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`} + aria-label={`status: ${directive.status}`} + title={`status: ${directive.status}`} + /> + <FolderIcon open={open} /> + <span className="truncate flex-1 text-left">{directive.title}</span> + {/* And RIGHT — same dot, plus a pulsing one if the orchestrator is live. */} + {!!directive.orchestratorTaskId && ( + <span + className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" + title="Orchestrator running" + aria-label="Orchestrator running" + /> + )} + <span + className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`} + aria-hidden + /> + </button> + + {open && ( + <ul className="py-0.5"> + {/* Pinned document entry — always at the top of the folder. */} + <li> + <button + type="button" + onClick={() => + onSelect({ directiveId: directive.id, taskId: null }) + } + className={`w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] transition-colors ${ + docSelected + ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent" + }`} + > + <PinIcon /> + <FileIcon /> + <span className="truncate flex-1">{fileName}</span> + </button> + </li> + + {tasks.length === 0 ? ( + <li className="pl-10 pr-3 py-1 font-mono text-[10px] text-[#556677]"> + No tasks yet + </li> + ) : ( + tasks.map((t) => { + const isSelected = + selection?.directiveId === directive.id && + selection?.taskId === t.taskId; + const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending; + const live = + t.status === "running" || t.kind === "orchestrator-active"; + return ( + <li key={t.taskId}> + <button + type="button" + onClick={() => + onSelect({ + directiveId: directive.id, + taskId: t.taskId, + }) + } + title={t.label} + className={`w-full text-left flex items-center gap-1.5 pl-10 pr-3 py-1 font-mono text-[11px] transition-colors ${ + isSelected + ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent" + }`} + > + <span + className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${tdot}`} + aria-hidden + /> + <span className="truncate flex-1">{t.label}</span> + {live && ( + <span + className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" + aria-hidden + /> + )} + </button> + </li> + ); + }) + )} + </ul> + )} + </div> + ); +} + +interface FolderTaskRow { + taskId: string; + label: string; + status: string; + kind: "orchestrator-active" | "completion" | "step"; +} + +function collectTasks( + detailed: DirectiveWithSteps | null, + summary: DirectiveSummary, +): FolderTaskRow[] { + const rows: FolderTaskRow[] = []; + + // Orchestrator (planner) — surfaces only while it's actively running so + // the folder is not flooded with stale orchestrator entries. + const orchestratorId = + detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null; + if (orchestratorId) { + rows.push({ + taskId: orchestratorId, + label: "orchestrator", + status: "running", + kind: "orchestrator-active", + }); + } + + // Completion (PR creation) task. + const completionId = + detailed?.completionTaskId ?? summary.completionTaskId ?? null; + if (completionId) { + rows.push({ + taskId: completionId, + label: "completion", + status: "running", + kind: "completion", + }); + } + + // Step tasks — only steps that have actually been started have a taskId. + if (detailed) { + for (const step of detailed.steps) { + if (!step.taskId) continue; + rows.push({ + taskId: step.taskId, + label: step.name, + status: step.status, + kind: "step", + }); } - // Sort each group alphabetically so it feels like a stable file tree. - (Object.keys(out) as SidebarGroup[]).forEach((k) => { - out[k].sort((a, b) => - a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), - ); + } + + return rows; +} + +interface SidebarProps { + directives: DirectiveSummary[]; + loading: boolean; + selection: SidebarSelection | null; + onSelect: (sel: SidebarSelection) => void; +} + +function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) { + // Sort active first, then idle, then paused, then archived. + const sorted = useMemo(() => { + const order: Record<DirectiveStatus, number> = { + active: 0, + paused: 1, + idle: 2, + draft: 3, + archived: 4, + }; + return [...directives].sort((a, b) => { + const oa = order[a.status] ?? 99; + const ob = order[b.status] ?? 99; + if (oa !== ob) return oa - ob; + return a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); }); - return out; }, [directives]); - // Default-collapsed state per folder. Archived is collapsed by default - // (it's history); the other two are open so users see their work. - const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({ - active: true, - idle: true, - archived: false, - }); - - const toggleGroup = (g: SidebarGroup) => - setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); + // Track which directive folders are open. The currently selected directive + // is forced open so deep links land on something visible. + const [openIds, setOpenIds] = useState<Set<string>>(new Set()); + const lastSelectedRef = useRef<string | null>(null); + useEffect(() => { + if (selection && selection.directiveId !== lastSelectedRef.current) { + lastSelectedRef.current = selection.directiveId; + setOpenIds((prev) => { + if (prev.has(selection.directiveId)) return prev; + const next = new Set(prev); + next.add(selection.directiveId); + return next; + }); + } + }, [selection]); + + const toggleOpen = useCallback((id: string) => { + setOpenIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); return ( <div className="flex flex-col h-full"> @@ -159,7 +396,7 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP </span> </div> - {/* Top-level "directives/" folder */} + {/* Top-level "directives/" folder header (informational, non-interactive). */} <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]"> <FolderIcon open /> <span>directives/</span> @@ -176,74 +413,16 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP No directives yet </div> ) : ( - (Object.keys(groups) as SidebarGroup[]).map((group) => { - const list = groups[group]; - if (list.length === 0) return null; - const open = openGroups[group]; - return ( - <div key={group} className="select-none"> - {/* Group header (sub-folder) */} - <button - type="button" - onClick={() => toggleGroup(group)} - className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" - > - <Caret open={open} /> - <FolderIcon open={open} /> - <span>{GROUP_LABEL[group]}/</span> - <span className="ml-auto text-[10px] text-[#556677]"> - {list.length} - </span> - </button> - - {/* Files inside the group */} - {open && ( - <ul className="py-0.5"> - {list.map((d) => { - const isSelected = d.id === selectedId; - const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft; - const slug = d.title - .trim() - .replace(/\s+/g, "-") - .replace(/[^a-zA-Z0-9._-]/g, "") - .toLowerCase(); - const fileName = - slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`; - const orchestratorRunning = !!d.orchestratorTaskId; - return ( - <li key={d.id}> - <button - type="button" - onClick={() => onSelect(d.id)} - title={d.title} - className={`w-full text-left flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] transition-colors ${ - isSelected - ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]" - : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent" - }`} - > - <FileIcon /> - <span - className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} - aria-hidden - /> - <span className="truncate flex-1">{fileName}</span> - {orchestratorRunning && ( - <span - className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" - title="Orchestrator running" - aria-label="Orchestrator running" - /> - )} - </button> - </li> - ); - })} - </ul> - )} - </div> - ); - }) + sorted.map((d) => ( + <DirectiveFolder + key={d.id} + directive={d} + open={openIds.has(d.id)} + onToggle={() => toggleOpen(d.id)} + selection={selection} + onSelect={onSelect} + /> + )) )} </div> </div> @@ -257,11 +436,19 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP interface EditorShellProps { selectedId: string | undefined; + selectedTaskId: string | null; hasDirectives: boolean; listLoading: boolean; + onClearTask: () => void; } -function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProps) { +function EditorShell({ + selectedId, + selectedTaskId, + hasDirectives, + listLoading, + onClearTask, +}: EditorShellProps) { const { directive, loading, @@ -301,6 +488,16 @@ function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProp ); } + // Resolve the label for the breadcrumb when a task is selected. + const taskLabel = selectedTaskId + ? selectedTaskId === directive.orchestratorTaskId + ? "orchestrator" + : selectedTaskId === directive.completionTaskId + ? "completion" + : directive.steps.find((s) => s.taskId === selectedTaskId)?.name ?? + selectedTaskId.slice(0, 8) + : null; + return ( <div className="flex-1 flex flex-col h-full overflow-hidden"> {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */} @@ -309,7 +506,20 @@ function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProp <FileIcon /> <span>directives /</span> <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span> - {!!directive.orchestratorTaskId && ( + {selectedTaskId && ( + <> + <span>/</span> + <span className="text-[#9bc3ff]">{taskLabel}</span> + <button + type="button" + onClick={onClearTask} + className="ml-2 px-1.5 py-0.5 text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded normal-case" + > + back to document + </button> + </> + )} + {!selectedTaskId && !!directive.orchestratorTaskId && ( <span className="ml-2 inline-flex items-center gap-1 text-yellow-400"> <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" /> orchestrator running @@ -318,22 +528,28 @@ function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProp </div> </div> - {/* Lexical editor body */} - <DocumentEditor - directive={directive} - onUpdateGoal={async (goal) => { - await updateGoal(goal); - }} - onCleanup={async () => { - await cleanup(); - }} - onCreatePR={async () => { - await createPR(); - }} - onPickUpOrders={async () => { - await pickUpOrders(); - }} - /> + {selectedTaskId ? ( + <DocumentTaskStream + taskId={selectedTaskId} + label={taskLabel ?? selectedTaskId.slice(0, 8)} + /> + ) : ( + <DocumentEditor + directive={directive} + onUpdateGoal={async (goal) => { + await updateGoal(goal); + }} + onCleanup={async () => { + await cleanup(); + }} + onCreatePR={async () => { + await createPR(); + }} + onPickUpOrders={async () => { + await pickUpOrders(); + }} + /> + )} </div> ); } @@ -346,6 +562,8 @@ export default function DocumentDirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedTaskId = searchParams.get("task"); const { directives, loading: listLoading } = useDirectives(); useEffect(() => { @@ -354,6 +572,22 @@ export default function DocumentDirectivesPage() { } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + const onSelect = useCallback( + (sel: SidebarSelection) => { + const next = `/directives/${sel.directiveId}${ + sel.taskId ? `?task=${sel.taskId}` : "" + }`; + navigate(next); + }, + [navigate], + ); + + const onClearTask = useCallback(() => { + const next = new URLSearchParams(searchParams); + next.delete("task"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + if (authLoading) { return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -365,6 +599,10 @@ export default function DocumentDirectivesPage() { ); } + const selection: SidebarSelection | null = selectedId + ? { directiveId: selectedId, taskId: selectedTaskId } + : null; + return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> <Masthead showNav /> @@ -373,20 +611,22 @@ export default function DocumentDirectivesPage() { style={{ height: "calc(100vh - 80px)" }} > {/* Left: file-tree sidebar */} - <div className="w-[240px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> + <div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> <DocumentSidebar directives={directives} loading={listLoading} - selectedId={selectedId ?? null} - onSelect={(id) => navigate(`/directives/${id}`)} + selection={selection} + onSelect={onSelect} /> </div> - {/* Right: Lexical editor */} + {/* Right: Lexical editor / task stream */} <EditorShell selectedId={selectedId} + selectedTaskId={selectedTaskId} hasDirectives={directives.length > 0} listLoading={listLoading} + onClearTask={onClearTask} /> </main> </div> |
