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

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

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

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

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

No files matched your search

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