summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives')
-rw-r--r--makima/frontend/src/components/directives/DocumentTaskStream.tsx248
1 files changed, 213 insertions, 35 deletions
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.