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

+218
View File
@@ -0,0 +1,218 @@
/**
* @fileoverview Reads a local claude session's transcripts for its edit history: the main
* transcript and its subagent transcripts, each from a byte offset.
*
* WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
* hooks report, so a session is read from wherever its claude really writes. Before the first
* hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
* `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
* (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
* guessed; it is found with the next hook. Subagents live in
* `<main path without .jsonl>/subagents/agent-<id>.jsonl`.
*
* HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
* (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
* comes along. `budgetBytes` is shared by all files, main first; once spent, files still
* report their size with an empty chunk.
*
* Tests: `test/edit-history-local-source.test.ts`.
*
* @module edit-history-local-source
*/
import { open, readdir, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join } from 'node:path';
import { claudeProjectsDir } from './utils/claude-transcript.js';
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
const META_MAX_BYTES = 8192;
/** One file to read from where the last read stopped (`len` only for an "older" block). */
export interface EditSourceRequest {
/** `main` or `subagents/agent-<id>.jsonl`. */
relPath: string;
offset: number;
len?: number;
}
export interface EditSourceFile {
relPath: string;
/** The file's size when it was read. */
size: number;
startOffset: number;
chunk: Buffer;
}
export interface EditSources {
/** The conversation id: the main transcript's file name without `.jsonl`. */
transcriptId: string;
files: EditSourceFile[];
/** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */
metas: Record<string, string>;
}
/** What a path turned out to be (for Bash image candidates). */
export interface PathProbe {
/** The path with symlinks resolved. */
realPath: string;
kind: 'file' | 'directory' | 'other';
/** Size in bytes (0 for anything that is not a regular file). */
size: number;
mtimeMs: number;
}
async function readRange(path: string, offset: number, len: number): Promise<Buffer> {
if (len <= 0) return Buffer.alloc(0);
const fh = await open(path, 'r');
try {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, offset);
return buf.subarray(0, bytesRead);
} catch {
return Buffer.alloc(0);
} finally {
await fh.close();
}
}
async function fileSize(path: string): Promise<number | null> {
try {
const s = await stat(path);
return s.isFile() ? s.size : null;
} catch {
return null;
}
}
/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */
export async function findClaudeTranscript(
ids: readonly string[],
projectsDir: string = claudeProjectsDir()
): Promise<string | null> {
let dirs: string[];
try {
dirs = await readdir(projectsDir);
} catch {
return null;
}
for (const id of new Set(ids)) {
if (!ID_PATTERN.test(id)) continue;
let best: { path: string; mtime: number } | null = null;
for (const dir of dirs) {
const path = join(projectsDir, dir, `${id}.jsonl`);
try {
const s = await stat(path);
if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
} catch {
// not in this project dir
}
}
if (best) return best.path;
}
return null;
}
/**
* The session's main transcript: the transcript watcher's (hook-reported) path when there is
* one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
* override; the server's config dir when unset). Null when neither finds one.
*/
export async function resolveClaudeTranscript(
watcherPath: string | null | undefined,
ids: readonly string[],
configDir?: string
): Promise<string | null> {
return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
}
/**
* Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
* is not an absolute `<id>.jsonl` path or the file does not exist.
*/
export async function readClaudeEditSources(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number
): Promise<EditSources | null> {
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
const transcriptId = basename(mainPath, '.jsonl');
if (!ID_PATTERN.test(transcriptId)) return null;
const byPath = new Map<string, EditSourceRequest>();
for (const r of requests) {
if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
byPath.set(r.relPath, r);
}
const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
try {
for (const name of (await readdir(subDir)).sort()) {
const m = AGENT_FILE_PATTERN.exec(name);
if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
}
} catch {
// no subagents yet
}
let remaining = Math.max(0, Math.floor(budgetBytes));
const files: EditSourceFile[] = [];
const metas: Record<string, string> = {};
for (const src of sources) {
const size = await fileSize(src.path);
if (size === null) {
if (src.relPath === 'main') return null;
continue;
}
const req = byPath.get(src.relPath);
let start: number;
let len: number;
if (req) {
start = Math.min(req.offset, size);
len = Math.min(req.len ?? remaining, remaining, size - start);
} else {
len = Math.min(size, remaining);
start = size - len;
}
const chunk = await readRange(src.path, start, len);
remaining -= chunk.length;
files.push({ relPath: src.relPath, size, startOffset: start, chunk });
if (!req && src.agentId) {
const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
const metaSize = await fileSize(metaPath);
if (metaSize !== null) {
metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
}
}
}
return { transcriptId, files, metas };
}
/** Per path its resolved path, kind, size and mtime, or null when it does not exist. `~/` means this user's home. */
export async function probeLocalPaths(
paths: readonly string[],
home: string = homedir()
): Promise<Array<PathProbe | null>> {
return Promise.all(
paths.map(async (p) => {
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
try {
const real = await realpath(path);
const s = await stat(real);
return {
realPath: real,
kind: s.isFile() ? 'file' : s.isDirectory() ? 'directory' : 'other',
size: s.isFile() ? s.size : 0,
mtimeMs: Math.floor(s.mtimeMs),
} satisfies PathProbe;
} catch {
return null;
}
})
);
}
+330
View File
@@ -0,0 +1,330 @@
/**
* @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent
* edited (with their diff) and the images its Bash calls may have produced.
*
* Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a
* `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no
* git. The whole-file fields next to it (`originalFile`, `content`) are never kept.
*
* Images rarely come from `Write`; they come from scripts run through Bash. A Bash call
* yields a CANDIDATE: the image-looking paths in its command and output, with the call's
* start and end time. Whether a candidate file was actually produced by the call (its
* mtime falls inside that window) is decided later by whoever can stat it, locally or on
* the remote host — this module does no IO.
*
* Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
* can land in different chunks. Unanswered calls are handed back as `pending` and passed
* into the next call.
*
* Tests: `test/edit-history-parse.test.ts`.
*
* @module edit-history-parse
*/
import { posix } from 'node:path';
/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */
export const MAX_HUNK_BYTES = 64 * 1024;
/** Image paths kept per Bash call. */
export const MAX_IMAGE_CANDIDATES = 20;
/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */
export const MAX_PENDING_BASH = 200;
/**
* Command text kept per unanswered Bash call. Pending calls are persisted with the store, so
* this bounds it; an image path past this point of a command is still found in the output.
*/
export const MAX_PENDING_COMMAND_CHARS = 8192;
export type EditKind = 'code' | 'image';
/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */
export type EditTool = 'Edit' | 'Write' | 'edit';
export interface EditHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
export interface EditRecord {
/** The `tool_use_id` of the edit — unique per edit, used to deduplicate. */
id: string;
/** ms since epoch, from the result line's `timestamp` (0 when missing). */
ts: number;
path: string;
kind: EditKind;
tool: EditTool;
/** A `Write` that created the file. */
created: boolean;
/** Counted over the whole patch, also when `hunks` was cut. */
added: number;
removed: number;
hunks: EditHunk[];
truncated: boolean;
/** Set for lines of a subagent transcript. */
agentId?: string;
}
export interface PendingBash {
id: string;
ts: number;
command: string;
cwd: string;
agentId?: string;
}
export interface ImageCandidate {
toolUseId: string;
start: number;
end: number;
paths: string[];
agentId?: string;
/**
* Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window
* applies; the file must still exist, and a version already listed is not listed again.
*/
sent?: boolean;
}
export interface EditParseResult {
records: EditRecord[];
candidates: ImageCandidate[];
pending: PendingBash[];
}
const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)';
const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i');
// After the extension: no further name character, and no `.ext` (a.png.bak).
const EXT_END = '(?![\\w-])(?!\\.\\w)';
// Characters that end an unquoted path token.
const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
const PATH_TOKEN_RE = new RegExp(
[
// 1. file:// URL (Codex: `Saved to: file:///…`) — may contain spaces up to the extension.
`file://(/[^\\n]*?\\.${IMAGE_EXT})${EXT_END}`,
// 2./3. Quoted paths, spaces allowed.
`"([^"\\n]*\\.${IMAGE_EXT})"`,
`'([^'\\n]*\\.${IMAGE_EXT})'`,
// 4. Unquoted token. The lookbehind keeps a match from starting inside a URL, a glob
// or a longer name; the name before the extension must not be empty.
`(?<![\\w:/.~*?$-])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
].join('|'),
'gi'
);
/** `image` for the image extensions the panel can thumbnail, else `code`. */
export function classifyEditPath(path: string): EditKind {
return IMAGE_PATH_RE.test(path) ? 'image' : 'code';
}
/**
* Image-looking paths in a Bash call's command and output, in order of appearance,
* deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised,
* `~/` paths stay literal (the host that stats them expands `~`), relative ones are
* resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are
* skipped.
*/
export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] {
const text = [command, stdout, stderr].join('\n');
const out: string[] = [];
const seen = new Set<string>();
for (const m of text.matchAll(PATH_TOKEN_RE)) {
const raw = m[1] ?? m[2] ?? m[3] ?? m[4];
if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue;
let resolved: string;
if (raw.startsWith('/')) resolved = posix.normalize(raw);
else if (raw.startsWith('~/')) resolved = raw;
else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw);
else continue;
if (seen.has(resolved)) continue;
seen.add(resolved);
out.push(resolved);
if (out.length >= MAX_IMAGE_CANDIDATES) break;
}
return out;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
const hunks: EditHunk[] = [];
let added = 0;
let removed = 0;
let budget = MAX_HUNK_BYTES;
let truncated = false;
for (const h of patch) {
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
const lines = h.lines.filter((l): l is string => typeof l === 'string');
for (const l of lines) {
if (l.startsWith('+')) added++;
else if (l.startsWith('-')) removed++;
}
if (truncated) continue;
const kept: string[] = [];
for (const l of lines) {
const cost = Buffer.byteLength(l) + 3;
if (cost > budget) {
truncated = true;
break;
}
budget -= cost;
kept.push(l);
}
hunks.push({
oldStart: Number(h.oldStart) || 0,
oldLines: Number(h.oldLines) || 0,
newStart: Number(h.newStart) || 0,
newLines: Number(h.newLines) || 0,
lines: kept,
});
}
return { hunks, added, removed, truncated };
}
/** A new file's content as one all-added hunk, in `structuredPatch` form. */
function createdFileHunk(content: string): Record<string, unknown> {
const lines = content.split('\n');
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) };
}
function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null {
if (!isRecord(result)) return null;
const { filePath, structuredPatch } = result;
if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null;
const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit';
const kind = classifyEditPath(filePath);
// A Write that created a file reports an empty patch; its diff is the whole content.
const patch =
result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string'
? [createdFileHunk(result.content)]
: structuredPatch;
const { hunks, added, removed, truncated } = toHunks(patch);
const record: EditRecord = {
id,
ts,
path: filePath,
kind,
tool,
created: result.type === 'create',
added,
removed,
hunks,
truncated,
};
if (agentId) record.agentId = agentId;
return record;
}
/**
* Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the
* files claude delivered to the user, which is how a session shows generated images.
*/
function sentImagePaths(result: unknown): string[] {
if (!isRecord(result) || !Array.isArray(result.attachments)) return [];
const out: string[] = [];
for (const a of result.attachments) {
if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue;
if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path);
}
return out;
}
/** The text of a `tool_result` block's content (a string or a list of text blocks). */
function blockText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : ''))
.filter(Boolean)
.join('\n');
}
/**
* Parse transcript lines into edit records and Bash image candidates.
*
* @param text complete JSONL lines; a first line cut mid-record (a byte range starting
* inside it) and anything else that does not parse is skipped.
* @param pending unanswered Bash calls from the previous chunk.
*/
export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult {
const open = new Map<string, PendingBash>(pending.map((p) => [p.id, p]));
const records: EditRecord[] = [];
const candidates: ImageCandidate[] = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
let rec: unknown;
try {
rec = JSON.parse(line);
} catch {
continue;
}
if (!isRecord(rec)) continue;
const message = rec.message;
const content = isRecord(message) ? message.content : undefined;
if (!Array.isArray(content)) continue;
const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
const ts = Number.isFinite(parsedTs) ? parsedTs : 0;
const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined;
const cwd = typeof rec.cwd === 'string' ? rec.cwd : '';
if (rec.type === 'assistant') {
for (const block of content) {
if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue;
if (typeof block.id !== 'string' || !isRecord(block.input)) continue;
const command = block.input.command;
if (typeof command !== 'string') continue;
const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
if (agentId) entry.agentId = agentId;
open.delete(block.id);
open.set(block.id, entry);
while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string);
}
continue;
}
if (rec.type !== 'user') continue;
const results = content.filter(
(b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string'
);
// `toolUseResult` describes the line's result; it is unambiguous only for one block.
const single = results.length === 1;
for (const block of results) {
const id = block.tool_use_id as string;
if (single) {
const edit = toEditRecord(id, ts, rec.toolUseResult, agentId);
if (edit) records.push(edit);
}
const sent = single ? sentImagePaths(rec.toolUseResult) : [];
if (sent.length > 0) {
const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true };
if (agentId) candidate.agentId = agentId;
candidates.push(candidate);
}
const call = open.get(id);
if (!call) continue;
open.delete(id);
const r = single ? rec.toolUseResult : undefined;
let stdout: string;
let stderr = '';
if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
stdout = typeof r.stdout === 'string' ? r.stdout : '';
stderr = typeof r.stderr === 'string' ? r.stderr : '';
} else {
stdout = blockText(block.content);
}
const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd);
if (paths.length === 0) continue;
const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths };
if (call.agentId) candidate.agentId = call.agentId;
candidates.push(candidate);
}
}
return { records, candidates, pending: [...open.values()] };
}
+451
View File
@@ -0,0 +1,451 @@
/**
* @fileoverview Per-session edit history: the parsed edits and Bash images of one claude
* session, plus where in each transcript file reading stopped.
*
* Sources are the main transcript (`main`) and each subagent transcript
* (`subagents/agent-<id>.jsonl`). Each has a cursor:
* - `offset` — the next byte to read forward; always a line boundary.
* - `floor` — the lowest byte already parsed; > 0 while older lines are unread
* ("ältere laden" reads `[floor − budget, floor)`).
* The store decides WHICH byte range to read (`nextRange`/`olderRange`); the caller reads
* it — locally or over ssh — and hands the bytes to `apply`. Cuts happen only at `\n`,
* which never occurs inside a UTF-8 sequence, so multibyte characters split by a chunk
* boundary are never decoded half.
*
* A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
* drops the cut first line and leaves `floor` there. A line longer than the budget is
* skipped instead of blocking the cursor.
*
* A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
* pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped.
*
* ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename (in a 0700
* dir), bounded, deleted with the session (`remove()`), never fed to `/api/search`, and
* blocked from the file routes by `isSensitivePath` (`web/sensitive-path.ts`).
*
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
*
* Tests: `test/edit-history-store.test.ts`.
*
* @module edit-history-store
*/
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { dataPath } from './config/instance.js';
import {
MAX_HUNK_BYTES,
parseClaudeEditLines,
type EditRecord,
type EditTool,
type ImageCandidate,
type PendingBash,
} from './edit-history-parse.js';
export const MAX_ENTRIES = 500;
/** Diff text (and paths) kept per session; see {@link entryBytes}. */
export const MAX_STORE_BYTES = 4 * 1024 * 1024;
/** Longest string kept from a subagent's `meta.json` field. */
export const MAX_AGENT_META_CHARS = 1024;
/** Bytes read per file and pass (first read, forward step, "older" step). */
export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
export const MAIN_SOURCE = 'main';
const SAVE_DEBOUNCE_MS = 1000;
const FILE_VERSION = 1;
const NL = 0x0a;
/** `bash`: an image a Bash call wrote; `sent`: an image claude delivered to the user. */
export type EditHistoryTool = EditTool | 'bash' | 'sent';
export interface EditHistoryEntry extends Omit<EditRecord, 'tool'> {
tool: EditHistoryTool;
}
export interface SourceCursor {
offset: number;
floor: number;
/** The next forward read starts inside a line longer than the budget; skip to its end. */
midLine?: boolean;
}
export interface AgentMeta {
description: string;
agentType: string;
worktreePath?: string;
worktreeBranch?: string;
}
export interface ByteRange {
offset: number;
len: number;
}
export interface ApplyInput {
transcriptId: string;
/** `main` or `subagents/agent-<id>.jsonl`. */
source: string;
startOffset: number;
chunk: Buffer;
/** Size of the file when the chunk was read. */
size: number;
}
export interface ApplyResult {
added: number;
candidates: ImageCandidate[];
}
export interface ConfirmedImage {
toolUseId: string;
path: string;
ts: number;
agentId?: string;
tool?: 'bash' | 'sent';
}
export interface EditHistoryStoreOptions {
/** Directory holding `<sessionId>.json`; default `dataPath('edit-history')`. */
dir?: string;
debounceMs?: number;
}
interface StoreFile {
version: number;
sessionId: string;
transcriptId: string | null;
cursors: Record<string, SourceCursor>;
pendingBash: PendingBash[];
agents: Record<string, AgentMeta>;
entries: EditHistoryEntry[];
}
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** What an entry costs against {@link MAX_STORE_BYTES}: its path and diff lines, plus a fixed overhead. */
export function entryBytes(e: EditHistoryEntry): number {
let n = 128 + Buffer.byteLength(e.path);
for (const h of e.hunks) if (Array.isArray(h?.lines)) for (const l of h.lines) n += Buffer.byteLength(String(l)) + 3;
return n;
}
function metaString(v: unknown): string | undefined {
return typeof v === 'string' && v ? v.slice(0, MAX_AGENT_META_CHARS) : undefined;
}
/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */
export function parseAgentMeta(text: string): AgentMeta | null {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return null;
}
if (!isObj(raw)) return null;
const meta: AgentMeta = {
description: metaString(raw.description) ?? '',
agentType: metaString(raw.agentType) ?? '',
};
const worktreePath = metaString(raw.worktreePath);
const worktreeBranch = metaString(raw.worktreeBranch);
if (worktreePath) meta.worktreePath = worktreePath;
if (worktreeBranch) meta.worktreeBranch = worktreeBranch;
return meta;
}
export class EditHistoryStore {
readonly sessionId: string;
private readonly file: string;
private readonly debounceMs: number;
private transcriptId: string | null = null;
private cursors = new Map<string, SourceCursor>();
private pendingBash: PendingBash[] = [];
private agents = new Map<string, AgentMeta>();
private entries = new Map<string, EditHistoryEntry>();
/** Sum of {@link entryBytes} over `entries`. */
private bytes = 0;
private saveTimer: NodeJS.Timeout | null = null;
private removed = false;
constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) {
this.sessionId = sessionId;
this.file = join(opts.dir ?? dataPath('edit-history'), `${sessionId}.json`);
this.debounceMs = opts.debounceMs ?? SAVE_DEBOUNCE_MS;
}
/** A store with the persisted state of `sessionId`; empty when the file is missing or unreadable. */
static async load(sessionId: string, opts: EditHistoryStoreOptions = {}): Promise<EditHistoryStore> {
const store = new EditHistoryStore(sessionId, opts);
let data: unknown;
try {
data = JSON.parse(await readFile(store.file, 'utf-8'));
} catch {
return store;
}
if (!isObj(data)) return store;
if (data.version !== FILE_VERSION) return store;
const f = data as unknown as StoreFile;
store.transcriptId = typeof f.transcriptId === 'string' ? f.transcriptId : null;
for (const [k, c] of Object.entries(isObj(f.cursors) ? f.cursors : {})) {
if (isObj(c) && typeof c.offset === 'number' && typeof c.floor === 'number') store.cursors.set(k, c);
}
if (Array.isArray(f.pendingBash)) store.pendingBash = f.pendingBash;
for (const [k, a] of Object.entries(isObj(f.agents) ? f.agents : {})) store.agents.set(k, a);
if (Array.isArray(f.entries)) {
const loaded = f.entries.filter(
(e): e is EditHistoryEntry =>
isObj(e) && typeof e.id === 'string' && typeof e.path === 'string' && Array.isArray(e.hunks)
);
store.addRecords(loaded);
}
return store;
}
getTranscriptId(): string | null {
return this.transcriptId;
}
getCursor(source: string): SourceCursor | undefined {
const c = this.cursors.get(source);
return c ? { ...c } : undefined;
}
/**
* True while any source has unread older lines that could still be shown. At a cap (no
* room left for another entry) an older edit would be dropped the moment it is read, so
* there is nothing more to load.
*/
get backfillTruncated(): boolean {
if (this.entries.size >= MAX_ENTRIES || this.bytes > MAX_STORE_BYTES - MAX_HUNK_BYTES) return false;
for (const c of this.cursors.values()) if (c.floor > 0) return true;
return false;
}
/** Entries newest first. */
list(): EditHistoryEntry[] {
return [...this.entries.values()].sort((a, b) => b.ts - a.ts);
}
get(id: string): EditHistoryEntry | undefined {
return this.entries.get(id);
}
getAgents(): Record<string, AgentMeta> {
return Object.fromEntries(this.agents);
}
hasAgentMeta(agentId: string): boolean {
return this.agents.has(agentId);
}
/** Record a subagent's `meta.json`; ignored when it does not parse. */
setAgentMeta(agentId: string, metaText: string): void {
const meta = parseAgentMeta(metaText);
if (!meta) return;
this.agents.set(agentId, meta);
this.scheduleSave();
}
/**
* The next forward range to read from `source`, or null when nothing is new.
* A conversation other than the stored one starts from scratch (see `apply`).
*/
nextRange(source: string, size: number, transcriptId: string, budget = READ_BUDGET_BYTES): ByteRange | null {
const cursor = transcriptId === this.transcriptId ? this.cursors.get(source) : undefined;
if (cursor && size >= cursor.offset) {
if (size === cursor.offset) return null;
return { offset: cursor.offset, len: Math.min(budget, size - cursor.offset) };
}
// Unknown file, new conversation, or a file that shrank (replaced): read its end.
const offset = Math.max(0, size - budget);
return size > offset ? { offset, len: size - offset } : null;
}
/** The next older range of `source` ("ältere laden"), or null when it is read to its start. */
olderRange(source: string, budget = READ_BUDGET_BYTES): ByteRange | null {
const cursor = this.cursors.get(source);
if (!cursor || cursor.floor <= 0) return null;
const offset = Math.max(0, cursor.floor - budget);
return { offset, len: cursor.floor - offset };
}
/** Every source with a cursor in the current conversation. */
knownSources(): string[] {
return [...this.cursors.keys()];
}
/** Sources with older lines left. */
sourcesWithOlder(): string[] {
return [...this.cursors.entries()].filter(([, c]) => c.floor > 0).map(([s]) => s);
}
/** Apply a forward chunk read at `nextRange`. Returns new entries and Bash image candidates. */
apply(input: ApplyInput): ApplyResult {
const { transcriptId, source, startOffset, chunk, size } = input;
if (transcriptId !== this.transcriptId) {
this.transcriptId = transcriptId;
this.cursors.clear();
this.pendingBash = [];
}
let cursor = this.cursors.get(source);
if (cursor && size < cursor.offset) {
this.cursors.delete(source);
cursor = undefined;
}
if (cursor && startOffset !== cursor.offset) return { added: 0, candidates: [] };
const atEof = startOffset + chunk.length >= size;
// Bytes before `from` belong to a line that started before this chunk.
let from = 0;
if (cursor ? cursor.midLine : startOffset > 0) {
const nl = chunk.indexOf(NL);
if (nl === -1) {
// Still inside that line: move past it unless it is the line being written.
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
return { added: 0, candidates: [] };
}
from = nl + 1;
}
const lastNl = chunk.lastIndexOf(NL);
if (lastNl < from) {
if (!atEof && chunk.length > 0) {
// One line longer than the budget: skip it instead of re-reading it forever.
this.setCursor(source, cursor, startOffset + chunk.length, startOffset + from, true);
} else if (!cursor) {
this.setCursor(source, undefined, startOffset + from, startOffset + from, false);
}
return { added: 0, candidates: [] };
}
const end = lastNl + 1;
this.setCursor(source, cursor, startOffset + end, startOffset + from, false);
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'), this.pendingBash);
this.pendingBash = parsed.pending;
const added = this.addRecords(parsed.records);
this.scheduleSave();
return { added, candidates: parsed.candidates };
}
/** Apply a chunk read at `olderRange`: it ends at the floor, a line boundary. */
applyOlder(input: ApplyInput): ApplyResult {
const { transcriptId, source, startOffset, chunk } = input;
const cursor = this.cursors.get(source);
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
return { added: 0, candidates: [] };
}
let from = 0;
if (startOffset > 0) {
const nl = chunk.indexOf(NL);
// No line start in the whole block: one oversized line, skip it.
from = nl === -1 ? chunk.length : nl + 1;
}
cursor.floor = startOffset + from;
// Calls whose results lie in already-read newer lines cannot be paired here.
const parsed = parseClaudeEditLines(chunk.subarray(from).toString('utf8'));
const added = this.addRecords(parsed.records);
this.scheduleSave();
return { added, candidates: parsed.candidates };
}
/** Add Bash images whose mtime was confirmed inside their call's window. */
addImageEntries(images: readonly ConfirmedImage[]): number {
const records: EditHistoryEntry[] = images.map((img) => {
const entry: EditHistoryEntry = {
id: `${img.toolUseId}:${img.path}`,
ts: img.ts,
path: img.path,
kind: 'image',
tool: img.tool ?? 'bash',
created: false,
added: 0,
removed: 0,
hunks: [],
truncated: false,
};
if (img.agentId) entry.agentId = img.agentId;
return entry;
});
const added = this.addRecords(records);
if (added) this.scheduleSave();
return added;
}
/** Write now (cancels a pending debounced write). */
async flush(): Promise<void> {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (this.removed) return;
const data: StoreFile = {
version: FILE_VERSION,
sessionId: this.sessionId,
transcriptId: this.transcriptId,
cursors: Object.fromEntries(this.cursors),
pendingBash: this.pendingBash,
agents: Object.fromEntries(this.agents),
entries: this.list(),
};
const dir = join(this.file, '..');
await mkdir(dir, { recursive: true, mode: 0o700 });
// `mode` applies only to a file being created: the fresh tmp file is 0600, and the rename
// makes it the store, so an existing store never keeps wider bits.
const tmp = `${this.file}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
await rename(tmp, this.file);
}
/** Delete the persisted file (session deleted); later changes are no longer written. */
async remove(): Promise<void> {
this.removed = true;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
await unlink(this.file).catch(() => undefined);
}
private setCursor(
source: string,
cursor: SourceCursor | undefined,
offset: number,
firstFloor: number,
midLine: boolean
): void {
const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor };
if (midLine) next.midLine = true;
this.cursors.set(source, next);
}
private addRecords(records: readonly EditHistoryEntry[]): number {
const fresh: string[] = [];
for (const r of records) {
if (this.entries.has(r.id)) continue;
this.entries.set(r.id, r);
this.bytes += entryBytes(r);
fresh.push(r.id);
}
if (this.entries.size > MAX_ENTRIES || this.bytes > MAX_STORE_BYTES) {
for (const e of [...this.entries.values()].sort((a, b) => a.ts - b.ts)) {
if (this.entries.size <= MAX_ENTRIES && this.bytes <= MAX_STORE_BYTES) break;
this.entries.delete(e.id);
this.bytes -= entryBytes(e);
}
}
// Only what is still there counts: an "older" block past the cap adds nothing.
return fresh.filter((id) => this.entries.has(id)).length;
}
private scheduleSave(): void {
if (this.removed || this.saveTimer) return;
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
void this.flush().catch((err) => {
console.warn(`[EditHistory] Save failed for session ${this.sessionId}:`, err);
});
}, this.debounceMs);
this.saveTimer.unref?.();
}
}
+6 -3
View File
@@ -2610,7 +2610,7 @@ export class Session extends EventEmitter {
if (entry?.launch.chain !== 'fallback') return options;
const resumeIdPattern = entry.launch.params?.resumeId;
const configDir = this._claudeConfigDir();
const configDir = this.claudeConfigDir();
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
for (const candidate of candidates) {
@@ -2632,8 +2632,11 @@ export class Session extends EventEmitter {
return options;
}
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
private _claudeConfigDir(): string | undefined {
/**
* The session's Claude config dir when it has been relocated (#255), else undefined.
* Public for the edit history reader, which looks for the transcript there.
*/
claudeConfigDir(): string | undefined {
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
// envOverrides schema validates keys only, and a whitespace-only value would
// otherwise resolve to a relative path and read "no transcript" for everything.
+307
View File
@@ -0,0 +1,307 @@
/**
* @fileoverview Reads a claude session's edit history on request: the new bytes of its
* transcripts (main + subagents) go into its `EditHistoryStore`, and the Bash image
* candidates that came with them are confirmed against the files on disk.
*
* WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the
* session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody
* looks at. One sync per session runs at a time; a request during a run is folded into one
* more run afterwards.
*
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
* `unsupported` and are never read.
*
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
*
* Tests: `test/edit-history-sync.test.ts`.
*
* @module web/edit-history-sync
*/
import type { SessionRemote } from '../types/session.js';
import type { EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
import type { ImageCandidate } from '../edit-history-parse.js';
import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js';
import { getCli } from '../config/cli-registry/registry.js';
/** Slack around a Bash call's window for the image mtime check. */
export const IMAGE_MTIME_SLACK_MS = 2000;
/** Forward passes per sync when a file grew by more than one budget. */
export const MAX_FORWARD_PASSES = 3;
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
export const NO_TRANSCRIPT = 'no-transcript';
export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported';
export interface EditHistoryStatus {
state: EditHistoryState;
reason?: string;
}
/** The slice of a session this needs. */
export interface EditHistorySession {
id: string;
mode: string;
remote: SessionRemote | undefined;
docker?: unknown;
claudeSessionId: string | null;
/** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
claudeConfigDir?: string;
}
export interface EditHistorySyncDeps {
/** The session's main transcript path, or null when it has none yet. */
transcriptPath(session: EditHistorySession): Promise<string | null>;
readSources(mainPath: string, requests: readonly EditSourceRequest[], budget: number): Promise<EditSources | null>;
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
loadStore(sessionId: string): Promise<EditHistoryStore>;
budget?: number;
}
export interface SyncOptions {
older?: boolean;
}
/** Why a session has no edit history, or undefined when it can have one. */
export function unsupportedReason(session: EditHistorySession): string | undefined {
if (session.remote) return 'remote';
if (session.docker) return 'docker';
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
return undefined;
}
/**
* Bash candidates whose probed file is a regular file with an mtime inside the call's window
* (files claude sent to the user skip the window: they exist and were shown on purpose).
* A candidate whose file version is already recorded is skipped: `known(path)` is the newest
* entry time for that path, and an entry at or after `mtime − slack` saw this version — the
* `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken
* in call order so a batch dedupes itself the same way.
*/
export function confirmImages(
candidates: readonly ImageCandidate[],
probe: (path: string) => PathProbe | null | undefined,
known: (path: string) => number | undefined = () => undefined
): ConfirmedImage[] {
const out: ConfirmedImage[] = [];
const seen = new Map<string, number>();
for (const c of [...candidates].sort((a, b) => a.end - b.end)) {
for (const path of c.paths) {
const p = probe(path);
if (!p || p.kind !== 'file' || !p.mtimeMs) continue;
const inWindow = p.mtimeMs >= c.start - IMAGE_MTIME_SLACK_MS && p.mtimeMs <= c.end + IMAGE_MTIME_SLACK_MS;
if (!c.sent && !inWindow) continue;
const entryPath = path.startsWith('~/') ? p.realPath : path;
const last = Math.max(seen.get(entryPath) ?? -Infinity, known(entryPath) ?? -Infinity);
if (last >= p.mtimeMs - IMAGE_MTIME_SLACK_MS) continue;
seen.set(entryPath, c.end);
const img: ConfirmedImage = {
toolUseId: c.toolUseId,
path: entryPath,
ts: c.end,
tool: c.sent ? 'sent' : 'bash',
};
if (c.agentId) img.agentId = c.agentId;
out.push(img);
}
}
return out;
}
interface SessionSlot {
store?: EditHistoryStore;
status: EditHistoryStatus;
running?: Promise<void>;
again?: { older: boolean };
}
export class EditHistorySync {
private readonly slots = new Map<string, SessionSlot>();
private readonly budget: number;
constructor(private readonly deps: EditHistorySyncDeps) {
this.budget = deps.budget ?? READ_BUDGET_BYTES;
}
/** The session's store, loaded from disk on first use. */
async getStore(sessionId: string): Promise<EditHistoryStore> {
const slot = this.slot(sessionId);
if (!slot.store) slot.store = await this.deps.loadStore(sessionId);
return slot.store;
}
getStatus(sessionId: string): EditHistoryStatus {
return { ...this.slot(sessionId).status };
}
/**
* Read what is new (or, with `older`, the next older block). Resolves when this
* request's run is done; never rejects. A call during a run queues one more run.
*/
async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> {
const why = unsupportedReason(session);
const slot = this.slot(session.id);
if (why) {
slot.status = { state: 'unsupported', reason: why };
return this.getStatus(session.id);
}
if (slot.running) {
slot.again = { older: (slot.again?.older ?? false) || !!opts.older };
await slot.running;
// The queued run starts when the current one ends; wait for it too.
if (slot.running) await slot.running;
return this.getStatus(session.id);
}
slot.running = this.runLoop(session, !!opts.older);
try {
await slot.running;
} finally {
slot.running = undefined;
}
return this.getStatus(session.id);
}
/** Write every loaded store's pending changes (server shutdown). */
async flushAll(): Promise<void> {
await Promise.all([...this.slots.values()].map((slot) => slot.store?.flush()));
}
/** Drop a deleted session: forget it and delete its stored history. */
async remove(sessionId: string): Promise<void> {
const store = await this.getStore(sessionId);
this.slots.delete(sessionId);
await store.remove();
}
private slot(sessionId: string): SessionSlot {
let slot = this.slots.get(sessionId);
if (!slot) {
slot = { status: { state: 'ok' } };
this.slots.set(sessionId, slot);
}
return slot;
}
private async runLoop(session: EditHistorySession, older: boolean): Promise<void> {
const slot = this.slot(session.id);
let next: boolean | null = older;
while (next !== null) {
await this.runOnce(session, next);
next = slot.again ? slot.again.older : null;
slot.again = undefined;
}
}
private async runOnce(session: EditHistorySession, older: boolean): Promise<void> {
const slot = this.slot(session.id);
const store = await this.getStore(session.id);
try {
const path = await this.deps.transcriptPath(session);
let found: boolean;
if (!path) {
found = false;
} else if (older) {
found = await this.readOlder(path, store);
} else {
found = true;
for (let pass = 0; pass < MAX_FORWARD_PASSES && found; pass++) {
const r = await this.readForward(path, store);
found = r.found;
if (!r.more) break;
}
}
slot.status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
} catch (err) {
slot.status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
}
}
private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
const out: EditSourceRequest[] = [];
for (const relPath of store.knownSources()) {
const c = store.getCursor(relPath);
if (c) out.push({ relPath, offset: c.offset });
}
return out;
}
/**
* One forward read of all sources. `more` = some file has unread bytes after it.
* A transcript other than the stored one (`/clear`, resume) is read like a first read.
*/
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
const known = store.getTranscriptId();
const requests = known !== null && path.endsWith(`/${known}.jsonl`) ? this.forwardRequests(store) : [];
const res = await this.deps.readSources(path, requests, this.budget);
if (!res) return { found: false, more: false };
for (const [agentId, text] of Object.entries(res.metas)) {
if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
}
let more = false;
const candidates: ImageCandidate[] = [];
for (const f of res.files) {
const r = store.apply({
transcriptId: res.transcriptId,
source: f.relPath,
startOffset: f.startOffset,
chunk: f.chunk,
size: f.size,
});
candidates.push(...r.candidates);
if (f.startOffset + f.chunk.length < f.size) more = true;
}
await this.addImages(store, candidates);
return { found: true, more };
}
private async readOlder(path: string, store: EditHistoryStore): Promise<boolean> {
if (!store.backfillTruncated) return true;
const requests: EditSourceRequest[] = [];
for (const relPath of store.sourcesWithOlder()) {
const range = store.olderRange(relPath, this.budget);
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
}
if (requests.length === 0) return true;
const res = await this.deps.readSources(path, requests, this.budget);
if (!res) return false;
if (res.transcriptId !== store.getTranscriptId()) return true;
const wanted = new Set(requests.map((r) => r.relPath));
const candidates: ImageCandidate[] = [];
for (const f of res.files) {
if (!wanted.has(f.relPath)) continue;
const r = store.applyOlder({
transcriptId: res.transcriptId,
source: f.relPath,
startOffset: f.startOffset,
chunk: f.chunk,
size: f.size,
});
candidates.push(...r.candidates);
}
await this.addImages(store, candidates);
return true;
}
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
if (paths.length === 0) return;
let probes: Array<PathProbe | null>;
try {
probes = await this.deps.probe(paths);
} catch {
// The edits of this read are in; only its images are lost. The read itself succeeded.
return;
}
const byPath = new Map(paths.map((p, i) => [p, probes[i]]));
const newest = new Map<string, number>();
for (const e of store.list()) newest.set(e.path, Math.max(newest.get(e.path) ?? -Infinity, e.ts));
store.addImageEntries(
confirmImages(
candidates,
(p) => byPath.get(p),
(p) => newest.get(p)
)
);
}
}
+6
View File
@@ -286,6 +286,10 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
// Edit history (edit-history-ui.js): an open panel follows its session's tool ends
[SSE_EVENTS.TRANSCRIPT_TOOL_END, '_onEditHistoryTranscriptEvent'],
[SSE_EVENTS.TRANSCRIPT_COMPLETE, '_onEditHistoryTranscriptEvent'],
// Tunnel
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
[SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'],
@@ -9654,6 +9658,8 @@ class CodemanApp {
// Remote-host reachability banner: only meaningful for a remote session, so this
// also clears it when the newly active tab is local.
this.refreshHostWakeBanner?.(sessionId);
// An open Edit history panel follows the active session (edit-history-ui.js; no poll).
this.onEditHistoryActiveSession?.(sessionId);
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
// the async buffer load. Without this, the offset is 0 during the
+643
View File
@@ -0,0 +1,643 @@
/**
* @fileoverview Edit history: a header button (next to Attachments) and the floating panel it opens
* right below, listing every file edit the ACTIVE session's agent (and its subagents) made, newest
* first.
*
* OPTIONAL and per-device: `showEditHistory` (App Settings → Header & Panels → Header), default
* OFF. While it is off the button never shows and nothing is requested. The panel DRIVES the
* server's reading, which never runs in the background: it reads
* `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
* only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
* tool that can change files and on `transcript:complete` (the existing transcript watcher's
* events). No poll, and nothing at all while the panel is closed.
*
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
* - An image entry shows a thumbnail and opens the shared file preview (panels-ui.js
* `openFilePreview`), as does a click on any path. Both go through the existing file routes only:
* inside the workspace the confined `file-raw`, outside it an id minted by the attachment route
* with `notify: false` (`_registerExternalPreview`, which enforces the attachment guard and its
* extension allowlist). A path from the transcript is never served by a route of its own.
* - `backfillTruncated` offers "Load older" (`POST /api/sessions/:id/edits/older`).
*
* Paths, agent descriptions and diffs are untrusted text: they are only ever written with
* `textContent`, never `innerHTML`.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
* @dependency api-client.js (this._api, this._apiJson; resolved at call time)
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath, _registerExternalPreview)
* @dependency i18n.js (window.codemanT)
* @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js
*/
/** SSE bursts (one event per tool call) collapse into one read. */
const EDIT_HISTORY_SSE_DEBOUNCE_MS = 600;
/** Tools whose end can mean changed files (Agent/Task: a subagent's edits; SendUserFile: an image). */
const EDIT_HISTORY_FOLLOW_TOOLS = new Set([
'Edit',
'Write',
'MultiEdit',
'NotebookEdit',
'Bash',
'Agent',
'Task',
'SendUserFile',
]);
/** Per-device view toggles (a viewer convenience, so plain localStorage). */
const EDIT_HISTORY_GROUP_KEY = 'codeman-edit-history-group';
const EDIT_HISTORY_MAIN_ONLY_KEY = 'codeman-edit-history-main-only';
Object.assign(CodemanApp.prototype, {
/** Per-device setting, default OFF. */
isEditHistoryEnabled() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.showEditHistory ?? defaults.showEditHistory ?? false) === true;
},
/** Translate an English source string (i18n.js), with `{var}` interpolation. */
_eht(source, vars) {
if (typeof window.codemanT === 'function') return window.codemanT(source, vars);
return vars ? source.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : source;
},
/** A row badge ("new"/"edit") through its scoped i18n key; English shows the bare word. */
_ehBadge(word) {
const key = `Edit history badge: ${word}`;
const out = this._eht(key);
return out === key ? word : out;
},
/** Called from applyHeaderVisibilitySettings() (boot and every settings save). */
applyEditHistoryVisibility() {
const on = this.isEditHistoryEnabled();
// Marker class, not [hidden]: the base rule is display:inline-flex !important (src/web/CLAUDE.md).
this.$('editHistoryBtn')?.classList.toggle('btn-edit-history--hidden', !on);
if (!on) {
this.closeEditHistoryPanel();
this._editHistory = null;
this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1; // an in-flight read must not repaint
clearTimeout(this._editHistoryDebounce);
this._editHistoryDebounce = null;
}
},
// ── Per-device view toggles ─────────────────────────────────────────────
_editHistoryPref(key) {
try {
return localStorage.getItem(key) === '1';
} catch {
return false;
}
},
_setEditHistoryPref(key, on) {
try {
localStorage.setItem(key, on ? '1' : '0');
} catch {
/* private window / blocked storage: the toggle still works for this page */
}
},
isEditHistoryGrouped() {
return this._editHistoryGroupOn ?? (this._editHistoryGroupOn = this._editHistoryPref(EDIT_HISTORY_GROUP_KEY));
},
isEditHistoryMainOnly() {
return this._editHistoryMainOnly ?? (this._editHistoryMainOnly = this._editHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY));
},
setEditHistoryGrouped(on) {
this._editHistoryGroupOn = !!on;
this._setEditHistoryPref(EDIT_HISTORY_GROUP_KEY, !!on);
this._renderEditHistoryPanel();
},
setEditHistoryMainOnly(on) {
this._editHistoryMainOnly = !!on;
this._setEditHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY, !!on);
this._renderEditHistoryPanel();
},
// ── Data ────────────────────────────────────────────────────────────────
/** The data for the session on screen, or null. */
_currentEditHistory() {
const h = this._editHistory;
return h && h.sessionId === this.activeSessionId ? h : null;
},
_editHistoryBase(sid) {
return `/api/sessions/${encodeURIComponent(sid)}/edits`;
},
/**
* Read the active session's history and repaint. `older` asks the server for entries before the
* backfill window (POST), otherwise the normal read. Answers for another session are dropped.
*/
async refreshEditHistory({ older = false } = {}) {
if (!this.isEditHistoryEnabled()) return;
const sid = this.activeSessionId || null;
if (!sid) {
this._editHistory = null;
this._renderEditHistoryPanel();
return;
}
const epoch = (this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1);
const prev = this._currentEditHistory();
if (!prev) {
// Another session's list must not stay on screen while this one loads.
this._editHistory = { sessionId: sid, state: 'loading', data: null, diffs: new Map(), open: new Set() };
this._renderEditHistoryPanel();
} else if (older) {
prev.loadingOlder = true;
this._renderEditHistoryPanel();
}
const base = this._editHistoryBase(sid);
const data = older
? await this._apiJson(`${base}/older`, { method: 'POST', body: {} })
: await this._apiJson(`${base}?sync=1`);
if (epoch !== this._editHistoryEpoch || sid !== this.activeSessionId || !this.isEditHistoryEnabled()) return;
const cur = this._currentEditHistory() || {
sessionId: sid,
diffs: new Map(),
open: new Set(),
};
cur.loadingOlder = false;
if (data && typeof data === 'object') {
cur.state = 'ok';
cur.data = data;
} else if (!cur.data) {
cur.state = 'error';
}
this._editHistory = cur;
this._renderEditHistoryPanel();
},
loadOlderEditHistory() {
return this.refreshEditHistory({ older: true });
},
/**
* SSE `transcript:tool_end` ({ sessionId, toolName }) and `transcript:complete` ({ sessionId }):
* while the panel is open on that session, re-read after a tool that can change files, and at
* the end of a turn (a parallel tool call reports only one tool end). Closed: nothing.
*/
_onEditHistoryTranscriptEvent(data) {
if (!data || data.sessionId !== this.activeSessionId || !this._isEditHistoryPanelOpen()) return;
if (data.toolName !== undefined && !EDIT_HISTORY_FOLLOW_TOOLS.has(data.toolName)) return;
if (!this.isEditHistoryEnabled()) return;
clearTimeout(this._editHistoryDebounce);
this._editHistoryDebounce = setTimeout(() => {
this._editHistoryDebounce = null;
if (this._isEditHistoryPanelOpen()) this.refreshEditHistory();
}, EDIT_HISTORY_SSE_DEBOUNCE_MS);
},
/** Called by selectSession(): the open panel follows the active session. */
onEditHistoryActiveSession(sessionId) {
if (!this.isEditHistoryEnabled() || !this._isEditHistoryPanelOpen()) return;
if (this._editHistory?.sessionId === sessionId) return;
this.refreshEditHistory();
},
// ── Panel ───────────────────────────────────────────────────────────────
_isEditHistoryPanelOpen() {
return !!this.$('editHistoryPanel')?.classList.contains('visible');
},
toggleEditHistoryPanel() {
if (this._isEditHistoryPanelOpen()) {
this.closeEditHistoryPanel();
return;
}
const panel = this.$('editHistoryPanel');
if (!panel) return;
panel.classList.add('visible');
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'true');
const group = this.$('editHistoryGroupToggle');
if (group) group.checked = this.isEditHistoryGrouped();
const main = this.$('editHistoryMainOnlyToggle');
if (main) main.checked = this.isEditHistoryMainOnly();
this._ensureEditHistoryPanelDrag();
this._renderEditHistoryPanel();
this.refreshEditHistory();
},
closeEditHistoryPanel() {
const panel = this.$('editHistoryPanel');
if (panel) {
panel.classList.remove('visible');
// Reset a dragged position so it reopens at the default spot (a resized size is kept).
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
}
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'false');
clearTimeout(this._editHistoryDebounce);
this._editHistoryDebounce = null;
},
/** Drag by the header (pointer events: mouse, pen and touch). One set of listeners per page. */
_ensureEditHistoryPanelDrag() {
const panel = this.$('editHistoryPanel');
const handle = panel?.querySelector('.edit-history-header');
if (!panel || !handle || handle._dragReady) return;
handle._dragReady = true;
let drag = null;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
// Pin the size too: the phone layout stretches between left and right, which a drag gives up.
panel.style.width = `${rect.width}px`;
panel.style.height = `${rect.height}px`;
handle.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!drag) return;
const maxX = window.innerWidth - panel.offsetWidth - 4;
const maxY = window.innerHeight - panel.offsetHeight - 4;
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
});
const end = (e) => {
drag = null;
handle.releasePointerCapture?.(e.pointerId);
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
},
_ehEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
},
/** "14:03:22", with the date in front when it is not today. */
_editHistoryTime(ts) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString()} ${time}`;
},
/** Open `path` in the shared file preview overlay (panels-ui.js). */
_openEditHistoryPreview(path) {
const sid = this.activeSessionId;
if (sid && path) this.openFilePreview?.(path, sid);
},
_editHistoryUrl(url) {
return typeof CodemanBase !== 'undefined' && CodemanBase.url ? CodemanBase.url(url) : url;
},
/**
* Thumbnail source for an image entry, through the routes the file preview uses: inside the
* workspace the confined `file-raw` route (a string), outside it the attachment `/raw` route by
* an id the attachment route mints with `notify: false` (a promise, one per path and panel
* state, resolving to the URL or null when the attachment guard refuses the file). SVGs get none
* (both routes serve them as a download); clicking still opens the preview.
*/
_editHistoryThumbSrc(path, sid, h) {
if (!path || /\.svg$/i.test(path)) return null;
if (!this._isExternalPreviewPath?.(path, sid)) {
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
}
if (typeof this._registerExternalPreview !== 'function') return null;
if (!h.thumbs) h.thumbs = new Map();
if (!h.thumbs.has(path)) {
h.thumbs.set(
path,
this._registerExternalPreview(path, sid).then((r) =>
r?.attachmentId
? this._editHistoryUrl(
`/api/sessions/${encodeURIComponent(sid)}/attachments/${encodeURIComponent(r.attachmentId)}/raw`
)
: null
)
);
}
return h.thumbs.get(path);
},
_renderEditHistoryPanel() {
const body = this.$('editHistoryBody');
const count = this.$('editHistoryCount');
if (!body || !this._isEditHistoryPanelOpen()) return;
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const t = (s, v) => this._eht(s, v);
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-edit-key]')?.dataset.editKey
: null;
const scroll = body.scrollTop;
body.replaceChildren();
if (count) count.textContent = '';
if (!this.activeSessionId) {
body.append(el('div', 'edit-history-empty', t('Open a session to see its edits.')));
return;
}
const h = this._currentEditHistory();
if (!h || h.state === 'loading') {
body.append(el('div', 'edit-history-empty', t('Reading the edit history…')));
return;
}
if (h.state === 'error' || !h.data) {
body.append(el('div', 'edit-history-empty', t('Could not read the edit history.')));
return;
}
const data = h.data;
if (data.state === 'unsupported') {
body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
return;
}
if (data.state === 'unavailable') {
body.append(
el(
'div',
'edit-history-note edit-history-note--warn',
data.reason === 'no-transcript'
? t(
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
)
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
)
);
}
const all = Array.isArray(data.entries) ? data.entries : [];
const entries = this.isEditHistoryMainOnly() ? all.filter((e) => !e.agentId) : all;
if (count) count.textContent = all.length ? String(entries.length) : '';
if (!all.length) {
body.append(el('div', 'edit-history-empty', t('No edits yet.')));
} else if (!entries.length) {
body.append(el('div', 'edit-history-empty', t('No edits by the main agent.')));
} else if (this.isEditHistoryGrouped()) {
const groups = new Map();
for (const e of entries) {
const key = e.path || e.relPath;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
for (const items of groups.values()) body.append(this._editHistoryGroup(items, data, h));
} else {
const list = el('div', 'edit-history-list');
for (const e of entries) list.append(...this._editHistoryEntry(e, data, h, true));
body.append(list);
}
if (data.backfillTruncated) {
const more = el(
'button',
'btn-toolbar btn-sm edit-history-older',
h.loadingOlder ? t('Loading older edits…') : t('Load older')
);
more.type = 'button';
more.disabled = !!h.loadingOlder;
more.addEventListener('click', () => this.loadOlderEditHistory());
body.append(more);
}
body.scrollTop = scroll;
if (focusKey) {
const again = [...body.querySelectorAll('[data-edit-key]')].find((n) => n.dataset.editKey === focusKey);
again?.focus({ preventScroll: true });
}
},
/** One file with all its edits (newest first), for the "group by file" view. */
_editHistoryGroup(items, data, h) {
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const first = items[0];
const group = el('section', 'edit-history-group');
const head = el('div', 'edit-history-group-head');
head.append(this._editHistoryPathButton(first));
const code = items.filter((e) => e.kind !== 'image');
if (code.length) {
const added = code.reduce((s, e) => s + (Number(e.added) || 0), 0);
const removed = code.reduce((s, e) => s + (Number(e.removed) || 0), 0);
head.append(this._editHistoryStats(added, removed));
}
head.append(el('span', 'edit-history-group-count', String(items.length)));
group.append(head);
for (const e of items) group.append(...this._editHistoryEntry(e, data, h, false));
return group;
},
/**
* The path as folder + file name: the folder is cut from its start when the row is narrow, so a
* long absolute path (an image in claude's scratchpad) still shows which file it is.
*/
_editHistoryPathButton(e) {
const shown = e.relPath || e.path || '';
const btn = this._ehEl('button', 'edit-history-path');
const cut = shown.lastIndexOf('/') + 1;
if (cut > 0) {
const dir = this._ehEl('span', 'edit-history-path-dir');
const bdi = this._ehEl('bdi', null, shown.slice(0, cut));
bdi.dir = 'ltr';
dir.append(bdi);
btn.append(dir);
}
btn.append(this._ehEl('span', 'edit-history-path-name', shown.slice(cut)));
btn.type = 'button';
btn.title = e.path || e.relPath || '';
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
this._openEditHistoryPreview(e.path);
});
return btn;
},
_editHistoryStats(added, removed) {
const stats = this._ehEl('span', 'edit-history-stats');
stats.append(this._ehEl('span', 'edit-history-add', `+${added}`));
stats.append(this._ehEl('span', 'edit-history-del', `−${removed}`));
return stats;
},
/** A row, followed by its diff when the row is open. `withPath` false inside a file group. */
_editHistoryEntry(e, data, h, withPath) {
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const t = (s, v) => this._eht(s, v);
const isImage = e.kind === 'image';
const row = el('div', `edit-history-entry edit-history-entry--${isImage ? 'image' : 'code'}`);
row.dataset.editKey = e.id;
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.append(el('span', 'edit-history-time', this._editHistoryTime(e.ts)));
let badge;
if (isImage) {
badge = el('span', 'edit-history-badge edit-history-badge--image');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '12');
svg.setAttribute('height', '12');
svg.setAttribute('aria-hidden', 'true');
for (const [tag, attrs] of [
['rect', { x: '3', y: '4', width: '18', height: '16', rx: '2' }],
['circle', { cx: '9', cy: '10', r: '2' }],
['path', { d: 'M21 17l-5-5-9 8' }],
]) {
const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
svg.append(n);
}
badge.append(svg);
badge.title = t('Image');
} else if (e.created) {
badge = el('span', 'edit-history-badge edit-history-badge--new', this._ehBadge('new'));
badge.title = t('File created');
} else {
badge = el('span', 'edit-history-badge edit-history-badge--edit', this._ehBadge('edit'));
badge.title = t('File edited ({tool})', { tool: e.tool || '' });
}
row.append(badge);
if (withPath) row.append(this._editHistoryPathButton(e));
row.append(el('span', 'edit-history-spacer'));
if (!isImage) row.append(this._editHistoryStats(Number(e.added) || 0, Number(e.removed) || 0));
if (e.agentId) {
const agent = data.agents?.[e.agentId];
const chip = el('span', 'edit-history-agent', agent?.description || e.agentId);
const bits = [agent?.agentType, agent?.worktreeBranch].filter(Boolean);
chip.title = bits.join(' · ') || t('Subagent');
row.append(chip);
}
const out = [row];
if (isImage) {
row.title = t('Open in the file preview');
const open = () => this._openEditHistoryPreview(e.path);
row.addEventListener('click', open);
row.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
open();
}
});
const src = this._editHistoryThumbSrc(e.path, h.sessionId, h);
if (src) {
const img = el('img', 'edit-history-thumb');
img.loading = 'lazy';
img.alt = e.relPath || '';
img.addEventListener('click', (ev) => {
ev.stopPropagation();
open();
});
img.addEventListener('error', () => img.remove());
if (typeof src === 'string') {
img.src = src;
} else {
// A re-render meanwhile replaced this element; the new one reads the same promise.
src.then((url) => {
if (url) img.src = url;
else img.remove();
});
}
out.push(img);
}
return out;
}
const isOpen = h.open.has(e.id);
row.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
row.title = isOpen ? t('Hide the diff') : t('Show what changed');
const toggle = () => this.toggleEditHistoryDiff(e.id);
row.addEventListener('click', toggle);
row.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
toggle();
}
});
if (isOpen) {
row.classList.add('edit-history-entry--open');
out.push(this._editHistoryDiffView(h.diffs.get(e.id)));
}
return out;
},
/** Open or close one entry's diff; the first open loads it, later ones reuse it. */
async toggleEditHistoryDiff(entryId) {
const h = this._currentEditHistory();
if (!h) return;
if (h.open.has(entryId)) {
h.open.delete(entryId);
this._renderEditHistoryPanel();
return;
}
h.open.add(entryId);
const cached = h.diffs.get(entryId);
if (cached && cached.state !== 'error') {
this._renderEditHistoryPanel();
return;
}
const view = { state: 'loading' };
h.diffs.set(entryId, view);
this._renderEditHistoryPanel();
const res = await this._api(`${this._editHistoryBase(h.sessionId)}/${encodeURIComponent(entryId)}`);
let body = null;
try {
body = res ? await res.json() : null;
} catch {
/* fall through */
}
if (h.diffs.get(entryId) !== view) return;
if (res && res.ok && body?.success && typeof body.data?.diff === 'string') {
view.state = 'ok';
view.diff = body.data.diff;
view.truncated = !!body.data.truncated;
} else {
view.state = 'error';
view.error = body?.error || this._eht('Could not read the diff.');
}
if (this._editHistory === h) this._renderEditHistoryPanel();
},
/** The diff block under an open row, drawn with the Git panel's classes. */
_editHistoryDiffView(view) {
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const t = (s, v) => this._eht(s, v);
const wrap = el('div', 'edit-history-diff');
if (!view || view.state === 'loading') {
wrap.append(el('div', 'edit-history-empty', t('Reading the diff…')));
return wrap;
}
if (view.state === 'error') {
wrap.append(el('div', 'edit-history-empty', view.error));
return wrap;
}
if (!view.diff.trim()) {
wrap.append(el('div', 'edit-history-empty', t('No textual changes.')));
} else {
const pre = el('pre', 'git-diff');
const frag = document.createDocumentFragment();
for (const line of view.diff.replace(/\n$/, '').split('\n')) {
let cls = 'git-diff-line';
// The API sends hunks only (no `---`/`+++` headers), so a `---` line is a removed `--` line.
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
else if (line.startsWith('+')) cls += ' git-diff-line--add';
else if (line.startsWith('-')) cls += ' git-diff-line--del';
frag.append(el('span', cls, line + '\n'));
}
pre.append(frag);
wrap.append(pre);
}
if (view.truncated)
wrap.append(el('div', 'edit-history-more', t('Diff cut short: it is larger than the viewer shows.')));
return wrap;
},
});
+33
View File
@@ -521,6 +521,39 @@
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
'Refresh git status': '刷新 Git 状态',
'Close git status': '关闭 Git 状态',
// Edit history (edit-history-ui.js): settings chip, header button and panel.
// The two row badges use scoped keys: a bare "new"/"edit" key would also
// translate unrelated words elsewhere (the lookup is case-insensitive).
'Edit history': '编辑历史',
'Open edit history': '打开编辑历史',
'Refresh edit history': '刷新编辑历史',
'Close edit history': '关闭编辑历史',
'Group by file': '按文件分组',
'Main agent only': '仅主智能体',
'Open a session to see its edits.': '打开一个会话以查看其编辑。',
'Reading the edit history…': '正在读取编辑历史…',
'Could not read the edit history.': '无法读取编辑历史。',
'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。',
'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}',
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.':
'尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。',
'No edits yet.': '暂无编辑。',
'No edits by the main agent.': '主智能体没有编辑。',
'Load older': '加载更早的编辑',
'Loading older edits…': '正在加载更早的编辑…',
'Edit history badge: new': '新建',
'Edit history badge: edit': '编辑',
'File created': '已创建文件',
'File edited ({tool})': '已编辑文件({tool})',
Image: '图片',
Subagent: '子智能体',
'Open in the file preview': '在文件预览中打开',
'Show what changed': '显示更改内容',
'Hide the diff': '隐藏差异',
'Reading the diff…': '正在读取差异…',
'Could not read the diff.': '无法读取差异。',
'No textual changes.': '没有文本更改。',
'Diff cut short: it is larger than the viewer shows.': '差异已截断:内容超出查看器可显示的范围。',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
+25
View File
@@ -192,6 +192,13 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button>
<!-- Edit history of the active session (edit-history-ui.js), next to Attachments: both show
what the session produced, and the panel opens right below. Optional and per-device
(App Settings → Header & Panels → Header, default OFF): hidden by its marker class,
never on phones (mobile.css). -->
<button type="button" class="btn-icon-header btn-edit-history btn-edit-history--hidden" id="editHistoryBtn" aria-expanded="false" aria-controls="editHistoryPanel" title="Edit history" aria-label="Open edit history" onclick="app.toggleEditHistoryPanel()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
</button>
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
@@ -608,6 +615,22 @@
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
</div>
<!-- Edit history panel (edit-history-ui.js): every file edit the active session's agents made. -->
<div class="edit-history-panel" id="editHistoryPanel" role="dialog" aria-label="Edit history">
<div class="edit-history-header">
<span class="edit-history-title">Edit history <span class="edit-history-count" id="editHistoryCount" data-i18n-skip></span></span>
<div class="edit-history-actions">
<button class="btn-icon-sm" onclick="app.refreshEditHistory()" title="Refresh" aria-label="Refresh edit history">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">&times;</button>
</div>
</div>
<div class="edit-history-controls">
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryGroupToggle" onchange="app.setEditHistoryGrouped(this.checked)"><span>Group by file</span></label>
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryMainOnlyToggle" onchange="app.setEditHistoryMainOnly(this.checked)"><span>Main agent only</span></label>
</div>
<div class="edit-history-body" id="editHistoryBody" data-i18n-skip></div>
</div>
<!-- File Preview Overlay -->
<div class="file-preview-overlay" id="filePreviewOverlay">
<div class="file-preview-window">
@@ -2102,6 +2125,7 @@
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
<label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label>
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
<label class="set-chip" data-search="edit history edits changed files diffs" data-preview="header" data-preview-order="9.5"><input type="checkbox" id="appSettingsShowEditHistory"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg><span>Edit history</span></label>
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
@@ -4366,6 +4390,7 @@
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="edit-history-ui.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+2
View File
@@ -586,6 +586,8 @@ html.mobile-init .file-browser-panel {
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer,
/* Edit history: a desktop panel; phones never show the button (opt-in, default OFF anyway). */
.btn-icon-header.btn-edit-history,
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
(inline approve/deny in mobile-overview.js); the bell would only crowd the
header it was designed to stay out of. */
+7 -1
View File
@@ -597,6 +597,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
document.getElementById('appSettingsShowEditHistory').checked = settings.showEditHistory ?? defaults.showEditHistory ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -2940,6 +2941,7 @@ Object.assign(CodemanApp.prototype, {
// Clamped here and again on the server; an empty or odd value falls back to the default.
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
showEditHistory: document.getElementById('appSettingsShowEditHistory').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -3237,6 +3239,8 @@ Object.assign(CodemanApp.prototype, {
gitStatusTree: _gst,
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
// Per-device header button (edit-history-ui.js), likewise absent from the .strict() schema.
showEditHistory: _seh,
showTabDetachButton: _tdb,
// Detach Tiles: what a tile drag does is a property of this device's
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
@@ -4526,6 +4530,8 @@ Object.assign(CodemanApp.prototype, {
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
// match the setting, so a live toggle needs no reload.
this.applyGitStatusVisibility?.();
// Header Edit history button (edit-history-ui.js): opt-in, per-device, marker-class hidden.
this.applyEditHistoryVisibility?.();
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
@@ -4889,7 +4895,7 @@ Object.assign(CodemanApp.prototype, {
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
+291
View File
@@ -22298,6 +22298,297 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
font-size: 0.68rem;
}
/* ── Edit history: header button + panel (edit-history-ui.js) ───────────────── */
/* "Edit history" header button — opt-in (App Settings → Header & Panels), hidden by default.
Same marker pattern as the Attachments button: a base inline-flex !important so an inline
style can't override it, and a more-specific marker rule to hide. Phones hide it regardless
(mobile.css). */
.btn-edit-history {
display: inline-flex !important;
}
.btn-edit-history.btn-edit-history--hidden {
display: none !important;
}
.btn-edit-history[aria-expanded='true'] {
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Same frame as the Git and Files windows: draggable by the header, resizable at the corner. */
.edit-history-panel {
position: fixed;
top: calc(var(--header-height) + 10px);
/* Left of the Git window's default spot; on a narrow viewport slide right so the left edge stays on screen. */
right: clamp(8px, calc(100vw - 428px), 410px);
width: 420px;
max-width: calc(100vw - 16px);
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
max-height: 640px;
min-width: 260px;
min-height: 220px;
background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 100;
display: none;
flex-direction: column;
overflow: hidden;
resize: both;
}
.edit-history-panel.visible {
display: flex;
}
.edit-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--bg-input);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
cursor: move;
user-select: none;
touch-action: none;
}
.edit-history-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
}
.edit-history-count {
margin-left: 0.4rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
font-weight: 400;
color: var(--text-muted);
}
.edit-history-actions {
display: flex;
gap: 0.25rem;
}
.edit-history-controls {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.9rem;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--border);
font-size: 0.68rem;
color: var(--text-muted);
flex-shrink: 0;
}
.edit-history-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.edit-history-body {
flex: 1;
overflow-y: auto;
padding: 0.4rem 0.5rem;
font-size: 0.74rem;
color: var(--text);
}
.edit-history-empty,
.edit-history-note,
.edit-history-more {
color: var(--text-muted);
padding: 0.25rem 0;
}
.edit-history-note--warn {
color: #e0a030;
}
.edit-history-entry {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.15rem 0.25rem;
border-radius: 4px;
min-width: 0;
cursor: pointer;
}
.edit-history-entry:hover,
.edit-history-entry:focus-visible,
.edit-history-entry--open {
background: var(--bg-hover);
outline: none;
}
.edit-history-time {
flex: 0 0 auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.64rem;
color: var(--text-muted);
}
.edit-history-badge {
flex: 0 0 auto;
min-width: 2rem;
text-align: center;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #e0a030;
}
.edit-history-badge--new {
color: rgb(34, 197, 94);
}
.edit-history-badge--image {
color: var(--text-muted);
align-self: center;
}
.edit-history-badge--image svg {
display: block;
margin: 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Only the name itself opens the preview: the rest of the row (the spacer) toggles the diff. */
/* The folder part gives way first, so the file name stays readable (…/icons/bremse_alt1.png). */
.edit-history-path {
display: inline-flex;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
padding: 0;
border: 0;
background: none;
color: var(--text);
text-align: left;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
cursor: pointer;
}
.edit-history-path-dir {
/* Shrinks long before the name does; the name only gives way once the folder is at min-width. */
flex: 0 10000 auto;
min-width: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
/* Ellipsis at the START of the folder (rtl); the inner <bdi dir="ltr"> keeps its text in order. */
direction: rtl;
text-align: left;
opacity: 0.7;
}
.edit-history-path-name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-history-path:hover,
.edit-history-path:focus-visible {
text-decoration: underline;
}
.edit-history-spacer {
flex: 1;
align-self: stretch;
min-width: 0.5rem;
min-height: 1em;
}
.edit-history-stats {
flex: 0 0 auto;
display: inline-flex;
gap: 0.3rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.64rem;
}
.edit-history-add {
color: rgb(34, 197, 94);
}
.edit-history-del {
color: #e5534b;
}
.edit-history-agent {
flex: 0 1 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 0.4rem;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.62rem;
color: var(--text-muted);
}
.edit-history-thumb {
display: block;
max-width: 160px;
max-height: 90px;
margin: 0.15rem 0 0.35rem 3.2rem;
border: 1px solid var(--border);
border-radius: 4px;
object-fit: contain;
cursor: zoom-in;
}
.edit-history-diff {
margin: 0.2rem 0 0.5rem 0.25rem;
}
.edit-history-group {
margin-bottom: 0.45rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--border);
}
.edit-history-group-head {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.2rem 0.25rem;
min-width: 0;
}
.edit-history-group-head .edit-history-path {
font-weight: 600;
}
.edit-history-group-count {
flex: 0 0 auto;
font-size: 0.64rem;
color: var(--text-muted);
}
.edit-history-older {
margin: 0.5rem auto 0.25rem;
}
/* ============================================================
Tab grouping by state (tabArrangement: 'state', opt-in;
Discussion #426 option C)
+130
View File
@@ -0,0 +1,130 @@
/**
* @fileoverview The edit history panel's API: which files a claude session edited (main agent
* and subagents), each edit's diff, and Bash-generated images (src/web/edit-history-sync.ts).
*
* GET /api/sessions/:id/edits[?sync=1] list (no diffs); `sync=1` reads what is new first
* GET /api/sessions/:id/edits/:entryId one edit's diff as unified text
* POST /api/sessions/:id/edits/older read the next older block, then list
*
* Read-only, and reads only on these requests (the panel drives it; nothing syncs in the
* background). Remote, Docker and non-claude sessions answer `state: 'unsupported'`
* without being read. Ownership goes through `findSessionOrFail` with `req`, like every
* session-scoped route. No route here serves a file: an image entry is shown through the
* existing file routes (workspace `file-raw`, or the attachment routes outside it).
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { findSessionOrFail } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
import type { EditHistoryEntry, EditHistoryStore } from '../../edit-history-store.js';
import type { EditHunk } from '../../edit-history-parse.js';
import {
unsupportedReason,
type EditHistorySession,
type EditHistoryState,
type EditHistorySync,
} from '../edit-history-sync.js';
export interface EditHistoryListEntry extends Omit<EditHistoryEntry, 'hunks'> {
relPath: string;
}
export interface EditHistoryList {
state: EditHistoryState;
reason?: string;
backfillTruncated: boolean;
agents: Record<string, { description: string; agentType: string; worktreeBranch?: string }>;
entries: EditHistoryListEntry[];
}
export interface EditHistoryDiff {
diff: string;
truncated: boolean;
}
/** `path` relative to `base` when it lies below it, else unchanged. */
export function relativeTo(path: string, base: string | undefined): string {
if (!base) return path;
const root = base.endsWith('/') ? base : `${base}/`;
return path.startsWith(root) ? path.slice(root.length) : path;
}
/** Hunks as unified diff text, the form the Git panel's renderer reads. */
export function hunksToUnified(hunks: readonly EditHunk[]): string {
const out: string[] = [];
for (const h of hunks) {
out.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`);
out.push(...h.lines);
}
return out.join('\n');
}
function listOf(
store: EditHistoryStore,
workingDir: string,
status: { state: EditHistoryState; reason?: string }
): EditHistoryList {
const agents = store.getAgents();
const entries = store.list().map(({ hunks: _hunks, ...rest }) => ({
...rest,
relPath: relativeTo(rest.path, (rest.agentId && agents[rest.agentId]?.worktreePath) || workingDir),
}));
const publicAgents: EditHistoryList['agents'] = {};
for (const [id, a] of Object.entries(agents)) {
publicAgents[id] = { description: a.description, agentType: a.agentType };
if (a.worktreeBranch) publicAgents[id].worktreeBranch = a.worktreeBranch;
}
const list: EditHistoryList = {
state: status.state,
backfillTruncated: store.backfillTruncated,
agents: publicAgents,
entries,
};
if (status.reason) list.reason = status.reason;
return list;
}
export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort, sync: EditHistorySync): void {
const sessionOf = (id: string, req: Parameters<typeof findSessionOrFail>[2]) => {
const session = findSessionOrFail(ctx, id, req);
const view: EditHistorySession = {
id: session.id,
mode: session.mode,
remote: session.remote,
docker: session.docker,
claudeSessionId: session.claudeSessionId,
claudeConfigDir: session.claudeConfigDir(),
};
return { session, view };
};
app.get('/api/sessions/:id/edits', async (req): Promise<ApiResponse<EditHistoryList>> => {
const { id } = req.params as { id: string };
const { sync: doSync } = req.query as { sync?: string };
const { session, view } = sessionOf(id, req);
const why = unsupportedReason(view);
const store = await sync.getStore(session.id);
if (why) return { success: true, data: listOf(store, session.workingDir, { state: 'unsupported', reason: why }) };
const status = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id);
return { success: true, data: listOf(store, session.workingDir, status) };
});
app.post('/api/sessions/:id/edits/older', async (req): Promise<ApiResponse<EditHistoryList>> => {
const { id } = req.params as { id: string };
const { session, view } = sessionOf(id, req);
const status = await sync.sync(view, { older: true });
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
});
app.get('/api/sessions/:id/edits/:entryId', async (req, reply): Promise<ApiResponse<EditHistoryDiff>> => {
const { id, entryId } = req.params as { id: string; entryId: string };
const { session } = sessionOf(id, req);
const entry = (await sync.getStore(session.id)).get(entryId);
if (!entry) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such edit in this session');
}
return { success: true, data: { diff: hunksToUnified(entry.hunks), truncated: entry.truncated } };
});
}
+1
View File
@@ -15,6 +15,7 @@ export { registerApprovalRoutes } from './approval-routes.js';
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerEditHistoryRoutes } from './edit-history-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerWorktreeRoutes } from './worktree-routes.js';
+4
View File
@@ -100,6 +100,10 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.codeman[^/]*\/settings\.json$/,
/\/\.codeman[^/]*\/push-keys\.json$/,
/\/\.codeman[^/]*\/intents\.json$/,
// The edit history panel's stores (edit-history-store.ts) hold every diff an
// agent wrote, keys and .env contents included; written 0600 and kept out of
// /api/search for the same reason as intents.json.
/\/\.codeman[^/]*\/edit-history\//,
];
/**
+31
View File
@@ -122,6 +122,9 @@ import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { EditHistorySync } from './edit-history-sync.js';
import { EditHistoryStore } from '../edit-history-store.js';
import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -204,6 +207,7 @@ import {
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
registerEditHistoryRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -329,6 +333,22 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } {
export class WebServer extends EventEmitter {
private app: FastifyInstance;
private sessions: Map<string, Session> = new Map();
/**
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks;
* the main transcript is the one the transcript watcher follows (hook-reported path), or,
* before the first hook after a restart, a lookup under the session's claude config dir.
*/
private readonly editHistorySync = new EditHistorySync({
transcriptPath: (session) =>
resolveClaudeTranscript(
this.transcriptWatchers.get(session.id)?.getPath(),
[session.claudeSessionId || session.id, session.id],
session.claudeConfigDir
),
readSources: readClaudeEditSources,
probe: probeLocalPaths,
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
});
private respawnControllers: Map<string, RespawnController> = new Map();
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
private runSummaryTrackers: Map<string, RunSummaryTracker> = new Map();
@@ -1232,6 +1252,7 @@ export class WebServer extends EventEmitter {
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
registerEditHistoryRoutes(this.app, ctx, this.editHistorySync);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
@@ -1555,6 +1576,13 @@ export class WebServer extends EventEmitter {
// Stop transcript watcher
this.stopTranscriptWatcher(sessionId);
// Edit history: a killed session takes its stored history (diffs can hold secrets) with it
if (killMux) {
void this.editHistorySync.remove(sessionId).catch((err) => {
console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err));
});
}
// Stop and remove run summary tracker
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
@@ -4301,6 +4329,9 @@ export class WebServer extends EventEmitter {
this.respawnTimers.clear();
this.runSummaryTrackers.clear();
this.transcriptWatchers.clear();
await this.editHistorySync.flushAll().catch((err) => {
console.warn('[EditHistory] Saving on shutdown failed:', getErrorMessage(err));
});
this.sessionListenerRefs.clear();
this.scheduledRuns.clear();
// Dispose StaleExpirationMaps (stops internal cleanup timers)