fix(chat): keep a long turn's Edited N files card whole across the page cut (#86)

The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the
chat loads the newest 50 messages, and the turn's other Write
(rbac_routes_test.go, +176) was in the part before the page.

- turnCarry (agent-edits.js): every message the route's sliding window
  drops goes through it; it keeps the file changes since the last prompt it
  dropped (a dropped call whose result is on the page, and dropped
  subagents, included).
- A Claude page that starts mid-turn answers them as `turn_edits`; the
  chat keeps the one from the page that holds its oldest message and adds
  it to that turn's card.

Files a turn changed through Bash (python/sed writing a file) are still not
listed: nothing in the transcript says which files a command wrote.
This commit is contained in:
2026-10-23 14:31:58 +07:00
parent 5ccbaf5c23
commit f51a6a895e
8 changed files with 205 additions and 17 deletions
@@ -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<TranscriptResult["turn_edits"]>(undefined);
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
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
}
@@ -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<string>();
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) : []),
@@ -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ĩ <vinnt@smartgift.vn>
*/
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(
<MessageList
loading={false}
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
messages={[
call("1", "Write", "/w/handler_test.go"),
result("1", { added: 113, removed: 0 }),
user("what changed?"),
call("2", "Edit", "/w/x.go"),
result("2", { added: 1, removed: 0 }),
]}
/>
);
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");
});
});
+4
View File
@@ -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
+1 -1
View File
@@ -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=<line>` returns only messages past that JSONL line (the chat's tail), `before=<line>` 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=<line>` returns only messages past that JSONL line (the chat's tail), `before=<line>` 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.
---
+57 -2
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
@@ -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"));
});
});
+77 -2
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
@@ -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: "<size>:<mtime>", 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 };
+9 -3
View File
@@ -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 });