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 +}