feat(edit-history): edit history panel for local claude sessions

Part 1 of #643 (local sessions only; remote follows as its own PR). An
opt-in, per-device header button opens a panel with the active session's
edits, newest first: every Edit/Write/MultiEdit/NotebookEdit of the main
agent and its subagents with its diff, plus images a Bash call rendered or
the agent sent. Remote, Docker and non-claude sessions answer `unsupported`.

- Reads only on request: `?sync=1` when the panel opens; while it is open on
  that session it re-reads after an edit-capable `transcript:tool_end` or a
  `transcript:complete` from the existing transcript watcher. No hook, timer,
  second watcher or new SSE event; a wiring guard pins it.
- Main transcript: the path the transcript watcher follows (hook-reported),
  else `<id>.jsonl` under the session's own CLAUDE_CONFIG_DIR override or
  the server's config dir. A config dir set outside Codeman is found with
  the next hook; until then the panel says so.
- Store: dataPath('edit-history')/<id>.json, 0600 via tmp+rename, capped at
  500 entries and 4 MiB of diff text per session, deleted with the session,
  blocked in isSensitivePath, never fed to /api/search. Every route goes
  through findSessionOrFail with req.
- Images only through existing file surfaces: file-raw inside the
  workspace, the attachment route with notify:false outside it.
- textContent rendering, bounded byte-offset reads, marker-class header
  button, hidden on phones (locked in the mobile policy test).

Docs: Working-With-Files, Settings-Reference, architecture invariants, API
reference, CLAUDE.md key pattern.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-23 14:00:48 +02:00
1 parent 002de32a08
commit 95e2272f89
31 files changed
+4631 -6

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
/**
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
import { EditHistoryStore } from '../../src/edit-history-store.js';
import type { EditSources } from '../../src/edit-history-local-source.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
let dir: string;
let session: Record<string, unknown>;
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
},
...extra,
}) + '\n'
);
}
function sources(main: string, sub?: string): EditSources {
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
const metas: Record<string, string> = {};
if (sub) {
files.push({
relPath: 'subagents/agent-a1.jsonl',
size: Buffer.byteLength(sub),
startOffset: 0,
chunk: Buffer.from(sub),
});
metas.a1 = JSON.stringify({
description: 'Fix it',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
});
}
return { transcriptId: 'conv1', files, metas };
}
async function setup(
deps: Partial<EditHistorySyncDeps> = {},
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
) {
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: read,
probe: async (p) => p.map(() => null),
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
...deps,
});
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
authUser: opts.authUser,
});
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = '/w';
session.mode = 'claude';
session.claudeSessionId = 'conv1';
return { ...h, read, sync };
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
const URL = '/api/sessions/test-session-1/edits';
describe('GET /api/sessions/:id/edits', () => {
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
expect(data.entries).toEqual([
expect.objectContaining({
id: 't1',
path: '/w/src/a.ts',
relPath: 'src/a.ts',
tool: 'Edit',
added: 2,
removed: 1,
}),
]);
expect(data.entries[0]).not.toHaveProperty('hunks');
});
it('without sync=1 answers from the store and does not read', async () => {
const { app, read } = await setup();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
expect(read).not.toHaveBeenCalled();
});
it('labels subagent entries and shows their paths relative to the worktree', async () => {
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
const { app } = await setup({ readSources: read });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data.agents).toEqual({
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
});
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
agentId: 'a1',
relPath: 'lib/x.ts',
});
});
it('a read error answers unavailable with the stored entries', async () => {
let fail = false;
const read = vi.fn(async () => {
if (fail) throw new Error('EACCES: permission denied');
return sources(editLine('t1', T0, '/w/src/a.ts'));
});
const { app } = await setup({ readSources: read });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
fail = true;
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect(data.entries).toHaveLength(1);
});
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
const seen: unknown[] = [];
const { app } = await setup({
transcriptPath: async (s) => {
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
return '/p/conv1.jsonl';
},
});
session.claudeConfigDirOverride = '/cfg/alt ';
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
});
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
const { app, read } = await setup({ transcriptPath: async () => null });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
expect(read).not.toHaveBeenCalled();
});
it.each([
['remote', { remote: { host: 'huff' } }],
['docker', { docker: { image: 'x' } }],
['mode', { mode: 'shell' }],
])('answers unsupported (%s) without reading', async (reason, patch) => {
const { app, read } = await setup();
Object.assign(session, patch);
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason });
expect(read).not.toHaveBeenCalled();
});
it('404s an unknown session', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
});
it('multi-user: another user’s session is not found and nothing is read', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/edits/:entryId', () => {
it('returns the diff as unified text', async () => {
const { app } = await setup();
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
});
it('404s an edit the session does not have', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
});
it('multi-user: another user’s diff is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
});
});
describe('POST /api/sessions/:id/edits/older', () => {
it('reads the next older block and lists the result', async () => {
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
const full = Buffer.from(lines);
const read = vi.fn(
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[]) => {
const req = requests.find((r) => r.relPath === 'main');
const start = req ? req.offset : full.length - 400;
const len = req?.len ?? full.length - start;
return {
transcriptId: 'conv1',
metas: {},
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
};
}
);
const { app } = await setup({ readSources: read });
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(first.backfillTruncated).toBe(true);
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(older.entries.length).toBeGreaterThan(first.entries.length);
});
it('answers unsupported for a remote session without reading', async () => {
const { app, read } = await setup();
session.remote = { host: 'huff' };
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
expect(read).not.toHaveBeenCalled();
});
});
describe('helpers', () => {
it('relativeTo strips only a real directory prefix', () => {
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
});
it('hunksToUnified writes one header per hunk', () => {
expect(
hunksToUnified([
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
])
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
});
});