@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.
- package/backend/index.ts +5 -1
- package/backend/lib/chat/stream-manager.ts +4 -1
- package/backend/lib/database/queries/session-queries.ts +13 -0
- package/backend/lib/database/queries/snapshot-queries.ts +1 -1
- package/backend/lib/engine/adapters/opencode/server.ts +8 -0
- package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
- package/backend/lib/snapshot/helpers.ts +22 -49
- package/backend/lib/snapshot/snapshot-service.ts +148 -83
- package/backend/ws/chat/stream.ts +13 -0
- package/backend/ws/snapshot/restore.ts +111 -12
- package/backend/ws/snapshot/timeline.ts +56 -29
- package/bin/clopen.ts +17 -1
- package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
- package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
- package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
- package/frontend/lib/components/git/CommitForm.svelte +6 -4
- package/frontend/lib/components/history/HistoryModal.svelte +1 -1
- package/frontend/lib/components/history/HistoryView.svelte +1 -1
- package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
- package/frontend/lib/stores/ui/update.svelte.ts +0 -12
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
- 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
|
-
|
|
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
|
-
|
|
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',
|
|
87
|
-
debug.log('snapshot', `Target
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
65
|
-
|
|
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.
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
22
|
-
const scrollHeight = textareaElement.scrollHeight
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
textareaElement.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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="
|
|
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
|
-
|
|
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)
|