diff options
| author | soryu <soryu@soryu.co> | 2026-04-30 12:12:48 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-30 12:12:48 +0100 |
| commit | a2148d4e3117cdda2e1d0a8e3df289bfe04789a3 (patch) | |
| tree | 38ee768964c99917e8f51bdbfa9ddc89cc57f97b /makima/frontend | |
| parent | c3e97bbcc32bd18d9344dd44cc54dfcdce32100b (diff) | |
| download | soryu-a2148d4e3117cdda2e1d0a8e3df289bfe04789a3.tar.gz soryu-a2148d4e3117cdda2e1d0a8e3df289bfe04789a3.zip | |
feat(document-mode): folder layout v2, glow on pending, inline formatting, autosave fix (#107)
## Autosave bug fix (top priority)
The 250ms debounce on the localStorage draft write was racing the unmount
cleanup: typing then navigating within 250ms cleared the pending timer
*before* it flushed, which is exactly when we needed the draft saved.
Drafts are now written synchronously on every keystroke. localStorage
.setItem on a small string is sub-millisecond — the debounce was a
premature optimisation.
## Sidebar v2 (document-directives.tsx)
- Tasks now live in a `tasks/` subfolder inside each directive folder
(orchestrator, completion, and started step tasks). The pinned `.md`
document remains at the top of the directive folder.
- Status circles moved to the RIGHT side only (previously rendered on
both sides, which the user found noisy).
- New `StatusDot` component composes the status colour with two optional
modifiers: a "live" pulse when the orchestrator is running, and a
GLOW (amber ring + pulse) when there is a pending user question for
that directive or task. The glow is sourced from the existing
SupervisorQuestionsContext, indexed by `directiveId` and `taskId`.
- New `TaskIcon` (terminal) and `CompletionIcon` (PR-bracket) so
orchestrator/step/completion entries look distinct from the .md file.
## Inline formatting in the editor (DocumentEditor.tsx)
- New `MarkdownShortcutPlugin` (scoped to TEXT_FORMAT_TRANSFORMERS only)
so typing `**foo**`, `*foo*`, `` `foo` ``, `~~foo~~` auto-formats inline.
Block-level shortcuts (# heading, - list) are intentionally excluded so
the document shape (H1 / goal / StepsBlock / trailing para) stays intact.
- New `FloatingFormatToolbar` appears above any non-collapsed selection
inside the goal paragraph, with B / I / U / S / </> buttons that
dispatch FORMAT_TEXT_COMMAND. Buttons highlight when the corresponding
format is active. Standard ⌘B / ⌘I / ⌘U keyboard shortcuts also work
via the existing RichTextPlugin.
- Round-trip via a small inline-only markdown serializer/parser so
formatting persists across saves. Supported markers: `\``code\``,
`***bold-italic***`, `**bold**`, `*italic* / _italic_`, `~~strike~~`.
Underline survives within the editor session (toolbar / shortcut) but
has no markdown syntax so it does not round-trip — by design.
- No backend schema change: `directive.goal` is still a TEXT column, it
just contains inline markdown now.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentEditor.tsx | 334 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 241 |
2 files changed, 479 insertions, 96 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index d953d45..270f5c3 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -22,11 +22,18 @@ import { $createParagraphNode, $createTextNode, $getRoot, + $getSelection, $isElementNode, + $isRangeSelection, + $isTextNode, COMMAND_PRIORITY_LOW, + FORMAT_TEXT_COMMAND, KEY_ESCAPE_COMMAND, + SELECTION_CHANGE_COMMAND, UNDO_COMMAND, type LexicalEditor, + type ElementNode, + type TextFormatType, } from "lexical"; import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; import { ListNode, ListItemNode } from "@lexical/list"; @@ -36,7 +43,12 @@ import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + TEXT_FORMAT_TRANSFORMERS, + type TextFormatTransformer, +} from "@lexical/markdown"; import type { DirectiveWithSteps } from "../../lib/api"; import { $createStepsBlockNode, @@ -60,11 +72,146 @@ const COUNTDOWN_RUNNING_MS = 10_000; /** The countdown bar only appears once we're inside this many ms from firing. */ const BAR_VISIBLE_MS = 10_000; const SAVED_TOAST_MS = 1200; -/** Debounce for writing the in-progress draft to localStorage (no backend hit). */ -const DRAFT_PERSIST_DEBOUNCE_MS = 250; +/** + * Drafts are written synchronously to localStorage on every keystroke. We used + * to debounce these by 250ms, but that lost the most recent edits whenever the + * user navigated away within the debounce window — the cleanup effect cleared + * the pending timer before it could flush. localStorage.setItem on a small + * string is sub-millisecond, so debouncing was a premature optimisation. + */ const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`; const LIVE_START_KEY = "makima:liveStartEnabled"; +// ============================================================================= +// Inline-only markdown round-trip for the goal paragraph. +// +// The directive goal is a single paragraph node in the editor (children[1]). +// We support inline formatting (bold, italic, underline, code, strikethrough) +// and persist it as inline markdown in `directive.goal`. We deliberately do +// NOT handle headings, lists, or blocks here — those would change the document +// shape and the goal column is just TEXT on the backend. +// +// Supported markers (single-format, no nesting except bold+italic): +// `code` → format: code +// ***x*** → format: bold + italic +// **x** → format: bold +// *x* / _x_ → format: italic +// ~~x~~ → format: strikethrough +// +// Underline is preserved at the editor level via the Cmd+U shortcut and the +// toolbar, but is intentionally not emitted in markdown (it has no native +// markdown syntax). It will silently round-trip as plain text. +// ============================================================================= + +interface InlineToken { + text: string; + bold: boolean; + italic: boolean; + code: boolean; + strike: boolean; +} + +const INLINE_MARKERS: Array<[RegExp, Partial<InlineToken>]> = [ + // Code wins first — content inside backticks is literal. + [/^`([^`]+)`/, { code: true }], + // ***bold italic*** + [/^\*\*\*([^*]+)\*\*\*/, { bold: true, italic: true }], + // **bold** + [/^\*\*([^*]+)\*\*/, { bold: true }], + // *italic* or _italic_ + [/^\*([^*]+)\*/, { italic: true }], + [/^_([^_]+)_/, { italic: true }], + // ~~strikethrough~~ + [/^~~([^~]+)~~/, { strike: true }], +]; + +function tokenizeInlineMarkdown(input: string): InlineToken[] { + const tokens: InlineToken[] = []; + let buf = ""; + let i = 0; + const flushBuf = () => { + if (buf.length > 0) { + tokens.push({ text: buf, bold: false, italic: false, code: false, strike: false }); + buf = ""; + } + }; + while (i < input.length) { + const slice = input.slice(i); + let matched = false; + for (const [re, fmt] of INLINE_MARKERS) { + const m = re.exec(slice); + if (m) { + flushBuf(); + tokens.push({ + text: m[1], + bold: !!fmt.bold, + italic: !!fmt.italic, + code: !!fmt.code, + strike: !!fmt.strike, + }); + i += m[0].length; + matched = true; + break; + } + } + if (!matched) { + buf += input[i]; + i++; + } + } + flushBuf(); + return tokens; +} + +function appendInlineMarkdownTo(parent: ElementNode, markdown: string): void { + const tokens = tokenizeInlineMarkdown(markdown); + for (const t of tokens) { + const node = $createTextNode(t.text); + if (t.bold) node.toggleFormat("bold" as TextFormatType); + if (t.italic) node.toggleFormat("italic" as TextFormatType); + if (t.code) node.toggleFormat("code" as TextFormatType); + if (t.strike) node.toggleFormat("strikethrough" as TextFormatType); + parent.append(node); + } +} + +/** + * Walk the goal paragraph's children and emit inline markdown. Only TextNodes + * are emitted — anything else falls back to its plain text content. We always + * wrap in the most specific marker pair available so the round-trip is stable. + */ +function serializeInlineMarkdown(parent: ElementNode): string { + let out = ""; + for (const child of parent.getChildren()) { + if (!$isTextNode(child)) { + out += child.getTextContent(); + continue; + } + let text = child.getTextContent(); + if (text.length === 0) continue; + // Code is exclusive — applying any other marker would break the literal + // semantics, so wrap once and skip the rest. + if (child.hasFormat("code")) { + out += "`" + text + "`"; + continue; + } + const bold = child.hasFormat("bold"); + const italic = child.hasFormat("italic"); + const strike = child.hasFormat("strikethrough"); + if (bold && italic) text = `***${text}***`; + else if (bold) text = `**${text}**`; + else if (italic) text = `*${text}*`; + if (strike) text = `~~${text}~~`; + out += text; + } + return out; +} + +// Keep only the inline transformers; stripping the block-level ones keeps the +// MarkdownShortcutPlugin from auto-converting `# ` to a heading or `- ` to a +// list inside the goal paragraph (which would break the document shape). +const INLINE_TRANSFORMERS: TextFormatTransformer[] = TEXT_FORMAT_TRANSFORMERS; + function isLiveStartEnabled(): boolean { if (typeof window === "undefined") return true; const raw = window.localStorage.getItem(LIVE_START_KEY); @@ -149,10 +296,12 @@ function SeedContentPlugin({ heading.append($createTextNode(directive.title)); root.append(heading); - // Paragraph: goal (editable). + // Paragraph: goal (editable). The persisted goal may contain inline + // markdown — parse it into formatted TextNodes so users see their + // bold/italic/code formatting on load. const goalPara = $createParagraphNode(); if (initialGoal.length > 0) { - goalPara.append($createTextNode(initialGoal)); + appendInlineMarkdownTo(goalPara, initialGoal); } root.append(goalPara); @@ -243,8 +392,11 @@ function GoalChangePlugin({ const children = root.getChildren(); // The goal lives at index 1 (after the H1 title). const goalNode = children[1]; - if (!goalNode) return; - onGoalChange(goalNode.getTextContent()); + if (!goalNode || !$isElementNode(goalNode)) return; + // Serialize the goal paragraph as INLINE MARKDOWN so bold/italic/code + // formatting round-trips through `directive.goal` (a plain TEXT + // column on the backend). + onGoalChange(serializeInlineMarkdown(goalNode)); }); }} /> @@ -289,6 +441,129 @@ function CountdownKeyBridge({ } // ============================================================================= +// Floating formatting toolbar +// +// Appears just above the current text selection when the selection covers any +// text inside the goal paragraph. Buttons dispatch FORMAT_TEXT_COMMAND which +// toggles the corresponding format flag on every covered TextNode — Lexical's +// built-in behaviour. Keyboard shortcuts (Cmd/Ctrl+B/I/U) also work via the +// RichTextPlugin even when the toolbar isn't shown. +// ============================================================================= + +function FloatingFormatToolbar() { + const [editor] = useLexicalComposerContext(); + const [coords, setCoords] = useState<{ x: number; y: number } | null>(null); + const [active, setActive] = useState({ + bold: false, + italic: false, + underline: false, + code: false, + strike: false, + }); + + useEffect(() => { + const update = () => { + editor.getEditorState().read(() => { + const sel = $getSelection(); + if (!$isRangeSelection(sel) || sel.isCollapsed()) { + setCoords(null); + return; + } + // Only show inside the goal paragraph (children[1] of root). + const anchorTop = sel.anchor.getNode().getTopLevelElement(); + const focusTop = sel.focus.getNode().getTopLevelElement(); + if (!anchorTop || !focusTop) { + setCoords(null); + return; + } + const root = $getRoot(); + const goalNode = root.getChildren()[1]; + if (anchorTop.getKey() !== goalNode?.getKey()) { + setCoords(null); + return; + } + // Selection rect from the DOM. + const domSel = window.getSelection(); + if (!domSel || domSel.rangeCount === 0) { + setCoords(null); + return; + } + const rect = domSel.getRangeAt(0).getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + setCoords(null); + return; + } + setCoords({ x: rect.left + rect.width / 2, y: rect.top }); + setActive({ + bold: sel.hasFormat("bold"), + italic: sel.hasFormat("italic"), + underline: sel.hasFormat("underline"), + code: sel.hasFormat("code"), + strike: sel.hasFormat("strikethrough"), + }); + }); + }; + + const unselect = editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + update(); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + const unupdate = editor.registerUpdateListener(({ editorState }) => { + // Use the just-committed state for format flags. + void editorState; + update(); + }); + return () => { + unselect(); + unupdate(); + }; + }, [editor]); + + if (!coords) return null; + + const fmt = (type: TextFormatType) => () => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, type); + }; + + const button = (label: string, onClick: () => void, isActive: boolean, hint: string) => ( + <button + type="button" + onMouseDown={(e) => e.preventDefault()} + onClick={onClick} + title={hint} + className={`px-2 py-1 text-[11px] font-mono uppercase tracking-wide border-r border-[rgba(117,170,252,0.2)] last:border-r-0 transition-colors ${ + isActive + ? "bg-[#75aafc] text-[#0a1628]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]" + }`} + > + {label} + </button> + ); + + return ( + <div + className="fixed z-40 flex items-stretch bg-[#0a1628] border border-[rgba(117,170,252,0.35)] shadow-lg pointer-events-auto" + style={{ + left: coords.x, + top: coords.y - 8, + transform: "translate(-50%, -100%)", + }} + > + {button("B", fmt("bold"), active.bold, "Bold (⌘B)")} + {button("I", fmt("italic"), active.italic, "Italic (⌘I)")} + {button("U", fmt("underline"), active.underline, "Underline (⌘U)")} + {button("S", fmt("strikethrough"), active.strike, "Strikethrough")} + {button("</>", fmt("code"), active.code, "Inline code")} + </div> + ); +} + +// ============================================================================= // Right-click context menu // ============================================================================= @@ -559,7 +834,6 @@ export function DocumentEditor({ const timerRef = useRef<number | null>(null); const tickRef = useRef<number | null>(null); const deadlineRef = useRef<number>(0); - const draftDebounceRef = useRef<number | null>(null); const editorRef = useRef<LexicalEditor | null>(null); function cancelTimers() { @@ -656,7 +930,9 @@ export function DocumentEditor({ /* ignore */ } // Also revert the editor's goal paragraph back to the persisted value, so - // the user sees the rollback. + // the user sees the rollback. The persisted value may contain inline + // markdown — re-parse it so formatting comes back styled, not as raw + // asterisks. const editor = editorRef.current; if (editor) { editor.update( @@ -666,7 +942,7 @@ export function DocumentEditor({ if (!goalNode || !$isElementNode(goalNode)) return; goalNode.getChildren().forEach((c) => c.remove()); if (directive.goal.length > 0) { - goalNode.append($createTextNode(directive.goal)); + appendInlineMarkdownTo(goalNode, directive.goal); } }, { tag: "history-merge" }, @@ -678,10 +954,6 @@ export function DocumentEditor({ useEffect(() => { return () => { cancelTimers(); - if (draftDebounceRef.current != null) { - window.clearTimeout(draftDebounceRef.current); - draftDebounceRef.current = null; - } }; }, []); @@ -689,24 +961,20 @@ export function DocumentEditor({ (goal: string) => { pendingGoalRef.current = goal; - // 1. Always persist work-in-progress to localStorage (debounced) so - // leaving the page does not lose typing. This is independent of - // whether we will trigger a backend save. - if (draftDebounceRef.current != null) { - window.clearTimeout(draftDebounceRef.current); - } - draftDebounceRef.current = window.setTimeout(() => { - try { - if (goal === directive.goal) { - window.localStorage.removeItem(DRAFT_KEY(directive.id)); - } else { - window.localStorage.setItem(DRAFT_KEY(directive.id), goal); - } - } catch { - /* localStorage may be unavailable / full; ignore */ + // 1. Always persist work-in-progress to localStorage IMMEDIATELY so + // leaving the page does not lose typing. We previously debounced + // this write by 250ms, but unmount could clear the pending timer + // before it flushed — losing the most recent edits exactly when + // we needed them most. + try { + if (goal === directive.goal) { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } else { + window.localStorage.setItem(DRAFT_KEY(directive.id), goal); } - draftDebounceRef.current = null; - }, DRAFT_PERSIST_DEBOUNCE_MS); + } catch { + /* localStorage may be unavailable / full; ignore */ + } // 2. State-machine. if (goal === directive.goal) { @@ -759,6 +1027,12 @@ export function DocumentEditor({ <HistoryPlugin /> <GoalChangePlugin onGoalChange={handleGoalChange} /> <CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} /> + {/* Inline markdown shortcuts: typing **foo** auto-formats as bold, + `foo` as code, etc. We pass only TEXT_FORMAT_TRANSFORMERS so + block-level shortcuts (# heading, - list) don't fire and + accidentally restructure the document. */} + <MarkdownShortcutPlugin transformers={INLINE_TRANSFORMERS} /> + <FloatingFormatToolbar /> <div className="flex-1 overflow-auto" 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)} /> )) )} |
