From 82aa39a05f9764e9ed7030d2ddb180339cddcc41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 19:40:26 +0000 Subject: [PATCH] chat: pin background tasks above the input and let you look inside them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-///tasks/.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 --- src/servers/api/chat/chat.ts | 13 ++ src/servers/api/chat/claude-sessions.ts | 102 ++++++++++ .../Chat/components/BackgroundTaskTray.tsx | 180 ++++++++++++++++++ .../src/apps/Chat/components/InputArea.tsx | 10 +- .../src/apps/Chat/components/ToolActivity.tsx | 2 +- .../src/apps/Chat/useBackgroundTasks.ts | 60 ++++++ 6 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx create mode 100644 src/workspaces/officerdev/src/apps/Chat/useBackgroundTasks.ts diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index ceddc0e2..1968b4c8 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -9,6 +9,7 @@ import { loadClaudeSessionById, deleteClaudeSession, renameClaudeSession, + loadBackgroundTask, } from './claude-sessions'; import { listOpenCodeSessions, @@ -99,6 +100,18 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => { return ctx.json({ ok: true }); }); +// GET /chat/tasks/:id — what a background task is doing right now: a subagent's own trace, or the tail of +// a backgrounded shell's log. Polled by the tray above the chat input while the task is running. +// +// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks +// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be +// an error state for the most ordinary thing that can happen. +chatRouter.get('/tasks/:id', (ctx) => { + const email = ctx.get('user').email; + const detail = loadBackgroundTask(email, ctx.req.param('id')); + return ctx.json(detail ?? { kind: 'pending' }); +}); + // GET /chat/models — Claude tiers only (the runner is the `claude` CLI). chatRouter.get('/models', async (ctx: Context) => { try { diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 9d3c4cbd..33e9f830 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -9,6 +9,7 @@ import { openSync, readSync, closeSync, + realpathSync, } from 'node:fs'; import { join } from 'node:path'; import { DATA_PATH } from '../../data-path'; @@ -291,6 +292,107 @@ export function renameClaudeSession(email: string, cwd: string, sessionId: strin return true; } +// ── Background tasks ── +// A background task (run_in_background, Monitor, a backgrounded subagent) reports nothing over the wire +// between `task:started` and its notification — the only thing that knows what it is doing is the file +// Claude Code streams it into: +// +// $TMPDIR/claude-///tasks/.output +// +// For a shell task that file IS the output. For an agent task it is a SYMLINK to the subagent's own +// transcript under ~/.claude/projects///subagents/agent-.jsonl — which is the +// same JSONL format as any other session, so the parser above reads it unchanged. Both kinds are +// reachable from the one directory, which is why we look there first; the projects tree is the fallback +// for when /tmp has been cleared out from under a still-referenced agent transcript. +// +// 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. + +/** Task ids come from Claude, but they reach us through a URL — never let one address a parent. */ +const TASK_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** How much of a shell task's log to return. It is a tail: the end is the part that is still moving. */ +const LOG_TAIL_BYTES = 64 * 1024; + +/** Per tool result. A background agent's greps run to megabytes and the tray shows a preview, not a file. */ +const OUTPUT_CAP = 4000; + +export type BackgroundTaskDetail = + | { kind: 'agent'; messages: ClaudeChatMessage[] } + | { kind: 'log'; text: string; truncated: boolean }; + +function findTaskOutput(email: string, taskId: string): string | null { + const tmpRoot = process.env.TMPDIR ?? '/tmp'; + const candidates: [string, string][] = [ + [tmpRoot, `claude-*/*/*/tasks/${taskId}.output`], + [claudeProjectsDir(email), `*/*/subagents/agent-${taskId}.jsonl`], + ]; + for (const [root, pattern] of candidates) { + try { + for (const rel of new Bun.Glob(pattern).scanSync({ cwd: root, followSymlinks: false, onlyFiles: false })) { + return join(root, rel); + } + } catch { + /* root doesn't exist — try the next one */ + } + } + return null; +} + +/** The tail of a file, as text, without reading the whole thing. */ +function tailFile(filePath: string, bytes: number): { text: string; truncated: boolean } { + const size = statSync(filePath).size; + const start = Math.max(0, size - bytes); + let fd: number | undefined; + try { + fd = openSync(filePath, 'r'); + const buf = Buffer.alloc(size - start); + const n = readSync(fd, buf, 0, buf.length, start); + let text = buf.toString('utf-8', 0, n); + // A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head. + if (start > 0) text = text.slice(text.indexOf('\n') + 1); + return { text, truncated: start > 0 }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +/** + * What a background task is doing right now. Returns null when nothing has been written yet — which is + * the normal state for the first second or two of a task's life, not an error. + */ +export function loadBackgroundTask(email: string, taskId: string): BackgroundTaskDetail | null { + if (!TASK_ID_RE.test(taskId)) return null; + const found = findTaskOutput(email, taskId); + if (!found) return null; + + let target = found; + try { + target = realpathSync(found); + } catch { + return null; // a dangling symlink — the task registered before its transcript existed + } + + if (target.endsWith('.jsonl')) { + const detail = parseClaudeTranscript(target, taskId); + if (!detail) return null; + const messages = detail.messages.map((m) => + m.role === 'tool' && m.output && m.output.length > OUTPUT_CAP + ? { ...m, output: `${m.output.slice(0, OUTPUT_CAP)}\n… (truncated)` } + : m, + ); + return { kind: 'agent', messages }; + } + + try { + const { text, truncated } = tailFile(target, LOG_TAIL_BYTES); + return { kind: 'log', text, truncated }; + } catch { + return null; + } +} + // ── Working directories (pwds) ── // Every dir you've run Claude in shows up as a project group under ~/.claude/projects. We read the // real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions". diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx new file mode 100644 index 00000000..e74814eb --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx @@ -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(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 ( +
+ {open && setOpenId(null)} />} + +
+ + {running.length > 0 ? `${running.length} running` : 'Background'} + +
+ {tasks.map((task) => ( + setOpenId(task.taskId === openId ? null : task.taskId)} + /> + ))} +
+ {tasks.length > running.length && ( + + )} +
+
+ ); +}; + +// ── 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 ( + + ); +}; + +// ── 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(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 ( +
+
+
+
{task.description || 'Background task'}
+
+ {task.taskType ?? 'task'} · {task.status ? (task.summary ?? task.status) : 'running…'} +
+
+ {running && } + +
+ +
{ + 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" + > + +
+
+ ); +}; + +type TaskBodyProps = { + detail: ReturnType['data']; + isLoading: boolean; +}; + +const TaskBody = ({ detail, isLoading }: TaskBodyProps) => { + if (isLoading && !detail) return ; + if (!detail || detail.kind === 'pending') return ; + + if (detail.kind === 'log') { + return ( + <> + {detail.truncated &&
… earlier output trimmed
} +
+          {detail.text || '(no output yet)'}
+        
+ + ); + } + + if (detail.messages.length === 0) return ; + return ; +}; + +const Placeholder = ({ text }: { text: string }) =>
{text}
; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index 2f781e4e..a3e490f0 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -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) => {
{commandFeedback}
)} + +
@@ -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 ? ( - ) : ( diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx index efb0c18f..e078eb85 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx @@ -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[] }) => (
{messages.map((m, i) => m.role === 'tool' ? ( diff --git a/src/workspaces/officerdev/src/apps/Chat/useBackgroundTasks.ts b/src/workspaces/officerdev/src/apps/Chat/useBackgroundTasks.ts new file mode 100644 index 00000000..71c6a736 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/useBackgroundTasks.ts @@ -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; + +/** + * 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([]); + + const tasks = useMemo(() => { + const byId = new Map(); + 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(`/chat/tasks/${taskId}`), + enabled: !!taskId, + refetchInterval: running ? 2000 : false, + staleTime: 0, + }); +}