From a2148d4e3117cdda2e1d0a8e3df289bfe04789a3 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 30 Apr 2026 12:12:48 +0100 Subject: feat(document-mode): folder layout v2, glow on pending, inline formatting, autosave fix (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../src/components/directives/DocumentEditor.tsx | 334 +++++++++++++++++++-- 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]> = [ + // 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)); }); }} /> @@ -288,6 +440,129 @@ function CountdownKeyBridge({ return null; } +// ============================================================================= +// 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) => ( + + ); + + return ( +
+ {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")} +
+ ); +} + // ============================================================================= // Right-click context menu // ============================================================================= @@ -559,7 +834,6 @@ export function DocumentEditor({ const timerRef = useRef(null); const tickRef = useRef(null); const deadlineRef = useRef(0); - const draftDebounceRef = useRef(null); const editorRef = useRef(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({ + {/* 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. */} + +
+ + + + ); +} + +/** PR-bracket icon for the completion task. */ +function CompletionIcon() { + return ( + + + + + + + ); +} + function PinIcon() { return ( void; selection: SidebarSelection | null; onSelect: (sel: SidebarSelection) => void; + /** Set of task ids that currently have pending user questions. */ + pendingTaskIds: Set; + /** 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(true); + return (
@@ -229,57 +265,113 @@ function DirectiveFolder({ - {tasks.length === 0 ? ( -
  • - No tasks yet -
  • - ) : ( - 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 ( -
  • - -
  • - ); - }) - )} + {/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */} +
  • + + + {tasksOpen && ( +
      + {tasks.length === 0 ? ( +
    • + No tasks yet +
    • + ) : ( + 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 ( +
    • + +
    • + ); + }) + )} +
    + )} +
  • )}
    ); } +/** + * 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 ( + + ); +} + 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(); + const tasks = new Set(); + 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 = { @@ -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)} /> )) )} -- cgit v1.2.3