diff options
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentEditor.tsx | 664 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/StepsBlockNode.tsx | 281 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 235 | ||||
| -rw-r--r-- | makima/frontend/tsconfig.tsbuildinfo | 2 |
4 files changed, 1120 insertions, 62 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx new file mode 100644 index 0000000..40fccf1 --- /dev/null +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -0,0 +1,664 @@ +/** + * DocumentEditor — the Lexical-based document body for Document Mode. + * + * Layout (top to bottom): + * - Read-only H1 with the directive's title. + * - Editable paragraph with the directive's goal. Editing the goal triggers + * a 3-second countdown bar at the bottom of the editor; if the timer + * expires, we call updateGoal(). Esc / ⌘Z cancels; further typing extends. + * - A custom non-editable StepsBlock decorator node showing each step. + * + * Right-click anywhere in the editor opens a custom context menu offering + * the three directive-level actions: Clean Up, Update PR, Plan Orders. + * + * Live updates: + * - The directive prop is updated by the parent's useDirective polling. + * - StepsBlockContextProvider wraps the LexicalComposer so that the steps + * block (a Lexical DecoratorNode) sees fresh data on every render + * without us having to mutate the editor state. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + COMMAND_PRIORITY_LOW, + KEY_ESCAPE_COMMAND, + UNDO_COMMAND, + type LexicalEditor, +} from "lexical"; +import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; +import { ListNode, ListItemNode } from "@lexical/list"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import type { DirectiveWithSteps } from "../../lib/api"; +import { + $createStepsBlockNode, + $isStepsBlockNode, + StepsBlockNode, + StepsBlockContextProvider, +} from "./StepsBlockNode"; + +// ============================================================================= +// Constants +// ============================================================================= + +const SAVE_COUNTDOWN_MS = 3000; +const SAVED_TOAST_MS = 1200; + +// ============================================================================= +// Editor theme — minimal, just enough so the rich-text plugin has something to +// hang class names on. We rely on our own typography otherwise. +// ============================================================================= + +const editorTheme = { + paragraph: "makima-doc-paragraph", + heading: { + h1: "makima-doc-h1", + h2: "makima-doc-h2", + }, + text: { + bold: "font-bold", + italic: "italic", + underline: "underline", + }, +}; + +// ============================================================================= +// Plugins +// ============================================================================= + +/** + * (Re)builds the editor's root content from the directive whenever the + * directive ID changes. We keep this controlled so that switching documents + * resets the editor cleanly. + * + * We deliberately do NOT re-seed on every directive update — only on id + * change — so the user's in-flight goal edits aren't trampled by a poll that + * happens mid-keystroke. + */ +function SeedContentPlugin({ + directive, +}: { + directive: DirectiveWithSteps; +}) { + const [editor] = useLexicalComposerContext(); + const seededIdRef = useRef<string | null>(null); + + useEffect(() => { + if (seededIdRef.current === directive.id) return; + seededIdRef.current = directive.id; + + editor.update( + () => { + const root = $getRoot(); + root.clear(); + + // H1: title (read-only — see ReadOnlyTitlePlugin). + const heading = $createHeadingNode("h1"); + heading.append($createTextNode(directive.title)); + root.append(heading); + + // Paragraph: goal (editable). + const goalPara = $createParagraphNode(); + if (directive.goal.length > 0) { + goalPara.append($createTextNode(directive.goal)); + } + root.append(goalPara); + + // Steps block (decorator — non-editable). + root.append($createStepsBlockNode()); + + // Trailing empty paragraph so the cursor has somewhere to land below + // the steps block. + root.append($createParagraphNode()); + }, + { tag: "history-merge" }, + ); + }, [editor, directive.id, directive.title, directive.goal]); + + return null; +} + +/** + * Prevents edits to the H1 (title) node. The title is meant to feel like a + * file name — clicking it shows a caret, but typing is no-op'd. We watch the + * editor's update stream and, if the H1's text drifts from the seed value, + * revert it on the next microtask so the user sees the change get rejected + * rather than silently swallowed. + */ +function ReadOnlyTitlePlugin({ title }: { title: string }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + let reverting = false; + return editor.registerUpdateListener(({ editorState }) => { + if (reverting) return; + editorState.read(() => { + const root = $getRoot(); + const first = root.getFirstChild(); + if (!first || first.getType() !== "heading") return; + const text = first.getTextContent(); + if (text === title) return; + reverting = true; + queueMicrotask(() => { + editor.update( + () => { + const r = $getRoot(); + const h = r.getFirstChild(); + if (!h || h.getType() !== "heading") { + reverting = false; + return; + } + if ($isElementNode(h) && h.getTextContent() !== title) { + h.getChildren().forEach((c) => c.remove()); + if (title.length > 0) { + h.append($createTextNode(title)); + } + } + reverting = false; + }, + { tag: "history-merge" }, + ); + }); + }); + }); + }, [editor, title]); + + return null; +} + +/** + * Watches the goal paragraph (the second top-level child) and reports its + * current text to the parent on every change. Kept separate from the + * countdown bar so the bar is purely a UI concern. + */ +function GoalChangePlugin({ + onGoalChange, +}: { + onGoalChange: (goal: string) => void; +}) { + return ( + <OnChangePlugin + ignoreSelectionChange + onChange={(editorState) => { + editorState.read(() => { + const root = $getRoot(); + const children = root.getChildren(); + // The goal lives at index 1 (after the H1 title). + const goalNode = children[1]; + if (!goalNode) return; + onGoalChange(goalNode.getTextContent()); + }); + }} + /> + ); +} + +/** + * Wires Lexical commands into UI callbacks. We forward Esc presses (used to + * cancel the countdown) and Undo (⌘/Ctrl-Z) without intercepting them. + */ +function CountdownKeyBridge({ + onEsc, + onUndo, +}: { + onEsc: () => void; + onUndo: () => void; +}) { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + const unEsc = editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + onEsc(); + return false; // don't consume; let other handlers run + }, + COMMAND_PRIORITY_LOW, + ); + const unUndo = editor.registerCommand( + UNDO_COMMAND, + () => { + onUndo(); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + return () => { + unEsc(); + unUndo(); + }; + }, [editor, onEsc, onUndo]); + return null; +} + +// ============================================================================= +// Right-click context menu +// ============================================================================= + +interface EditorContextMenuProps { + x: number; + y: number; + onClose: () => void; + onCleanup: () => void; + onUpdatePR: () => void; + onPlanOrders: () => void; +} + +function EditorContextMenu({ + x, + y, + onClose, + onCleanup, + onUpdatePR, + onPlanOrders, +}: EditorContextMenuProps) { + const ref = useRef<HTMLDivElement>(null); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [onClose]); + + // Clamp into viewport. + useEffect(() => { + if (!ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + if (rect.right > vw) ref.current.style.left = `${x - rect.width}px`; + if (rect.bottom > vh) ref.current.style.top = `${y - rect.height}px`; + }, [x, y]); + + const item = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; + + return ( + <div + ref={ref} + className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" + style={{ left: x, top: y }} + > + <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)]"> + Document + </div> + <button + type="button" + className={item} + onClick={() => { + onCleanup(); + onClose(); + }} + > + <span className="text-[#75aafc]">⎚</span> + Clean Up + </button> + <button + type="button" + className={item} + onClick={() => { + onUpdatePR(); + onClose(); + }} + > + <span className="text-[#75aafc]">↗</span> + Update PR + </button> + <button + type="button" + className={item} + onClick={() => { + onPlanOrders(); + onClose(); + }} + > + <span className="text-[#c084fc]">◆</span> + Plan Orders + </button> + </div> + ); +} + +// ============================================================================= +// Countdown bar +// ============================================================================= + +interface SaveCountdownBarProps { + state: "idle" | "pending" | "saving" | "saved" | "error"; + remainingMs: number; + totalMs: number; + onCancel: () => void; +} + +function SaveCountdownBar({ + state, + remainingMs, + totalMs, + onCancel, +}: SaveCountdownBarProps) { + if (state === "idle") return null; + + let label: string; + let progressPct = 0; + let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]"; + + if (state === "pending") { + const seconds = Math.max(0, Math.ceil(remainingMs / 1000)); + label = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`; + progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100)); + } else if (state === "saving") { + label = "Saving…"; + progressPct = 100; + tone = "border-emerald-700 text-emerald-300"; + } else if (state === "saved") { + label = "Saved"; + progressPct = 100; + tone = "border-emerald-700 text-emerald-300"; + } else { + label = "Save failed — try again."; + progressPct = 100; + tone = "border-red-700 text-red-300"; + } + + return ( + <div + className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`} + data-makima-countdown={state} + > + <div className="h-0.5 bg-[#10203a]"> + <div + className="h-full bg-[#75aafc] transition-[width] duration-100 ease-linear" + style={{ width: `${progressPct}%` }} + /> + </div> + <div className="flex items-center justify-between px-4 py-1.5"> + <span className="text-[10px] font-mono">{label}</span> + {state === "pending" && ( + <button + type="button" + onClick={onCancel} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + )} + </div> + </div> + ); +} + +// ============================================================================= +// Main component +// ============================================================================= + +export interface DocumentEditorProps { + directive: DirectiveWithSteps; + onUpdateGoal: (goal: string) => Promise<void> | void; + onCleanup: () => Promise<void> | void; + onCreatePR: () => Promise<void> | void; + onPickUpOrders: () => Promise<unknown> | unknown; +} + +type SaveState = "idle" | "pending" | "saving" | "saved" | "error"; + +export function DocumentEditor({ + directive, + onUpdateGoal, + onCleanup, + onCreatePR, + onPickUpOrders, +}: DocumentEditorProps) { + // ---- Lexical config ---------------------------------------------------- + const initialConfig = useMemo( + () => ({ + // Re-key the composer when the directive id changes so we get a clean + // editor state per document. We do this via the `key` prop on + // <LexicalComposer> below as well. + namespace: `makima-doc-${directive.id}`, + onError: (err: Error) => { + // eslint-disable-next-line no-console + console.error("[DocumentEditor]", err); + }, + nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode], + theme: editorTheme, + editable: true, + }), + [directive.id], + ); + + // ---- Goal auto-save state machine -------------------------------------- + const [saveState, setSaveState] = useState<SaveState>("idle"); + const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS); + const pendingGoalRef = useRef<string>(directive.goal); + const timerRef = useRef<number | null>(null); + const tickRef = useRef<number | null>(null); + const deadlineRef = useRef<number>(0); + const editorRef = useRef<LexicalEditor | null>(null); + + // Reset state when switching directives. + useEffect(() => { + pendingGoalRef.current = directive.goal; + cancelTimers(); + setSaveState("idle"); + setRemainingMs(SAVE_COUNTDOWN_MS); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [directive.id]); + + // If the persisted goal updated externally and matches the pending goal, + // settle the bar. + useEffect(() => { + if (saveState === "pending" && pendingGoalRef.current === directive.goal) { + cancelTimers(); + setSaveState("idle"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [directive.goal]); + + function cancelTimers() { + if (timerRef.current != null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + if (tickRef.current != null) { + window.clearInterval(tickRef.current); + tickRef.current = null; + } + } + + const fireSave = useCallback(async () => { + const next = pendingGoalRef.current; + cancelTimers(); + setSaveState("saving"); + try { + await onUpdateGoal(next); + setSaveState("saved"); + window.setTimeout(() => { + // Only fade if no new edit has reopened a pending state in the meantime. + setSaveState((s) => (s === "saved" ? "idle" : s)); + }, SAVED_TOAST_MS); + } catch (e) { + // eslint-disable-next-line no-console + console.error("Failed to save goal", e); + setSaveState("error"); + window.setTimeout(() => { + setSaveState((s) => (s === "error" ? "idle" : s)); + }, 2500); + } + }, [onUpdateGoal]); + + const startOrExtendCountdown = useCallback(() => { + cancelTimers(); + deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS; + setSaveState("pending"); + setRemainingMs(SAVE_COUNTDOWN_MS); + tickRef.current = window.setInterval(() => { + const remaining = Math.max(0, deadlineRef.current - Date.now()); + setRemainingMs(remaining); + if (remaining <= 0 && tickRef.current != null) { + window.clearInterval(tickRef.current); + tickRef.current = null; + } + }, 100); + timerRef.current = window.setTimeout(() => { + void fireSave(); + }, SAVE_COUNTDOWN_MS); + }, [fireSave]); + + const cancelCountdown = useCallback(() => { + if (saveState !== "pending") return; + cancelTimers(); + pendingGoalRef.current = directive.goal; // reset pending edit + setSaveState("idle"); + setRemainingMs(SAVE_COUNTDOWN_MS); + // Also revert the editor's goal paragraph back to the persisted value, so + // the user sees the rollback. + const editor = editorRef.current; + if (editor) { + editor.update( + () => { + const root = $getRoot(); + const goalNode = root.getChildren()[1]; + if (!goalNode || !$isElementNode(goalNode)) return; + goalNode.getChildren().forEach((c) => c.remove()); + if (directive.goal.length > 0) { + goalNode.append($createTextNode(directive.goal)); + } + }, + { tag: "history-merge" }, + ); + } + }, [directive.goal, saveState]); + + // Cleanup on unmount. + useEffect(() => { + return cancelTimers; + }, []); + + const handleGoalChange = useCallback( + (goal: string) => { + pendingGoalRef.current = goal; + if (goal === directive.goal) { + // Edit reverted — cancel the countdown (if any). + if (saveState === "pending") { + cancelTimers(); + setSaveState("idle"); + } + return; + } + startOrExtendCountdown(); + }, + [directive.goal, saveState, startOrExtendCountdown], + ); + + // ---- Right-click context menu ----------------------------------------- + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY }); + }, []); + + // ---- Render ------------------------------------------------------------ + return ( + <div className="flex flex-col h-full overflow-hidden"> + <StepsBlockContextProvider value={{ directive }}> + <LexicalComposer key={directive.id} initialConfig={initialConfig}> + {/* Capture the editor ref via a tiny inline plugin */} + <EditorRefCapture editorRef={editorRef} /> + <SeedContentPlugin directive={directive} /> + <ReadOnlyTitlePlugin title={directive.title} /> + <HistoryPlugin /> + <GoalChangePlugin onGoalChange={handleGoalChange} /> + <CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} /> + + <div + className="flex-1 overflow-auto" + onContextMenu={handleContextMenu} + > + <div className="max-w-3xl mx-auto px-8 py-10"> + <RichTextPlugin + contentEditable={ + <ContentEditable + aria-placeholder="Describe the directive's goal…" + placeholder={ + <div className="pointer-events-none absolute text-[#445566] font-mono text-[13px] mt-2"> + Describe the directive's goal… + </div> + } + className="outline-none font-mono text-[13px] leading-relaxed text-[#dbe7ff] [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:text-white [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] [&_.makima-doc-paragraph]:text-[#c0d0e0] relative" + /> + } + ErrorBoundary={LexicalErrorBoundary} + /> + </div> + </div> + </LexicalComposer> + </StepsBlockContextProvider> + + <SaveCountdownBar + state={saveState} + remainingMs={remainingMs} + totalMs={SAVE_COUNTDOWN_MS} + onCancel={cancelCountdown} + /> + + {menu && ( + <EditorContextMenu + x={menu.x} + y={menu.y} + onClose={() => setMenu(null)} + onCleanup={() => { + void onCleanup(); + }} + onUpdatePR={() => { + void onCreatePR(); + }} + onPlanOrders={() => { + void onPickUpOrders(); + }} + /> + )} + </div> + ); +} + +/** + * Tiny plugin that stashes the LexicalEditor instance on a ref so the parent + * component can issue updates from outside (e.g. to revert the goal on cancel). + */ +function EditorRefCapture({ + editorRef, +}: { + editorRef: React.MutableRefObject<LexicalEditor | null>; +}) { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + editorRef.current = editor; + return () => { + if (editorRef.current === editor) { + editorRef.current = null; + } + }; + }, [editor, editorRef]); + return null; +} + +// Re-export the steps-block helpers so consumers can include the node class +// in their own initial configs if needed. +export { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode }; diff --git a/makima/frontend/src/components/directives/StepsBlockNode.tsx b/makima/frontend/src/components/directives/StepsBlockNode.tsx new file mode 100644 index 0000000..ab3d7da --- /dev/null +++ b/makima/frontend/src/components/directives/StepsBlockNode.tsx @@ -0,0 +1,281 @@ +/** + * StepsBlockNode — a Lexical DecoratorNode that renders the directive's steps + * as an in-document, non-editable diagram. + * + * The actual data (steps, orchestratorTaskId, etc.) does NOT live on the node + * itself — that would require us to dispatch a Lexical update on every poll, + * which is wasteful and fights against Lexical's content-equality model. The + * node is a marker that says "render the steps block here", and the React + * component pulls live data from a context provided by DocumentEditor. So when + * `useDirective` polls and produces new steps, the StepsBlock re-renders + * automatically without touching the editor state at all. + */ +import { + DecoratorNode, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from "lexical"; +import { createContext, useContext, type JSX } from "react"; +import type { DirectiveStep, DirectiveWithSteps, StepStatus } from "../../lib/api"; + +// ============================================================================= +// Context provided by DocumentEditor — the StepsBlock reads live directive data +// ============================================================================= + +interface StepsBlockContextValue { + directive: DirectiveWithSteps | null; +} + +const StepsBlockContext = createContext<StepsBlockContextValue>({ directive: null }); + +export const StepsBlockContextProvider = StepsBlockContext.Provider; + +// ============================================================================= +// Status palette (matches StepNode.tsx for consistency) +// ============================================================================= + +const STATUS_COLORS: Record<StepStatus, { bg: string; border: string; text: string; pill: string }> = { + pending: { + bg: "bg-[#1a2540]", + border: "border-[#2a3a5a]", + text: "text-[#7788aa]", + pill: "bg-[#0f1a30] text-[#7788aa] border-[#2a3a5a]", + }, + ready: { + bg: "bg-[#2a2a10]", + border: "border-[#4a4a20]", + text: "text-yellow-400", + pill: "bg-[#1a1a08] text-yellow-300 border-[#4a4a20]", + }, + running: { + bg: "bg-[#0a2a1a]", + border: "border-[#1a5a3a]", + text: "text-green-400", + pill: "bg-[#062014] text-green-300 border-[#1a5a3a]", + }, + completed: { + bg: "bg-[#0a2a2a]", + border: "border-[#1a5a5a]", + text: "text-emerald-400", + pill: "bg-[#062424] text-emerald-300 border-[#1a5a5a]", + }, + failed: { + bg: "bg-[#2a1a1a]", + border: "border-[#5a2a2a]", + text: "text-red-400", + pill: "bg-[#241010] text-red-300 border-[#5a2a2a]", + }, + skipped: { + bg: "bg-[#1a1a2a]", + border: "border-[#2a2a4a]", + text: "text-[#7788aa]", + pill: "bg-[#101020] text-[#7788aa] border-[#2a2a4a]", + }, +}; + +const STATUS_LABEL: Record<StepStatus, string> = { + pending: "PENDING", + ready: "READY", + running: "RUNNING", + completed: "DONE", + failed: "FAILED", + skipped: "SKIP", +}; + +// ============================================================================= +// React component rendered inside the editor body +// ============================================================================= + +function StepCard({ step }: { step: DirectiveStep }) { + const colors = STATUS_COLORS[step.status] ?? STATUS_COLORS.pending; + const label = STATUS_LABEL[step.status] ?? step.status.toUpperCase(); + return ( + <div + className={`${colors.bg} border ${colors.border} rounded px-3 py-2 flex items-start gap-3`} + > + <span + className="text-[10px] font-mono text-[#556677] shrink-0 w-5 text-right" + aria-hidden + > + {String(step.orderIndex + 1).padStart(2, "0")} + </span> + <div className="flex-1 min-w-0"> + <div className="flex items-center justify-between gap-2"> + <span className="text-[12px] font-mono text-white truncate"> + {step.name} + </span> + <span + className={`text-[9px] font-mono uppercase tracking-wide border rounded px-1.5 py-0.5 shrink-0 ${colors.pill}`} + > + {label} + </span> + </div> + {step.description && ( + <p className="text-[10px] font-mono text-[#7788aa] truncate mt-0.5"> + {step.description} + </p> + )} + </div> + </div> + ); +} + +function StepsBlock(): JSX.Element { + const { directive } = useContext(StepsBlockContext); + + // While the directive is loading or absent, render a quiet placeholder so the + // editor body still has something visible — but make sure it has the same + // outline as the loaded view so the document doesn't reflow. + if (!directive) { + return ( + <div + contentEditable={false} + className="my-3 border border-dashed border-[rgba(117,170,252,0.2)] rounded px-3 py-4 select-none" + > + <div className="text-[10px] font-mono text-[#556677] uppercase tracking-wide"> + steps · loading + </div> + </div> + ); + } + + const steps = [...directive.steps].sort((a, b) => a.orderIndex - b.orderIndex); + const isOrchestratorRunning = !!directive.orchestratorTaskId; + const completed = steps.filter((s) => s.status === "completed").length; + const total = steps.length; + const caption = isOrchestratorRunning + ? "makima is editing this document" + : total === 0 + ? "no steps yet" + : total === 1 + ? "1 step" + : `${total} steps`; + + return ( + <div + // contentEditable={false} keeps Lexical from treating this as editable + // content — the user can't put a caret inside it. + contentEditable={false} + // Use a small data attribute so external CSS / tests can target it. + data-makima-block="steps" + className="my-3 border border-[rgba(117,170,252,0.2)] rounded bg-[#091428] select-none" + > + {/* Caption */} + <div className="flex items-center justify-between px-3 py-1.5 border-b border-dashed border-[rgba(117,170,252,0.15)]"> + <div className="flex items-center gap-1.5"> + {isOrchestratorRunning && ( + <span + className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" + aria-hidden + /> + )} + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + {caption} + </span> + </div> + {total > 0 && ( + <span className="text-[10px] font-mono text-[#556677]"> + {completed}/{total} done + </span> + )} + </div> + + {/* Step diagram */} + {steps.length === 0 ? ( + <div className="px-3 py-4 text-[11px] font-mono text-[#556677] italic"> + {isOrchestratorRunning + ? "Planner is generating steps…" + : "No steps yet — start the directive or plan orders to populate."} + </div> + ) : ( + <ol className="px-3 py-3 flex flex-col gap-1.5"> + {steps.map((step, idx) => ( + <li key={step.id} className="relative"> + <StepCard step={step} /> + {idx < steps.length - 1 && ( + <div + className="absolute left-[18px] -bottom-1 h-1 w-px bg-[rgba(117,170,252,0.2)]" + aria-hidden + /> + )} + </li> + ))} + </ol> + )} + </div> + ); +} + +// ============================================================================= +// Lexical decorator node +// ============================================================================= + +export type SerializedStepsBlockNode = Spread< + { /* No fields — the block is a marker; live data comes from context. */ }, + SerializedLexicalNode +>; + +export class StepsBlockNode extends DecoratorNode<JSX.Element> { + static getType(): string { + return "makima-steps-block"; + } + + static clone(node: StepsBlockNode): StepsBlockNode { + return new StepsBlockNode(node.__key); + } + + constructor(key?: NodeKey) { + super(key); + } + + createDOM(): HTMLElement { + const el = document.createElement("div"); + el.className = "makima-steps-block-host"; + return el; + } + + updateDOM(): false { + return false; + } + + static importJSON(_serializedNode: SerializedStepsBlockNode): StepsBlockNode { + return $createStepsBlockNode(); + } + + exportJSON(): SerializedStepsBlockNode { + return { + type: StepsBlockNode.getType(), + version: 1, + }; + } + + isInline(): boolean { + return false; + } + + isIsolated(): boolean { + // Isolated decorator nodes can't be partially selected — the user can only + // click into them, not drag a selection into them. That's what we want. + return true; + } + + isKeyboardSelectable(): boolean { + return true; + } + + decorate(): JSX.Element { + return <StepsBlock />; + } +} + +export function $createStepsBlockNode(): StepsBlockNode { + return new StepsBlockNode(); +} + +export function $isStepsBlockNode( + node: LexicalNode | null | undefined, +): node is StepsBlockNode { + return node instanceof StepsBlockNode; +} diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 81ca584..42e6a69 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router"; import { Masthead } from "../components/Masthead"; import { useDirective, useDirectives } from "../hooks/useDirectives"; import { useAuth } from "../contexts/AuthContext"; +import { DocumentEditor } from "../components/directives/DocumentEditor"; import type { DirectiveSummary, DirectiveStatus } from "../lib/api"; // Status dot color, matching the existing tabular UI's badge palette so the @@ -16,6 +17,27 @@ const STATUS_DOT: Record<DirectiveStatus, string> = { }; // ============================================================================= +// Sidebar grouping — group directives by lifecycle stage so the file tree +// reads like a folder per status. We collapse the noisy ones (Archived) by +// default and keep Active / Idle expanded. +// ============================================================================= + +type SidebarGroup = "active" | "idle" | "archived"; + +const GROUP_LABEL: Record<SidebarGroup, string> = { + active: "active", + idle: "idle", + archived: "archived", +}; + +function bucketOf(status: DirectiveStatus): SidebarGroup { + if (status === "active" || status === "paused") return "active"; + if (status === "archived") return "archived"; + // draft + idle land in the idle bucket (i.e. "not currently running"). + return "idle"; +} + +// ============================================================================= // Sidebar icons (inline SVG, no new deps) // ============================================================================= @@ -70,6 +92,20 @@ function FileIcon() { ); } +function Caret({ open }: { open: boolean }) { + return ( + <svg + viewBox="0 0 8 8" + width={8} + height={8} + className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`} + aria-hidden + > + <path d="M2 1l4 3-4 3z" fill="#7788aa" /> + </svg> + ); +} + // ============================================================================= // Sidebar // ============================================================================= @@ -82,6 +118,35 @@ interface SidebarProps { } function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) { + const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => { + const out: Record<SidebarGroup, DirectiveSummary[]> = { + active: [], + idle: [], + archived: [], + }; + for (const d of directives) { + out[bucketOf(d.status)].push(d); + } + // Sort each group alphabetically so it feels like a stable file tree. + (Object.keys(out) as SidebarGroup[]).forEach((k) => { + out[k].sort((a, b) => + a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), + ); + }); + return out; + }, [directives]); + + // Default-collapsed state per folder. Archived is collapsed by default + // (it's history); the other two are open so users see their work. + const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({ + active: true, + idle: true, + archived: false, + }); + + const toggleGroup = (g: SidebarGroup) => + setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); + return ( <div className="flex flex-col h-full"> {/* Sidebar header */} @@ -94,14 +159,14 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP </span> </div> - {/* "Folder" header */} + {/* Top-level "directives/" folder */} <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]"> <FolderIcon open /> <span>directives/</span> </div> - {/* Document list */} - <div className="flex-1 overflow-y-auto"> + {/* Body */} + <div className="flex-1 overflow-y-auto pb-4"> {loading && directives.length === 0 ? ( <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> Loading... @@ -111,40 +176,74 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP No directives yet </div> ) : ( - <ul className="py-1"> - {directives.map((d) => { - const isSelected = d.id === selectedId; - const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft; - // Slugify the title to look more like a file name in a tree. - const slug = d.title - .trim() - .replace(/\s+/g, "-") - .replace(/[^a-zA-Z0-9._-]/g, "") - .toLowerCase(); - const fileName = slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`; - return ( - <li key={d.id}> - <button - type="button" - onClick={() => onSelect(d.id)} - title={d.title} - className={`w-full text-left flex items-center gap-1.5 pl-6 pr-3 py-1 font-mono text-[11px] transition-colors ${ - isSelected - ? "bg-[rgba(117,170,252,0.12)] text-white" - : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]" - }`} - > - <FileIcon /> - <span - className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} - aria-hidden - /> - <span className="truncate">{fileName}</span> - </button> - </li> - ); - })} - </ul> + (Object.keys(groups) as SidebarGroup[]).map((group) => { + const list = groups[group]; + if (list.length === 0) return null; + const open = openGroups[group]; + return ( + <div key={group} className="select-none"> + {/* Group header (sub-folder) */} + <button + type="button" + onClick={() => toggleGroup(group)} + className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" + > + <Caret open={open} /> + <FolderIcon open={open} /> + <span>{GROUP_LABEL[group]}/</span> + <span className="ml-auto text-[10px] text-[#556677]"> + {list.length} + </span> + </button> + + {/* Files inside the group */} + {open && ( + <ul className="py-0.5"> + {list.map((d) => { + const isSelected = d.id === selectedId; + const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft; + const slug = d.title + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-zA-Z0-9._-]/g, "") + .toLowerCase(); + const fileName = + slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`; + const orchestratorRunning = !!d.orchestratorTaskId; + return ( + <li key={d.id}> + <button + type="button" + onClick={() => onSelect(d.id)} + title={d.title} + className={`w-full text-left flex items-center gap-1.5 pl-9 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" + }`} + > + <FileIcon /> + <span + className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} + aria-hidden + /> + <span className="truncate flex-1">{fileName}</span> + {orchestratorRunning && ( + <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" + /> + )} + </button> + </li> + ); + })} + </ul> + )} + </div> + ); + }) )} </div> </div> @@ -152,7 +251,8 @@ function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarP } // ============================================================================= -// Editor shell (placeholder — actual Lexical body lands in the next step) +// Editor shell — wraps DocumentEditor and handles the "no document selected" +// and loading states. // ============================================================================= interface EditorShellProps { @@ -162,7 +262,14 @@ interface EditorShellProps { } function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProps) { - const { directive, loading } = useDirective(selectedId); + const { + directive, + loading, + updateGoal, + cleanup, + createPR, + pickUpOrders, + } = useDirective(selectedId); if (!selectedId) { return ( @@ -196,24 +303,37 @@ function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProp return ( <div className="flex-1 flex flex-col h-full overflow-hidden"> - {/* Document header */} - <div className="px-6 py-4 border-b border-dashed border-[rgba(117,170,252,0.2)]"> - <div className="flex items-center gap-2 mb-1 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]"> + {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */} + <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]"> <FileIcon /> <span>directives /</span> <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span> + {!!directive.orchestratorTaskId && ( + <span className="ml-2 inline-flex items-center gap-1 text-yellow-400"> + <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" /> + orchestrator running + </span> + )} </div> - <h1 className="text-[18px] font-mono text-white truncate"> - {directive.title} - </h1> </div> - {/* Placeholder editor body — Lexical rich text comes in the next step. */} - <div className="flex-1 overflow-auto"> - <div className="max-w-3xl mx-auto px-6 py-10 font-mono text-[12px] text-[#556677]"> - Document editor coming soon… - </div> - </div> + {/* Lexical editor body */} + <DocumentEditor + directive={directive} + onUpdateGoal={async (goal) => { + await updateGoal(goal); + }} + onCleanup={async () => { + await cleanup(); + }} + onCreatePR={async () => { + await createPR(); + }} + onPickUpOrders={async () => { + await pickUpOrders(); + }} + /> </div> ); } @@ -234,13 +354,6 @@ export default function DocumentDirectivesPage() { } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - const sortedDirectives = useMemo(() => { - // Stable alphabetical order by title — feels right for a "file tree". - return [...directives].sort((a, b) => - a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), - ); - }, [directives]); - if (authLoading) { return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -262,14 +375,14 @@ export default function DocumentDirectivesPage() { {/* Left: file-tree sidebar */} <div className="w-[240px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> <DocumentSidebar - directives={sortedDirectives} + directives={directives} loading={listLoading} selectedId={selectedId ?? null} onSelect={(id) => navigate(`/directives/${id}`)} /> </div> - {/* Right: empty editor shell */} + {/* Right: Lexical editor */} <EditorShell selectedId={selectedId} hasDirectives={directives.length > 0} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index c078688..56c723a 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/doglist.tsx","./src/components/directives/directivecontextmenu.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/orchestratorstepnode.tsx","./src/components/directives/stepnode.tsx","./src/components/directives/taskslideoutpanel.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/orders/ordercontextmenu.tsx","./src/components/orders/orderdetail.tsx","./src/components/orders/orderlist.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usedogs.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/useorders.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useusersettings.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/daemons.tsx","./src/routes/directives.tsx","./src/routes/document-directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/orders.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/doglist.tsx","./src/components/directives/directivecontextmenu.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/documenteditor.tsx","./src/components/directives/orchestratorstepnode.tsx","./src/components/directives/stepnode.tsx","./src/components/directives/stepsblocknode.tsx","./src/components/directives/taskslideoutpanel.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/orders/ordercontextmenu.tsx","./src/components/orders/orderdetail.tsx","./src/components/orders/orderlist.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usedogs.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/useorders.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useusersettings.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/daemons.tsx","./src/routes/directives.tsx","./src/routes/document-directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/orders.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file |
