Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7aed53a
feat: add code snapshot support for fork rollback
lyw405 Dec 24, 2025
82fe0e9
feat: implement snapshot restoration with restore options modal
lyw405 Dec 25, 2025
101b669
refactor: extract snapshot creation logic into dedicated method
lyw405 Dec 25, 2025
3f837cb
refactor: remove unused TOOL_NAMES import from project.ts
lyw405 Dec 25, 2025
c539b38
refactor: improve fork rollback code organization and performance
lyw405 Dec 29, 2025
33b3989
feat: auto-cleanup snapshots when restoring code without conversation
lyw405 Dec 29, 2025
5ec42d1
Merge master into feat/enhance-previous-message
lyw405 Dec 29, 2025
af830bf
Fix visibility of sendWithSystemPromptAndTools method
lyw405 Dec 29, 2025
e090ddd
fix(fork): preserve full command history across sessions when forking
lyw405 Dec 29, 2025
11413f3
feat: implement fork code rollback with physical backup system
lyw405 Dec 30, 2025
315175e
refactor(snapshot): improve snapshot state management and JSONL logging
lyw405 Dec 30, 2025
ae8da27
fix(snapshot): remove unreliable mtime optimization in file change de…
lyw405 Dec 30, 2025
1be85ee
Improve snapshot manager performance and reliability
lyw405 Jan 6, 2026
c1cd784
Merge branch 'master' into feat/enhance-previous-message
lyw405 Jan 6, 2026
66eddbd
refactor: move snapshot and fork-related functions to appropriate uti…
lyw405 Jan 6, 2026
bbc84f5
Merge branch 'master' into feat/enhance-previous-message
lyw405 Jan 10, 2026
43cf89d
refactor: optimize session config management in nodeBridge
lyw405 Jan 12, 2026
f604f4f
feat: enhance snapshot utility with encoding detection and improved b…
lyw405 Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,017 changes: 1,017 additions & 0 deletions docs/designs/2025-12-23-fork-code-rollback.md

Large diffs are not rendered by default.

147 changes: 117 additions & 30 deletions src/commands/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ type NormalizedMessage = {
uiContent?: string;
};

type SnapshotMessage = {
type: 'file-history-snapshot';
messageId: string;
snapshot: {
messageId: string;
timestamp: string;
trackedFileBackups: Record<string, any>;
};
isSnapshotUpdate: boolean;
};

type RequestLogEntry =
| ({ type: 'metadata'; timestamp: string; requestId: string } & AnyJson)
| ({ type: 'chunk'; timestamp: string; requestId: string } & AnyJson);
Expand All @@ -108,6 +119,13 @@ function loadAllSessionMessages(logPath: string): NormalizedMessage[] {
return items.filter((i) => i && i.type === 'message') as NormalizedMessage[];
}

function loadAllSnapshots(logPath: string): SnapshotMessage[] {
const items = readJsonlFile(logPath);
return items.filter(
(i) => i && i.type === 'file-history-snapshot',
) as SnapshotMessage[];
}

function loadAllRequestLogs(
requestsDir: string,
messages: NormalizedMessage[],
Expand Down Expand Up @@ -174,9 +192,22 @@ type RenderableItem =
name: string;
result: any;
isError: boolean;
}
| {
type: 'snapshot';
indent: true;
messageId: string;
fileCount: number;
timestamp: string;
isUpdate: boolean;
snapshotIndex: number;
snapshotData: SnapshotMessage;
};

function buildRenderableItems(messages: NormalizedMessage[]): RenderableItem[] {
function buildRenderableItems(
messages: NormalizedMessage[],
snapshots: SnapshotMessage[],
): RenderableItem[] {
const items: RenderableItem[] = [];
const toolResultsMap = new Map<
string,
Expand Down Expand Up @@ -213,42 +244,80 @@ function buildRenderableItems(messages: NormalizedMessage[]): RenderableItem[] {
}
}

type TimelineItem =
| { type: 'message'; time: number; message: NormalizedMessage }
| {
type: 'snapshot';
time: number;
snapshot: SnapshotMessage;
index: number;
};

const timeline: TimelineItem[] = [];

for (const msg of messages) {
items.push({ type: 'message', message: msg, indent: false });

if (msg.role === 'assistant' && Array.isArray(msg.content)) {
const toolUses = msg.content.filter(
(
part,
): part is {
type: 'tool_use';
id: string;
name: string;
input: Record<string, any>;
} => part.type === 'tool_use',
);

for (const toolUse of toolUses) {
items.push({
type: 'tool-call',
indent: true,
id: toolUse.id,
name: toolUse.name,
input: toolUse.input,
});
const time = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
timeline.push({ type: 'message', time, message: msg });
}

for (let i = 0; i < snapshots.length; i++) {
const snapshot = snapshots[i]!;
const time = new Date(snapshot.snapshot.timestamp).getTime();
timeline.push({ type: 'snapshot', time, snapshot, index: i });
}

timeline.sort((a, b) => a.time - b.time);
for (const item of timeline) {
if (item.type === 'message') {
const msg = item.message;
items.push({ type: 'message', message: msg, indent: false });

if (msg.role === 'assistant' && Array.isArray(msg.content)) {
const toolUses = msg.content.filter(
(
part,
): part is {
type: 'tool_use';
id: string;
name: string;
input: Record<string, any>;
} => part.type === 'tool_use',
);

const resultData = toolResultsMap.get(toolUse.id);
if (resultData) {
for (const toolUse of toolUses) {
items.push({
type: 'tool-result',
type: 'tool-call',
indent: true,
id: toolUse.id,
name: resultData.name,
result: resultData.result,
isError: resultData.isError,
name: toolUse.name,
input: toolUse.input,
});

const resultData = toolResultsMap.get(toolUse.id);
if (resultData) {
items.push({
type: 'tool-result',
indent: true,
id: toolUse.id,
name: resultData.name,
result: resultData.result,
isError: resultData.isError,
});
}
}
}
} else if (item.type === 'snapshot') {
const snapshot = item.snapshot;
items.push({
type: 'snapshot',
indent: true,
messageId: snapshot.messageId,
fileCount: Object.keys(snapshot.snapshot.trackedFileBackups).length,
timestamp: snapshot.snapshot.timestamp,
isUpdate: snapshot.isSnapshotUpdate,
snapshotIndex: item.index,
snapshotData: snapshot,
});
}
}

Expand All @@ -261,6 +330,7 @@ function buildHtml(opts: {
messages: NormalizedMessage[];
requestLogs: ReturnType<typeof loadAllRequestLogs>;
activeUuids: Set<string>;
snapshots?: SnapshotMessage[];
}) {
const { sessionId, sessionLogPath, messages, requestLogs, activeUuids } =
opts;
Expand All @@ -284,7 +354,8 @@ function buildHtml(opts: {
messagesMap[m.uuid] = m as AnyJson;
}

const renderableItems = buildRenderableItems(messages);
const snapshots = opts.snapshots || [];
const renderableItems = buildRenderableItems(messages, snapshots);

const messagesHtml = renderableItems
.map((item) => {
Expand Down Expand Up @@ -350,6 +421,16 @@ function buildHtml(opts: {
${uuidBadge}
<div class="meta">${statusLabel} Tool Result: ${escapeHtml(item.name)}</div>
<div class="content"><pre>${resultStr}</pre></div>
</div>`;
} else if (item.type === 'snapshot') {
const ts = formatDate(new Date(item.timestamp));
const updateLabel = item.isUpdate ? '🔄 Updated' : '📸 Created';
const snapshotStr = escapeHtml(pretty(item.snapshotData));
const uuidBadge = `<div class="uuid-badge">${escapeHtml(item.messageId.slice(0, 8))}</div>`;
return `<div class="msg snapshot indented">
${uuidBadge}
<div class="meta">${updateLabel} Snapshot · ${item.fileCount} file(s) · ${escapeHtml(ts)}</div>
<div class="content"><pre>${snapshotStr}</pre></div>
</div>`;
}
return '';
Expand All @@ -372,11 +453,13 @@ function buildHtml(opts: {
.msg.tool-call { background: #fffbf0; border-left: 3px solid #f59e0b; }
.msg.tool-result { background: #f0fdf4; border-left: 3px solid #10b981; }
.msg.tool-result.error { background: #fef2f2; border-left: 3px solid #ef4444; }
.msg.snapshot { background: #faf5ff; border-left: 3px solid #a855f7; font-size: 11px; padding: 6px 10px; }
.uuid-badge { position: absolute; top: 8px; right: 10px; font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #999; background: rgba(255, 255, 255, 0.8); padding: 2px 6px; border-radius: 3px; }
.msg.disabled { opacity: 0.4; }
.msg.disabled.tool-call,
.msg.disabled.tool-result { opacity: 0.4; }
.msg.tool-call pre, .msg.tool-result pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 12px; }
.msg.snapshot pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 11px; color: #666; }
.details code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.details pre { background: #fafafa; border: 1px solid #eee; padding: 8px; border-radius: 6px; overflow: auto; }
.muted { color: #777; }
Expand Down Expand Up @@ -493,6 +576,7 @@ function buildHtml(opts: {
async function generateHtmlForSession(context: Context, sessionId: string) {
const sessionLogPath = context.paths.getSessionLogPath(sessionId);
const messages = loadAllSessionMessages(sessionLogPath);
const snapshots = loadAllSnapshots(sessionLogPath);
const activeMessages = filterMessages(messages as any);
const activeUuids = new Set(activeMessages.map((m) => m.uuid));
const requestsDir = path.join(path.dirname(sessionLogPath), 'requests');
Expand All @@ -504,6 +588,7 @@ async function generateHtmlForSession(context: Context, sessionId: string) {
messages,
requestLogs,
activeUuids,
snapshots,
});

const outDir = path.join(process.cwd(), '.log-outputs');
Expand All @@ -519,6 +604,7 @@ async function generateHtmlForFile(filePath: string) {
// Extract session ID from filename for display
const sessionId = path.basename(filePath, '.jsonl');
const messages = loadAllSessionMessages(filePath);
const snapshots = loadAllSnapshots(filePath);
const activeMessages = filterMessages(messages as any);
const activeUuids = new Set(activeMessages.map((m) => m.uuid));
// Locate requests/ directory relative to the session file
Expand All @@ -531,6 +617,7 @@ async function generateHtmlForFile(filePath: string) {
messages,
requestLogs,
activeUuids,
snapshots,
});

const outDir = path.join(process.cwd(), '.log-outputs');
Expand Down
48 changes: 47 additions & 1 deletion src/jsonl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'pathe';
import type { NormalizedMessage } from './message';
import type { NormalizedMessage, SnapshotMessage } from './message';
import { createUserMessage } from './message';
import type { StreamResult } from './loop';

Expand Down Expand Up @@ -46,6 +46,52 @@ export class JsonlLogger {
message,
});
}

/**
* Add a snapshot message to the log file
* This records file history snapshot information similar to Claude Code
*
* IMPORTANT: This method only appends to the log file, never deletes.
* The isSnapshotUpdate flag is used during reconstruction (see session.ts loadSnapshotEntries)
* to determine whether to create a new snapshot or update an existing one.
* This follows Claude Code's design pattern.
*/
addSnapshotMessage(opts: {
messageId: string;
timestamp: string;
trackedFileBackups: Record<
string,
{
backupFileName: string | null;
backupTime: string;
version: number;
}
>;
isSnapshotUpdate: boolean;
}) {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

const snapshotMessage: SnapshotMessage = {
type: 'file-history-snapshot',
messageId: opts.messageId,
snapshot: {
messageId: opts.messageId,
timestamp: opts.timestamp,
trackedFileBackups: opts.trackedFileBackups,
},
isSnapshotUpdate: opts.isSnapshotUpdate,
};

// Simply append the snapshot message to the log file
// Do NOT delete old snapshots - the reconstruction logic in loadSnapshotEntries
// and rebuildSnapshotState will handle updates correctly using the isSnapshotUpdate flag
fs.appendFileSync(this.filePath, JSON.stringify(snapshotMessage) + '\n');

return snapshotMessage;
}
}

export class RequestLogger {
Expand Down
22 changes: 22 additions & 0 deletions src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,28 @@ export type ToolResultPart = {
agentType?: string;
};

/**
* Snapshot message type for tracking file history snapshots
* Similar to Claude Code's file-history-snapshot message
*/
export type SnapshotMessage = {
type: 'file-history-snapshot';
messageId: string;
snapshot: {
messageId: string;
timestamp: string;
trackedFileBackups: Record<
string,
{
backupFileName: string | null;
backupTime: string;
version: number;
}
>;
};
isSnapshotUpdate: boolean;
};

export type Message =
| SystemMessage
| UserMessage
Expand Down
Loading