chat: pin background tasks above the input and let you look inside them

Background tasks already had a row in the transcript, but a row scrolls away — and a
task started ten minutes ago is precisely the one you want to keep an eye on. The same
`role: 'task'` rows now also drive a tray docked above the chat input: a chip per task,
running ones pulsing, finished ones dismissable.

Clicking a chip opens what the task is actually doing right now. Nothing about that
crosses the wire between `task:started` and the notification, so it is read from the
file Claude Code streams the task into:

  $TMPDIR/claude-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.output

For a backgrounded shell that file IS the log; for an agent it is a symlink to the
subagent's own transcript, which is ordinary session JSONL and so parses with the
reader we already had. Both kinds are therefore reachable from one directory.

Resolution is by task id alone, deliberately: the client learns a task id from
`task:started` and nothing else — officer's per-connection session key is not Claude's
session uuid, and the uuid only arrives with the turn result, long after the tray needs
to show the task. A task that has not written anything yet answers 200 `{kind:'pending'}`
rather than 404, because that is the ordinary first second of a task's life.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:40:26 +00:00
co-authored by Claude Opus 5
parent 6fde7afd7f
commit 82aa39a05f
6 changed files with 365 additions and 2 deletions
@@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown, CircleSlash, Loader2, X } from 'lucide-react';
import type { ChatMessage } from '../types';
import { useBackgroundTasks, useTaskDetail, type BackgroundTask } from '../useBackgroundTasks';
import { SubagentTrace } from './ToolActivity';
type BackgroundTaskTrayProps = {
messages: ChatMessage[];
};
/**
* The background tasks of this conversation, docked above the input.
*
* They are also rows in the transcript, and stay there — this is the same information pinned where it
* doesn't scroll away, because a task you started ten minutes ago is the one you most want to watch.
* Clicking a chip opens what it is actually doing right now: a subagent's own tool-by-tool trace, or the
* tail of a backgrounded shell's log, both read from the file Claude Code streams the task into.
*/
export const BackgroundTaskTray = ({ messages }: BackgroundTaskTrayProps) => {
const { tasks, running, dismissFinished } = useBackgroundTasks(messages);
const [openId, setOpenId] = useState<string | null>(null);
// Derived, not stored: dismissing the chip that is open closes the panel without a second piece of state
// having to be kept in step with the first.
const open = tasks.find((t) => t.taskId === openId) ?? null;
if (tasks.length === 0) return null;
return (
<div className="mb-2 rounded-lg border border-duck-dark/10 bg-duck-dark/[0.03]">
{open && <TaskPanel task={open} onClose={() => setOpenId(null)} />}
<div className="flex items-center gap-2 px-2 py-1.5">
<span className="shrink-0 text-[10px] uppercase tracking-wider text-duck-dark/40">
{running.length > 0 ? `${running.length} running` : 'Background'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{tasks.map((task) => (
<TaskChip
key={task.taskId}
task={task}
active={task.taskId === openId}
onClick={() => setOpenId(task.taskId === openId ? null : task.taskId)}
/>
))}
</div>
{tasks.length > running.length && (
<button
type="button"
onClick={dismissFinished}
title="Clear finished"
className="shrink-0 rounded p-1 text-duck-dark/30 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark/60 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
};
// ── Chip ──
type TaskChipProps = {
task: BackgroundTask;
active: boolean;
onClick: () => void;
};
const TaskChip = ({ task, active, onClick }: TaskChipProps) => {
const { status } = task;
const tone =
status === 'completed'
? 'text-green-600'
: status === 'failed'
? 'text-red-600'
: status === 'stopped'
? 'text-duck-dark/40'
: 'text-amber-500';
return (
<button
type="button"
onClick={onClick}
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs transition-colors cursor-pointer ${
active
? 'border-duck-teal/40 bg-duck-teal/10 text-duck-dark/80'
: 'border-duck-dark/10 bg-background/60 text-duck-dark/60 hover:border-duck-dark/20 hover:text-duck-dark/80'
}`}
>
{status === 'completed' ? (
<Check className={`h-3 w-3 ${tone}`} />
) : status ? (
<CircleSlash className={`h-3 w-3 ${tone}`} />
) : (
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-amber-400" />
)}
<span className="max-w-[14rem] truncate">{task.description || 'Background task'}</span>
</button>
);
};
// ── Panel ──
type TaskPanelProps = {
task: BackgroundTask;
onClose: () => void;
};
const TaskPanel = ({ task, onClose }: TaskPanelProps) => {
const running = !task.status;
const { data, isLoading } = useTaskDetail(task.taskId, running);
const bodyRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef(true);
// Follow the tail while the owner is at the bottom, and stop the moment they scroll up to read
// something — a live trace that yanks itself back down is unreadable.
useEffect(() => {
const body = bodyRef.current;
if (body && pinnedRef.current) body.scrollTop = body.scrollHeight;
}, [data]);
return (
<div className="border-b border-duck-dark/10">
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-duck-dark/70">{task.description || 'Background task'}</div>
<div className="truncate text-[10px] text-duck-dark/40">
{task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : 'running…'}
</div>
</div>
{running && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-amber-500" />}
<button
type="button"
onClick={onClose}
className="shrink-0 rounded p-1 text-duck-dark/30 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark/60 cursor-pointer"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<div
ref={bodyRef}
onScroll={(ev) => {
const el = ev.currentTarget;
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className="max-h-64 overflow-y-auto border-t border-duck-dark/10 px-2 py-1.5"
>
<TaskBody detail={data} isLoading={isLoading} />
</div>
</div>
);
};
type TaskBodyProps = {
detail: ReturnType<typeof useTaskDetail>['data'];
isLoading: boolean;
};
const TaskBody = ({ detail, isLoading }: TaskBodyProps) => {
if (isLoading && !detail) return <Placeholder text="Reading…" />;
if (!detail || detail.kind === 'pending') return <Placeholder text="Nothing written yet." />;
if (detail.kind === 'log') {
return (
<>
{detail.truncated && <div className="mb-1 text-[10px] text-duck-dark/40"> earlier output trimmed</div>}
<pre className="whitespace-pre-wrap break-all rounded bg-gray-900 p-2 font-mono text-[11px] text-green-400">
{detail.text || '(no output yet)'}
</pre>
</>
);
}
if (detail.messages.length === 0) return <Placeholder text="Nothing written yet." />;
return <SubagentTrace messages={detail.messages} />;
};
const Placeholder = ({ text }: { text: string }) => <div className="px-1 py-2 text-xs text-duck-dark/40">{text}</div>;
@@ -7,6 +7,7 @@ import { ModelSelector } from './ModelSelector';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
import { BackgroundTaskTray } from './BackgroundTaskTray';
type InputAreaProps = {
manager: UseEmbeddableChatType;
@@ -47,6 +48,8 @@ export const InputArea = ({ manager }: InputAreaProps) => {
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
)}
<BackgroundTaskTray messages={messages} />
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
<div className="flex items-end gap-1 md:gap-2">
@@ -75,7 +78,12 @@ export const InputArea = ({ manager }: InputAreaProps) => {
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{isGenerating ? (
<Button onClick={stopGeneration} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
<Button
onClick={stopGeneration}
variant="destructive"
size="icon"
className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer"
>
<Square className="h-4 w-4" />
</Button>
) : (
@@ -145,7 +145,7 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
* What a subagent did, in order — its own tool calls nested one level further, its prose as plain text.
* Deliberately not markdown-rendered: this is a trace, and it sits inside an already-nested panel.
*/
const SubagentTrace = ({ messages }: { messages: ChatMessage[] }) => (
export const SubagentTrace = ({ messages }: { messages: ChatMessage[] }) => (
<div className="space-y-1">
{messages.map((m, i) =>
m.role === 'tool' ? (
@@ -0,0 +1,60 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { ChatMessage } from './types';
export type BackgroundTask = Extract<ChatMessage, { role: 'task' }>;
/**
* The background tasks of a conversation, lifted out of the transcript.
*
* They already exist as `role: 'task'` rows in `messages` — but a row scrolls away, and a task that was
* started three screens ago is exactly the one you want to keep an eye on. This reads the same rows and
* hands them to a tray that stays put above the input.
*
* Finished tasks stay in the tray until dismissed rather than vanishing on completion: the notification
* lands while you are reading something else, and a chip that removes itself the instant it succeeds is a
* chip you never saw.
*/
export function useBackgroundTasks(messages: ChatMessage[]) {
const [dismissed, setDismissed] = useState<string[]>([]);
const tasks = useMemo(() => {
const byId = new Map<string, BackgroundTask>();
for (const m of messages) if (m.role === 'task') byId.set(m.taskId, m);
return [...byId.values()];
}, [messages]);
const visible = tasks.filter((t) => !dismissed.includes(t.taskId));
const running = visible.filter((t) => !t.status);
return {
tasks: visible,
running,
/** Clear the finished chips. Running ones are never dismissable — they'd leave nothing watching them. */
dismissFinished: () => setDismissed(tasks.filter((t) => t.status).map((t) => t.taskId)),
};
}
export type TaskDetail =
| { kind: 'pending' }
| { kind: 'agent'; messages: ChatMessage[] }
| { kind: 'log'; text: string; truncated: boolean };
/**
* What one task is doing, from the file Claude Code streams it into. Polled while the task is running and
* fetched once more after it finishes, so the panel settles on the final state instead of the last poll.
*/
export function useTaskDetail(taskId: string | null, running: boolean) {
const { get } = useClient();
return useQuery({
// `running` is part of the key on purpose. Polling stops the moment a task finishes, and the last poll
// fired up to two seconds before the end — so without a key change the panel would keep showing a
// trace that stops just short of the last thing the task did.
queryKey: ['chat', 'task', taskId, running],
queryFn: () => get<TaskDetail>(`/chat/tasks/${taskId}`),
enabled: !!taskId,
refetchInterval: running ? 2000 : false,
staleTime: 0,
});
}