summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx241
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)}
/>
))
)}