From a2148d4e3117cdda2e1d0a8e3df289bfe04789a3 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
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) <noreply@anthropic.com>
---
 makima/frontend/src/routes/document-directives.tsx | 241 +++++++++++++++------
 1 file changed, 175 insertions(+), 66 deletions(-)

(limited to 'makima/frontend/src/routes/document-directives.tsx')

diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 687d86f..aba3613 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -3,6 +3,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router";
 import { Masthead } from "../components/Masthead";
 import { useDirective, useDirectives } from "../hooks/useDirectives";
 import { useAuth } from "../contexts/AuthContext";
+import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
 import { DocumentEditor } from "../components/directives/DocumentEditor";
 import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
 import type {
@@ -87,6 +88,40 @@ function FileIcon() {
   );
 }
 
+/** Terminal/prompt icon for orchestrator and step tasks. */
+function TaskIcon() {
+  return (
+    <svg
+      viewBox="0 0 16 16"
+      width={12}
+      height={12}
+      className="shrink-0"
+      aria-hidden
+    >
+      <rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#9bc3ff" strokeWidth="1" />
+      <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#9bc3ff" strokeWidth="1" fill="none" strokeLinecap="round" />
+    </svg>
+  );
+}
+
+/** PR-bracket icon for the completion task. */
+function CompletionIcon() {
+  return (
+    <svg
+      viewBox="0 0 16 16"
+      width={12}
+      height={12}
+      className="shrink-0"
+      aria-hidden
+    >
+      <circle cx="4" cy="4" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
+      <circle cx="4" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
+      <circle cx="12" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
+      <path d="M4 5.4v5.2 M4 12h6.6 M12 4l0 6.6" stroke="#9bc3ff" strokeWidth="1" fill="none" />
+    </svg>
+  );
+}
+
 function PinIcon() {
   return (
     <svg
@@ -147,10 +182,11 @@ interface SidebarProps {
 
 /**
  * Per-directive folder. Renders the directive as a collapsible folder whose
- * children are the pinned document entry (always first) and the live task list
- * — orchestrator, completion, and any step tasks. We fetch the directive's
- * full step list lazily, only when the folder is expanded, to avoid a thundering
- * herd of GETs at page load.
+ * body is the pinned document entry (always first) followed by a `tasks/`
+ * subfolder containing the orchestrator, completion, and step tasks.
+ *
+ * Status dot lives on the right side only (single-side, per the v2 design).
+ * If a directive or task has a pending user question, its icon glows.
  */
 function DirectiveFolder({
   directive,
@@ -158,12 +194,18 @@ function DirectiveFolder({
   onToggle,
   selection,
   onSelect,
+  pendingTaskIds,
+  hasPendingForDirective,
 }: {
   directive: DirectiveSummary;
   open: boolean;
   onToggle: () => void;
   selection: SidebarSelection | null;
   onSelect: (sel: SidebarSelection) => void;
+  /** Set of task ids that currently have pending user questions. */
+  pendingTaskIds: Set<string>;
+  /** Whether any pending question is associated with this directive. */
+  hasPendingForDirective: boolean;
 }) {
   const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
   const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;
@@ -177,6 +219,10 @@ function DirectiveFolder({
   // Collect the tasks to surface in the folder body.
   const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]);
 
+  const orchestratorRunning = !!directive.orchestratorTaskId;
+  // Tasks subfolder open state — independent of the directive folder.
+  const [tasksOpen, setTasksOpen] = useState<boolean>(true);
+
   return (
     <div className="select-none">
       <button
@@ -186,25 +232,15 @@ function DirectiveFolder({
         className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
       >
         <Caret open={open} />
-        {/* Color icon LEFT — the user explicitly asked for an icon, not a /status text label. */}
-        <span
-          className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`}
-          aria-label={`status: ${directive.status}`}
-          title={`status: ${directive.status}`}
-        />
         <FolderIcon open={open} />
         <span className="truncate flex-1 text-left">{directive.title}</span>
-        {/* And RIGHT — same dot, plus a pulsing one if the orchestrator is live. */}
-        {!!directive.orchestratorTaskId && (
-          <span
-            className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
-            title="Orchestrator running"
-            aria-label="Orchestrator running"
-          />
-        )}
-        <span
-          className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`}
-          aria-hidden
+        {/* Status dot — RIGHT side only. Glows when this directive has a
+            pending user question, or pulses when the orchestrator is live. */}
+        <StatusDot
+          color={dotColor}
+          live={orchestratorRunning}
+          glow={hasPendingForDirective}
+          status={directive.status}
         />
       </button>
 
@@ -229,57 +265,113 @@ function DirectiveFolder({
             </button>
           </li>
 
-          {tasks.length === 0 ? (
-            <li className="pl-10 pr-3 py-1 font-mono text-[10px] text-[#556677]">
-              No tasks yet
-            </li>
-          ) : (
-            tasks.map((t) => {
-              const isSelected =
-                selection?.directiveId === directive.id &&
-                selection?.taskId === t.taskId;
-              const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
-              const live =
-                t.status === "running" || t.kind === "orchestrator-active";
-              return (
-                <li key={t.taskId}>
-                  <button
-                    type="button"
-                    onClick={() =>
-                      onSelect({
-                        directiveId: directive.id,
-                        taskId: t.taskId,
-                      })
-                    }
-                    title={t.label}
-                    className={`w-full text-left flex items-center gap-1.5 pl-10 pr-3 py-1 font-mono text-[11px] transition-colors ${
-                      isSelected
-                        ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
-                        : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
-                    }`}
-                  >
-                    <span
-                      className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${tdot}`}
-                      aria-hidden
-                    />
-                    <span className="truncate flex-1">{t.label}</span>
-                    {live && (
-                      <span
-                        className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
-                        aria-hidden
-                      />
-                    )}
-                  </button>
-                </li>
-              );
-            })
-          )}
+          {/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */}
+          <li>
+            <button
+              type="button"
+              onClick={() => setTasksOpen((p) => !p)}
+              className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
+            >
+              <Caret open={tasksOpen} />
+              <FolderIcon open={tasksOpen} />
+              <span className="truncate flex-1 text-left">tasks/</span>
+              {tasks.length > 0 && (
+                <span className="text-[10px] text-[#556677]">{tasks.length}</span>
+              )}
+            </button>
+
+            {tasksOpen && (
+              <ul className="py-0.5">
+                {tasks.length === 0 ? (
+                  <li className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
+                    No tasks yet
+                  </li>
+                ) : (
+                  tasks.map((t) => {
+                    const isSelected =
+                      selection?.directiveId === directive.id &&
+                      selection?.taskId === t.taskId;
+                    const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
+                    const live =
+                      t.status === "running" || t.kind === "orchestrator-active";
+                    const glow = pendingTaskIds.has(t.taskId);
+                    const Icon =
+                      t.kind === "completion" ? CompletionIcon : TaskIcon;
+                    return (
+                      <li key={t.taskId}>
+                        <button
+                          type="button"
+                          onClick={() =>
+                            onSelect({
+                              directiveId: directive.id,
+                              taskId: t.taskId,
+                            })
+                          }
+                          title={t.label}
+                          className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
+                            isSelected
+                              ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
+                              : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
+                          }`}
+                        >
+                          <Icon />
+                          <span className="truncate flex-1">{t.label}</span>
+                          <StatusDot
+                            color={tdot}
+                            live={live}
+                            glow={glow}
+                            status={t.status}
+                          />
+                        </button>
+                      </li>
+                    );
+                  })
+                )}
+              </ul>
+            )}
+          </li>
         </ul>
       )}
     </div>
   );
 }
 
+/**
+ * Right-side status indicator. Composes the colored status dot with optional
+ * "live" pulse (orchestrator running) and "glow" attention ring (pending user
+ * question waiting on a response).
+ */
+function StatusDot({
+  color,
+  live,
+  glow,
+  status,
+}: {
+  color: string;
+  live: boolean;
+  glow: boolean;
+  status: string;
+}) {
+  // The glow is a soft amber ring pulsed via box-shadow. Keep it subtle so it
+  // doesn't fight the live pulse for attention when both are present.
+  const ring = glow
+    ? "shadow-[0_0_0_2px_rgba(251,191,36,0.45),0_0_8px_2px_rgba(251,191,36,0.55)] animate-pulse"
+    : "";
+  const livePulse = live && !glow ? "animate-pulse" : "";
+  const title = glow
+    ? `${status} — needs response`
+    : live
+    ? `${status} — running`
+    : `status: ${status}`;
+  return (
+    <span
+      className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`}
+      aria-label={title}
+      title={title}
+    />
+  );
+}
+
 interface FolderTaskRow {
   taskId: string;
   label: string;
@@ -342,6 +434,21 @@ interface SidebarProps {
 }
 
 function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) {
+  // Pending user questions — drives the "glow" attention ring. We split into
+  // two indices so the directive folder header glows whenever ANY of its
+  // tasks has a pending question, while individual task rows glow only for
+  // their own question.
+  const { pendingQuestions } = useSupervisorQuestions();
+  const { directivesWithPending, tasksWithPending } = useMemo(() => {
+    const dirs = new Set<string>();
+    const tasks = new Set<string>();
+    for (const q of pendingQuestions) {
+      if (q.directiveId) dirs.add(q.directiveId);
+      if (q.taskId) tasks.add(q.taskId);
+    }
+    return { directivesWithPending: dirs, tasksWithPending: tasks };
+  }, [pendingQuestions]);
+
   // Sort active first, then idle, then paused, then archived.
   const sorted = useMemo(() => {
     const order: Record<DirectiveStatus, number> = {
@@ -421,6 +528,8 @@ function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarPr
               onToggle={() => toggleOpen(d.id)}
               selection={selection}
               onSelect={onSelect}
+              pendingTaskIds={tasksWithPending}
+              hasPendingForDirective={directivesWithPending.has(d.id)}
             />
           ))
         )}
-- 
cgit v1.2.3

