mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user