Skip to content

Add background tasks UI with sidebar rows, bottom panel, and log tailing#40

Open
anglinb wants to merge 4 commits intojakemor:mainfrom
anglinb:feat/background-tasks-ui
Open

Add background tasks UI with sidebar rows, bottom panel, and log tailing#40
anglinb wants to merge 4 commits intojakemor:mainfrom
anglinb:feat/background-tasks-ui

Conversation

@anglinb
Copy link
Copy Markdown
Contributor

@anglinb anglinb commented Apr 3, 2026

Summary

  • Background task tracking: New BackgroundTaskRegistry on the server persistently tracks bash commands run with run_in_background, independently of the SDK draining state. Per-task file-growth polling detects stopped tasks; KillShell tool results are detected instantly.
  • Inline sidebar rows: Running background tasks appear as indented sub-rows under their parent chat with a green spinner and truncated command. Stopped tasks show as a muted "N terminated" count.
  • Bottom TaskPanel: A slide-up panel (like the terminal) scoped to the active chat, with a task list sidebar (running + collapsed terminated section) and a live log viewer showing newest lines on top. Toggled via an Activity button in the navbar.
  • Log tailing: New TaskOutputManager tails the actual SDK output files via fs.watchFile, streamed to the client over a task-output WebSocket subscription with initial snapshot + delta events.
  • Stop action: Sends a chat message to Claude asking it to stop the specific task ID, which triggers the KillShell tool for a real process kill.
  • Per-chat isolation: Tasks, panel contents, and navbar badge are all scoped to the active chat. No cross-chat interference.

Test plan

  • Start a background task in a chat → verify inline sidebar row appears with spinner and command
  • Open the bottom panel via the Activity button → verify task list and live log output
  • Send a new message in the same chat → verify background task persists (not cleared)
  • Stop a task via the panel → verify Claude calls KillShell and task moves to "terminated"
  • Start tasks in two different chats → verify each chat only shows its own tasks
  • Wait for a task to stop naturally → verify it auto-detects as stopped after ~30s of no output
  • npx tsc --noEmit passes, bun test passes (321/321, 1 pre-existing TerminalManager timeout)

🤖 Generated with Claude Code

anglinb and others added 3 commits April 3, 2026 11:55
Replaces the "diffs coming soon" placeholder in the right sidebar with a
full unified diff viewer that shows the live `git diff HEAD` for the
current project repo.

Components: unified diff renderer with collapsible file cards, line-level
word-diff highlighting (via the `diff` npm package), and an inline
commenting system with thread support and copy-as-prompt for sending
review comments back to chat.

Wiring: new `git.diff` WebSocket command executes `git diff HEAD` on the
server, `useGitDiff` hook fetches on sidebar open, and a refresh button
lets users re-fetch manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a "Send all" button next to "Copy all" in the diff viewer toolbar.
Clicking it sends a message to the active chat with all comment threads
formatted as "Address these comments:" followed by the structured prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…og tailing

Adds first-class background task tracking and management to the UI. When Claude
runs bash commands with run_in_background, they are now visible, browsable, and
controllable without leaving the chat interface.

**Server-side:**
- BackgroundTaskRegistry: persistent in-memory registry that tracks tasks
  independently of draining state, with per-task file-growth polling to detect
  stopped tasks and instant detection via KillShell tool results
- TaskOutputManager: file tailer that streams output via WebSocket subscription
- New protocol: task-output subscription topic with snapshot + delta events
- Agent scans transcript for background task tool results and registers them

**Client-side:**
- Inline sidebar rows: running tasks appear indented under their parent chat
  with a green spinner and truncated command, newest first
- Bottom TaskPanel: slides up like the terminal, scoped to the active chat,
  with a task list (running + collapsed terminated section) and live log viewer
  showing newest lines on top
- Activity button in navbar with running task count badge
- Stop action sends a chat message to Claude to use KillShell
- Task lifecycle fully per-task: no cross-chat interference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 3, 2026

Greptile Summary

This PR introduces a full background task monitoring system: a server-side BackgroundTaskRegistry that tracks bash commands run with run_in_background, a TaskOutputManager that tails output files via watchFile, a new task-output WebSocket subscription type, and client-side UI (sidebar sub-rows + a slide-up TaskPanel with live log streaming).

Two server-side bugs need attention before merging:

  • File watcher leak (ws-router.ts handleClose): active task-output subscriptions are never cleaned up when a WebSocket closes abruptly (browser tab close, network drop), leaving watchFile listeners running indefinitely.
  • Byte/character mismatch (task-output-manager.ts): entry.lastSize is initialised in bytes (Buffer.byteLength) but later used as a JavaScript character index in content.slice(entry.lastSize) and reset to content.length (character count). Non-ASCII log output will produce garbled or duplicated deltas.

Confidence Score: 4/5

Safe to merge after fixing the two server-side P1 bugs; client-side UI is solid and well-scoped.

Two P1 issues exist: a file watcher resource leak on WebSocket close and a byte/character index mismatch that corrupts streaming deltas for non-ASCII output. Both are in newly added server files and are straightforward to fix, but they will manifest in normal usage (any browser tab close, any non-ASCII command output).

src/server/task-output-manager.ts and src/server/ws-router.ts require fixes before merging.

Important Files Changed

Filename Overview
src/server/task-output-manager.ts New class for tailing background task output files via watchFile; has a byte/character mismatch bug in checkForNewContent that corrupts deltas for non-ASCII output.
src/server/ws-router.ts Adds task-output subscription type and TaskOutputManager wiring; handleClose does not stop tailing active subscriptions, causing file watcher leaks on abrupt disconnects.
src/server/background-task-registry.ts New per-chat background task registry with file-size polling (5 s interval, 30 s stale threshold) and transcript-scanning for task IDs and stop events; looks correct.
src/client/components/chat-ui/TaskPanel.tsx New slide-up panel with task list and live log viewer; uses an as any cast for the event handler passed to socket.subscribe, hiding a type mismatch.
src/client/components/chat-ui/sidebar/ChatRow.tsx Adds inline background task sub-rows under each chat in the sidebar; minor: stopped-task filter is evaluated twice redundantly.
src/shared/types.ts Adds BackgroundTaskInfo interface and backgroundTasks/isDraining fields to SidebarChatRow; clean additions.
src/shared/protocol.ts Extends protocol with task-output subscription topic, TaskOutputEvent, TaskOutputSnapshot, and adds TaskOutputEvent to ServerEnvelope; looks correct.
src/client/stores/taskPanelStore.ts Zustand store for task panel visibility and task selection state; correct and simple.
src/server/read-models.ts Updates deriveSidebarData to populate backgroundTasks per chat via an optional callback; straightforward and well-scoped.

Sequence Diagram

sequenceDiagram
    participant Client
    participant WsRouter
    participant TaskOutputManager
    participant BackgroundTaskRegistry
    participant AgentCoordinator

    Client->>WsRouter: subscribe {type:"task-output", taskId, outputPath}
    WsRouter->>TaskOutputManager: startTailing(taskId, outputPath)
    TaskOutputManager-->>WsRouter: initialContent (snapshot)
    WsRouter-->>Client: snapshot {type:"task-output", content}
    Note over TaskOutputManager: watchFile polls every 500ms
    TaskOutputManager->>TaskOutputManager: checkForNewContent()
    TaskOutputManager->>WsRouter: onEvent(TaskOutputEvent)
    WsRouter-->>Client: event {type:"task.output", data}

    Client->>WsRouter: unsubscribe id
    WsRouter->>TaskOutputManager: stopTailing(taskId)

    Note over WsRouter: handleClose does NOT call stopTailing
    Client--xWsRouter: connection drops (no unsubscribe)
    WsRouter->>WsRouter: handleClose — only removes socket
    Note over TaskOutputManager: watchFile leaks

    AgentCoordinator->>BackgroundTaskRegistry: scanAndRegister(chatId, messages)
    BackgroundTaskRegistry->>BackgroundTaskRegistry: register(taskId, outputPath)
    BackgroundTaskRegistry->>WsRouter: onChange() → broadcastSnapshots()
    WsRouter-->>Client: sidebar snapshot with backgroundTasks[]
Loading

Comments Outside Diff (1)

  1. src/server/ws-router.ts, line 425-427 (link)

    P1 File watcher leak on abrupt WebSocket close

    handleClose removes the socket from the set but never calls taskOutputManager.stopTailing() for any active task-output subscriptions. When a browser tab closes, a network drops, or any connection ends without an explicit unsubscribe message, every watchFile registered for that connection is permanently orphaned — they keep firing and the TailedFile entry is never removed from the map.

Reviews (1): Last reviewed commit: "Add background tasks UI with inline side..." | Re-trigger Greptile

Comment thread src/server/task-output-manager.ts Outdated
Comment on lines +88 to +94
try {
const stats = await stat(entry.outputPath)
if (stats.size <= entry.lastSize) return

const content = await readFile(entry.outputPath, "utf-8")
const newData = content.slice(entry.lastSize)
entry.lastSize = content.length
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Byte vs. character-index mismatch corrupts delta output

entry.lastSize is initialised with Buffer.byteLength(initialContent, "utf-8") (bytes) in startTailing. Here, stats.size (bytes) is correctly compared against it the first time, but content.slice(entry.lastSize) treats it as a JavaScript character index. For any non-ASCII byte in the output (Unicode symbols, emojis, accented characters…) byte count ≠ character count, so the slice starts at the wrong position and emits garbage or duplicate characters. After the first update entry.lastSize is reset to content.length (character count), making the stats.size guard in the next poll also wrong.

Fix by tracking sizes consistently in bytes throughout, or keep the text-based approach and measure everything in characters: read the full file each time and compute content.slice(prevCharLength) using character counts consistently.

Comment on lines +228 to +233
((event: TaskOutputEvent) => {
if (event.type === "task.output" && event.taskId === task.taskId) {
setLogContent((prev) => prev + event.data)
}
}) as any
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 as any cast hiding a type mismatch in the event handler

The third argument to socket.subscribe is cast to any to silence a type error. This suggests KannaSocket.subscribe was not designed to accept an event-handler callback as a third argument, or the generic signature doesn't account for it. The cast prevents TypeScript from catching future breakage (e.g. if the event type changes). Consider extending KannaSocket.subscribe's signature to accept an optional event handler with the correct union type, or handle events through a separate onEvent callback.

Comment on lines +122 to +131
{chat.backgroundTasks.filter(t => t.status === "stopped").length > 0 && (
<div
className="pl-6 pr-1 py-0.5 text-[10px] text-muted-foreground/50 cursor-pointer hover:text-muted-foreground transition-colors"
onClick={() => {
const firstStopped = chat.backgroundTasks.find(t => t.status === "stopped")
if (firstStopped) onOpenTask?.(firstStopped)
}}
title="Delete chat"
>
<Archive className="size-3.5" />
</Button>
</div>
</div>
{chat.backgroundTasks.filter(t => t.status === "stopped").length} terminated
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 filter called redundantly on the same array

chat.backgroundTasks.filter(t => t.status === "stopped") is evaluated twice in adjacent render paths — once to check .length > 0 and again to get .length for the count. Derive stoppedTasks once at the top of the component render to avoid the duplicate pass.

…ant filter

- TaskOutputManager: track file position in character counts consistently
  instead of mixing bytes (Buffer.byteLength) and characters (string.length),
  which corrupted delta output for non-ASCII content
- TaskPanel/BackgroundTaskDrawer: replace `as any` cast with proper generic
  type parameters on socket.subscribe<TSnapshot, TEvent>()
- ChatRow: derive runningTasks/stoppedTasks once instead of calling .filter()
  redundantly on the same array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant