summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-30 12:12:48 +0100
committerGitHub <noreply@github.com>2026-04-30 12:12:48 +0100
commita2148d4e3117cdda2e1d0a8e3df289bfe04789a3 (patch)
tree38ee768964c99917e8f51bdbfa9ddc89cc57f97b
parentc3e97bbcc32bd18d9344dd44cc54dfcdce32100b (diff)
downloadsoryu-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>
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx334
-rw-r--r--makima/frontend/src/routes/document-directives.tsx241
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)}
/>
))
)}