mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
002de32a08
commit
95e2272f89
31 files changed
+4631
-6
No files matched your search
@@ -232,6 +232,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
**Edit history panel** (`showEditHistory`, per-device, default OFF; `src/edit-history-parse.ts`, `edit-history-store.ts`, `edit-history-local-source.ts`, `web/edit-history-sync.ts`, `routes/edit-history-routes.ts`, `edit-history-ui.js`): which files a LOCAL claude session edited (main agent and subagents, labelled), each edit's diff, and images a Bash call produced. Read from the session's transcripts by byte offset, never from git. `GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId` (unified diff), `POST …/edits/older`. ⚠️ **Reads only on request:** the panel sends `?sync=1` when it opens and, while it is open on that session, after an edit-capable `transcript:tool_end` or a `transcript:complete` (debounced). The server never syncs on its own: no hook, no timer, no second watcher; `server.ts` hands the reader to the routes and only calls `remove`/`flushAll` itself (wiring-guard test). The main transcript is the path the session's `TranscriptWatcher` follows (reported by the hooks), else `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's (`resolveClaudeTranscript`); a config dir set outside Codeman is found with the next hook, until then `unavailable` / `no-transcript`. ⚠️ The store holds every diff, secrets included: `dataPath('edit-history')/<id>.json`, 0600 via tmp + rename, capped per session (500 entries, 4 MiB diff text), deleted with the session, blocked in `isSensitivePath`, never fed to `/api/search`. Images use the existing file surfaces only (workspace `file-raw`; outside it the attachment route with `notify: false` and its extension allowlist); no route here serves a file. A Bash image counts only when the file's mtime lies in the call's window (±2 s) and no entry already covers that file version. Remote, Docker and non-claude sessions answer `unsupported`. Tests: `test/edit-history-*.test.ts`, `test/routes/edit-history-routes.test.ts`, `test/edit-history.browser.test.ts` (browser suite).
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
@@ -45,6 +45,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/case-custom-path.browser.test.ts',
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/edit-history.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/spreadsheet-preview.browser.test.ts',
|
||||
|
||||
@@ -1083,6 +1083,18 @@ Both routes accept two optional query parameters, which the UI sends from its pe
|
||||
|
||||
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||
|
||||
## Edit history
|
||||
|
||||
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
|
||||
|
||||
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read; without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
|
||||
|
||||
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each hunk is capped at 64 KiB). `404` for an edit the session does not have.
|
||||
|
||||
`POST /api/sessions/:id/edits/older` reads the next older block of the transcripts and answers like the list.
|
||||
|
||||
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted with the session and never fed into `/api/v1/search`. No route here serves a file: the panel shows images through `file-raw` and the attachment routes.
|
||||
|
||||
## Git worktrees of a case
|
||||
|
||||
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
|
||||
|
||||
@@ -722,6 +722,22 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||
|
||||
### Edit history panel
|
||||
|
||||
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
|
||||
|
||||
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (session deleted: `cleanupSession` with `killMux`) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile) or a `transcript:complete`, both events the existing `TranscriptWatcher` already broadcasts. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
|
||||
|
||||
⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume) is read from its end with fresh cursors; the stored entries stay.
|
||||
|
||||
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first); a first read starts that far before the end, and "Load older" reads one older block. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run.
|
||||
|
||||
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (directory 0700, so an existing store never keeps wider bits) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text and paths), oldest dropped, also on load. Each hunk is capped at `MAX_HUNK_BYTES` (64 KiB), a pending Bash command at 8 KiB and agent meta strings at 1 KiB. The store is deleted with the session, blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
|
||||
|
||||
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail through the confined `file-raw` route inside the workspace, and outside it through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply) plus the attachment `/raw` route, one registration per path. SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
|
||||
|
||||
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
|
||||
|
||||
### Config backups
|
||||
|
||||
**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced).
|
||||
|
||||
@@ -61,7 +61,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
@@ -77,6 +77,12 @@ several projects. **Git status: git timeout** (per device, 5 to 120 seconds, def
|
||||
long one git command may run before that repository is reported as unreadable; raise it for
|
||||
repositories on a slow network share.
|
||||
|
||||
**Edit history** (per device, off by default, never on phones) adds a header button for the
|
||||
active session's edit history: every edit the agent and its subagents made, with its diff, and
|
||||
images it produced. Local Claude sessions only. Nothing is read until you open the window, and it
|
||||
stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
|
||||
[Working With Files](Working-With-Files#edit-history).
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||
|
||||
@@ -253,6 +253,52 @@ that repository's own configuration on the host). The
|
||||
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
|
||||
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Edit history
|
||||
|
||||
The Git panel shows what is different now. The edit history shows what the agent did, one edit
|
||||
at a time, including edits that were later undone or committed. Turn on **App Settings → Header &
|
||||
Panels → Header → Edit history** (per device, off by default) and a clock button appears in the
|
||||
header next to Attachments. It is not shown on phones.
|
||||
|
||||
Click it for a draggable window listing the active session's edits, newest first:
|
||||
|
||||
- One row per Edit, Write or MultiEdit, with `new` for a file the agent created, `edit` otherwise,
|
||||
and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the
|
||||
Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview.
|
||||
- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and
|
||||
its worktree branch. **Main agent only** hides them. **Group by file** puts the edits under
|
||||
their file, with the totals on the file's row.
|
||||
- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with
|
||||
a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp` or `.avif` path named in the command or its output
|
||||
counts when the file's modification time falls within that command's run. Images the agent sent
|
||||
you count as well. Click one to open it in the preview.
|
||||
- **Load older** reads further back in the conversation.
|
||||
|
||||
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
|
||||
|
||||
How it reads:
|
||||
|
||||
- **Only while you look.** The history is read from the session's Claude transcript when you open
|
||||
the window, and again after each edit while it stays open on that session. A closed window reads
|
||||
nothing, and Codeman does no work in the background for sessions nobody looks at.
|
||||
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
|
||||
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
|
||||
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
|
||||
directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
|
||||
is only known after the next prompt; until then the window says "No transcript found for this
|
||||
session yet".
|
||||
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
|
||||
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
|
||||
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
|
||||
are dropped), never part of search, blocked from every file route, and deleted with the session.
|
||||
- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
|
||||
Viewer's route; one outside it is registered like a clicked path (no notification, same
|
||||
file-type allowlist and sensitive-file blocklist). No new route serves a file.
|
||||
|
||||
The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and
|
||||
`POST /api/sessions/:id/edits/older` (see the
|
||||
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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()] };
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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': '项目洞察',
|
||||
|
||||
@@ -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">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">×</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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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\//,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset
|
||||
* reads of the main transcript and its subagents, and the image probe.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
findClaudeTranscript,
|
||||
probeLocalPaths,
|
||||
readClaudeEditSources,
|
||||
resolveClaudeTranscript,
|
||||
} from '../src/edit-history-local-source.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'edit-src-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function put(rel: string, content: string | Buffer, mtimeSec?: number): string {
|
||||
const path = join(root, rel);
|
||||
mkdirSync(join(path, '..'), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('findClaudeTranscript', () => {
|
||||
it('returns null when no transcript exists under any id', async () => {
|
||||
put('p/other.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['sid'], root)).toBeNull();
|
||||
expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull();
|
||||
});
|
||||
|
||||
it('takes the first id that has a transcript, the newest one across project dirs', async () => {
|
||||
put('a/sid.jsonl', 'old\n', 1000);
|
||||
const newest = put('b/sid.jsonl', 'new\n', 2000);
|
||||
put('c/second.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeTranscript', () => {
|
||||
it('takes the watcher path (what the hooks reported) as is', async () => {
|
||||
expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl');
|
||||
});
|
||||
|
||||
it("without one, looks under the session's own claude config dir", async () => {
|
||||
const path = put('cfg/projects/p/sid.jsonl', 'x\n');
|
||||
expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path);
|
||||
expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readClaudeEditSources', () => {
|
||||
it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => {
|
||||
const path = put('p/sid.jsonl', 'x\n');
|
||||
expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('names the conversation after the file', async () => {
|
||||
const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100);
|
||||
expect(res?.transcriptId).toBe('conv-1');
|
||||
});
|
||||
|
||||
it('reads a new file from its end within the budget, a requested one from its offset', async () => {
|
||||
const path = put('p/sid.jsonl', '0123456789');
|
||||
const fresh = await readClaudeEditSources(path, [], 4);
|
||||
expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]);
|
||||
const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100);
|
||||
expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') });
|
||||
});
|
||||
|
||||
it('clamps an offset past the end to the size (a replaced, shorter file)', async () => {
|
||||
const path = put('p/sid.jsonl', 'abc');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100);
|
||||
expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) });
|
||||
});
|
||||
|
||||
it('lists subagents with their meta for new ones only, and shares the budget main first', async () => {
|
||||
const path = put('p/sid.jsonl', 'MAIN');
|
||||
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
|
||||
put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}');
|
||||
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
|
||||
put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}');
|
||||
put('p/sid/subagents/notes.txt', 'ignored');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6);
|
||||
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
|
||||
['main', 0, 'MAIN'],
|
||||
['subagents/agent-a1.jsonl', 2, 'AA'],
|
||||
['subagents/agent-b2.jsonl', 0, ''],
|
||||
]);
|
||||
expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' });
|
||||
});
|
||||
|
||||
it('ignores invalid requests', async () => {
|
||||
const path = put('p/sid.jsonl', 'abcdef');
|
||||
const res = await readClaudeEditSources(
|
||||
path,
|
||||
[
|
||||
{ relPath: '../etc/passwd', offset: 0 },
|
||||
{ relPath: 'main', offset: -1 },
|
||||
],
|
||||
3
|
||||
);
|
||||
// The invalid main request is dropped, so main is read like a new file.
|
||||
expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') });
|
||||
});
|
||||
|
||||
it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => {
|
||||
const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8'));
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
|
||||
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeLocalPaths', () => {
|
||||
it('reports kind, size and mtime, expands ~/ against home, null for missing', async () => {
|
||||
put('home/img.png', 'PNG!', 1_700_000_000);
|
||||
mkdirSync(join(root, 'dir'));
|
||||
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
|
||||
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
|
||||
expect(res[1]).toMatchObject({ kind: 'directory', size: 0 });
|
||||
expect(res[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Edit history parser: claude transcript lines → edit records and Bash image candidates.
|
||||
* Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the
|
||||
* fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyEditPath,
|
||||
extractImagePathCandidates,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_IMAGE_CANDIDATES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
parseClaudeEditLines,
|
||||
} from '../src/edit-history-parse.js';
|
||||
|
||||
const CWD = '/work/repo';
|
||||
|
||||
function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines });
|
||||
|
||||
const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', {
|
||||
filePath: '/work/repo/src/app.ts',
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'x'.repeat(5000),
|
||||
structuredPatch: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: edits', () => {
|
||||
it('turns an Edit result into a record without the whole-file fields', () => {
|
||||
const { records } = parseClaudeEditLines(editLine + '\n');
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: 'toolu_edit',
|
||||
ts: Date.parse('2026-10-15T19:01:28.880Z'),
|
||||
path: '/work/repo/src/app.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 2,
|
||||
removed: 1,
|
||||
hunks: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(records)).not.toContain('xxxxx');
|
||||
});
|
||||
|
||||
it('reads replaceAll edits like any other Edit', () => {
|
||||
const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', {
|
||||
filePath: '/work/repo/a.ts',
|
||||
oldString: 'x',
|
||||
newString: 'y',
|
||||
originalFile: '',
|
||||
structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }],
|
||||
userModified: false,
|
||||
replaceAll: true,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 });
|
||||
expect(rec.hunks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks a Write that created the file, and one that replaced it', () => {
|
||||
const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hello\nworld',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', {
|
||||
type: 'update',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hi',
|
||||
structuredPatch: [hunk(['-hello', '-world', '+hi'])],
|
||||
originalFile: 'hello\nworld',
|
||||
});
|
||||
const { records } = parseClaudeEditLines([create, update].join('\n'));
|
||||
expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([
|
||||
['Write', true, 2, 0],
|
||||
['Write', false, 1, 2],
|
||||
]);
|
||||
expect(records[0].hunks).toEqual([
|
||||
{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a created file’s diff drops the trailing newline and is capped like any hunk', () => {
|
||||
const big = 'x'.repeat(1000) + '\n';
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.txt',
|
||||
content: big.repeat(100),
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.added).toBe(100);
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(100);
|
||||
expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an image written by Write as an image', () => {
|
||||
const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/icon.SVG',
|
||||
content: '<svg/>',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].kind).toBe('image');
|
||||
});
|
||||
|
||||
it('keeps a result of an unknown edit tool that has a path and a patch', () => {
|
||||
const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', {
|
||||
filePath: '/work/repo/n.ipynb',
|
||||
structuredPatch: [hunk(['+cell'])],
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 });
|
||||
});
|
||||
|
||||
it('skips failed tool results (a string), unrelated results and lines that do not parse', () => {
|
||||
const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found');
|
||||
const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } });
|
||||
const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n');
|
||||
expect(parseClaudeEditLines(text).records).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a first line cut mid-record (a byte range starts inside it)', () => {
|
||||
const cut = editLine.slice(40);
|
||||
expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('carries the subagent id of a sidechain line', () => {
|
||||
const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, {
|
||||
isSidechain: true,
|
||||
agentId: 'a0038561ea4d57a42',
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42');
|
||||
});
|
||||
|
||||
it('caps an oversized patch and says so, counting lines from the whole patch', () => {
|
||||
const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`);
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.ts',
|
||||
content: '',
|
||||
structuredPatch: [hunk(big)],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.added).toBe(4000);
|
||||
expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: Bash image candidates', () => {
|
||||
const start = '2026-10-15T20:00:00.000Z';
|
||||
const end = '2026-10-15T20:00:05.000Z';
|
||||
|
||||
it('pairs a Bash call with its result and collects image paths from command and output', () => {
|
||||
const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png');
|
||||
const res = toolResultLine('toolu_b', end, {
|
||||
stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.',
|
||||
stderr: '',
|
||||
interrupted: false,
|
||||
isImage: false,
|
||||
});
|
||||
const { candidates, pending } = parseClaudeEditLines([use, res].join('\n'));
|
||||
expect(pending).toEqual([]);
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_b',
|
||||
start: Date.parse(start),
|
||||
end: Date.parse(end),
|
||||
paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields no candidate for a Bash call without image paths', () => {
|
||||
const use = bashUseLine('toolu_n', start, 'git status');
|
||||
const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => {
|
||||
const use = bashUseLine('toolu_f', start, 'make render');
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: end,
|
||||
cwd: CWD,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }],
|
||||
},
|
||||
toolUseResult: 'Error: Exit 2',
|
||||
});
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']);
|
||||
});
|
||||
|
||||
it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => {
|
||||
const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n');
|
||||
expect(first.candidates).toEqual([]);
|
||||
expect(first.pending).toEqual([
|
||||
{ id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD },
|
||||
]);
|
||||
const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
const second = parseClaudeEditLines(res, first.pending);
|
||||
expect(second.pending).toEqual([]);
|
||||
expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']);
|
||||
});
|
||||
|
||||
it('carries the subagent id onto the candidate', () => {
|
||||
const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' });
|
||||
const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc');
|
||||
});
|
||||
|
||||
it('bounds the pending list, dropping the oldest', () => {
|
||||
const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) =>
|
||||
bashUseLine(`toolu_${i}`, start, 'sleep 1')
|
||||
).join('\n');
|
||||
const { pending } = parseClaudeEditLines(lines);
|
||||
expect(pending).toHaveLength(MAX_PENDING_BASH);
|
||||
expect(pending[0].id).toBe('toolu_5');
|
||||
});
|
||||
|
||||
it('keeps only the head of a long command (the pending list is persisted)', () => {
|
||||
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
|
||||
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
|
||||
expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: images sent to the user', () => {
|
||||
it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => {
|
||||
const line = toolResultLine(
|
||||
'toolu_send',
|
||||
'2026-10-15T20:50:12.538Z',
|
||||
{
|
||||
caption: 'Vier Alternativen',
|
||||
attachments: [
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' },
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png' },
|
||||
{ path: '/tmp/s/report.pdf' },
|
||||
{ path: 'relative.png' },
|
||||
{ nope: 1 },
|
||||
],
|
||||
},
|
||||
{ agentId: 'a1' }
|
||||
);
|
||||
const { candidates, records } = parseClaudeEditLines(line);
|
||||
expect(records).toEqual([]);
|
||||
const at = Date.parse('2026-10-15T20:50:12.538Z');
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_send',
|
||||
start: at,
|
||||
end: at,
|
||||
paths: ['/tmp/s/icons/bremse_alt1_pedal.png'],
|
||||
sent: true,
|
||||
agentId: 'a1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImagePathCandidates', () => {
|
||||
it('resolves relative paths against cwd and keeps ~/ paths literal', () => {
|
||||
expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([
|
||||
'/work/repo/a.png',
|
||||
'/work/b.jpeg',
|
||||
'~/shots/c.gif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads quoted paths with spaces', () => {
|
||||
expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([
|
||||
'/work/repo/in dir/x.png',
|
||||
'/work/repo/out dir/y.avif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores URLs, globs and names that only contain an image extension', () => {
|
||||
const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png';
|
||||
expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']);
|
||||
});
|
||||
|
||||
it('splits key=value and trailing punctuation off', () => {
|
||||
expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([
|
||||
'/work/repo/out/a.png',
|
||||
'/work/repo/out/b.png',
|
||||
'/work/repo/out/c.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates and caps the list', () => {
|
||||
const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' ');
|
||||
const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD);
|
||||
expect(out).toHaveLength(MAX_IMAGE_CANDIDATES);
|
||||
expect(out[0]).toBe('/work/repo/a.png');
|
||||
expect(out[1]).toBe('/work/repo/f0.png');
|
||||
});
|
||||
|
||||
it('drops relative paths when cwd is unknown', () => {
|
||||
expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyEditPath', () => {
|
||||
it.each([
|
||||
['/a/b.png', 'image'],
|
||||
['/a/b.JPG', 'image'],
|
||||
['/a/b.jpeg', 'image'],
|
||||
['/a/b.webp', 'image'],
|
||||
['/a/b.gif', 'image'],
|
||||
['/a/b.avif', 'image'],
|
||||
['/a/b.svg', 'image'],
|
||||
['/a/b.ts', 'code'],
|
||||
['/a/png', 'code'],
|
||||
['/a/b.png.orig', 'code'],
|
||||
])('%s → %s', (path, kind) => {
|
||||
expect(classifyEditPath(path)).toBe(kind);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
|
||||
* Transcript bytes are built from JSONL lines shaped like real claude records.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EditHistoryStore,
|
||||
entryBytes,
|
||||
MAIN_SOURCE,
|
||||
MAX_AGENT_META_CHARS,
|
||||
MAX_ENTRIES,
|
||||
MAX_STORE_BYTES,
|
||||
parseAgentMeta,
|
||||
} from '../src/edit-history-store.js';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
|
||||
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
|
||||
|
||||
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'a',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
/** An Edit whose diff carries `size` bytes of added text. */
|
||||
function bigEditLine(id: string, sec: number, size: number): string {
|
||||
const line = JSON.parse(editLine(id, sec));
|
||||
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
|
||||
return JSON.stringify(line);
|
||||
}
|
||||
|
||||
function bashUse(id: string, sec: number, command: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
}
|
||||
|
||||
function bashResult(id: string, sec: number): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
}
|
||||
|
||||
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
|
||||
|
||||
/** Read like the sync will: ask the store for a range, slice it out of `file`, apply. */
|
||||
function syncForward(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||||
const range = store.nextRange(source, file.length, tid, budget);
|
||||
if (!range) return null;
|
||||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||||
return store.apply({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||||
const range = store.olderRange(source, budget);
|
||||
if (!range) return null;
|
||||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||||
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
|
||||
|
||||
describe('EditHistoryStore: forward reads', () => {
|
||||
it('reads a small file whole, then only what was appended', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
expect(syncForward(store, file)?.added).toBe(2);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
|
||||
expect(syncForward(store, file)).toBeNull();
|
||||
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a partial last line (still being written) for the next read', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
const firstLineEnd = full.indexOf(0x0a) + 1;
|
||||
const partial = full.subarray(0, firstLineEnd + 30);
|
||||
expect(syncForward(store, partial)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
|
||||
expect(syncForward(store, full)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e2', 'e1']);
|
||||
});
|
||||
|
||||
it('deduplicates by tool_use_id', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const budget = Math.floor(file.length / 3);
|
||||
syncForward(store, file, budget);
|
||||
const cursor = store.getCursor(MAIN_SOURCE)!;
|
||||
expect(cursor.offset).toBe(file.length);
|
||||
expect(cursor.floor).toBeGreaterThan(file.length - budget);
|
||||
expect(file[cursor.floor - 1]).toBe(0x0a);
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
expect(ids(store)[0]).toBe('e19');
|
||||
expect(ids(store)).not.toContain('e0');
|
||||
});
|
||||
|
||||
it('reads a backlog in budget-sized forward steps without losing a line', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
|
||||
const file = Buffer.concat([first, jsonl(more)]);
|
||||
const budget = 700;
|
||||
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
|
||||
expect(store.list()).toHaveLength(31);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('skips a line longer than the budget instead of re-reading it forever', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
|
||||
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
|
||||
expect(ids(store)).toEqual(['e2', 'e0']);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('keeps a multibyte character split by the chunk boundary intact', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const path = '/work/Größe – Ümlaut 😀.ts';
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
|
||||
const emoji = file.indexOf(Buffer.from('😀'));
|
||||
// Cut inside the 4-byte emoji, then continue.
|
||||
const cut = file.subarray(0, emoji + 2);
|
||||
syncForward(store, cut);
|
||||
syncForward(store, file);
|
||||
expect(store.get('e2')?.path).toBe(path);
|
||||
});
|
||||
|
||||
it('restarts a file that shrank (replaced) from its end', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
const replaced = jsonl([editLine('n1', 3)]);
|
||||
expect(syncForward(store, replaced)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
|
||||
});
|
||||
|
||||
it('ignores a chunk that does not start at the cursor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
syncForward(store, file);
|
||||
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: conversations and sources', () => {
|
||||
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const second = jsonl([editLine('f1', 5)]);
|
||||
expect(store.nextRange(MAIN_SOURCE, second.length, 'conv-2')).toEqual({ offset: 0, len: second.length });
|
||||
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
|
||||
expect(store.getTranscriptId()).toBe('conv-2');
|
||||
expect(ids(store)).toEqual(['f1', 'e1']);
|
||||
});
|
||||
|
||||
it('keeps one cursor per source and the subagent id on its entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const main = jsonl([editLine('m1', 1)]);
|
||||
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
|
||||
const subSource = 'subagents/agent-a1.jsonl';
|
||||
syncForward(store, main);
|
||||
syncForward(store, sub, undefined, subSource);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
|
||||
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
|
||||
expect(store.get('s1')?.agentId).toBe('a1');
|
||||
});
|
||||
|
||||
it('records agent meta from meta.json text', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
store.setAgentMeta(
|
||||
'a1',
|
||||
JSON.stringify({
|
||||
agentType: 'coding',
|
||||
description: 'Impl: fix',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
toolUseId: 'toolu_x',
|
||||
})
|
||||
);
|
||||
store.setAgentMeta('a2', 'not json');
|
||||
expect(store.getAgents()).toEqual({
|
||||
a1: {
|
||||
description: 'Impl: fix',
|
||||
agentType: 'coding',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
},
|
||||
});
|
||||
expect(store.hasAgentMeta('a2')).toBe(false);
|
||||
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: older blocks', () => {
|
||||
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const budget = Math.floor(file.length / 4);
|
||||
syncForward(store, file, budget);
|
||||
const before = store.list().length;
|
||||
expect(before).toBeLessThan(30);
|
||||
let rounds = 0;
|
||||
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
|
||||
expect(rounds).toBeGreaterThan(1);
|
||||
expect(store.list()).toHaveLength(30);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an older chunk that does not end at the floor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
|
||||
syncForward(store, file, 300);
|
||||
const res = store.applyOlder({
|
||||
transcriptId: 'conv-1',
|
||||
source: MAIN_SOURCE,
|
||||
startOffset: 0,
|
||||
chunk: file.subarray(0, 10),
|
||||
size: file.length,
|
||||
});
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: Bash images', () => {
|
||||
it('pairs a Bash call and its result across a chunk boundary', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
|
||||
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
|
||||
expect(syncForward(store, part1)?.candidates).toEqual([]);
|
||||
const res = syncForward(store, file)!;
|
||||
expect(res.candidates).toEqual([
|
||||
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds confirmed images as entries without a diff, once', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
|
||||
expect(store.addImageEntries([img, img])).toBe(1);
|
||||
expect(store.addImageEntries([img])).toBe(0);
|
||||
expect(store.list()[0]).toEqual({
|
||||
id: 'b1:/work/out/frame.png',
|
||||
ts: T0 + 4000,
|
||||
path: '/work/out/frame.png',
|
||||
kind: 'image',
|
||||
tool: 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
agentId: 'a1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: cap', () => {
|
||||
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const res = syncForward(store, file, file.length);
|
||||
expect(res?.added).toBe(MAX_ENTRIES);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.get('e19')).toBeUndefined();
|
||||
expect(store.get('e20')).toBeDefined();
|
||||
// Older than everything kept: counts as nothing added.
|
||||
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
|
||||
});
|
||||
|
||||
it('offers no older block once the cap is full (it would be dropped on read)', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
|
||||
syncForward(store, file, tail + 10);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const n = 300;
|
||||
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, file.length);
|
||||
const kept = store.list();
|
||||
expect(kept.length).toBeLessThan(n);
|
||||
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
|
||||
expect(store.get(`e${n - 1}`)).toBeDefined();
|
||||
expect(store.get('e0')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers no older block once the byte cap is full', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, 5_000_000);
|
||||
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the agent meta strings', () => {
|
||||
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
|
||||
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
|
||||
expect(meta?.agentType).toBe('coding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: persistence', () => {
|
||||
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
|
||||
syncForward(store, file);
|
||||
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
|
||||
await store.flush();
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(ids(loaded)).toEqual(['e1']);
|
||||
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
|
||||
expect(loaded.getTranscriptId()).toBe('conv-1');
|
||||
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
|
||||
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
|
||||
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
|
||||
});
|
||||
|
||||
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
|
||||
const sub = join(dir, 'edit-history');
|
||||
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
expect(statSync(sub).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
|
||||
await store.flush();
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('applies the caps and drops malformed entries when loading', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
|
||||
await store.flush();
|
||||
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
|
||||
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
|
||||
data.entries.push(
|
||||
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
|
||||
{ id: 'broken', path: 7, hunks: [] },
|
||||
{ id: 'nohunks', path: '/a.ts' },
|
||||
extra
|
||||
);
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(loaded.get('newest')).toBeDefined();
|
||||
expect(loaded.get('e0')).toBeUndefined();
|
||||
expect(loaded.get('broken')).toBeUndefined();
|
||||
expect(loaded.get('nohunks')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a file of an unknown version', async () => {
|
||||
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
|
||||
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes debounced', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const path = join(dir, 's1.json');
|
||||
expect(existsSync(path)).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts empty from a missing or corrupt file', async () => {
|
||||
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
|
||||
const store = new EditHistoryStore('bad', { dir });
|
||||
await store.flush();
|
||||
writeFileSync(join(dir, 'bad.json'), '{broken');
|
||||
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('remove() deletes the file and stops later writes', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
await store.remove();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await store.flush();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to the data directory', () => {
|
||||
const store = new EditHistoryStore('s1');
|
||||
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Edit history sync: reader, store, image confirmation, single-flight, failure states and the
|
||||
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import type { EditSourceRequest, EditSources, PathProbe } from '../src/edit-history-local-source.js';
|
||||
import {
|
||||
confirmImages,
|
||||
EditHistorySync,
|
||||
NO_TRANSCRIPT,
|
||||
type EditHistorySession,
|
||||
type EditHistorySyncDeps,
|
||||
unsupportedReason,
|
||||
} from '../src/web/edit-history-sync.js';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-sync-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
|
||||
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
},
|
||||
...extra,
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function bashPair(id: string, start: number, end: number, command: string): string {
|
||||
const use = JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date(start).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(end).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
return use + '\n' + res + '\n';
|
||||
}
|
||||
|
||||
/** An in-memory transcript: files by relPath, served with the reader contract. */
|
||||
class FakeHost {
|
||||
transcriptId = 'conv1';
|
||||
files = new Map<string, Buffer>();
|
||||
metas: Record<string, string> = {};
|
||||
calls: EditSourceRequest[][] = [];
|
||||
paths: string[] = [];
|
||||
/** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */
|
||||
get path(): string | null {
|
||||
return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null;
|
||||
}
|
||||
read = async (
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number
|
||||
): Promise<EditSources | null> => {
|
||||
this.calls.push([...requests]);
|
||||
this.paths.push(mainPath);
|
||||
if (this.files.size === 0) return null;
|
||||
let remaining = budget;
|
||||
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
|
||||
for (const [relPath, buf] of this.files) {
|
||||
const req = requests.find((r) => r.relPath === relPath);
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req) {
|
||||
start = Math.min(req.offset, buf.length);
|
||||
len = Math.min(req.len ?? remaining, remaining, buf.length - start);
|
||||
} else {
|
||||
len = Math.min(buf.length, remaining);
|
||||
start = buf.length - len;
|
||||
const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1];
|
||||
if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent];
|
||||
}
|
||||
remaining -= len;
|
||||
out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
append(relPath: string, text: string): void {
|
||||
this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)]));
|
||||
}
|
||||
}
|
||||
|
||||
function setup(over: Partial<EditHistorySyncDeps> = {}) {
|
||||
const host = new FakeHost();
|
||||
const probes = new Map<string, PathProbe>();
|
||||
const lookups: string[] = [];
|
||||
const deps: EditHistorySyncDeps = {
|
||||
transcriptPath: async (session) => {
|
||||
lookups.push(session.id);
|
||||
return host.path;
|
||||
},
|
||||
readSources: (path, req, budget) => host.read(path, req, budget),
|
||||
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
...over,
|
||||
};
|
||||
return { host, probes, lookups, deps, sync: new EditHistorySync(deps) };
|
||||
}
|
||||
|
||||
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
|
||||
const remote: EditHistorySession = {
|
||||
id: 's2',
|
||||
mode: 'claude',
|
||||
remote: { host: 'huff' } as EditHistorySession['remote'],
|
||||
claudeSessionId: 'conv1',
|
||||
};
|
||||
|
||||
describe('unsupportedReason', () => {
|
||||
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
|
||||
expect(unsupportedReason(local)).toBeUndefined();
|
||||
expect(unsupportedReason(remote)).toBe('remote');
|
||||
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
|
||||
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistorySync', () => {
|
||||
it('reads new edits forward from the stored offset', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']);
|
||||
expect(host.calls[1]).toEqual([
|
||||
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length },
|
||||
]);
|
||||
expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']);
|
||||
});
|
||||
|
||||
it('reads subagent transcripts and records their meta', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' }));
|
||||
host.metas.a1 = '{"description":"Fix it","agentType":"coding"}';
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.get('t9')?.agentId).toBe('a1');
|
||||
expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' });
|
||||
});
|
||||
|
||||
it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
host.transcriptId = 'conv2';
|
||||
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
|
||||
await sync.sync(local);
|
||||
expect(host.paths.at(-1)).toBe('/p/conv2.jsonl');
|
||||
expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.getTranscriptId()).toBe('conv2');
|
||||
expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']);
|
||||
});
|
||||
|
||||
it('keeps reading in passes when a file grew by more than one budget', async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
host.append('main', editLine('t0', T0, '/w/z.ts'));
|
||||
await sync.sync(local);
|
||||
for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('turns a Bash image into an entry only when its mtime lies in the call window', async () => {
|
||||
const { host, probes, sync } = setup();
|
||||
host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png'));
|
||||
probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 });
|
||||
probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 });
|
||||
probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 });
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([
|
||||
['/w/out/new.png', 'bash', 'image'],
|
||||
['/home/u/x.png', 'bash', 'image'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the edits when only the image probe fails', async () => {
|
||||
const { host, sync } = setup({
|
||||
probe: async () => {
|
||||
throw new Error('probe failed');
|
||||
},
|
||||
});
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('reports a session without a transcript yet, then reads it once there is one', async () => {
|
||||
const { host, sync } = setup();
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.calls).toEqual([]);
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a transcript that vanished (watcher path stale) the same way', async () => {
|
||||
const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' });
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.paths).toEqual(['/p/gone.jsonl']);
|
||||
});
|
||||
|
||||
it('reports a read error as unavailable and keeps the stored entries', async () => {
|
||||
const { host, sync, deps } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
deps.readSources = async () => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
};
|
||||
const status = await sync.sync(local);
|
||||
expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
|
||||
const { host, lookups, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
|
||||
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
|
||||
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
|
||||
expect(lookups).toEqual([]);
|
||||
expect(host.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs one sync per session at a time and folds occasions into one more run', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
await gate;
|
||||
return orig(...a);
|
||||
};
|
||||
const runs = [sync.sync(local), sync.sync(local), sync.sync(local)];
|
||||
release();
|
||||
await Promise.all(runs);
|
||||
expect(host.calls).toHaveLength(2); // the first run + one folded follow-up
|
||||
});
|
||||
|
||||
it('loads older blocks on request', async () => {
|
||||
const { host, sync } = setup({ budget: 400 });
|
||||
for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
const before = store.list().length;
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(store.list().length).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => {
|
||||
const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await sync.flushAll();
|
||||
const reloaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(reloaded.list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('remove deletes the stored history', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await (await sync.getStore('s1')).flush();
|
||||
await sync.remove('s1');
|
||||
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmImages', () => {
|
||||
it('ignores directories and missing files', () => {
|
||||
const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }];
|
||||
const out = confirmImages(c, (p) =>
|
||||
p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists a file sent to the user outside any window, once per file version', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 };
|
||||
const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true };
|
||||
expect(confirmImages([sent], () => png)).toEqual([
|
||||
{ toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' },
|
||||
]);
|
||||
// The Bash call that wrote it was already listed: same version, not again.
|
||||
expect(
|
||||
confirmImages(
|
||||
[sent],
|
||||
() => png,
|
||||
() => T0 + 500
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(confirmImages([sent], () => null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 };
|
||||
const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] };
|
||||
const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] };
|
||||
expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']);
|
||||
// A Write entry for the same path at T0 + 950 already covers it.
|
||||
expect(
|
||||
confirmImages(
|
||||
[ls],
|
||||
() => png,
|
||||
() => T0 + 950
|
||||
)
|
||||
).toEqual([]);
|
||||
// A later re-render (newer mtime, outside the slack) is a new version.
|
||||
const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] };
|
||||
const newer = { ...png, mtimeMs: T0 + 10_500 };
|
||||
expect(
|
||||
confirmImages(
|
||||
[again],
|
||||
() => newer,
|
||||
() => T0 + 1000
|
||||
).map((i) => i.toolUseId)
|
||||
).toEqual(['r2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads only on request (wiring guard)', () => {
|
||||
const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8');
|
||||
|
||||
it('the server never starts a sync itself: it only hands the reader to the routes', () => {
|
||||
const server = read('src/web/server.ts');
|
||||
const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? '');
|
||||
expect(uses.sort()).toEqual(['', '.flushAll', '.remove']);
|
||||
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
|
||||
});
|
||||
|
||||
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
|
||||
for (const file of ['src/web/session-listener-wiring.ts', 'src/web/routes/hook-event-routes.ts']) {
|
||||
expect(read(file)).not.toMatch(/editHistory/i);
|
||||
}
|
||||
for (const file of [
|
||||
'src/web/edit-history-sync.ts',
|
||||
'src/edit-history-store.ts',
|
||||
'src/edit-history-local-source.ts',
|
||||
'src/edit-history-parse.ts',
|
||||
'src/web/routes/edit-history-routes.ts',
|
||||
]) {
|
||||
const src = read(file);
|
||||
expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/);
|
||||
}
|
||||
// The store's only timer is its debounced save.
|
||||
expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/);
|
||||
});
|
||||
});
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
|
||||
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
|
||||
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
|
||||
* events the open panel follows go through the real server.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page, type Route } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
ts: number;
|
||||
path: string;
|
||||
relPath: string;
|
||||
kind: 'code' | 'image';
|
||||
tool: string;
|
||||
created: boolean;
|
||||
added: number;
|
||||
removed: number;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
describe('Edit history panel in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let root: string;
|
||||
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
|
||||
let ext: string;
|
||||
let sessionA: string;
|
||||
let sessionB: string;
|
||||
const settingsPutStatuses: number[] = [];
|
||||
/** Bodies of POST /api/sessions/:id/attachments (the external thumbnail registration). */
|
||||
const attachmentPosts: unknown[] = [];
|
||||
/** Every request to the mocked API: `METHOD url`. */
|
||||
const calls: string[] = [];
|
||||
/** The mocked server state per session id. */
|
||||
const state = new Map<string, Record<string, unknown>>();
|
||||
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
|
||||
let olderEntries: Entry[] = [];
|
||||
|
||||
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
|
||||
ts: Date.now(),
|
||||
path: join(root, over.relPath),
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 1,
|
||||
removed: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
const json = (route: Route, data: unknown) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
|
||||
|
||||
const buttonVisible = () =>
|
||||
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
|
||||
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
|
||||
const setSetting = async (on: boolean) => {
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
|
||||
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
|
||||
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||
};
|
||||
const createSession = (dir: string) =>
|
||||
page.evaluate(async (workingDir) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id as string;
|
||||
}, dir);
|
||||
const select = (id: string) =>
|
||||
page.evaluate(async (sid) => {
|
||||
const app = (window as any).app;
|
||||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
await app.selectSession(sid);
|
||||
}, id);
|
||||
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
|
||||
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
|
||||
const toolEnd = (sessionId: string, toolName: string) =>
|
||||
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
|
||||
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
|
||||
|
||||
beforeAll(async () => {
|
||||
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
|
||||
writeFileSync(join(root, 'shot.png'), PNG);
|
||||
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
|
||||
writeFileSync(join(ext, 'render.png'), PNG);
|
||||
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
});
|
||||
page.on('request', (r) => {
|
||||
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
|
||||
attachmentPosts.push(r.postDataJSON());
|
||||
});
|
||||
await page.route('**/api/sessions/*/edits**', async (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
calls.push(`${req.method()} ${url.pathname}${url.search}`);
|
||||
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
|
||||
const sid = decodeURIComponent(m[1]);
|
||||
const rest = m[2] ? decodeURIComponent(m[2]) : '';
|
||||
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
|
||||
if (req.method() === 'POST' && rest === 'older') {
|
||||
const entries = [...(s.entries as Entry[]), ...olderEntries];
|
||||
state.set(sid, { ...s, entries, backfillTruncated: false });
|
||||
return json(route, state.get(sid));
|
||||
}
|
||||
if (req.method() === 'GET' && rest) {
|
||||
const d = diffs[rest];
|
||||
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
|
||||
return json(route, d);
|
||||
}
|
||||
return json(route, s);
|
||||
});
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
sessionA = await createSession(root);
|
||||
sessionB = await createSession(root);
|
||||
await select(sessionA);
|
||||
|
||||
const t0 = Date.now();
|
||||
state.set(sessionA, {
|
||||
state: 'ok',
|
||||
backfillTruncated: true,
|
||||
agents: {
|
||||
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
|
||||
},
|
||||
entries: [
|
||||
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
|
||||
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
|
||||
entry({
|
||||
id: 'e2',
|
||||
ts: t0 - 2000,
|
||||
relPath: 'src/b.ts',
|
||||
created: true,
|
||||
tool: 'Write',
|
||||
added: 10,
|
||||
removed: 0,
|
||||
agentId: 'agent1',
|
||||
}),
|
||||
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
|
||||
],
|
||||
});
|
||||
diffs = {
|
||||
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
|
||||
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
|
||||
};
|
||||
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(ext, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
it('is off by default: no button, and nothing asks the edits API', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
|
||||
await setSetting(true);
|
||||
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||||
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||||
expect(await buttonVisible()).toBe(true);
|
||||
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
|
||||
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
|
||||
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
|
||||
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
|
||||
});
|
||||
|
||||
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
|
||||
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
|
||||
expect(await rows().count()).toBe(4);
|
||||
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
|
||||
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
|
||||
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
|
||||
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
|
||||
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
|
||||
// The image row: a symbol instead of a word, no +/− counts.
|
||||
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
|
||||
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
|
||||
// The subagent chip: its description, and type + branch on hover.
|
||||
const chip = rows().nth(2).locator('.edit-history-agent');
|
||||
expect(await chip.textContent()).toBe('Refactor the parser');
|
||||
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
|
||||
// The thumbnail is served by the real file-raw route and decodes.
|
||||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
|
||||
expect(await thumb.count()).toBe(1);
|
||||
expect(await thumb.getAttribute('src')).toBe(
|
||||
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||||
);
|
||||
expect(await page.textContent('#editHistoryCount')).toBe('4');
|
||||
});
|
||||
|
||||
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
|
||||
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
|
||||
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
|
||||
['', ' context\n'],
|
||||
['git-diff-line--del', '-old line\n'],
|
||||
['git-diff-line--add', '+new line\n'],
|
||||
['git-diff-line--add', '+another\n'],
|
||||
]);
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
|
||||
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
|
||||
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
|
||||
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
|
||||
await reload();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
|
||||
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('a path and an image open the shared file preview with the absolute path', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__previewed = [];
|
||||
(window as any).app.openFilePreview = (p: string, sid: string) => (window as any).__previewed.push([p, sid]);
|
||||
});
|
||||
await rows().nth(2).locator('.edit-history-path').click();
|
||||
await page.locator('#editHistoryBody img.edit-history-thumb').click();
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
|
||||
[join(root, 'src/b.ts'), sessionA],
|
||||
[join(root, 'shot.png'), sessionA],
|
||||
]);
|
||||
// Clicking the path never toggles the row's diff.
|
||||
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
|
||||
await page.check('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(3);
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
|
||||
expect(await bodyText()).not.toContain('src/b.ts');
|
||||
await page.uncheck('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(4);
|
||||
|
||||
await page.check('#editHistoryGroupToggle');
|
||||
const groups = page.locator('#editHistoryBody .edit-history-group');
|
||||
expect(await groups.count()).toBe(3);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
|
||||
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
|
||||
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
|
||||
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
|
||||
await page.uncheck('#editHistoryGroupToggle');
|
||||
});
|
||||
|
||||
it('"Load older" asks the server with a POST and shows what it returns', async () => {
|
||||
const older = page.locator('#editHistoryBody button.edit-history-older');
|
||||
expect(await older.count()).toBe(1);
|
||||
await older.click();
|
||||
await page.waitForFunction(() =>
|
||||
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
|
||||
expect(await rows().count()).toBe(5);
|
||||
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('while open it follows the session: an edit-capable tool end re-reads (debounced), other tools and sessions do not', async () => {
|
||||
const before = syncReads(sessionA);
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
|
||||
});
|
||||
toolEnd(sessionA, 'Read');
|
||||
toolEnd(sessionA, 'Grep');
|
||||
toolEnd('someone-else', 'Edit');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
toolEnd(sessionA, 'Bash');
|
||||
await page.waitForFunction(() =>
|
||||
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
await page.waitForTimeout(800);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
// The end of a turn re-reads too (a parallel tool call reports one tool end only).
|
||||
(server as any).broadcast('transcript:complete', { sessionId: sessionA, timestamp: Date.now() });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before + 2);
|
||||
});
|
||||
|
||||
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
|
||||
const before = syncReads(sessionA);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
(server as any).broadcast('transcript:complete', { sessionId: sessionA, timestamp: Date.now() });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('an image outside the workspace gets its thumbnail through the attachment route, without a notification', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const outside = join(ext, 'render.png');
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb').first();
|
||||
await page.waitForFunction(() =>
|
||||
/\/attachments\/[^/]+\/raw$/.test(document.querySelector('img.edit-history-thumb')?.getAttribute('src') ?? '')
|
||||
);
|
||||
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
|
||||
expect(await thumb.getAttribute('src')).toMatch(new RegExp(`^/api/sessions/${sessionA}/attachments/[^/]+/raw$`));
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||||
);
|
||||
// A re-read reuses the registration instead of minting another id.
|
||||
await reload();
|
||||
expect(attachmentPosts).toHaveLength(1);
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const long =
|
||||
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const row = rows().nth(0);
|
||||
await row.waitFor();
|
||||
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
|
||||
const name = row.locator('.edit-history-path-name');
|
||||
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
|
||||
// The name is shown whole; only the folder part is shortened.
|
||||
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
it('renders a hostile path as text, never as markup', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
|
||||
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
|
||||
});
|
||||
await reload();
|
||||
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
|
||||
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
|
||||
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
|
||||
expect(await page.$('#editHistoryBody script')).toBeNull();
|
||||
expect(await page.$('#editHistoryBody b')).toBeNull();
|
||||
expect(
|
||||
await page.$$eval(
|
||||
'#editHistoryBody img',
|
||||
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
|
||||
)
|
||||
).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unavailable: the saved entries stay, with the reason on top', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
|
||||
'Showing saved entries only: EACCES: permission denied'
|
||||
);
|
||||
expect(await rows().count()).toBeGreaterThan(0);
|
||||
state.set(sessionA, { ...s, state: 'ok' });
|
||||
});
|
||||
|
||||
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
|
||||
expect(note).toContain('No transcript found for this session yet');
|
||||
expect(note).toContain('CLAUDE_CONFIG_DIR');
|
||||
state.set(sessionA, s);
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('follows the active session, and says when a session is not a Claude session', async () => {
|
||||
await select(sessionB);
|
||||
await page.waitForFunction(() =>
|
||||
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
|
||||
expect(await rows().count()).toBe(0);
|
||||
await select(sessionA);
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
});
|
||||
|
||||
it('drags by the header', async () => {
|
||||
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
const box = (await page.locator('.edit-history-header').boundingBox())!;
|
||||
await page.mouse.move(box.x + 40, box.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
expect(after).toBeLessThan(before - 100);
|
||||
});
|
||||
|
||||
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
|
||||
await setSetting(false);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
|
||||
const before = calls.length;
|
||||
toolEnd(sessionA, 'Edit');
|
||||
await page.waitForTimeout(1200);
|
||||
expect(calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,15 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
|
||||
// The edit history button is opt-in the same way, but it IS here: its panel is
|
||||
// desktop-sized, so it stays off phones even when the setting is on.
|
||||
const KNOWN_PHONE_HIDDEN = [
|
||||
'btn-settings',
|
||||
'btn-lifecycle-log',
|
||||
'btn-session-manager',
|
||||
'btn-file-viewer',
|
||||
'btn-edit-history',
|
||||
];
|
||||
|
||||
function attrOf(openTag: string, name: string): string {
|
||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
|
||||
@@ -81,6 +81,13 @@ export class MockSession extends EventEmitter {
|
||||
claudeSessionIdIsFirstHand: boolean = false;
|
||||
/** Mirrors Session.claudeSessionChain — oldest first, current last. */
|
||||
claudeSessionChain: string[] = [];
|
||||
/** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */
|
||||
claudeConfigDirOverride: string | undefined = undefined;
|
||||
|
||||
/** Mirrors Session.claudeConfigDir — the override, trimmed; undefined when unset. */
|
||||
claudeConfigDir(): string | undefined {
|
||||
return this.claudeConfigDirOverride?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
|
||||
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
|
||||
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
|
||||
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
|
||||
import { EditHistoryStore } from '../../src/edit-history-store.js';
|
||||
import type { EditSources } from '../../src/edit-history-local-source.js';
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
let dir: string;
|
||||
let session: Record<string, unknown>;
|
||||
|
||||
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
|
||||
},
|
||||
...extra,
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function sources(main: string, sub?: string): EditSources {
|
||||
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
|
||||
const metas: Record<string, string> = {};
|
||||
if (sub) {
|
||||
files.push({
|
||||
relPath: 'subagents/agent-a1.jsonl',
|
||||
size: Buffer.byteLength(sub),
|
||||
startOffset: 0,
|
||||
chunk: Buffer.from(sub),
|
||||
});
|
||||
metas.a1 = JSON.stringify({
|
||||
description: 'Fix it',
|
||||
agentType: 'coding',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
});
|
||||
}
|
||||
return { transcriptId: 'conv1', files, metas };
|
||||
}
|
||||
|
||||
async function setup(
|
||||
deps: Partial<EditHistorySyncDeps> = {},
|
||||
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
|
||||
) {
|
||||
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => '/p/conv1.jsonl',
|
||||
readSources: read,
|
||||
probe: async (p) => p.map(() => null),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
...deps,
|
||||
});
|
||||
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
|
||||
authUser: opts.authUser,
|
||||
});
|
||||
session = h.ctx._session as unknown as Record<string, unknown>;
|
||||
session.workingDir = '/w';
|
||||
session.mode = 'claude';
|
||||
session.claudeSessionId = 'conv1';
|
||||
return { ...h, read, sync };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const URL = '/api/sessions/test-session-1/edits';
|
||||
|
||||
describe('GET /api/sessions/:id/edits', () => {
|
||||
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
|
||||
expect(data.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 't1',
|
||||
path: '/w/src/a.ts',
|
||||
relPath: 'src/a.ts',
|
||||
tool: 'Edit',
|
||||
added: 2,
|
||||
removed: 1,
|
||||
}),
|
||||
]);
|
||||
expect(data.entries[0]).not.toHaveProperty('hunks');
|
||||
});
|
||||
|
||||
it('without sync=1 answers from the store and does not read', async () => {
|
||||
const { app, read } = await setup();
|
||||
const res = await app.inject({ method: 'GET', url: URL });
|
||||
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('labels subagent entries and shows their paths relative to the worktree', async () => {
|
||||
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
|
||||
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
|
||||
const { app } = await setup({ readSources: read });
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data.agents).toEqual({
|
||||
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
|
||||
});
|
||||
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
|
||||
agentId: 'a1',
|
||||
relPath: 'lib/x.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('a read error answers unavailable with the stored entries', async () => {
|
||||
let fail = false;
|
||||
const read = vi.fn(async () => {
|
||||
if (fail) throw new Error('EACCES: permission denied');
|
||||
return sources(editLine('t1', T0, '/w/src/a.ts'));
|
||||
});
|
||||
const { app } = await setup({ readSources: read });
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
fail = true;
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
expect(data.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
|
||||
const seen: unknown[] = [];
|
||||
const { app } = await setup({
|
||||
transcriptPath: async (s) => {
|
||||
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
|
||||
return '/p/conv1.jsonl';
|
||||
},
|
||||
});
|
||||
session.claudeConfigDirOverride = '/cfg/alt ';
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
|
||||
});
|
||||
|
||||
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
|
||||
const { app, read } = await setup({ transcriptPath: async () => null });
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['remote', { remote: { host: 'huff' } }],
|
||||
['docker', { docker: { image: 'x' } }],
|
||||
['mode', { mode: 'shell' }],
|
||||
])('answers unsupported (%s) without reading', async (reason, patch) => {
|
||||
const { app, read } = await setup();
|
||||
Object.assign(session, patch);
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unsupported', reason });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
const { app } = await setup();
|
||||
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('multi-user: another user’s session is not found and nothing is read', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
|
||||
session.owner = 'alice';
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/edits/:entryId', () => {
|
||||
it('returns the diff as unified text', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
|
||||
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
|
||||
});
|
||||
|
||||
it('404s an edit the session does not have', async () => {
|
||||
const { app } = await setup();
|
||||
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('multi-user: another user’s diff is not found', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
|
||||
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
|
||||
session.owner = 'alice';
|
||||
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/edits/older', () => {
|
||||
it('reads the next older block and lists the result', async () => {
|
||||
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
|
||||
const full = Buffer.from(lines);
|
||||
const read = vi.fn(
|
||||
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[]) => {
|
||||
const req = requests.find((r) => r.relPath === 'main');
|
||||
const start = req ? req.offset : full.length - 400;
|
||||
const len = req?.len ?? full.length - start;
|
||||
return {
|
||||
transcriptId: 'conv1',
|
||||
metas: {},
|
||||
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
const { app } = await setup({ readSources: read });
|
||||
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(first.backfillTruncated).toBe(true);
|
||||
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
|
||||
expect(older.entries.length).toBeGreaterThan(first.entries.length);
|
||||
});
|
||||
|
||||
it('answers unsupported for a remote session without reading', async () => {
|
||||
const { app, read } = await setup();
|
||||
session.remote = { host: 'huff' };
|
||||
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('relativeTo strips only a real directory prefix', () => {
|
||||
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
|
||||
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
|
||||
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
|
||||
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
|
||||
});
|
||||
|
||||
it('hunksToUnified writes one header per hunk', () => {
|
||||
expect(
|
||||
hunksToUnified([
|
||||
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
|
||||
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
|
||||
])
|
||||
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,10 @@ describe('isSensitivePath', () => {
|
||||
['codeman push keys (VAPID private)', `${HOME}/.codeman/push-keys.json`],
|
||||
['codeman intent profiles', `${HOME}/.codeman/intents.json`],
|
||||
['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`],
|
||||
// The edit history stores hold every diff an agent wrote (keys and .env
|
||||
// contents included): 0600 and kept out of /api/search like intents.json.
|
||||
['codeman edit history store', `${HOME}/.codeman/edit-history/s1.json`],
|
||||
['codeman edit history on a named instance', `${HOME}/.codeman-beta/edit-history/s1.json`],
|
||||
];
|
||||
|
||||
it.each(blocked)('blocks the %s', (_label, path) => {
|
||||
|
||||
Reference in new issue
Block a user