diff options
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 241 |
1 files changed, 175 insertions, 66 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 687d86f..aba3613 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router"; import { Masthead } from "../components/Masthead"; import { useDirective, useDirectives } from "../hooks/useDirectives"; import { useAuth } from "../contexts/AuthContext"; +import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; import { DocumentEditor } from "../components/directives/DocumentEditor"; import { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; import type { @@ -87,6 +88,40 @@ function FileIcon() { ); } +/** Terminal/prompt icon for orchestrator and step tasks. */ +function TaskIcon() { + return ( + <svg + viewBox="0 0 16 16" + width={12} + height={12} + className="shrink-0" + aria-hidden + > + <rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#9bc3ff" strokeWidth="1" /> + <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#9bc3ff" strokeWidth="1" fill="none" strokeLinecap="round" /> + </svg> + ); +} + +/** PR-bracket icon for the completion task. */ +function CompletionIcon() { + return ( + <svg + viewBox="0 0 16 16" + width={12} + height={12} + className="shrink-0" + aria-hidden + > + <circle cx="4" cy="4" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" /> + <circle cx="4" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" /> + <circle cx="12" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" /> + <path d="M4 5.4v5.2 M4 12h6.6 M12 4l0 6.6" stroke="#9bc3ff" strokeWidth="1" fill="none" /> + </svg> + ); +} + function PinIcon() { return ( <svg @@ -147,10 +182,11 @@ interface SidebarProps { /** * 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. + * body is the pinned document entry (always first) followed by a `tasks/` + * subfolder containing the orchestrator, completion, and step tasks. + * + * Status dot lives on the right side only (single-side, per the v2 design). + * If a directive or task has a pending user question, its icon glows. */ function DirectiveFolder({ directive, @@ -158,12 +194,18 @@ function DirectiveFolder({ onToggle, selection, onSelect, + pendingTaskIds, + hasPendingForDirective, }: { directive: DirectiveSummary; open: boolean; onToggle: () => void; selection: SidebarSelection | null; onSelect: (sel: SidebarSelection) => void; + /** Set of task ids that currently have pending user questions. */ + pendingTaskIds: Set<string>; + /** Whether any pending question is associated with this directive. */ + hasPendingForDirective: boolean; }) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`; @@ -177,6 +219,10 @@ function DirectiveFolder({ // Collect the tasks to surface in the folder body. const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]); + const orchestratorRunning = !!directive.orchestratorTaskId; + // Tasks subfolder open state — independent of the directive folder. + const [tasksOpen, setTasksOpen] = useState<boolean>(true); + return ( <div className="select-none"> <button @@ -186,25 +232,15 @@ function DirectiveFolder({ 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 + {/* Status dot — RIGHT side only. Glows when this directive has a + pending user question, or pulses when the orchestrator is live. */} + <StatusDot + color={dotColor} + live={orchestratorRunning} + glow={hasPendingForDirective} + status={directive.status} /> </button> @@ -229,57 +265,113 @@ function DirectiveFolder({ </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> - ); - }) - )} + {/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */} + <li> + <button + type="button" + onClick={() => setTasksOpen((p) => !p)} + className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" + > + <Caret open={tasksOpen} /> + <FolderIcon open={tasksOpen} /> + <span className="truncate flex-1 text-left">tasks/</span> + {tasks.length > 0 && ( + <span className="text-[10px] text-[#556677]">{tasks.length}</span> + )} + </button> + + {tasksOpen && ( + <ul className="py-0.5"> + {tasks.length === 0 ? ( + <li className="pl-14 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"; + const glow = pendingTaskIds.has(t.taskId); + const Icon = + t.kind === "completion" ? CompletionIcon : TaskIcon; + 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-14 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" + }`} + > + <Icon /> + <span className="truncate flex-1">{t.label}</span> + <StatusDot + color={tdot} + live={live} + glow={glow} + status={t.status} + /> + </button> + </li> + ); + }) + )} + </ul> + )} + </li> </ul> )} </div> ); } +/** + * Right-side status indicator. Composes the colored status dot with optional + * "live" pulse (orchestrator running) and "glow" attention ring (pending user + * question waiting on a response). + */ +function StatusDot({ + color, + live, + glow, + status, +}: { + color: string; + live: boolean; + glow: boolean; + status: string; +}) { + // The glow is a soft amber ring pulsed via box-shadow. Keep it subtle so it + // doesn't fight the live pulse for attention when both are present. + const ring = glow + ? "shadow-[0_0_0_2px_rgba(251,191,36,0.45),0_0_8px_2px_rgba(251,191,36,0.55)] animate-pulse" + : ""; + const livePulse = live && !glow ? "animate-pulse" : ""; + const title = glow + ? `${status} — needs response` + : live + ? `${status} — running` + : `status: ${status}`; + return ( + <span + className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`} + aria-label={title} + title={title} + /> + ); +} + interface FolderTaskRow { taskId: string; label: string; @@ -342,6 +434,21 @@ interface SidebarProps { } function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) { + // Pending user questions — drives the "glow" attention ring. We split into + // two indices so the directive folder header glows whenever ANY of its + // tasks has a pending question, while individual task rows glow only for + // their own question. + const { pendingQuestions } = useSupervisorQuestions(); + const { directivesWithPending, tasksWithPending } = useMemo(() => { + const dirs = new Set<string>(); + const tasks = new Set<string>(); + for (const q of pendingQuestions) { + if (q.directiveId) dirs.add(q.directiveId); + if (q.taskId) tasks.add(q.taskId); + } + return { directivesWithPending: dirs, tasksWithPending: tasks }; + }, [pendingQuestions]); + // Sort active first, then idle, then paused, then archived. const sorted = useMemo(() => { const order: Record<DirectiveStatus, number> = { @@ -421,6 +528,8 @@ function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarPr onToggle={() => toggleOpen(d.id)} selection={selection} onSelect={onSelect} + pendingTaskIds={tasksWithPending} + hasPendingForDirective={directivesWithPending.has(d.id)} /> )) )} |
