From 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef Mon Sep 17 00:00:00 2001 From: soryu 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/?task=`. Otherwise renders the legacy `MeshPage` as before. - New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for orphan tasks, with the masthead and a `tmp / ` breadcrumb. Co-authored-by: Claude Opus 4.7 (1M context) --- .../components/directives/DocumentTaskStream.tsx | 248 ++++++++++++++++++--- 1 file changed, 213 insertions(+), 35 deletions(-) (limited to 'makima/frontend/src/components/directives') 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([]); - 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(); + +export function DocumentTaskStream({ + taskId, + label, + ephemeral, + status, +}: DocumentTaskStreamProps) { + const navigate = useNavigate(); + const [entries, setEntries] = useState( + () => 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(null); + const [stopping, setStopping] = useState(false); const containerRef = useRef(null); - const [autoScroll, setAutoScroll] = useState(true); + const composerRef = useRef(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 ( -
+
+ {/* Action header strip — explicit Stop / Send / Open-in-task-page so + users don't have to right-click to discover task controls. */} +
+ + Task actions + + + + + {/* 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) && ( + + )} + + +
+ {/* Document body */}
-
+

{label} @@ -183,8 +340,23 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) {

- {/* Comment / interrupt footer */} -
+ {/* "Resume auto-scroll" floating chip when the user has scrolled up. */} + {showResumeScroll && ( + + )} + + {/* Sticky comment composer — always pinned to the viewport bottom so + users can interact with the task no matter where they've scrolled. */} +
{sendError && (
{sendError} @@ -192,7 +364,7 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { )}
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 { if (!input) return ""; // Common shapes — show the most informative single value. -- cgit v1.2.3