diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index 896327d2..5204f710 100644 --- a/client/src/components/conversation/ConversationView.tsx +++ b/client/src/components/conversation/ConversationView.tsx @@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin"; import { useOnScreen, isShown } from "../../hooks/useOnScreen"; import { throttle } from "../../lib/throttle"; import { useTranslation } from "react-i18next"; -import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types"; +import type { + TranscriptMessage, + TranscriptInfo, + TranscriptResult, + WSMessage, +} from "../../lib/types"; // Catch-up poll interval. Claude Code only fires hooks on PreToolUse / // PostToolUse / Stop, which means a user-typed message (no hook) and any @@ -175,6 +180,8 @@ function ConversationViewPane({ initialTranscriptId ?? null ); const [hasMore, setHasMore] = useState(false); + // The edits of the turn the oldest loaded page starts inside of (`turn_edits`). + const [leadEdits, setLeadEdits] = useState(undefined); const [error, setError] = useState(null); const [transcripts, setTranscripts] = useState([]); const [showNewMsg, setShowNewMsg] = useState(false); @@ -283,6 +290,7 @@ function ConversationViewPane({ if (cancelled) return; setNotStarted(false); setMessages(result.messages); + setLeadEdits(result.turn_edits); setTotal(result.total); setHasMore(result.has_more); lastLineRef.current = result.last_line; @@ -343,6 +351,7 @@ function ConversationViewPane({ // Seed the view in a single render so the user sees the whole // catch-up batch instead of a blank panel followed by a partial one. setMessages(result.messages); + setLeadEdits(result.turn_edits); firstLineRef.current = result.first_line; setHasMore(result.has_more); } else { @@ -487,6 +496,7 @@ function ConversationViewPane({ firstLineRef.current = result.first_line; setMessages((prev) => [...result.messages, ...prev]); + setLeadEdits(result.turn_edits); setHasMore(result.has_more); // Preserve scroll position (don't jump to top) @@ -686,6 +696,7 @@ function ConversationViewPane({ stopped={stopped} agentLinks={agentLinks} compact={compact} + leadEdits={leadEdits} sessionId={ selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId } diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index e956dc1c..bb1d417f 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -172,6 +172,9 @@ interface MessageListProps { /** A subagent's conversation inside its step: tight spacing, and the task * the orchestrator gave it reads as the prompt it is. */ compact?: boolean; + /** The files the first, cut-off turn changed before `messages` begins (the + * transcript page's `turn_edits`): counted in that turn's card. */ + leadEdits?: TranscriptContent["edits"]; } /** Build a map from tool_use id → tool_result for matching */ @@ -493,6 +496,8 @@ type Item = const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); // A subagent's result carries the files IT changed (`edits`): they count too. const AGENT_TOOLS = new Set(["Agent", "Task"]); +// The stand-in for the part of a turn the page cut off: its `edits` count. +const LEAD = "__lead_edits"; /** A line that is only Claude Code's local-command plumbing — the caveat it * writes first, the command, what the command printed — or null for @@ -517,11 +522,18 @@ export function localCommand( return { name, output, error }; } -export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] { +export function chatItems( + messages: TranscriptMessage[], + keys: string[], + leadEdits?: TranscriptContent["edits"] +): Item[] { const items: Item[] = []; // The file-changing calls of the turn so far, closed into one card when the - // human speaks again (or the list ends). - let edits: TranscriptContent[] = []; + // human speaks again (or the list ends) — starting with what the page's + // cut-off first turn changed before it. + let edits: TranscriptContent[] = leadEdits?.length + ? [{ type: "tool_use", name: LEAD, edits: leadEdits }] + : []; // The turn's prompt time, its newest assistant line and its output so far, // for the "Done in …" line under a turn that ended. let start: string | null = null; @@ -684,8 +696,8 @@ function EditedFilesCard({ const byAgent = new Set(); for (const b of blocks) { const r0 = b.id ? toolResultMap.get(b.id) : undefined; - if (AGENT_TOOLS.has(b.name ?? "")) { - for (const e of r0?.edits ?? []) { + if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) { + for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) { if (!files.has(e.path)) byAgent.add(e.path); const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false }; f.added += e.added; @@ -798,6 +810,7 @@ function MessageListView({ sessionId, agentLinks, compact = false, + leadEdits, }: MessageListProps) { const { t } = useTranslation("sessions"); const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]); @@ -823,9 +836,10 @@ function MessageListView({ m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m ) : messages, - keys + keys, + leadEdits ), - [messages, keys, compact] + [messages, keys, compact, leadEdits] ); const agents = useMemo( () => (live ? backgroundAgents(messages, toolResultMap) : []), diff --git a/client/src/components/conversation/__tests__/MessageList.edits.test.tsx b/client/src/components/conversation/__tests__/MessageList.edits.test.tsx index a9443e7e..26b9b72b 100644 --- a/client/src/components/conversation/__tests__/MessageList.edits.test.tsx +++ b/client/src/components/conversation/__tests__/MessageList.edits.test.tsx @@ -6,7 +6,8 @@ * to the caller, and a row (or the header, every file) dragged as `@path` * attachments for a prompt box. Files a subagent changed (its Agent * result's `edits`, read from its own transcript) are counted in the same - * card, never as an artifact. + * card, never as an artifact; so are the files a turn changed before the + * loaded page began (`leadEdits`, the page's `turn_edits`). * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi } from "vitest"; @@ -174,4 +175,26 @@ describe("the Edited N files card", () => { expect(within(card).getByText("report.html")).toBeTruthy(); expect(screen.queryByTestId("artifact-card")).toBeNull(); }); + + it("adds what the cut-off first turn changed before the page to that turn's card", () => { + render( + + ); + const [first, second] = screen.getAllByTestId("edited-files"); + expect(first!.textContent).toContain("Edited 2 files"); + expect(within(first!).getByText("rbac_test.go")).toBeTruthy(); + expect(within(first!).getByText("handler_test.go")).toBeTruthy(); + // The next turn's card is its own. + expect(second!.textContent).toContain("Edited 1 file"); + }); }); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 71e06b0f..04682e93 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -3324,6 +3324,10 @@ export interface TranscriptResult { last_line: number; /** JSONL line number of the first message in this page. */ first_line: number; + /** Claude only, when the page starts in the middle of a turn: the files + * that turn changed before the page's first message — the chat adds them + * to the turn's "Edited N files" card. */ + turn_edits?: { path: string; added: number; removed: number; counted: boolean }[]; } /** One entry in a session's transcript picker (main agent, a subagent, or a diff --git a/docs/API.md b/docs/API.md index d38f1b19..988f17a7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2081,7 +2081,7 @@ GET /api/sessions/:id/transcript?agent_id=&run_id=&limit=&after=&before=&offset= `transcripts` lists the session's conversation files — the main one first, then each subagent's and compaction's — as `{ transcripts: [{ id, name, type: "main"|"subagent"|"compaction", subagent_type?, db_agent_id, … }] }`; `db_agent_id` links an entry to its `agents` row (`null` when none matches). A Codex, Antigravity, Cursor, omp or OpenCode session lists its own tool's journal instead. -`transcript` reads one of them as structured messages: no `agent_id` = the main conversation, `agent_id` (+ `run_id` for a Workflow-tool agent) = a subagent's — each one plain name, never a path. `limit` defaults to 50 (max 200); `after=` returns only messages past that JSONL line (the chat's tail), `before=` the page before it (history), `offset` is the legacy pager. Answers `{ messages, total, has_more, last_line, first_line }` (empty when the file is gone). A Claude Agent/Task `tool_result` whose subagent (or one it started, up to 3 levels) wrote or edited files carries them as `edits: [{ path, added, removed, counted }]`, read from the subagent's own transcript (`server/lib/agent-edits.js`, cached by size + mtime) — the chat's "Edited N files" card counts them with the turn's own edits. The live file under the Claude projects folder is preferred, else the snapshot taken at import. `404 NOT_FOUND` for an unknown session. +`transcript` reads one of them as structured messages: no `agent_id` = the main conversation, `agent_id` (+ `run_id` for a Workflow-tool agent) = a subagent's — each one plain name, never a path. `limit` defaults to 50 (max 200); `after=` returns only messages past that JSONL line (the chat's tail), `before=` the page before it (history), `offset` is the legacy pager. Answers `{ messages, total, has_more, last_line, first_line }` (empty when the file is gone). A Claude Agent/Task `tool_result` whose subagent (or one it started, up to 3 levels) wrote or edited files carries them as `edits: [{ path, added, removed, counted }]`, read from the subagent's own transcript (`server/lib/agent-edits.js`, cached by size + mtime) — the chat's "Edited N files" card counts them with the turn's own edits. A Claude page that starts in the middle of a turn also carries `turn_edits: [{ path, added, removed, counted }]` — the files that turn changed before the page's first message, gathered from the messages the page dropped — so the card stays whole when the turn is longer than `limit`. The live file under the Claude projects folder is preferred, else the snapshot taken at import. `404 NOT_FOUND` for an unknown session. --- diff --git a/server/__tests__/agent-edits.test.js b/server/__tests__/agent-edits.test.js index cefd2d4d..c0e42d48 100644 --- a/server/__tests__/agent-edits.test.js +++ b/server/__tests__/agent-edits.test.js @@ -2,7 +2,9 @@ * @file Tests for server/lib/agent-edits.js (issue #86): the files a * subagent wrote or edited are read from its own transcript — +/- lines from * `structuredPatch` or a created file's content, a failed call left out, a - * nested subagent folded in — and put on the parent's Agent result as `edits`. + * nested subagent folded in — and put on the parent's Agent result as `edits`; + * and `turnCarry` hands a page of the chat the edits its cut-off first turn + * made before the page began. * @author Nguyễn Ngọc Trí Vĩ */ @@ -16,7 +18,7 @@ const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agent-edits-")); process.env.CLAUDE_HOME = HOME; const { encodeCwd } = require("../lib/claude-home"); -const { agentEdits, attachAgentEdits, _cache } = require("../lib/agent-edits"); +const { agentEdits, attachAgentEdits, turnCarry, _cache } = require("../lib/agent-edits"); const CWD = "/work/repo"; const SID = "11111111-2222-3333-4444-555555555555"; @@ -110,3 +112,56 @@ describe("attachAgentEdits", () => { assert.equal(failed.edits, undefined); }); }); + +describe("turnCarry", () => { + const prompt = (text) => ({ type: "user", sender: "user", content: [{ type: "text", text }] }); + const use = (id, name, file_path) => ({ + type: "assistant", + sender: "assistant", + content: [{ type: "tool_use", id, name, input: file_path ? { file_path } : {} }], + }); + const res = (id, extra = {}) => ({ + type: "user", + sender: "tool", + content: [{ type: "tool_result", id, ...extra }], + }); + + it("answers the files the cut-off turn changed before the page", async () => { + const c = turnCarry(); + for (const m of [ + prompt("old turn"), + use("o1", "Write", "/w/old.go"), + res("o1", { diff: { added: 9, removed: 0 } }), + prompt("this turn"), + use("1", "Write", "/w/a_test.go"), + res("1", { diff: { added: 176, removed: 0 } }), + use("2", "Edit", "/w/bad.go"), + res("2", { is_error: true }), + use("3", "Edit", "/w/b.go"), + ]) + c.drop(m); + // The page: the result of a dropped call, then more of the same turn. + const page = [res("3", { diff: { added: 2, removed: 1 } }), use("4", "Write", "/w/c.go")]; + assert.deepEqual(await c.result(page, SID, CWD), [ + { path: "/w/a_test.go", added: 176, removed: 0, counted: true }, + { path: "/w/b.go", added: 2, removed: 1, counted: true }, + ]); + }); + + it("answers null when the page starts on a prompt", async () => { + const c = turnCarry(); + c.drop(prompt("p")); + c.drop(use("1", "Write", "/w/a.go")); + c.drop(res("1", { diff: { added: 1, removed: 0 } })); + assert.equal(await c.result([prompt("next")], SID, CWD), null); + }); + + it("folds in a dropped subagent's own edits", async () => { + const c = turnCarry(); + c.drop(prompt("p")); + c.drop(use("9", "Agent")); + c.drop(res("9", { agent: { id: "a1" } })); + const got = await c.result([use("10", "Bash")], SID, CWD); + assert.ok(got.some((e) => e.path === "/work/repo/new_test.go")); + }); +}); diff --git a/server/lib/agent-edits.js b/server/lib/agent-edits.js index 0b9e8ace..e146a7cc 100644 --- a/server/lib/agent-edits.js +++ b/server/lib/agent-edits.js @@ -8,7 +8,8 @@ * subagent's own subagents are folded in, `MAX_DEPTH` levels down. The file is * read through `readLines` (a member's transcript only as that member's), and * the answer is cached by path + size + mtime, so a finished subagent is read - * once however often its parent's chat is polled. + * once however often its parent's chat is polled. `turnCarry` does the same + * for the part of a turn the chat's page of messages cut off. * @author Nguyễn Ngọc Trí Vĩ */ @@ -21,6 +22,7 @@ const { } = require("./claude-home"); const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); +const AGENT_TOOLS = new Set(["Agent", "Task"]); const MAX_DEPTH = 3; const CACHE_MAX = 500; // path → { key: ":", edits, agents } @@ -129,4 +131,77 @@ async function attachAgentEdits(messages, sessionId, cwd) { } } -module.exports = { agentEdits, attachAgentEdits, _cache: cache }; +/** A human prompt: the line that starts a turn (the chat's own rule). */ +const isPrompt = (m) => + (m.sender ?? (m.type === "assistant" ? "assistant" : "user")) === "user" && + (m.content ?? []).some((c) => c.type === "text"); + +/** + * The edits of the turn a page of the chat starts in the middle of. The route + * keeps only the newest `limit` messages; every message it drops goes through + * `drop()`, which keeps the file changes since the last prompt it dropped. + * `result(window)` then answers the files that turn changed BEFORE the page's + * first message — `[{ path, added, removed, counted }]`, or null when the page + * starts on a prompt or nothing was changed — so the turn's "Edited N files" + * card is whole although its start was never sent (issue #86: a 120-message + * turn showed 1 of its files). + */ +function turnCarry() { + let pending = new Map(); // dropped Write/Edit call id → path + let pendingAgents = new Set(); // dropped Agent/Task call ids + let files = new Map(); + let agents = []; + const add = (p, f) => { + const o = files.get(p) ?? { added: 0, removed: 0, counted: false }; + o.added += f?.added ?? 0; + o.removed += f?.removed ?? 0; + o.counted ||= !!f && f.counted !== false; + files.set(p, o); + }; + return { + drop(m) { + if (!m) return; + if (isPrompt(m)) { + pending = new Map(); + pendingAgents = new Set(); + files = new Map(); + agents = []; + return; + } + for (const c of m.content ?? []) { + if (c.type === "tool_use" && c.id) { + if (AGENT_TOOLS.has(c.name)) pendingAgents.add(c.id); + if (!EDIT_TOOLS.has(c.name)) continue; + const p = c.input?.file_path ?? c.input?.notebook_path; + if (typeof p === "string" && p) pending.set(c.id, p); + } else if (c.type === "tool_result") { + if (c.agent?.id && !c.is_error) agents.push(c.agent.id); + pendingAgents.delete(c.id); + const p = pending.get(c.id); + if (!p) continue; + pending.delete(c.id); + if (!c.is_error) add(p, c.diff ?? null); + } + } + }, + async result(window, sessionId, cwd) { + if (!window.length || isPrompt(window[0])) return null; + // A call dropped whose result is on the page: the page shows the result + // with no call to hang it on, so it counts here. + for (const m of window) { + for (const c of m.content ?? []) { + if (c.type !== "tool_result" || c.is_error) continue; + const p = pending.get(c.id); + if (p) add(p, c.diff ?? null); + if (pendingAgents.has(c.id)) for (const e of c.edits ?? []) add(e.path, e); + } + } + for (const id of agents) { + for (const e of await agentEdits(sessionId, cwd, id)) add(e.path, e); + } + return files.size ? [...files].map(([path, f]) => ({ path, ...f })) : null; + }, + }; +} + +module.exports = { agentEdits, attachAgentEdits, turnCarry, _cache: cache }; diff --git a/server/routes/sessions.js b/server/routes/sessions.js index 7df10d66..7d17f68d 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -1177,6 +1177,9 @@ router.get("/:id/transcript", async (req, res) => { : (e, n) => parseMessage(e, n); const parseMessage = createClaudeLineParser(isSubagentFile); + const claudeFile = !isCodex && !isOmp && !isCursor && !isAgy; + // The edits of the turn the page starts inside of (agent-edits.js). + const carry = claudeFile ? require("../lib/agent-edits").turnCarry() : null; if (afterLine !== null) { // Incremental mode: skip lines until after afterLine, collect up to limit, then stop. @@ -1232,7 +1235,7 @@ router.get("/:id/transcript", async (req, res) => { messages.push(message); // Sliding window: only keep the last `limit` messages if (messages.length > limit) { - messages.shift(); + carry?.drop(messages.shift()); } } if (total > limit) hasMore = true; @@ -1283,7 +1286,7 @@ router.get("/:id/transcript", async (req, res) => { messages.push(message); // Sliding window: only keep the last `limit` messages in memory if (messages.length > limit) { - messages.shift(); + carry?.drop(messages.shift()); } } // If we shifted any messages out, there are more @@ -1292,8 +1295,10 @@ router.get("/:id/transcript", async (req, res) => { // A Claude subagent's own Write/Edit calls are in ITS transcript: put the // files on its Agent result, so the turn's "Edited N files" counts them. - if (!isCodex && !isOmp && !isCursor && !isAgy) { + let turnEdits = null; + if (claudeFile) { await require("../lib/agent-edits").attachAgentEdits(messages, req.params.id, session.cwd); + turnEdits = await carry.result(messages, req.params.id, session.cwd); } const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0; @@ -1310,6 +1315,7 @@ router.get("/:id/transcript", async (req, res) => { has_more: hasMore, last_line: lastLine, first_line: firstLine, + ...(turnEdits ? { turn_edits: turnEdits } : {}), }); } catch (err) { res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });