@myrialabs/clopen 0.1.9 → 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 (28) hide show
  1. package/backend/index.ts +5 -1
  2. package/backend/lib/chat/stream-manager.ts +4 -1
  3. package/backend/lib/database/queries/session-queries.ts +13 -0
  4. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  5. package/backend/lib/engine/adapters/opencode/server.ts +8 -0
  6. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  7. package/backend/lib/snapshot/helpers.ts +22 -49
  8. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  9. package/backend/ws/chat/stream.ts +13 -0
  10. package/backend/ws/snapshot/restore.ts +111 -12
  11. package/backend/ws/snapshot/timeline.ts +56 -29
  12. package/bin/clopen.ts +17 -1
  13. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  14. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  15. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  16. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  17. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  18. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  19. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  20. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  21. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  22. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  23. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  24. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  25. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  26. package/frontend/lib/stores/ui/update.svelte.ts +0 -12
  27. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  28. package/package.json +1 -1
@@ -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
  }));
@@ -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
  });
package/bin/clopen.ts CHANGED
@@ -368,7 +368,23 @@ async function main() {
368
368
 
369
369
  // Show version if requested
370
370
  if (options.version) {
371
- console.log(getVersion());
371
+ const currentVersion = getVersion();
372
+ console.log(`v${currentVersion}`);
373
+
374
+ try {
375
+ const response = await fetch('https://registry.npmjs.org/@myrialabs/clopen/latest');
376
+ if (response.ok) {
377
+ const data = await response.json() as { version: string };
378
+ if (isNewerVersion(currentVersion, data.version)) {
379
+ console.log(`\x1b[33mUpdate available: v${data.version}\x1b[0m — run \x1b[36mclopen update\x1b[0m to update`);
380
+ } else {
381
+ console.log('\x1b[32m(latest)\x1b[0m');
382
+ }
383
+ }
384
+ } catch {
385
+ // Silent fail — network unavailable
386
+ }
387
+
372
388
  process.exit(0);
373
389
  }
374
390
 
@@ -407,7 +407,7 @@
407
407
  class="
408
408
  relative z-10 flex items-end gap-3 lg:gap-4 overflow-hidden bg-white dark:bg-slate-900
409
409
  border border-slate-200 dark:border-slate-700 rounded-xl transition-all duration-200
410
- focus-within:ring-2 focus-within:ring-violet-500 {fileHandling.isDragging && 'ring-2 ring-violet-500'}"
410
+ focus-within:ring-1 focus-within:ring-violet-500 {fileHandling.isDragging && 'ring-1 ring-violet-500'}"
411
411
  role="region"
412
412
  aria-label="Message input with file drop zone"
413
413
  ondragover={fileHandling.handleDragOver}
@@ -428,7 +428,6 @@
428
428
  placeholder={chatPlaceholder}
429
429
  class="flex-1 w-full px-4 pt-2 pb-4 border-0 bg-transparent resize-none focus:outline-none text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 text-base leading-relaxed disabled:opacity-50 disabled:cursor-not-allowed"
430
430
  rows="1"
431
- style="max-height: 22.5rem; overflow-y: hidden;"
432
431
  disabled={isInputDisabled}
433
432
  oninput={handleTextareaInput}
434
433
  onkeydown={handleKeyDown}
@@ -122,9 +122,9 @@
122
122
  // Model Picker (existing logic)
123
123
  // ════════════════════════════════════════════
124
124
 
125
- // Track whether a chat has started (any user message in current session)
125
+ // Track whether a chat has started (any user message in current session, or session has history e.g. restored to initial)
126
126
  const hasStartedChat = $derived(
127
- sessionState.messages.some(m => m.type === 'user')
127
+ sessionState.messages.some(m => m.type === 'user') || sessionState.hasMessageHistory
128
128
  );
129
129
 
130
130
  // Engine lock: once chat starts, the engine is locked for this session.
@@ -45,10 +45,10 @@ export function useChatActions(params: ChatActionsParams) {
45
45
  // If in edit mode, restore to parent of edited message first
46
46
  if (editModeState.isEditing) {
47
47
  try {
48
- // Restore to parent of edited message (if exists)
49
- const restoreTargetId = editModeState.parentMessageId || editModeState.messageId;
50
-
51
- if (restoreTargetId && sessionState.currentSession?.id) {
48
+ if (sessionState.currentSession?.id) {
49
+ // Restore to parent of edited message (state before the edited message)
50
+ // When parentMessageId is null (editing first message), restore to initial state
51
+ const restoreTargetId = editModeState.parentMessageId || '__initial__';
52
52
  await snapshotService.restore(restoreTargetId, sessionState.currentSession.id);
53
53
  }
54
54
 
@@ -7,33 +7,25 @@ export function useTextareaResize() {
7
7
  messageText: string
8
8
  ) {
9
9
  if (textareaElement) {
10
- // Reset height to auto first to get accurate scrollHeight
10
+ // Hide overflow during measurement to prevent scrollbar from affecting width
11
+ textareaElement.style.overflowY = 'hidden';
12
+ // Reset height to auto to get accurate scrollHeight
11
13
  textareaElement.style.height = 'auto';
12
14
 
13
15
  // If content is empty or only whitespace, keep at minimum height
14
16
  if (!messageText || !messageText.trim()) {
15
- // Force single line height
16
- textareaElement.style.height = 'auto';
17
- textareaElement.style.overflowY = 'hidden';
18
17
  return;
19
18
  }
20
19
 
21
- // Calculate required height based on content
22
- const scrollHeight = textareaElement.scrollHeight + 7;
23
- const lineHeight = parseInt(getComputedStyle(textareaElement).lineHeight) || 24;
24
- const paddingTop = parseInt(getComputedStyle(textareaElement).paddingTop) || 0;
25
- const paddingBottom = parseInt(getComputedStyle(textareaElement).paddingBottom) || 0;
26
- const minHeight = lineHeight + paddingTop + paddingBottom;
20
+ // Measure content height and cap at max
21
+ const scrollHeight = textareaElement.scrollHeight;
22
+ const newHeight = Math.min(scrollHeight, MAX_HEIGHT_PX);
23
+ textareaElement.style.height = newHeight + 'px';
27
24
 
28
- const newHeight = Math.max(minHeight, scrollHeight);
29
-
30
- if (newHeight >= MAX_HEIGHT_PX) {
31
- textareaElement.style.height = '22.5rem';
32
- textareaElement.style.overflowY = 'auto';
33
- } else {
34
- textareaElement.style.height = newHeight / 16 + 'rem';
35
- textareaElement.style.overflowY = 'hidden';
36
- }
25
+ // Check actual overflow AFTER setting height to handle edge cases
26
+ // where collapsed measurement differs from rendered content height
27
+ textareaElement.style.overflowY =
28
+ textareaElement.scrollHeight > textareaElement.clientHeight ? 'auto' : 'hidden';
37
29
  }
38
30
  }
39
31
 
@@ -13,6 +13,7 @@
13
13
  import { snapshotService } from '$frontend/lib/services/snapshot/snapshot.service';
14
14
  import type { RestoreConflict, ConflictResolution } from '$frontend/lib/services/snapshot/snapshot.service';
15
15
  import type { TimelineResponse, GraphNode, GraphEdge, VersionGroup, AnimationState } from './timeline/types';
16
+ import ws from '$frontend/lib/utils/ws';
16
17
 
17
18
  let {
18
19
  isOpen = $bindable(false),
@@ -107,6 +108,19 @@
107
108
  }
108
109
  });
109
110
 
111
+ // Reload timeline when a snapshot is captured (stats become available after stream ends)
112
+ $effect(() => {
113
+ if (!isOpen) return;
114
+
115
+ const unsub = ws.on('snapshot:captured', (data: { chatSessionId: string }) => {
116
+ if (data.chatSessionId === sessionId && !processingAction && !animationState.isAnimating) {
117
+ loadTimeline();
118
+ }
119
+ });
120
+
121
+ return unsub;
122
+ });
123
+
110
124
  // Scroll to bottom on initial load
111
125
  $effect(() => {
112
126
  if (timelineData && graphNodes.length > 0 && scrollContainer && !hasScrolledToBottom) {
@@ -445,9 +459,7 @@
445
459
  type="warning"
446
460
  title="Restore Checkpoint"
447
461
  message={pendingNode
448
- ? `Are you sure you want to restore to this checkpoint?
449
- "${getTruncatedMessage(pendingNode.checkpoint.messageText)}"
450
- This will restore your files to this point within this session.`
462
+ ? `Are you sure you want to restore to this checkpoint?\n"${getTruncatedMessage(pendingNode.checkpoint.messageText)}"\nThis will restore your files to this point within this session.`
451
463
  : ''}
452
464
  confirmText="Restore"
453
465
  cancelText="Cancel"
@@ -21,6 +21,7 @@
21
21
 
22
22
  const pos = $derived(getInterpolatedPosition(node, animationState));
23
23
  const nodeClass = $derived(getInterpolatedNodeClass(node));
24
+ const isInitial = $derived(!!node.checkpoint.isInitial);
24
25
  </script>
25
26
 
26
27
  <!-- Node group -->
@@ -33,6 +34,7 @@
33
34
  aria-label={`${node.type === 'main' ? 'Checkpoint' : 'Version'} - ${node.checkpoint.messageText}`}
34
35
  >
35
36
  <title>{node.checkpoint.messageText}</title>
37
+
36
38
  <!-- Node circle -->
37
39
  <circle
38
40
  cx={pos.x}
@@ -84,25 +86,34 @@
84
86
  height={SIZE.labelHeight - 8}
85
87
  >
86
88
  <div class="flex flex-col h-full justify-center pointer-events-none">
87
- <!-- Timestamp and file stats in one line -->
88
- <div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 leading-tight">
89
- <span>{formatTime(node.checkpoint.timestamp)}</span>
90
- <span class="w-px h-3 bg-slate-300 dark:bg-slate-600"></span>
91
- <span class="flex items-center gap-0.5">
92
- <Icon name="lucide:file-text" class="w-2.5 h-2.5" />
93
- {node.checkpoint.filesChanged ?? 0}
94
- </span>
95
- <span class="text-green-600 dark:text-green-400">
96
- +{node.checkpoint.insertions ?? 0}
97
- </span>
98
- <span class="text-red-600 dark:text-red-400">
99
- -{node.checkpoint.deletions ?? 0}
100
- </span>
101
- </div>
102
- <!-- Message text below timestamp with auto truncation -->
103
- <div class="text-sm text-slate-900 dark:text-slate-100 leading-tight truncate mt-0.5">
104
- {node.checkpoint.messageText}
105
- </div>
89
+ {#if isInitial}
90
+ <div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 leading-tight">
91
+ <span>{formatTime(node.checkpoint.timestamp)}</span>
92
+ </div>
93
+ <div class="text-sm text-slate-900 dark:text-slate-100 leading-tight truncate mt-0.5">
94
+ Session Start
95
+ </div>
96
+ {:else}
97
+ <!-- Timestamp and file stats in one line -->
98
+ <div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 leading-tight">
99
+ <span>{formatTime(node.checkpoint.timestamp)}</span>
100
+ <span class="w-px h-3 bg-slate-300 dark:bg-slate-600"></span>
101
+ <span class="flex items-center gap-0.5">
102
+ <Icon name="lucide:file-text" class="w-2.5 h-2.5" />
103
+ {node.checkpoint.filesChanged ?? 0}
104
+ </span>
105
+ <span class="text-green-600 dark:text-green-400">
106
+ +{node.checkpoint.insertions ?? 0}
107
+ </span>
108
+ <span class="text-red-600 dark:text-red-400">
109
+ -{node.checkpoint.deletions ?? 0}
110
+ </span>
111
+ </div>
112
+ <!-- Message text below timestamp with auto truncation -->
113
+ <div class="text-sm text-slate-900 dark:text-slate-100 leading-tight truncate mt-0.5">
114
+ {node.checkpoint.messageText}
115
+ </div>
116
+ {/if}
106
117
  </div>
107
118
  </foreignObject>
108
119
  </g>
@@ -2,6 +2,9 @@
2
2
  * Timeline data structures and type definitions
3
3
  */
4
4
 
5
+ /** Sentinel ID for the "initial state" node (before any chat messages) */
6
+ export const INITIAL_NODE_ID = '__initial__';
7
+
5
8
  export interface CheckpointNode {
6
9
  id: string;
7
10
  messageId: string;
@@ -13,6 +16,7 @@ export interface CheckpointNode {
13
16
  isOrphaned: boolean;
14
17
  isCurrent: boolean;
15
18
  hasSnapshot: boolean;
19
+ isInitial?: boolean; // true for the "initial state" node
16
20
  senderName?: string | null;
17
21
  // File change statistics (git-like)
18
22
  filesChanged?: number;
@@ -30,11 +30,13 @@
30
30
  if (!textareaEl) return;
31
31
  // Reset to single line to measure content
32
32
  textareaEl.style.height = 'auto';
33
- // Line height is ~20px for text-xs, so 5 lines max = 100px
33
+ // Line height is ~20px for text-sm, so 5 lines max = 100px
34
34
  const lineHeight = 20;
35
35
  const maxHeight = lineHeight * 5;
36
36
  const scrollHeight = textareaEl.scrollHeight;
37
- textareaEl.style.height = Math.min(scrollHeight, maxHeight) + 'px';
37
+ const newHeight = Math.min(scrollHeight, maxHeight);
38
+ textareaEl.style.height = newHeight + 'px';
39
+ textareaEl.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
38
40
  }
39
41
 
40
42
  function handleInput() {
@@ -48,9 +50,9 @@
48
50
  bind:this={textareaEl}
49
51
  bind:value={commitMessage}
50
52
  placeholder="Commit message..."
51
- class="w-full px-2.5 py-2 text-sm bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 resize-none outline-none focus:border-violet-500/40 focus:ring-1 focus:ring-violet-500/20 transition-colors overflow-hidden"
53
+ class="w-full px-2.5 py-2 text-sm bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 resize-none outline-none focus:ring-1 focus:ring-violet-500 transition-colors"
52
54
  rows="1"
53
- style="height: 27px"
55
+ style="overflow-y: hidden;"
54
56
  onkeydown={handleKeydown}
55
57
  oninput={handleInput}
56
58
  disabled={isCommitting}
@@ -71,7 +71,7 @@
71
71
  }
72
72
 
73
73
  try {
74
- const messages = await ws.http('messages:list', { session_id: sessionId });
74
+ const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
75
75
 
76
76
  const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
77
77
  let title = 'New Conversation';
@@ -79,7 +79,7 @@
79
79
 
80
80
  try {
81
81
  // Get messages from current HEAD checkpoint (active branch only)
82
- const messages = await ws.http('messages:list', { session_id: sessionId });
82
+ const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
83
83
 
84
84
  // Get title from first user message in current HEAD
85
85
  const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
@@ -277,7 +277,7 @@
277
277
  >
278
278
  <Icon name="lucide:history" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
279
279
  </button>
280
- {#if sessionState.messages.length > 0}
280
+ {#if sessionState.messages.length > 0 || sessionState.hasMessageHistory}
281
281
  <button
282
282
  type="button"
283
283
  class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
@@ -24,10 +24,11 @@
24
24
 
25
25
  const { showMobileHeader = false }: Props = $props();
26
26
 
27
- // Welcome state - don't show during restoration
27
+ // Welcome state - don't show during restoration or when session has history (restored to initial)
28
28
  const isWelcomeState = $derived(
29
29
  sessionState.messages.length === 0 &&
30
- !appState.isRestoring
30
+ !appState.isRestoring &&
31
+ !sessionState.hasMessageHistory
31
32
  );
32
33
 
33
34
  // Check if we should show input (not during restoration)