@myrialabs/clopen 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/backend/index.ts +5 -1
  2. package/backend/lib/chat/stream-manager.ts +4 -1
  3. package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
  4. package/backend/lib/database/migrations/index.ts +7 -0
  5. package/backend/lib/database/queries/session-queries.ts +50 -0
  6. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  7. package/backend/lib/engine/adapters/opencode/server.ts +8 -0
  8. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  9. package/backend/lib/snapshot/helpers.ts +22 -49
  10. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  11. package/backend/ws/chat/stream.ts +13 -0
  12. package/backend/ws/sessions/crud.ts +34 -2
  13. package/backend/ws/snapshot/restore.ts +111 -12
  14. package/backend/ws/snapshot/timeline.ts +56 -29
  15. package/backend/ws/user/crud.ts +8 -4
  16. package/bin/clopen.ts +17 -1
  17. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  18. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  19. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  20. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  21. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  22. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  23. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  24. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  25. package/frontend/lib/components/git/GitLog.svelte +26 -12
  26. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  27. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  28. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
  29. package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
  30. package/frontend/lib/components/terminal/Terminal.svelte +1 -1
  31. package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
  32. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  33. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
  34. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  35. package/frontend/lib/stores/core/app.svelte.ts +46 -0
  36. package/frontend/lib/stores/core/sessions.svelte.ts +39 -4
  37. package/frontend/lib/stores/ui/update.svelte.ts +0 -12
  38. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  39. package/package.json +1 -1
@@ -285,47 +285,18 @@ export class SnapshotService {
285
285
  */
286
286
  async checkRestoreConflicts(
287
287
  sessionId: string,
288
- targetCheckpointMessageId: string,
289
- projectPath?: string
288
+ targetCheckpointMessageId: string | null,
289
+ projectPath?: string,
290
+ targetPath?: string[]
290
291
  ): Promise<RestoreConflictCheck> {
291
292
  const sessionSnapshots = snapshotQueries.getBySessionId(sessionId);
293
+ const isInitialRestore = targetCheckpointMessageId === null;
292
294
 
293
- const targetIndex = sessionSnapshots.findIndex(
294
- s => s.message_id === targetCheckpointMessageId
295
+ // Build expected state at target (branch-aware when targetPath is provided)
296
+ const expectedState = this.buildExpectedState(
297
+ sessionSnapshots, targetCheckpointMessageId, targetPath
295
298
  );
296
299
 
297
- if (targetIndex === -1) {
298
- return { hasConflicts: false, conflicts: [], checkpointsToUndo: [] };
299
- }
300
-
301
- // Build expected state at target (same bidirectional algorithm as restoreSessionScoped)
302
- // This determines ALL files that would be affected by the restore
303
- const expectedState = new Map<string, string>(); // filepath → expectedHash
304
-
305
- for (let i = 0; i <= targetIndex; i++) {
306
- const snap = sessionSnapshots[i];
307
- if (!snap.session_changes) continue;
308
- try {
309
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
310
- for (const [filepath, change] of Object.entries(changes)) {
311
- expectedState.set(filepath, change.newHash);
312
- }
313
- } catch { /* skip malformed */ }
314
- }
315
-
316
- for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
317
- const snap = sessionSnapshots[i];
318
- if (!snap.session_changes) continue;
319
- try {
320
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
321
- for (const [filepath, change] of Object.entries(changes)) {
322
- if (!expectedState.has(filepath)) {
323
- expectedState.set(filepath, change.oldHash);
324
- }
325
- }
326
- } catch { /* skip malformed */ }
327
- }
328
-
329
300
  if (expectedState.size === 0) {
330
301
  return { hasConflicts: false, conflicts: [], checkpointsToUndo: [] };
331
302
  }
@@ -354,8 +325,13 @@ export class SnapshotService {
354
325
 
355
326
  // Determine reference time for cross-session conflict check
356
327
  // Use min(targetTime, currentHeadTime) to cover both undo and redo
357
- const targetSnapshot = sessionSnapshots[targetIndex];
358
- const targetTime = targetSnapshot.created_at;
328
+ // For initial restore, use the session's created_at or the earliest snapshot time
329
+ const targetSnapshot = isInitialRestore
330
+ ? null
331
+ : sessionSnapshots.find(s => s.message_id === targetCheckpointMessageId) || null;
332
+ const targetTime = targetSnapshot
333
+ ? targetSnapshot.created_at
334
+ : (sessionSnapshots[0]?.created_at || new Date(0).toISOString());
359
335
  let referenceTime = targetTime;
360
336
 
361
337
  const currentHead = sessionQueries.getHead(sessionId);
@@ -384,7 +360,9 @@ export class SnapshotService {
384
360
 
385
361
  // Check for cross-session conflicts
386
362
  const conflicts: RestoreConflict[] = [];
387
- const projectId = targetSnapshot.project_id;
363
+ const projectId = targetSnapshot
364
+ ? targetSnapshot.project_id
365
+ : (sessionSnapshots[0]?.project_id || '');
388
366
  const allProjectSnapshots = this.getAllProjectSnapshots(projectId);
389
367
 
390
368
  for (const otherSnap of allProjectSnapshots) {
@@ -472,60 +450,29 @@ export class SnapshotService {
472
450
  * Restore to a checkpoint using session-scoped changes.
473
451
  * Works in both directions (forward and backward).
474
452
  *
475
- * Algorithm:
476
- * 1. Walk snapshots [0..targetIndex] build expected file state at target
477
- * 2. Walk snapshots [targetIndex+1..end] files changed only after target need reverting
478
- * 3. For each file in the expected state map, compare with current disk and restore if different
453
+ * When targetPath is provided, uses branch-aware algorithm:
454
+ * 1. Only apply changes from snapshots on the path (root target)
455
+ * 2. Revert ALL changes from snapshots on other branches
456
+ * 3. Compare expected state with disk and restore if different
479
457
  * 4. Update in-memory baseline to match restored state
458
+ *
459
+ * Falls back to linear algorithm when targetPath is not provided.
480
460
  */
481
461
  async restoreSessionScoped(
482
462
  projectPath: string,
483
463
  sessionId: string,
484
- targetCheckpointMessageId: string,
485
- conflictResolutions?: ConflictResolution
464
+ targetCheckpointMessageId: string | null,
465
+ conflictResolutions?: ConflictResolution,
466
+ targetPath?: string[]
486
467
  ): Promise<{ restoredFiles: number; skippedFiles: number }> {
487
468
  try {
488
469
  const sessionSnapshots = snapshotQueries.getBySessionId(sessionId);
489
470
 
490
- const targetIndex = sessionSnapshots.findIndex(
491
- s => s.message_id === targetCheckpointMessageId
492
- );
493
-
494
- if (targetIndex === -1) {
495
- debug.warn('snapshot', 'Target checkpoint snapshot not found');
496
- return { restoredFiles: 0, skippedFiles: 0 };
497
- }
498
-
499
471
  // Build expected file state at the target checkpoint
500
- // filepath hash that the file should be at the target
501
- const expectedState = new Map<string, string>();
502
-
503
- // Walk snapshots from first to target (inclusive): apply forward changes
504
- for (let i = 0; i <= targetIndex; i++) {
505
- const snap = sessionSnapshots[i];
506
- if (!snap.session_changes) continue;
507
- try {
508
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
509
- for (const [filepath, change] of Object.entries(changes)) {
510
- expectedState.set(filepath, change.newHash);
511
- }
512
- } catch { /* skip */ }
513
- }
514
-
515
- // Walk snapshots after target: files changed only after target need reverting to oldHash
516
- for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
517
- const snap = sessionSnapshots[i];
518
- if (!snap.session_changes) continue;
519
- try {
520
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
521
- for (const [filepath, change] of Object.entries(changes)) {
522
- if (!expectedState.has(filepath)) {
523
- // File was first changed AFTER target → revert to pre-change state
524
- expectedState.set(filepath, change.oldHash);
525
- }
526
- }
527
- } catch { /* skip */ }
528
- }
472
+ // Branch-aware when targetPath is provided
473
+ const expectedState = this.buildExpectedState(
474
+ sessionSnapshots, targetCheckpointMessageId, targetPath
475
+ );
529
476
 
530
477
  debug.log('snapshot', `Restore to checkpoint: ${expectedState.size} files in expected state`);
531
478
 
@@ -600,6 +547,124 @@ export class SnapshotService {
600
547
  // Helpers
601
548
  // ========================================================================
602
549
 
550
+ /**
551
+ * Build the expected file state map for a restore operation.
552
+ *
553
+ * Branch-aware algorithm (when targetPath is provided):
554
+ * 1. Separate snapshots into path (root→target) vs non-path
555
+ * 2. Forward walk: only apply newHash from snapshots on the path (in path order)
556
+ * 3. Revert walk: revert ALL non-path snapshots using oldHash (first-wins)
557
+ *
558
+ * This correctly handles multi-branch checkpoint trees by NOT including
559
+ * changes from other branches in the forward walk.
560
+ *
561
+ * Fallback linear algorithm (when targetPath is not provided):
562
+ * Walks all snapshots chronologically — only correct for single-branch paths.
563
+ */
564
+ private buildExpectedState(
565
+ sessionSnapshots: MessageSnapshot[],
566
+ targetCheckpointMessageId: string | null,
567
+ targetPath?: string[]
568
+ ): Map<string, string> {
569
+ const expectedState = new Map<string, string>();
570
+ const isInitialRestore = targetCheckpointMessageId === null;
571
+
572
+ if (isInitialRestore) {
573
+ // Revert ALL snapshots → everything goes back to oldHash (first-wins)
574
+ for (const snap of sessionSnapshots) {
575
+ if (!snap.session_changes) continue;
576
+ try {
577
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
578
+ for (const [filepath, change] of Object.entries(changes)) {
579
+ if (!expectedState.has(filepath)) {
580
+ expectedState.set(filepath, change.oldHash);
581
+ }
582
+ }
583
+ } catch { /* skip malformed */ }
584
+ }
585
+ } else if (targetPath && targetPath.length > 0) {
586
+ // Branch-aware restore: only include snapshots on the path from root to target
587
+ const pathSet = new Set(targetPath);
588
+
589
+ // Separate snapshots into path vs non-path
590
+ const snapshotByMsgId = new Map<string, MessageSnapshot>();
591
+ const nonPathSnapshots: MessageSnapshot[] = [];
592
+
593
+ for (const snap of sessionSnapshots) {
594
+ if (pathSet.has(snap.message_id)) {
595
+ snapshotByMsgId.set(snap.message_id, snap);
596
+ } else {
597
+ nonPathSnapshots.push(snap);
598
+ }
599
+ }
600
+
601
+ // Forward walk: apply path snapshots in path order (root → target)
602
+ // Later path snapshots overwrite earlier ones for the same file (correct)
603
+ for (const cpId of targetPath) {
604
+ const snap = snapshotByMsgId.get(cpId);
605
+ if (!snap?.session_changes) continue;
606
+ try {
607
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
608
+ for (const [filepath, change] of Object.entries(changes)) {
609
+ expectedState.set(filepath, change.newHash);
610
+ }
611
+ } catch { /* skip malformed */ }
612
+ }
613
+
614
+ // Revert all non-path snapshots (changes on other branches)
615
+ // Process in chronological order with first-wins semantics:
616
+ // if two non-path snapshots change the same file, the earliest one's
617
+ // oldHash is used (state before any branch diverged)
618
+ for (const snap of nonPathSnapshots) {
619
+ if (!snap.session_changes) continue;
620
+ try {
621
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
622
+ for (const [filepath, change] of Object.entries(changes)) {
623
+ if (!expectedState.has(filepath)) {
624
+ expectedState.set(filepath, change.oldHash);
625
+ }
626
+ }
627
+ } catch { /* skip malformed */ }
628
+ }
629
+ } else {
630
+ // Fallback: linear algorithm (no path info available)
631
+ const targetIndex = sessionSnapshots.findIndex(
632
+ s => s.message_id === targetCheckpointMessageId
633
+ );
634
+
635
+ if (targetIndex === -1) {
636
+ debug.warn('snapshot', 'Target checkpoint snapshot not found (linear fallback)');
637
+ return expectedState;
638
+ }
639
+
640
+ for (let i = 0; i <= targetIndex; i++) {
641
+ const snap = sessionSnapshots[i];
642
+ if (!snap.session_changes) continue;
643
+ try {
644
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
645
+ for (const [filepath, change] of Object.entries(changes)) {
646
+ expectedState.set(filepath, change.newHash);
647
+ }
648
+ } catch { /* skip malformed */ }
649
+ }
650
+
651
+ for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
652
+ const snap = sessionSnapshots[i];
653
+ if (!snap.session_changes) continue;
654
+ try {
655
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
656
+ for (const [filepath, change] of Object.entries(changes)) {
657
+ if (!expectedState.has(filepath)) {
658
+ expectedState.set(filepath, change.oldHash);
659
+ }
660
+ }
661
+ } catch { /* skip malformed */ }
662
+ }
663
+ }
664
+
665
+ return expectedState;
666
+ }
667
+
603
668
  /**
604
669
  * Calculate line-level change stats for changed files.
605
670
  */
@@ -49,6 +49,14 @@ streamManager.on('stream:lifecycle', (event: { status: string; streamId: string;
49
49
  broadcastPresence().catch(() => {});
50
50
  });
51
51
 
52
+ // Notify project members when a snapshot is captured (so the timeline modal can refresh stats)
53
+ streamManager.on('snapshot:captured', (event: { projectId: string; chatSessionId: string }) => {
54
+ const { projectId, chatSessionId } = event;
55
+ if (!projectId) return;
56
+
57
+ ws.emit.projectMembers(projectId, 'snapshot:captured', { projectId, chatSessionId });
58
+ });
59
+
52
60
  // In-memory store for latest chat input state per chat session (keyed by chatSessionId)
53
61
  const chatSessionInputState = new Map<string, { text: string; senderId: string; attachments?: any[] }>();
54
62
 
@@ -789,4 +797,9 @@ export const streamHandler = createRouter()
789
797
  chatSessionId: t.String(),
790
798
  toolUseId: t.String(),
791
799
  timestamp: t.String()
800
+ }))
801
+
802
+ .emit('snapshot:captured', t.Object({
803
+ projectId: t.String(),
804
+ chatSessionId: t.String()
792
805
  }));
@@ -33,7 +33,11 @@ export const crudHandler = createRouter()
33
33
  started_at: t.String(),
34
34
  ended_at: t.Optional(t.String())
35
35
  })),
36
- currentSessionId: t.Optional(t.String())
36
+ currentSessionId: t.Optional(t.String()),
37
+ unreadSessionIds: t.Array(t.Object({
38
+ sessionId: t.String(),
39
+ projectId: t.String()
40
+ }))
37
41
  })
38
42
  }, async ({ conn }) => {
39
43
  const projectId = ws.getProjectId(conn);
@@ -43,6 +47,10 @@ export const crudHandler = createRouter()
43
47
  // Get the user's saved current session for this project
44
48
  const currentSessionId = projectQueries.getCurrentSessionId(userId, projectId);
45
49
 
50
+ // Get unread sessions for this user/project
51
+ const unreadRows = sessionQueries.getUnreadSessions(userId, projectId);
52
+ debug.log('session', `[unread] sessions:list — user=${userId}, project=${projectId}, unreadCount=${unreadRows.length}`, unreadRows);
53
+
46
54
  // Convert null to undefined for TypeScript optional fields
47
55
  return {
48
56
  sessions: sessions.map(session => ({
@@ -54,7 +62,8 @@ export const crudHandler = createRouter()
54
62
  current_head_message_id: session.current_head_message_id ?? undefined,
55
63
  ended_at: session.ended_at ?? undefined
56
64
  })),
57
- currentSessionId: currentSessionId ?? undefined
65
+ currentSessionId: currentSessionId ?? undefined,
66
+ unreadSessionIds: unreadRows.map(r => ({ sessionId: r.session_id, projectId: r.project_id }))
58
67
  };
59
68
  })
60
69
 
@@ -324,4 +333,27 @@ export const crudHandler = createRouter()
324
333
  const userId = ws.getUserId(conn);
325
334
  projectQueries.setCurrentSessionId(userId, projectId, data.sessionId);
326
335
  debug.log('session', `User ${userId} set current session to ${data.sessionId} in project ${projectId}`);
336
+ })
337
+
338
+ // Mark a session as read for the current user
339
+ .on('sessions:mark-read', {
340
+ data: t.Object({
341
+ sessionId: t.String()
342
+ })
343
+ }, async ({ data, conn }) => {
344
+ const userId = ws.getUserId(conn);
345
+ sessionQueries.markRead(userId, data.sessionId);
346
+ debug.log('session', `[unread] Marked session ${data.sessionId} as READ for user ${userId}`);
347
+ })
348
+
349
+ // Mark a session as unread for the current user
350
+ .on('sessions:mark-unread', {
351
+ data: t.Object({
352
+ sessionId: t.String(),
353
+ projectId: t.String()
354
+ })
355
+ }, async ({ data, conn }) => {
356
+ const userId = ws.getUserId(conn);
357
+ sessionQueries.markUnread(userId, data.sessionId, data.projectId);
358
+ debug.log('session', `[unread] Marked session ${data.sessionId} as UNREAD for user ${userId} in project ${data.projectId}`);
327
359
  });
@@ -14,7 +14,9 @@ import { debug } from '$shared/utils/logger';
14
14
  import {
15
15
  buildCheckpointTree,
16
16
  getCheckpointPathToRoot,
17
- findSessionEnd
17
+ findCheckpointForHead,
18
+ findSessionEnd,
19
+ INITIAL_NODE_ID
18
20
  } from '../../lib/snapshot/helpers';
19
21
  import { ws } from '$backend/lib/utils/ws';
20
22
 
@@ -53,7 +55,31 @@ export const restoreHandler = createRouter()
53
55
  if (project) projectPath = project.path;
54
56
  }
55
57
 
56
- const result = await snapshotService.checkRestoreConflicts(sessionId, messageId, projectPath);
58
+ // Build checkpoint path for branch-aware conflict detection
59
+ let targetPath: string[] | undefined;
60
+ let resolvedMessageId: string | null = messageId === INITIAL_NODE_ID ? null : messageId;
61
+
62
+ if (messageId !== INITIAL_NODE_ID) {
63
+ const allMessages = messageQueries.getAllBySessionId(sessionId);
64
+ const { checkpoints, parentMap } = buildCheckpointTree(allMessages);
65
+ const checkpointIdSet = new Set(checkpoints.map(c => c.id));
66
+
67
+ const resolvedId = checkpointIdSet.has(messageId)
68
+ ? messageId
69
+ : findCheckpointForHead(messageId, allMessages, checkpointIdSet);
70
+
71
+ if (resolvedId) {
72
+ resolvedMessageId = resolvedId;
73
+ targetPath = getCheckpointPathToRoot(resolvedId, parentMap);
74
+ }
75
+ }
76
+
77
+ const result = await snapshotService.checkRestoreConflicts(
78
+ sessionId,
79
+ resolvedMessageId,
80
+ projectPath,
81
+ targetPath
82
+ );
57
83
 
58
84
  debug.log('snapshot', `Conflict check: ${result.conflicts.length} conflicts, ${result.checkpointsToUndo.length} checkpoints to undo`);
59
85
 
@@ -82,12 +108,67 @@ export const restoreHandler = createRouter()
82
108
  })
83
109
  }, async ({ data }) => {
84
110
  const { messageId, sessionId, conflictResolutions } = data;
111
+ const isInitialRestore = messageId === INITIAL_NODE_ID;
85
112
 
86
- debug.log('snapshot', 'RESTORE - Moving HEAD to checkpoint');
87
- debug.log('snapshot', `Target checkpoint: ${messageId}`);
113
+ debug.log('snapshot', `RESTORE - ${isInitialRestore ? 'Restoring to initial state' : 'Moving HEAD to checkpoint'}`);
114
+ debug.log('snapshot', `Target: ${messageId}`);
88
115
  debug.log('snapshot', `Session: ${sessionId}`);
89
116
 
90
- // 1. Get the checkpoint message
117
+ // Handle restore to initial state (before any messages)
118
+ if (isInitialRestore) {
119
+ // Clear HEAD (no messages active)
120
+ sessionQueries.clearHead(sessionId);
121
+ debug.log('snapshot', 'HEAD cleared (initial state)');
122
+
123
+ // Clear latest_sdk_session_id so next chat starts fresh
124
+ const db = (await import('../../lib/database')).getDatabase();
125
+ db.prepare(`UPDATE chat_sessions SET latest_sdk_session_id = NULL WHERE id = ?`).run(sessionId);
126
+
127
+ // Clear checkpoint_tree_state
128
+ checkpointQueries.deleteForSession(sessionId);
129
+
130
+ // Restore file system: revert ALL session changes
131
+ let filesRestored = 0;
132
+ let filesSkipped = 0;
133
+
134
+ const session = sessionQueries.getById(sessionId);
135
+ if (session) {
136
+ const project = projectQueries.getById(session.project_id);
137
+ if (project) {
138
+ const result = await snapshotService.restoreSessionScoped(
139
+ project.path,
140
+ sessionId,
141
+ null, // null = restore to initial (before all snapshots)
142
+ conflictResolutions
143
+ );
144
+ filesRestored = result.restoredFiles;
145
+ filesSkipped = result.skippedFiles;
146
+ }
147
+ }
148
+
149
+ // Broadcast messages-changed
150
+ try {
151
+ ws.emit.chatSession(sessionId, 'chat:messages-changed', {
152
+ sessionId,
153
+ reason: 'restore',
154
+ timestamp: new Date().toISOString()
155
+ });
156
+ } catch (err) {
157
+ debug.error('snapshot', 'Failed to broadcast messages-changed:', err);
158
+ }
159
+
160
+ return {
161
+ restoredTo: {
162
+ messageId: INITIAL_NODE_ID,
163
+ timestamp: new Date().toISOString()
164
+ },
165
+ filesRestored,
166
+ filesSkipped
167
+ };
168
+ }
169
+
170
+ // Regular checkpoint restore
171
+ // 1. Get the target message
91
172
  const checkpointMessage = messageQueries.getById(messageId);
92
173
  if (!checkpointMessage) {
93
174
  throw new Error('Checkpoint message not found');
@@ -99,9 +180,19 @@ export const restoreHandler = createRouter()
99
180
 
100
181
  // 3. Get all messages and build checkpoint tree
101
182
  const allMessages = messageQueries.getAllBySessionId(sessionId);
102
- const { parentMap } = buildCheckpointTree(allMessages);
183
+ const { checkpoints, parentMap } = buildCheckpointTree(allMessages);
184
+
185
+ // 3b. Resolve the correct checkpoint for snapshot/tree operations
186
+ // The target message may be a non-checkpoint (e.g., assistant response)
187
+ // when called from edit mode. Walk back to find the nearest ancestor checkpoint.
188
+ const checkpointIdSet = new Set(checkpoints.map(c => c.id));
189
+ const resolvedCheckpointId = checkpointIdSet.has(messageId)
190
+ ? messageId
191
+ : findCheckpointForHead(messageId, allMessages, checkpointIdSet);
103
192
 
104
- // 4. Find session end (last message of checkpoint's session)
193
+ debug.log('snapshot', `Resolved checkpoint: ${resolvedCheckpointId} (target was ${messageId})`);
194
+
195
+ // 4. Find session end (last message of target's session)
105
196
  const sessionEnd = findSessionEnd(checkpointMessage, allMessages);
106
197
  debug.log('snapshot', `Session end: ${sessionEnd.id}`);
107
198
 
@@ -137,12 +228,19 @@ export const restoreHandler = createRouter()
137
228
  }
138
229
 
139
230
  // 6. Update checkpoint_tree_state for ancestors
140
- const checkpointPath = getCheckpointPathToRoot(messageId, parentMap);
141
- if (checkpointPath.length > 1) {
142
- checkpointQueries.updateActiveChildrenAlongPath(sessionId, checkpointPath);
231
+ // Use resolved checkpoint ID (not raw messageId which may be a non-checkpoint)
232
+ // Also compute checkpointPath for branch-aware file restore
233
+ let checkpointPath: string[] = [];
234
+ if (resolvedCheckpointId) {
235
+ checkpointPath = getCheckpointPathToRoot(resolvedCheckpointId, parentMap);
236
+ if (checkpointPath.length > 1) {
237
+ checkpointQueries.updateActiveChildrenAlongPath(sessionId, checkpointPath);
238
+ }
143
239
  }
144
240
 
145
241
  // 7. Restore file system state using session-scoped restore
242
+ // Use resolved checkpoint ID so the snapshot lookup matches correctly
243
+ // (snapshots are keyed by checkpoint user message IDs, not assistant messages)
146
244
  let filesRestored = 0;
147
245
  let filesSkipped = 0;
148
246
 
@@ -153,8 +251,9 @@ export const restoreHandler = createRouter()
153
251
  const result = await snapshotService.restoreSessionScoped(
154
252
  project.path,
155
253
  sessionId,
156
- messageId,
157
- conflictResolutions
254
+ resolvedCheckpointId,
255
+ conflictResolutions,
256
+ checkpointPath.length > 0 ? checkpointPath : undefined
158
257
  );
159
258
  filesRestored = result.restoredFiles;
160
259
  filesSkipped = result.skippedFiles;
@@ -15,7 +15,8 @@ import {
15
15
  getCheckpointPathToRoot,
16
16
  findCheckpointForHead,
17
17
  isDescendant,
18
- getCheckpointFileStats
18
+ getCheckpointFileStats,
19
+ INITIAL_NODE_ID
19
20
  } from '../../lib/snapshot/helpers';
20
21
  import type { CheckpointNode, TimelineResponse } from '../../lib/snapshot/helpers';
21
22
  import type { SDKMessage } from '$shared/types/messaging';
@@ -36,11 +37,8 @@ export const timelineHandler = createRouter()
36
37
 
37
38
  // 1. Get current HEAD
38
39
  const currentHead = sessionQueries.getHead(sessionId);
39
- debug.log('snapshot', `Current HEAD: ${currentHead || 'null'}`);
40
-
41
- if (!currentHead) {
42
- return { nodes: [], currentHeadId: null };
43
- }
40
+ const isAtInitialState = !currentHead;
41
+ debug.log('snapshot', `Current HEAD: ${currentHead || 'null (initial state)'}`);
44
42
 
45
43
  // 2. Get all messages
46
44
  const allMessages = messageQueries.getAllBySessionId(sessionId);
@@ -61,8 +59,10 @@ export const timelineHandler = createRouter()
61
59
  const checkpointIdSet = new Set(checkpoints.map(c => c.id));
62
60
 
63
61
  // 4. Find which checkpoint HEAD belongs to
64
- const activeCheckpointId = findCheckpointForHead(currentHead, allMessages, checkpointIdSet);
65
- debug.log('snapshot', `Active checkpoint: ${activeCheckpointId}`);
62
+ const activeCheckpointId = isAtInitialState
63
+ ? null
64
+ : findCheckpointForHead(currentHead, allMessages, checkpointIdSet);
65
+ debug.log('snapshot', `Active checkpoint: ${activeCheckpointId || '(initial)'}`);
66
66
 
67
67
  // 5. Build active path (from root to active checkpoint)
68
68
  const activePathIds = new Set<string>();
@@ -76,22 +76,47 @@ export const timelineHandler = createRouter()
76
76
  // 6. Get active children map from database
77
77
  const activeChildrenMap = checkpointQueries.getAllActiveChildren(sessionId);
78
78
 
79
- // 7. Sort checkpoints by timestamp for file stats calculation
80
- const sortedCheckpoints = [...checkpoints].sort(
81
- (a, b) => a.timestamp.localeCompare(b.timestamp)
82
- );
83
-
84
- // Build next-checkpoint timestamp map for stats
85
- const nextTimestampMap = new Map<string, string>();
86
- for (let i = 0; i < sortedCheckpoints.length; i++) {
87
- const next = sortedCheckpoints[i + 1];
88
- if (next) {
89
- nextTimestampMap.set(sortedCheckpoints[i].id, next.timestamp);
90
- }
79
+ // 7. Build response nodes
80
+ const nodes: CheckpointNode[] = [];
81
+
82
+ // Find root checkpoints (those with no parent)
83
+ const rootCheckpointIds = checkpoints
84
+ .filter(cp => !parentMap.has(cp.id))
85
+ .map(cp => cp.id);
86
+
87
+ // Get session started_at for the initial node timestamp
88
+ const session = sessionQueries.getById(sessionId);
89
+ const sessionStartedAt = session?.started_at || new Date().toISOString();
90
+
91
+ // Add the "Initial State" node at the beginning
92
+ // Its activeChildId points to the first root checkpoint on the active path,
93
+ // or the first root checkpoint if we're at initial state
94
+ let initialActiveChildId: string | null = null;
95
+ if (isAtInitialState) {
96
+ // At initial state: the first root checkpoint (by timestamp) is the active child
97
+ initialActiveChildId = rootCheckpointIds[0] || null;
98
+ } else {
99
+ // Find root checkpoint on active path
100
+ initialActiveChildId = rootCheckpointIds.find(id => activePathIds.has(id)) || rootCheckpointIds[0] || null;
91
101
  }
92
102
 
93
- // 8. Build response nodes
94
- const nodes: CheckpointNode[] = [];
103
+ nodes.push({
104
+ id: INITIAL_NODE_ID,
105
+ messageId: INITIAL_NODE_ID,
106
+ parentId: null,
107
+ activeChildId: initialActiveChildId,
108
+ timestamp: sessionStartedAt,
109
+ messageText: 'Session Start',
110
+ isOnActivePath: isAtInitialState || activePathIds.size > 0,
111
+ isOrphaned: false,
112
+ isCurrent: isAtInitialState,
113
+ hasSnapshot: false,
114
+ isInitial: true,
115
+ senderName: null,
116
+ filesChanged: 0,
117
+ insertions: 0,
118
+ deletions: 0
119
+ });
95
120
 
96
121
  for (const cp of checkpoints) {
97
122
  const sdk = JSON.parse(cp.sdk_message) as SDKMessage;
@@ -107,16 +132,16 @@ export const timelineHandler = createRouter()
107
132
  isOrphaned = isDescendant(cp.id, activeCheckpointId, childrenMap);
108
133
  }
109
134
 
110
- // File stats
111
- const nextTimestamp = nextTimestampMap.get(cp.id);
112
- const stats = getCheckpointFileStats(cp, allMessages, nextTimestamp);
135
+ // File stats from checkpoint's own snapshot
136
+ const stats = getCheckpointFileStats(cp);
113
137
 
114
138
  const snapshot = snapshotQueries.getByMessageId(cp.id);
115
139
 
116
140
  nodes.push({
117
141
  id: cp.id,
118
142
  messageId: cp.id,
119
- parentId: parentCpId,
143
+ // Root checkpoints have initial node as parent
144
+ parentId: parentCpId || INITIAL_NODE_ID,
120
145
  activeChildId,
121
146
  timestamp: cp.timestamp,
122
147
  messageText,
@@ -131,11 +156,13 @@ export const timelineHandler = createRouter()
131
156
  });
132
157
  }
133
158
 
134
- debug.log('snapshot', `Timeline nodes: ${nodes.length}`);
135
- debug.log('snapshot', `Active path: ${activePathIds.size} nodes`);
159
+ const currentHeadId = isAtInitialState ? INITIAL_NODE_ID : activeCheckpointId;
160
+
161
+ debug.log('snapshot', `Timeline nodes: ${nodes.length} (including initial)`);
162
+ debug.log('snapshot', `Active path: ${activePathIds.size} nodes, current: ${currentHeadId}`);
136
163
 
137
164
  return {
138
165
  nodes,
139
- currentHeadId: activeCheckpointId
166
+ currentHeadId
140
167
  };
141
168
  });