1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
|
/**
* DocumentTaskStream — renders a running task's output as a flowing document
* (assistant prose, tool blocks) instead of the boxy log style of TaskOutput.
*
* Key differences from TaskOutput:
* - Document typography (serif-ish paragraphs, not monospace logs).
* - Interleaved with subtle marginalia for tool calls and results.
* - 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, 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;
}
// =============================================================================
// 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 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. Render the cache
// immediately if we have it; refetch in the background regardless.
useEffect(() => {
let cancelled = false;
const cached = entriesCache.get(taskId);
if (cached) {
setEntries(cached);
setLoading(false);
} else {
setEntries([]);
setLoading(true);
}
setIsStreaming(false);
getTaskOutput(taskId)
.then((res) => {
if (cancelled) return;
const mapped: TaskOutputEvent[] = res.entries.map((e) => ({
taskId: e.taskId,
messageType: e.messageType,
content: e.content,
toolName: e.toolName,
toolInput: e.toolInput,
isError: e.isError,
costUsd: e.costUsd,
durationMs: e.durationMs,
isPartial: false,
}));
entriesCache.set(taskId, mapped);
setEntries(mapped);
})
.catch((err) => {
if (cancelled) return;
// eslint-disable-next-line no-console
console.error("Failed to load task output history:", err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [taskId]);
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 === "interrupted" ||
event.status === "merged" ||
event.status === "done"
) {
setIsStreaming(false);
} else if (event.status === "running") {
setIsStreaming(true);
}
}, []);
useTaskSubscription({
taskId,
subscribeOutput: true,
onOutput: handleOutput,
onUpdate: handleUpdate,
});
// 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 (!loading && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
autoScrollRef.current = true;
setShowResumeScroll(false);
}
}, [loading, taskId]);
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const atBottom = distanceFromBottom < 80;
autoScrollRef.current = atBottom;
setShowResumeScroll(!atBottom);
}, []);
const submitComment = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = comment.trim();
if (!trimmed || sending) return;
setSending(true);
setSendError(null);
// Show the comment immediately as a user-input entry.
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("");
} catch (err) {
setSendError(
err instanceof Error ? err.message : "Failed to send comment",
);
window.setTimeout(() => setSendError(null), 5000);
} finally {
setSending(false);
}
},
[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] 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 pb-32 text-[#dbe7ff]">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-[24px] font-medium text-white tracking-tight">
{label}
</h1>
{isStreaming && (
<span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
Live
</span>
)}
</div>
<p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-8">
Live transcript — comments below are sent to the task as input.
</p>
{loading && entries.length === 0 ? (
<p className="text-[#556677] font-mono text-xs italic">
Loading transcript…
</p>
) : entries.length === 0 ? (
<p className="text-[#556677] font-mono text-xs italic">
{isStreaming ? "Waiting for output…" : "No output yet."}
</p>
) : (
<div className="space-y-4">
{entries.map((entry, idx) => (
<DocumentEntry key={idx} entry={entry} />
))}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse align-baseline" />
)}
</div>
)}
</div>
</div>
{/* "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}
</div>
)}
<form
onSubmit={submitComment}
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
</span>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyDown={(e) => {
// ⌘/Ctrl-Enter submits.
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
void submitComment(e as unknown as React.FormEvent);
}
}}
placeholder={
isStreaming
? "Add a comment to interrupt and redirect…"
: "Task is not streaming — comments will queue if accepted."
}
rows={2}
disabled={sending}
className="flex-1 bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none"
/>
<button
type="submit"
disabled={sending || !comment.trim()}
className="px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
>
{sending ? "Sending…" : "Send"}
</button>
</form>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Entry rendering — document-style, not log-style.
// ---------------------------------------------------------------------------
function DocumentEntry({ entry }: { entry: TaskOutputEvent }) {
switch (entry.messageType) {
case "user_input":
return (
<blockquote className="border-l-2 border-cyan-400/60 pl-4 py-1 italic text-cyan-200">
<span className="not-italic text-[10px] font-mono text-cyan-400 uppercase tracking-wide block mb-1">
You
</span>
{entry.content}
</blockquote>
);
case "assistant":
return (
<div className="leading-relaxed text-[14px]">
<SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" />
</div>
);
case "system":
return (
<p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
{entry.content}
</p>
);
case "tool_use":
return (
<p className="text-[11px] font-mono text-[#7788aa] flex items-center gap-2">
<span className="text-yellow-500">·</span>
<span className="text-[#75aafc]">{entry.toolName || "tool"}</span>
{firstLineOfInput(entry.toolInput) && (
<span className="text-[#445566] truncate">
{firstLineOfInput(entry.toolInput)}
</span>
)}
</p>
);
case "tool_result":
if (!entry.content) return null;
return (
<p className="text-[11px] font-mono pl-4">
<span className={entry.isError ? "text-red-400" : "text-emerald-400"}>
{entry.isError ? "✗" : "→"}
</span>{" "}
<span className="text-[#7788aa]">
{entry.content.split("\n")[0]}
{entry.content.includes("\n") && "…"}
</span>
</p>
);
case "result":
return (
<div className="border-t border-[rgba(117,170,252,0.15)] pt-3 mt-6">
<p className="text-[10px] font-mono text-emerald-400 uppercase tracking-wide mb-2">
Result
</p>
<div className="leading-relaxed text-[13px]">
<SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" />
</div>
{(entry.costUsd !== undefined || entry.durationMs !== undefined) && (
<p className="text-[10px] font-mono text-[#556677] mt-2">
{entry.durationMs !== undefined &&
`Duration: ${(entry.durationMs / 1000).toFixed(1)}s`}
{entry.costUsd !== undefined && entry.durationMs !== undefined && " · "}
{entry.costUsd !== undefined &&
`Cost: $${entry.costUsd.toFixed(4)}`}
</p>
)}
</div>
);
case "error":
return (
<p className="border-l-2 border-red-400/60 pl-4 py-1 text-red-300 text-[13px]">
{entry.content}
</p>
);
default:
// Fall back to a quiet rendering for unknown message types so users
// still see the data, just inconspicuously.
if (!entry.content) return null;
return (
<p className="text-[11px] font-mono text-[#556677]">
{entry.content}
</p>
);
}
}
/** 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.
for (const key of ["command", "file_path", "path", "url", "pattern", "query"]) {
const v = input[key];
if (typeof v === "string" && v.length > 0) {
return v.split("\n")[0].slice(0, 96);
}
}
return "";
}
|