Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/chat/conversation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 && (
<AsyncTaskStrip tasks={asyncTasks} onStop={onStopAsyncTask} />
<>
<AsyncTaskStrip tasks={asyncTasks} onStop={onStopAsyncTask} />
{/* 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). */}
<LiveOutputFileWatcher tasks={asyncTasks} />
</>
)}

<div className="flex-1 min-h-0">{children}</div>
Expand Down
209 changes: 209 additions & 0 deletions src/components/chat/live-output-file-watcher.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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> = {}): 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(<LiveOutputFileWatcher tasks={[liveTask()]} />)
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(<LiveOutputFileWatcher tasks={[liveTask()]} />)
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(<LiveOutputFileWatcher tasks={[liveTask()]} />)
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(<LiveOutputFileWatcher tasks={[liveTask()]} />)
await tick()
expect(mocks.applyExternalReload).toHaveBeenCalledTimes(1)
mocks.applyExternalReload.mockClear()

rerender(
<LiveOutputFileWatcher tasks={[liveTask({ state: "completed" })]} />
)
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(<LiveOutputFileWatcher tasks={[liveTask()]} />)
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(<LiveOutputFileWatcher tasks={[liveTask()]} />)
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<FileEditContent>((resolve) => {
resolveRead = resolve
})
)
mocks.state.activeFileTab = fileTab()
const { rerender } = render(<LiveOutputFileWatcher tasks={[liveTask()]} />)
await vi.advanceTimersByTimeAsync(POLL) // tick fires, read in flight

mocks.state.activeFileTab = null
rerender(<LiveOutputFileWatcher tasks={[liveTask()]} />)
resolveRead(fetched())
await tick()
expect(mocks.applyExternalReload).not.toHaveBeenCalled()
})
})
140 changes: 140 additions & 0 deletions src/components/chat/live-output-file-watcher.tsx
Original file line number Diff line number Diff line change
@@ -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<string>()
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<FileWorkspaceTab | null>(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
}
Loading