diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx
index 3082dc95e9..9195ab1226 100644
--- a/src/components/chat/conversation-shell.tsx
+++ b/src/components/chat/conversation-shell.tsx
@@ -20,6 +20,7 @@ import type {
import type { SessionFailureAction } from "@/lib/session-failures"
import { SessionFailureBanner } from "@/components/chat/session-failure-banner"
import { AsyncTaskStrip } from "@/components/chat/async-task-strip"
+import { LiveOutputFileWatcher } from "@/components/chat/live-output-file-watcher"
import type {
PendingPermission,
PendingQuestion,
@@ -298,7 +299,13 @@ export function ConversationShell({
the pointer. The dock below is for things that come and go with the
turn (retry line, last error). */}
{asyncTasks && asyncTasks.length > 0 && (
-
+ <>
+
+ {/* Null-rendering leaf: keeps the output tabs the strip's button
+ opens fresh while their task is still writing (temp-dir logs
+ sit outside the notify-watched roots, so nothing else does). */}
+
+ >
)}
{children}
diff --git a/src/components/chat/live-output-file-watcher.test.tsx b/src/components/chat/live-output-file-watcher.test.tsx
new file mode 100644
index 0000000000..8711c66f87
--- /dev/null
+++ b/src/components/chat/live-output-file-watcher.test.tsx
@@ -0,0 +1,209 @@
+import { render } from "@testing-library/react"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const mocks = vi.hoisted(() => ({
+ readFileForEdit: vi.fn(),
+ applyExternalReload: vi.fn(),
+ rejectFileTab: vi.fn(),
+ state: {
+ activeFileTab: null as null | {
+ id: string
+ kind: string
+ folderId: number | null
+ title: string
+ description: string | null
+ path: string | null
+ language: string
+ content: string
+ loading: boolean
+ isDirty?: boolean
+ etag?: string | null
+ mtimeMs?: number | null
+ readonly?: boolean
+ lineEnding?: string
+ saveState?: string
+ stale?: boolean
+ },
+ },
+}))
+
+vi.mock("@/lib/api", () => ({
+ readFileForEdit: (...args: unknown[]) => mocks.readFileForEdit(...args),
+}))
+
+vi.mock("@/contexts/workspace-context", () => ({
+ useWorkspaceFileTabs: () => ({ activeFileTab: mocks.state.activeFileTab }),
+ useWorkspaceActions: () => ({
+ applyExternalReload: mocks.applyExternalReload,
+ rejectFileTab: mocks.rejectFileTab,
+ }),
+}))
+
+import { LiveOutputFileWatcher } from "./live-output-file-watcher"
+import type { AsyncTaskRecord, FileEditContent } from "@/lib/types"
+import type { FileWorkspaceTab } from "@/contexts/workspace-context"
+
+const LOG_PATH = "/private/tmp/claude-501/t1.output"
+const POLL = 2000
+
+function liveTask(overrides: Partial = {}): AsyncTaskRecord {
+ return {
+ task_id: "t1",
+ name: "pnpm test",
+ task_type: "shell",
+ description: "",
+ show_in_transcript: true,
+ can_stop: true,
+ state: "running",
+ output_file_path: LOG_PATH,
+ ...overrides,
+ }
+}
+
+function fileTab(overrides: Partial = {}): FileWorkspaceTab {
+ return {
+ id: `file:${LOG_PATH}`,
+ kind: "file",
+ folderId: null,
+ title: "t1.output",
+ description: null,
+ path: LOG_PATH,
+ language: "text",
+ content: "line1",
+ loading: false,
+ isDirty: false,
+ etag: "e1",
+ mtimeMs: 1,
+ readonly: false,
+ lineEnding: "lf",
+ saveState: "idle",
+ stale: false,
+ ...overrides,
+ }
+}
+
+function fetched(overrides: Partial = {}): FileEditContent {
+ return {
+ path: LOG_PATH,
+ content: "line1\nline2",
+ etag: "e2",
+ mtime_ms: 2,
+ readonly: false,
+ line_ending: "lf",
+ ...overrides,
+ }
+}
+
+// One interval tick plus a microtask drain so the in-flight async chain
+// (read → guard → apply) completes.
+async function tick() {
+ await vi.advanceTimersByTimeAsync(POLL)
+ await vi.advanceTimersByTimeAsync(0)
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ mocks.readFileForEdit.mockReset()
+ mocks.applyExternalReload.mockReset().mockResolvedValue(undefined)
+ mocks.rejectFileTab.mockReset()
+ mocks.state.activeFileTab = null
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe("LiveOutputFileWatcher", () => {
+ it("applies a disk change to the active output tab while its task lives", async () => {
+ // The strip's "Output" button opens the log and the user watches it grow;
+ // the temp-dir path sits outside every notify-watched root, so polling
+ // is the only thing that can surface appends.
+ mocks.state.activeFileTab = fileTab()
+ mocks.readFileForEdit.mockResolvedValue(fetched())
+ render()
+ await tick()
+ expect(mocks.readFileForEdit).toHaveBeenCalledWith(
+ "/private/tmp/claude-501",
+ "t1.output"
+ )
+ expect(mocks.applyExternalReload).toHaveBeenCalledWith(
+ LOG_PATH,
+ expect.objectContaining({ etag: "e2" })
+ )
+ })
+
+ it("does nothing while the etag still matches", async () => {
+ mocks.state.activeFileTab = fileTab()
+ mocks.readFileForEdit.mockResolvedValue(fetched({ etag: "e1" }))
+ render()
+ await tick()
+ await tick()
+ expect(mocks.applyExternalReload).not.toHaveBeenCalled()
+ })
+
+ it("never polls a tab with unsaved edits", async () => {
+ // The buffer belongs to the user; the activation pass surfaces the
+ // divergence on switch-back instead of clobbering.
+ mocks.state.activeFileTab = fileTab({ isDirty: true })
+ render()
+ await tick()
+ expect(mocks.readFileForEdit).not.toHaveBeenCalled()
+ })
+
+ it("stops polling the moment the task settles", async () => {
+ mocks.state.activeFileTab = fileTab()
+ mocks.readFileForEdit.mockResolvedValue(fetched())
+ const { rerender } = render()
+ await tick()
+ expect(mocks.applyExternalReload).toHaveBeenCalledTimes(1)
+ mocks.applyExternalReload.mockClear()
+
+ rerender(
+
+ )
+ await tick()
+ await tick()
+ expect(mocks.applyExternalReload).not.toHaveBeenCalled()
+ })
+
+ it("ignores an active tab that is not a live task's output", async () => {
+ mocks.state.activeFileTab = fileTab({
+ id: "file:/repo/notes.txt",
+ path: "/repo/notes.txt",
+ })
+ render()
+ await tick()
+ expect(mocks.readFileForEdit).not.toHaveBeenCalled()
+ })
+
+ it("surfaces a vanished log through rejectFileTab", async () => {
+ // The temp sweep can eat the file while the tab sits open — the tab must
+ // say so instead of freezing on a stale buffer.
+ mocks.state.activeFileTab = fileTab()
+ mocks.readFileForEdit.mockRejectedValue(new Error("file is gone"))
+ render()
+ await tick()
+ expect(mocks.rejectFileTab).toHaveBeenCalledWith(LOG_PATH, "file is gone")
+ })
+
+ it("drops the change when the tab is switched away mid-read", async () => {
+ // The tab identity check runs against the LIVE ref after the read: a
+ // late-arriving payload must not paint onto a tab the user has left.
+ let resolveRead: (value: FileEditContent) => void = () => {}
+ mocks.readFileForEdit.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveRead = resolve
+ })
+ )
+ mocks.state.activeFileTab = fileTab()
+ const { rerender } = render()
+ await vi.advanceTimersByTimeAsync(POLL) // tick fires, read in flight
+
+ mocks.state.activeFileTab = null
+ rerender()
+ resolveRead(fetched())
+ await tick()
+ expect(mocks.applyExternalReload).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/components/chat/live-output-file-watcher.tsx b/src/components/chat/live-output-file-watcher.tsx
new file mode 100644
index 0000000000..7d7dd817a7
--- /dev/null
+++ b/src/components/chat/live-output-file-watcher.tsx
@@ -0,0 +1,140 @@
+"use client"
+
+/**
+ * Poll-based freshness for LIVE background-task output logs.
+ *
+ * The strip's "Output" button (`async-task-strip.tsx`) opens a task's log
+ * through `openFilePreview`. That log lives under the OS temp root — OUTSIDE
+ * every registered workspace folder — so the notify-driven stream that
+ * auto-reloads ordinary open tabs (`use-open-file-tabs-watch.ts`) never sees
+ * it: nothing watches a path no root contains. The only existing coverage was
+ * the activation-time freshness pass, which fires on the tab SWITCH but not
+ * while the user sits on the tab watching a log the task is still appending
+ * to. This closes exactly that gap.
+ *
+ * While (a) this conversation has a live (non-terminal) async task reporting
+ * an `output_file_path` and (b) that very file is the ACTIVE, clean text tab,
+ * compare its etag against disk every few seconds and route any change
+ * through the same `applyExternalReload` the notify watcher uses — its
+ * generation guards, atomic dirty refusal and git-base refresh are all
+ * wanted here too. A settled task drops out of `liveAsyncTasks`, the interval
+ * is torn down, and the disk is quiet again.
+ *
+ * Why this is cheap (the load-bearing gates):
+ * • One tab, the active one — and only when its path is a LIVE task's
+ * output. Closed tabs, background tabs, and unrelated files never tick.
+ * • Clean tabs only: a dirty buffer is the user's, not ours to clobber (the
+ * activation pass surfaces that divergence on switch-back instead).
+ * • In-flight ticks are skipped, not stacked, so a slow read cannot pile
+ * up reads behind a 2 s interval.
+ * • It is its own leaf component: the subscription to the high-frequency
+ * fileTabs slice re-renders THIS (null-rendering) component on keystroke
+ * churn, never the conversation shell around it.
+ */
+
+import { useEffect, useMemo, useRef } from "react"
+
+import { readFileForEdit } from "@/lib/api"
+import { toErrorMessage } from "@/lib/app-error"
+import {
+ useWorkspaceActions,
+ useWorkspaceFileTabs,
+} from "@/contexts/workspace-context"
+import { liveAsyncTasks } from "@/lib/async-tasks"
+import { normalizeAbsPath, splitAbsPath } from "@/lib/file-open-target"
+import { isImageFile, isOfficePreviewable } from "@/lib/language-detect"
+import type { FileWorkspaceTab } from "@/contexts/workspace-context"
+import type { AsyncTaskRecord } from "@/lib/types"
+
+const POLL_INTERVAL_MS = 2000
+
+// Gates on the tab itself. Image tabs carry no etag (and load via base64)
+// and office tabs refresh through their own officecli watch — both are
+// excluded from etag polling exactly like the activation pass excludes them.
+function isPollableTab(tab: FileWorkspaceTab | null): tab is FileWorkspaceTab {
+ if (!tab || tab.kind !== "file" || !tab.path) return false
+ if (tab.loading || tab.isDirty || tab.saveState === "saving") return false
+ return !isImageFile(tab.path) && !isOfficePreviewable(tab.path)
+}
+
+export function LiveOutputFileWatcher({ tasks }: { tasks: AsyncTaskRecord[] }) {
+ const { activeFileTab } = useWorkspaceFileTabs()
+ const { applyExternalReload, rejectFileTab } = useWorkspaceActions()
+
+ // Live tasks' log files in tab-identity form (the same canonical shape
+ // `openFilePreview` gives the tab's `path`). Recomputed per task delta,
+ // but the effect below keys on the RESULTING booleans, so progress ticks
+ // that flip nothing never tear down the interval.
+ const liveOutputPaths = useMemo(() => {
+ const set = new Set()
+ for (const task of liveAsyncTasks(tasks)) {
+ if (task.output_file_path) {
+ set.add(normalizeAbsPath(task.output_file_path))
+ }
+ }
+ return set
+ }, [tasks])
+
+ const activePath =
+ activeFileTab?.kind === "file" && activeFileTab.path
+ ? normalizeAbsPath(activeFileTab.path)
+ : null
+ const pollable =
+ activePath !== null &&
+ liveOutputPaths.has(activePath) &&
+ isPollableTab(activeFileTab)
+
+ // Latest snapshot for the interval closure: the effect (and its closure)
+ // is rebuilt only when the gates change, but the ref keeps the tab itself
+ // fresh through every re-render.
+ const activeTabRef = useRef(activeFileTab)
+ useEffect(() => {
+ activeTabRef.current = activeFileTab
+ }, [activeFileTab])
+
+ useEffect(() => {
+ if (!pollable || activePath === null) return
+ const io = splitAbsPath(activePath)
+ if (!io) return
+ // The identity this poll belongs to: same tab id AND same path, checked
+ // against the LIVE ref AFTER the read resolves (close/switch mid-read
+ // must not paint the old file onto a different tab).
+ const isStillOurTab = (
+ tab: FileWorkspaceTab | null
+ ): tab is FileWorkspaceTab =>
+ isPollableTab(tab) && normalizeAbsPath(tab.path as string) === activePath
+
+ let inFlight = false
+ const timer = setInterval(() => {
+ if (inFlight) return
+ if (!isStillOurTab(activeTabRef.current)) return
+ inFlight = true
+ void (async () => {
+ try {
+ const latest = await readFileForEdit(io.rootPath, io.ioPath)
+ // Malformed payload: inconclusive, not a divergence to apply.
+ if (!latest) return
+ const current = activeTabRef.current
+ // The gate doubles as the keystroke guard: an edit that landed
+ // during the read makes the tab dirty, isStillOurTab refuses it,
+ // and the buffer stays the user's (the activation pass surfaces
+ // that divergence on switch-back instead).
+ if (!isStillOurTab(current)) return
+ if ((current.etag ?? null) === latest.etag) return
+ await applyExternalReload(activePath, latest)
+ } catch (error) {
+ // Read failed — most commonly the sweep ate the log. Mirror the
+ // notify watcher's clean-tab routing: surface it on the tab.
+ const current = activeTabRef.current
+ if (!isStillOurTab(current)) return
+ rejectFileTab(activePath, toErrorMessage(error))
+ } finally {
+ inFlight = false
+ }
+ })()
+ }, POLL_INTERVAL_MS)
+ return () => clearInterval(timer)
+ }, [pollable, activePath, applyExternalReload, rejectFileTab])
+
+ return null
+}