From 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Fri, 1 May 2026 18:06:38 +0100
Subject: feat(doc-mode): unified surface — ephemeral tasks, tmp/, /exec
 redirect, palette, SWR (#117)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Phases 1-3 of the unified-surface plan, bundled per the user's request.
The directive document/folder UI is now the canonical place to interact
with makima; legacy /exec is subsumed by routing redirects, /contracts is
already hidden in nav, and orphan tasks surface under a top-level tmp/
pseudo-folder. Phase 4 (porting contract-only features) was confirmed
out of scope; Phase 5 (deleting the contracts code) is a follow-up.

## Backend

- New migration `20260501000000_archive_existing_contracts.sql` flips
  every legacy contract to `archived` so /contracts is read-only history.
- New endpoint `POST /api/v1/directives/{id}/tasks` creates an ephemeral
  task — `directive_id` set, `directive_step_id` NULL, repo/branch
  inherited from the directive. Reuses `create_task_for_owner`.
- New endpoint `GET /api/v1/directives/{id}/tasks` lists ephemeral tasks
  attached to a directive (drives the per-folder ephemeral group).
- `GET /api/v1/mesh/tasks?orphan=true` returns top-level tasks with no
  `directive_id` AND no `parent_task_id` — backs the sidebar's tmp/.
- New repo helpers `list_ephemeral_directive_tasks_for_owner` and
  `list_orphan_tasks_for_owner`.
- The existing `mesh_merge` endpoints are reused as-is for ephemeral
  task merge (no new merge logic needed).

## Frontend

### Sticky composer + auto-scroll fix (`DocumentTaskStream.tsx`)
- Sticky comment composer pinned to viewport bottom; padding compensates
  so the last entry isn't hidden behind it.
- `autoScroll` now resumes when the user scrolls back within 80px of
  the bottom (previously stuck off forever after a single scroll-up).
- Floating "↓ Jump to latest" chip when the user has scrolled away.
- Action header strip: explicit Stop / Send / Open-in-task-page +
  conditional "Merge to base ↗" button on ephemeral terminal tasks.
- Module-level cache of historical entries by taskId so re-selecting a
  task you've viewed renders instantly while a fresh fetch runs.

### Sidebar (`document-directives.tsx`)
- Top-level `tmp/` folder: orphan tasks, polled every 5s.
- Per-directive `tasks/` subfolder now also surfaces ephemeral tasks
  (lazily fetched on folder open) with a distinct asterisk-on-terminal
  icon (`EphemeralTaskIcon`).
- Inline hover-action chips on each directive folder header: Start /
  Pause / PR / +New task. Right-click menu still works as a power-user
  fallback.
- "Now executing" amber strip in the editor pane: surfaces the live
  orchestrator/completion/running-step task with a one-click jump.
- Inline `+ New task` modal (name + plan); on submit calls
  `createDirectiveTask` and navigates into the freshly-spawned task.
- New `EphemeralAwareTaskStream` wrapper passes `ephemeral` and
  `status` to `DocumentTaskStream` so the merge button only shows when
  the selected task is genuinely an ephemeral spinoff in a terminal
  state. Step-spawned tasks merge via the directive's PR completion.

### SWR cache (`useDirectives.ts`)
- Module-level `listCache` and per-id `detailCache` (mirrors the pattern
  in `useUserSettings.ts`). Mounting the hook renders the cache value
  immediately if present and kicks a background refresh; subscribers see
  the new value when it lands. Cuts perceived navigation latency to
  near-zero on warm cache hits.

### QuickSwitcher (`QuickSwitcher.tsx`, new)
- IntelliJ-style double-Shift command palette mounted at app root.
- Listens at the document level for two `Shift` keydowns within 300ms
  with no other key in between; ignores while focus is in an
  input/textarea so capitalising letters doesn't pop the palette.
- Searches across directives + their tasks (orchestrator/completion/
  steps/ephemerals) + orphan tmp tasks. Fuzzy matches on title.
- Eagerly loads task details for the first 20 directives on open so
  searches don't block on per-directive fetches.

### Routing (`main.tsx` + `exec-redirect.tsx` + `tmp.tsx`)
- New `ExecRedirect` wrapper at `/exec/:id`: when documentMode is on
  AND the task has a `directiveId`, replaces the URL with
  `/directives/<directiveId>?task=<taskId>`. Otherwise renders the
  legacy `MeshPage` as before.
- New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for
  orphan tasks, with the masthead and a `tmp / <slug>` breadcrumb.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../components/directives/DocumentTaskStream.tsx   | 248 ++++++++++++++++++---
 1 file changed, 213 insertions(+), 35 deletions(-)

(limited to 'makima/frontend/src/components/directives/DocumentTaskStream.tsx')

diff --git a/makima/frontend/src/components/directives/DocumentTaskStream.tsx b/makima/frontend/src/components/directives/DocumentTaskStream.tsx
index 62c1a52..b718ae4 100644
--- a/makima/frontend/src/components/directives/DocumentTaskStream.tsx
+++ b/makima/frontend/src/components/directives/DocumentTaskStream.tsx
@@ -5,39 +5,88 @@
  * Key differences from TaskOutput:
  *   - Document typography (serif-ish paragraphs, not monospace logs).
  *   - Interleaved with subtle marginalia for tool calls and results.
- *   - "Comment" footer interrupts the running task via sendTaskMessage —
- *     same backend wire as the existing input bar, just framed as a comment.
+ *   - Sticky comment composer at the bottom that's always in view.
+ *   - Header strip with explicit Stop / Send / Open-in-task-page buttons so
+ *     primary task controls don't require a right-click discovery step.
+ *   - Module-level cache of historical entries per taskId so re-selecting a
+ *     task you've already viewed renders instantly while a fresh fetch
+ *     refreshes in the background.
  */
 import { useCallback, useEffect, useRef, useState } from "react";
+import { useNavigate } from "react-router";
 import { SimpleMarkdown } from "../SimpleMarkdown";
 import {
   useTaskSubscription,
   type TaskOutputEvent,
 } from "../../hooks/useTaskSubscription";
-import { getTaskOutput, sendTaskMessage } from "../../lib/api";
+import { getTaskOutput, sendTaskMessage, stopTask } from "../../lib/api";
 
 interface DocumentTaskStreamProps {
   taskId: string;
   /** Human label used as the document header (e.g. "orchestrator", step name) */
   label: string;
+  /**
+   * When this task is ephemeral (spawned via the directive's "+ New task"
+   * action) AND has reached a terminal state, surface a "Merge to base"
+   * affordance that navigates the user to the standalone task page where
+   * the existing merge UI handles the actual merge / conflict flow.
+   *
+   * Step-spawned tasks have their own merge path (the directive's PR), so
+   * this affordance is intentionally off by default.
+   */
+  ephemeral?: boolean;
+  /** Current status of the task; drives whether merge button is enabled. */
+  status?: string;
 }
 
-export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
-  const [entries, setEntries] = useState<TaskOutputEvent[]>([]);
-  const [loading, setLoading] = useState(true);
+// =============================================================================
+// Module-level cache for historical task entries.
+//
+// Switching between tasks you've already viewed used to re-fire
+// getTaskOutput and show "Loading transcript…" for the duration of the
+// network round-trip. We now keep the entries cached per taskId; on
+// re-selection we render the cache immediately and refetch in the
+// background. The WS subscription continues to handle live deltas.
+// =============================================================================
+const entriesCache = new Map<string, TaskOutputEvent[]>();
+
+export function DocumentTaskStream({
+  taskId,
+  label,
+  ephemeral,
+  status,
+}: DocumentTaskStreamProps) {
+  const navigate = useNavigate();
+  const [entries, setEntries] = useState<TaskOutputEvent[]>(
+    () => entriesCache.get(taskId) ?? [],
+  );
+  const [loading, setLoading] = useState(!entriesCache.has(taskId));
   const [isStreaming, setIsStreaming] = useState(false);
   const [comment, setComment] = useState("");
   const [sending, setSending] = useState(false);
   const [sendError, setSendError] = useState<string | null>(null);
+  const [stopping, setStopping] = useState(false);
   const containerRef = useRef<HTMLDivElement>(null);
-  const [autoScroll, setAutoScroll] = useState(true);
+  const composerRef = useRef<HTMLDivElement>(null);
+  // autoScroll lives in a ref so the scroll handler reads the latest value
+  // synchronously without re-creating the effect.
+  const autoScrollRef = useRef(true);
+  const [showResumeScroll, setShowResumeScroll] = useState(false);
 
-  // Load historical output when the selected task changes.
+  // Load historical output when the selected task changes. Render the cache
+  // immediately if we have it; refetch in the background regardless.
   useEffect(() => {
     let cancelled = false;
-    setLoading(true);
-    setEntries([]);
+    const cached = entriesCache.get(taskId);
+    if (cached) {
+      setEntries(cached);
+      setLoading(false);
+    } else {
+      setEntries([]);
+      setLoading(true);
+    }
     setIsStreaming(false);
+
     getTaskOutput(taskId)
       .then((res) => {
         if (cancelled) return;
@@ -52,6 +101,7 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
           durationMs: e.durationMs,
           isPartial: false,
         }));
+        entriesCache.set(taskId, mapped);
         setEntries(mapped);
       })
       .catch((err) => {
@@ -67,17 +117,27 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
     };
   }, [taskId]);
 
-  const handleOutput = useCallback((event: TaskOutputEvent) => {
-    if (event.isPartial) return;
-    setEntries((prev) => [...prev, event]);
-    setIsStreaming(true);
-  }, []);
+  const handleOutput = useCallback(
+    (event: TaskOutputEvent) => {
+      if (event.isPartial) return;
+      setEntries((prev) => {
+        const next = [...prev, event];
+        entriesCache.set(taskId, next);
+        return next;
+      });
+      setIsStreaming(true);
+    },
+    [taskId],
+  );
 
   const handleUpdate = useCallback((event: { status: string }) => {
     if (
       event.status === "completed" ||
       event.status === "failed" ||
-      event.status === "cancelled"
+      event.status === "cancelled" ||
+      event.status === "interrupted" ||
+      event.status === "merged" ||
+      event.status === "done"
     ) {
       setIsStreaming(false);
     } else if (event.status === "running") {
@@ -92,18 +152,32 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
     onUpdate: handleUpdate,
   });
 
-  // Auto-scroll while at bottom.
+  // Auto-scroll while at bottom. The previous version only flipped autoScroll
+  // off and never resumed; now a scroll back into the bottom 80px reactivates
+  // it so a brief read-up doesn't permanently freeze the stream at the top.
+  useEffect(() => {
+    if (autoScrollRef.current && containerRef.current) {
+      containerRef.current.scrollTop = containerRef.current.scrollHeight;
+    }
+  }, [entries]);
+
+  // After loading the initial transcript, snap to the bottom unconditionally
+  // so users see the latest output, not the start.
   useEffect(() => {
-    if (autoScroll && containerRef.current) {
+    if (!loading && containerRef.current) {
       containerRef.current.scrollTop = containerRef.current.scrollHeight;
+      autoScrollRef.current = true;
+      setShowResumeScroll(false);
     }
-  }, [entries, autoScroll]);
+  }, [loading, taskId]);
 
   const handleScroll = useCallback(() => {
     if (!containerRef.current) return;
     const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
-    const atBottom = scrollHeight - scrollTop - clientHeight < 80;
-    setAutoScroll(atBottom);
+    const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
+    const atBottom = distanceFromBottom < 80;
+    autoScrollRef.current = atBottom;
+    setShowResumeScroll(!atBottom);
   }, []);
 
   const submitComment = useCallback(
@@ -114,15 +188,19 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
       setSending(true);
       setSendError(null);
       // Show the comment immediately as a user-input entry.
-      setEntries((prev) => [
-        ...prev,
-        {
-          taskId,
-          messageType: "user_input",
-          content: trimmed,
-          isPartial: false,
-        },
-      ]);
+      setEntries((prev) => {
+        const next: TaskOutputEvent[] = [
+          ...prev,
+          {
+            taskId,
+            messageType: "user_input",
+            content: trimmed,
+            isPartial: false,
+          },
+        ];
+        entriesCache.set(taskId, next);
+        return next;
+      });
       try {
         await sendTaskMessage(taskId, trimmed);
         setComment("");
@@ -138,15 +216,94 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
     [comment, sending, taskId],
   );
 
+  const handleStop = useCallback(async () => {
+    if (stopping || !isStreaming) return;
+    if (!window.confirm("Stop this task? It will be marked failed.")) return;
+    setStopping(true);
+    try {
+      await stopTask(taskId);
+    } catch (err) {
+      // eslint-disable-next-line no-console
+      console.error("Failed to stop task", err);
+    } finally {
+      setStopping(false);
+    }
+  }, [taskId, stopping, isStreaming]);
+
+  const focusComposer = useCallback(() => {
+    const input = composerRef.current?.querySelector("textarea");
+    input?.focus();
+  }, []);
+
+  const resumeScroll = useCallback(() => {
+    if (!containerRef.current) return;
+    containerRef.current.scrollTop = containerRef.current.scrollHeight;
+    autoScrollRef.current = true;
+    setShowResumeScroll(false);
+  }, []);
+
   return (
-    <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]">
+    <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628] relative">
+      {/* Action header strip — explicit Stop / Send / Open-in-task-page so
+          users don't have to right-click to discover task controls. */}
+      <div className="shrink-0 flex items-center gap-2 px-6 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)] bg-[#091428]">
+        <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
+          Task actions
+        </span>
+        <button
+          type="button"
+          onClick={focusComposer}
+          className="ml-auto px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400"
+        >
+          Send (⌘↵)
+        </button>
+        <button
+          type="button"
+          onClick={handleStop}
+          disabled={!isStreaming || stopping}
+          className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-amber-300 border border-amber-600/60 hover:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed"
+        >
+          {stopping ? "Stopping…" : "Stop"}
+        </button>
+
+        {/* Manual merge affordance — visible only on ephemeral tasks that
+            have reached a terminal state. Navigates to the standalone task
+            page where the existing mesh_merge UI drives the real merge /
+            conflict resolution flow. The user explicitly asked for this to
+            be a manual button press for safety. */}
+        {ephemeral && isTerminalStatus(status) && (
+          <button
+            type="button"
+            onClick={() => {
+              const ok = window.confirm(
+                "Merge this ephemeral task into the base branch? You'll be taken to the task page where the merge runs and any conflicts are resolved.",
+              );
+              if (!ok) return;
+              navigate(`/exec/${taskId}#merge`);
+            }}
+            title="Manual merge — opens the merge UI on the task page"
+            className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400"
+          >
+            Merge to base ↗
+          </button>
+        )}
+
+        <button
+          type="button"
+          onClick={() => navigate(`/exec/${taskId}`)}
+          className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-[#9bc3ff] border border-[rgba(117,170,252,0.35)] hover:border-[#75aafc]"
+        >
+          Open in task page
+        </button>
+      </div>
+
       {/* Document body */}
       <div
         ref={containerRef}
         onScroll={handleScroll}
         className="flex-1 overflow-y-auto"
       >
-        <div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]">
+        <div className="max-w-3xl mx-auto px-8 py-10 pb-32 text-[#dbe7ff]">
           <div className="flex items-center gap-3 mb-1">
             <h1 className="text-[24px] font-medium text-white tracking-tight">
               {label}
@@ -183,8 +340,23 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
         </div>
       </div>
 
-      {/* Comment / interrupt footer */}
-      <div className="shrink-0 border-t border-dashed border-[rgba(117,170,252,0.25)] bg-[#091428]">
+      {/* "Resume auto-scroll" floating chip when the user has scrolled up. */}
+      {showResumeScroll && (
+        <button
+          type="button"
+          onClick={resumeScroll}
+          className="absolute bottom-32 right-6 z-10 px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-[#9bc3ff] bg-[#091428] border border-[rgba(117,170,252,0.4)] hover:border-[#75aafc] shadow-lg"
+        >
+          ↓ Jump to latest
+        </button>
+      )}
+
+      {/* Sticky comment composer — always pinned to the viewport bottom so
+          users can interact with the task no matter where they've scrolled. */}
+      <div
+        ref={composerRef}
+        className="absolute bottom-0 left-0 right-0 border-t border-dashed border-[rgba(117,170,252,0.25)] bg-[#091428]/95 backdrop-blur"
+      >
         {sendError && (
           <div className="px-6 py-1 bg-red-900/20 text-red-400 text-xs font-mono">
             {sendError}
@@ -192,7 +364,7 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {
         )}
         <form
           onSubmit={submitComment}
-          className="max-w-3xl mx-auto px-8 py-4 flex items-start gap-3"
+          className="max-w-3xl mx-auto px-8 py-3 flex items-start gap-3"
         >
           <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide pt-2 shrink-0">
             Comment
@@ -325,6 +497,12 @@ function DocumentEntry({ entry }: { entry: TaskOutputEvent }) {
   }
 }
 
+/** Terminal task statuses where the merge button is meaningful. */
+function isTerminalStatus(status?: string): boolean {
+  if (!status) return false;
+  return ["done", "completed", "merged"].includes(status);
+}
+
 function firstLineOfInput(input?: Record<string, unknown>): string {
   if (!input) return "";
   // Common shapes — show the most informative single value.
-- 
cgit v1.2.3

