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>
---
 makima/frontend/src/routes/tmp.tsx | 93 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 93 insertions(+)
 create mode 100644 makima/frontend/src/routes/tmp.tsx

(limited to 'makima/frontend/src/routes/tmp.tsx')

diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx
new file mode 100644
index 0000000..69f13a2
--- /dev/null
+++ b/makima/frontend/src/routes/tmp.tsx
@@ -0,0 +1,93 @@
+/**
+ * Standalone task page for orphan tasks (`/tmp/:taskId`). These are tasks
+ * with no directive attachment — the document-mode sidebar surfaces them
+ * under the `tmp/` pseudo-folder. We render `DocumentTaskStream` directly
+ * without the directive sidebar selection, framed by the masthead so users
+ * still have global navigation.
+ */
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
+import { useAuth } from "../contexts/AuthContext";
+import { getTask, type Task } from "../lib/api";
+
+export default function TmpTaskPage() {
+  const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+  const navigate = useNavigate();
+  const { taskId } = useParams<{ taskId: string }>();
+  const [task, setTask] = useState<Task | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (!authLoading && isAuthConfigured && !isAuthenticated) {
+      navigate("/login");
+    }
+  }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+  useEffect(() => {
+    if (!taskId) return;
+    let cancelled = false;
+    getTask(taskId)
+      .then((t) => {
+        if (cancelled) return;
+        const directiveId = (t as Task & { directiveId?: string | null })
+          .directiveId ?? null;
+        // If this task actually IS attached to a directive, redirect
+        // there — /tmp/ is reserved for genuine orphans.
+        if (directiveId) {
+          navigate(`/directives/${directiveId}?task=${taskId}`, {
+            replace: true,
+          });
+          return;
+        }
+        setTask(t);
+      })
+      .catch((e) => {
+        if (!cancelled) setError(e instanceof Error ? e.message : String(e));
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [taskId, navigate]);
+
+  if (authLoading) {
+    return (
+      <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+        <Masthead showNav />
+        <main className="flex-1 flex items-center justify-center">
+          <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+        </main>
+      </div>
+    );
+  }
+
+  return (
+    <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+      <Masthead showNav />
+      <main
+        className="flex-1 flex flex-col overflow-hidden"
+        style={{ height: "calc(100vh - 80px)" }}
+      >
+        {/* Breadcrumb echoing the document-mode header style. */}
+        <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]">
+            <span>tmp /</span>
+            <span className="text-[#9bc3ff]">
+              {taskId ? taskId.slice(0, 8) : ""}
+            </span>
+            {task && <span className="normal-case text-[#dbe7ff]">— {task.name}</span>}
+          </div>
+        </div>
+
+        {error ? (
+          <div className="flex-1 flex items-center justify-center">
+            <p className="text-red-400 font-mono text-xs">{error}</p>
+          </div>
+        ) : taskId ? (
+          <DocumentTaskStream taskId={taskId} label={task?.name ?? taskId.slice(0, 8)} />
+        ) : null}
+      </main>
+    </div>
+  );
+}
-- 
cgit v1.2.3

