From 4b1d608b839769052634b4facc345b891d468926 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 29 Apr 2026 01:10:11 +0100 Subject: feat: document-mode directive UI proof of concept (Lexical) (#101) * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Backend: feature flag + goal-edit interrupt messaging * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Frontend: Lexical document editor with step blocks, context menu, countdown --- .../src/components/directives/DocumentEditor.tsx | 664 +++++++++++++++++++++ .../src/components/directives/StepsBlockNode.tsx | 281 +++++++++ makima/frontend/src/hooks/useUserSettings.ts | 115 ++++ makima/frontend/src/lib/api.ts | 46 ++ makima/frontend/src/routes/directives.tsx | 33 + makima/frontend/src/routes/document-directives.tsx | 394 ++++++++++++ makima/frontend/src/routes/settings.tsx | 64 ++ 7 files changed, 1597 insertions(+) create mode 100644 makima/frontend/src/components/directives/DocumentEditor.tsx create mode 100644 makima/frontend/src/components/directives/StepsBlockNode.tsx create mode 100644 makima/frontend/src/hooks/useUserSettings.ts create mode 100644 makima/frontend/src/routes/document-directives.tsx (limited to 'makima/frontend/src') 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(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 ( + { + 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(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 ( +
+
+ Document +
+ + + +
+ ); +} + +// ============================================================================= +// 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 ( +
+
+
+
+
+ {label} + {state === "pending" && ( + + )} +
+
+ ); +} + +// ============================================================================= +// Main component +// ============================================================================= + +export interface DocumentEditorProps { + directive: DirectiveWithSteps; + onUpdateGoal: (goal: string) => Promise | void; + onCleanup: () => Promise | void; + onCreatePR: () => Promise | void; + onPickUpOrders: () => Promise | 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 + // 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("idle"); + const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS); + const pendingGoalRef = useRef(directive.goal); + const timerRef = useRef(null); + const tickRef = useRef(null); + const deadlineRef = useRef(0); + const editorRef = useRef(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 ( +
+ + + {/* Capture the editor ref via a tiny inline plugin */} + + + + + + + +
+
+ + Describe the directive's goal… +
+ } + 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} + /> +
+
+
+ + + + + {menu && ( + setMenu(null)} + onCleanup={() => { + void onCleanup(); + }} + onUpdatePR={() => { + void onCreatePR(); + }} + onPlanOrders={() => { + void onPickUpOrders(); + }} + /> + )} +
+ ); +} + +/** + * 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; +}) { + 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({ directive: null }); + +export const StepsBlockContextProvider = StepsBlockContext.Provider; + +// ============================================================================= +// Status palette (matches StepNode.tsx for consistency) +// ============================================================================= + +const STATUS_COLORS: Record = { + 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 = { + 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 ( +
+ + {String(step.orderIndex + 1).padStart(2, "0")} + +
+
+ + {step.name} + + + {label} + +
+ {step.description && ( +

+ {step.description} +

+ )} +
+
+ ); +} + +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 ( +
+
+ steps · loading +
+
+ ); + } + + 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 ( +
+ {/* Caption */} +
+
+ {isOrchestratorRunning && ( + + )} + + {caption} + +
+ {total > 0 && ( + + {completed}/{total} done + + )} +
+ + {/* Step diagram */} + {steps.length === 0 ? ( +
+ {isOrchestratorRunning + ? "Planner is generating steps…" + : "No steps yet — start the directive or plan orders to populate."} +
+ ) : ( +
    + {steps.map((step, idx) => ( +
  1. + + {idx < steps.length - 1 && ( +
    + )} +
  2. + ))} +
+ )} +
+ ); +} + +// ============================================================================= +// 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 { + 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 ; + } +} + +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/hooks/useUserSettings.ts b/makima/frontend/src/hooks/useUserSettings.ts new file mode 100644 index 0000000..b39244d --- /dev/null +++ b/makima/frontend/src/hooks/useUserSettings.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useState } from "react"; +import { + getUserSettings, + updateUserSettings, + type UserSettings, +} from "../lib/api"; + +const DEFAULT_SETTINGS: UserSettings = { documentModeEnabled: false }; + +// Module-level cache + pub-sub so multiple components mounting the hook stay +// in sync without a full provider/context. Toggling the flag in +// will reactively update if it's mounted, and vice versa. +let cachedSettings: UserSettings | null = null; +let inflight: Promise | null = null; +const subscribers = new Set<(s: UserSettings | null) => void>(); + +function notify() { + for (const sub of subscribers) sub(cachedSettings); +} + +function loadOnce(): Promise { + if (inflight) return inflight; + inflight = getUserSettings() + .then((s) => { + cachedSettings = s; + notify(); + }) + .catch((err) => { + // Swallow but log — fall back to safe defaults so the existing UI keeps + // rendering even if /settings endpoint is unavailable. + console.error("Failed to load user settings:", err); + cachedSettings = DEFAULT_SETTINGS; + notify(); + }) + .finally(() => { + inflight = null; + }); + return inflight; +} + +export interface UseUserSettingsResult { + /** Loaded settings, or null while loading for the first time. */ + settings: UserSettings | null; + /** True while the initial GET is in flight. */ + loading: boolean; + /** Update one or more settings; persists via PUT and updates the cache. */ + update: (patch: Partial) => Promise; + /** Force a refresh from the server (e.g. after sign-in). */ + refresh: () => Promise; +} + +/** + * React hook for the per-user settings record (feature flags). + * + * Calls GET /api/v1/users/me/settings on first mount and caches the result. + * Subsequent mounts read from the cache. `update()` PUTs to the server and + * notifies all live subscribers so UI gates reactively flip without a reload. + */ +export function useUserSettings(): UseUserSettingsResult { + const [settings, setSettings] = useState(cachedSettings); + const [loading, setLoading] = useState(cachedSettings === null); + + useEffect(() => { + let mounted = true; + const sub = (s: UserSettings | null) => { + if (!mounted) return; + setSettings(s); + setLoading(false); + }; + subscribers.add(sub); + + if (cachedSettings === null) { + loadOnce(); + } else { + // Already cached — make sure local state matches. + setSettings(cachedSettings); + setLoading(false); + } + + return () => { + mounted = false; + subscribers.delete(sub); + }; + }, []); + + const update = useCallback( + async (patch: Partial): Promise => { + const base = cachedSettings ?? DEFAULT_SETTINGS; + const merged: UserSettings = { ...base, ...patch }; + // Optimistic update so the toggle flips immediately. + cachedSettings = merged; + notify(); + try { + const result = await updateUserSettings(merged); + cachedSettings = result; + notify(); + return result; + } catch (err) { + // Roll back to last-known-good on failure. + cachedSettings = base; + notify(); + throw err; + } + }, + [], + ); + + const refresh = useCallback(async () => { + cachedSettings = null; + notify(); + await loadOnce(); + }, []); + + return { settings, loading, update, refresh }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index d597b44..8896f2c 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1695,6 +1695,52 @@ export async function deleteAccount( return res.json(); } +// ============================================================================= +// User Settings (per-user feature flags) +// ============================================================================= + +/** Per-user settings / feature flags. */ +export interface UserSettings { + /** Whether the new "document mode" UI is enabled for this user. */ + documentModeEnabled: boolean; +} + +/** Request body for updating user settings. */ +export interface UpdateUserSettingsRequest { + documentModeEnabled: boolean; +} + +/** + * Get the authenticated user's settings (feature flags). + */ +export async function getUserSettings(): Promise { + const res = await authFetch(`${API_BASE}/api/v1/users/me/settings`); + if (!res.ok) { + const errorData = await res.json().catch(() => null); + const errorMessage = errorData?.message || res.statusText; + throw new Error(errorMessage); + } + return res.json(); +} + +/** + * Replace the authenticated user's settings (feature flags). + */ +export async function updateUserSettings( + req: UpdateUserSettingsRequest +): Promise { + const res = await authFetch(`${API_BASE}/api/v1/users/me/settings`, { + method: "PUT", + body: JSON.stringify(req), + }); + if (!res.ok) { + const errorData = await res.json().catch(() => null); + const errorMessage = errorData?.message || res.statusText; + throw new Error(errorMessage); + } + return res.json(); +} + // ============================================================================= // Contract Types for Workflow Management // ============================================================================= diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index 8de0335..895c86a 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -5,10 +5,43 @@ import { DirectiveList } from "../components/directives/DirectiveList"; import { DirectiveDetail } from "../components/directives/DirectiveDetail"; import { useDirectives, useDirective } from "../hooks/useDirectives"; import { useDogs } from "../hooks/useDogs"; +import { useUserSettings } from "../hooks/useUserSettings"; import { useAuth } from "../contexts/AuthContext"; +import DocumentDirectivesPage from "./document-directives"; import { getRepositorySuggestions, startDirective, pauseDirective, updateDirective, type RepositoryHistoryEntry, type DirectiveSummary } from "../lib/api"; +/** + * Top-level /directives route. Gates between the legacy tabular UI and the + * Document Mode (POC) UI based on the user's settings flag. + * + * Both code paths support /directives/:id deep links — the param is read by + * each branch independently via useParams. + */ export default function DirectivesPage() { + const { settings, loading: settingsLoading } = useUserSettings(); + + // While settings are loading for the very first time, render nothing inside + // a Masthead-wrapped shell so we don't briefly flash the legacy UI just to + // swap to document mode a moment later. + if (settingsLoading && !settings) { + return ( +
+ +
+

Loading...

+
+
+ ); + } + + if (settings?.documentModeEnabled) { + return ; + } + + return ; +} + +function LegacyDirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx new file mode 100644 index 0000000..42e6a69 --- /dev/null +++ b/makima/frontend/src/routes/document-directives.tsx @@ -0,0 +1,394 @@ +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 +// document mode feels like a sibling of the existing list, not a foreign UI. +const STATUS_DOT: Record = { + draft: "bg-[#556677]", + active: "bg-green-400", + idle: "bg-yellow-400", + paused: "bg-orange-400", + archived: "bg-[#3a4a6a]", +}; + +// ============================================================================= +// 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 = { + 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) +// ============================================================================= + +function FolderIcon({ open = false }: { open?: boolean }) { + return ( + + {open ? ( + + ) : ( + + )} + + ); +} + +function FileIcon() { + return ( + + + + + ); +} + +function Caret({ open }: { open: boolean }) { + return ( + + + + ); +} + +// ============================================================================= +// Sidebar +// ============================================================================= + +interface SidebarProps { + directives: DirectiveSummary[]; + loading: boolean; + selectedId: string | null; + onSelect: (id: string) => void; +} + +function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) { + const groups: Record = useMemo(() => { + const out: Record = { + 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>({ + active: true, + idle: true, + archived: false, + }); + + const toggleGroup = (g: SidebarGroup) => + setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); + + return ( +
+ {/* Sidebar header */} +
+ + Documents + + + {directives.length} + +
+ + {/* Top-level "directives/" folder */} +
+ + directives/ +
+ + {/* Body */} +
+ {loading && directives.length === 0 ? ( +
+ Loading... +
+ ) : directives.length === 0 ? ( +
+ No directives yet +
+ ) : ( + (Object.keys(groups) as SidebarGroup[]).map((group) => { + const list = groups[group]; + if (list.length === 0) return null; + const open = openGroups[group]; + return ( +
+ {/* Group header (sub-folder) */} + + + {/* Files inside the group */} + {open && ( +
    + {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 ( +
  • + +
  • + ); + })} +
+ )} +
+ ); + }) + )} +
+
+ ); +} + +// ============================================================================= +// Editor shell — wraps DocumentEditor and handles the "no document selected" +// and loading states. +// ============================================================================= + +interface EditorShellProps { + selectedId: string | undefined; + hasDirectives: boolean; + listLoading: boolean; +} + +function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProps) { + const { + directive, + loading, + updateGoal, + cleanup, + createPR, + pickUpOrders, + } = useDirective(selectedId); + + if (!selectedId) { + return ( +
+

+ {listLoading + ? "Loading documents..." + : hasDirectives + ? "Select a document from the sidebar" + : "No documents yet — create one from the legacy UI"} +

+
+ ); + } + + if (loading && !directive) { + return ( +
+

Loading document...

+
+ ); + } + + if (!directive) { + return ( +
+

Document not found

+
+ ); + } + + return ( +
+ {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */} +
+
+ + directives / + {directive.id.slice(0, 8)} + {!!directive.orchestratorTaskId && ( + + + orchestrator running + + )} +
+
+ + {/* Lexical editor body */} + { + await updateGoal(goal); + }} + onCleanup={async () => { + await cleanup(); + }} + onCreatePR={async () => { + await createPR(); + }} + onPickUpOrders={async () => { + await pickUpOrders(); + }} + /> +
+ ); +} + +// ============================================================================= +// Page +// ============================================================================= + +export default function DocumentDirectivesPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + const { id: selectedId } = useParams<{ id: string }>(); + const { directives, loading: listLoading } = useDirectives(); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + if (authLoading) { + return ( +
+ +
+

Loading...

+
+
+ ); + } + + return ( +
+ +
+ {/* Left: file-tree sidebar */} +
+ navigate(`/directives/${id}`)} + /> +
+ + {/* Right: Lexical editor */} + 0} + listLoading={listLoading} + /> +
+
+ ); +} diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx index 73537bd..a77ad95 100644 --- a/makima/frontend/src/routes/settings.tsx +++ b/makima/frontend/src/routes/settings.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, type FormEvent } from "react"; import { useAuth } from "../contexts/AuthContext"; import { useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; +import { useUserSettings } from "../hooks/useUserSettings"; import { getApiKey, createApiKey, @@ -267,6 +268,11 @@ export default function SettingsPage() { const { user, isAuthConfigured, signOut } = useAuth(); const navigate = useNavigate(); + // User settings (feature flags) state + const { settings: userSettings, loading: userSettingsLoading, update: updateUserSettings } = useUserSettings(); + const [featureFlagSaving, setFeatureFlagSaving] = useState(false); + const [featureFlagError, setFeatureFlagError] = useState(null); + // API Key state const [apiKeyInfo, setApiKeyInfo] = useState(null); const [newKey, setNewKey] = useState(null); @@ -490,6 +496,21 @@ export default function SettingsPage() { } }; + // Feature flag toggle handlers + const handleToggleDocumentMode = async () => { + if (featureFlagSaving) return; + setFeatureFlagError(null); + setFeatureFlagSaving(true); + try { + const next = !(userSettings?.documentModeEnabled ?? false); + await updateUserSettings({ documentModeEnabled: next }); + } catch (err) { + setFeatureFlagError(err instanceof Error ? err.message : "Failed to update setting"); + } finally { + setFeatureFlagSaving(false); + } + }; + const passwordStrength = getPasswordStrength(passwordForm.newPassword); return ( @@ -789,6 +810,49 @@ export default function SettingsPage() { )} + {/* Feature Flags (POC) */} +
+ Feature Flags (POC) + {featureFlagError && {featureFlagError}} +
+ +
+
+ Document Mode for directives +
+

+ Replaces the tabular directives UI with a Lexical-based interactive + document editor. Proof of concept; expect rough edges. +

+ {(userSettingsLoading || featureFlagSaving) && ( +

+ {userSettingsLoading ? "Loading..." : "Saving..."} +

+ )} +
+
+
+ {/* Danger Zone */} {isAuthConfigured && user && (
-- cgit v1.2.3