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, + }); +}