dsh-plugin-message-edit 1.0.0 → 1.1.0

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/lib/tree-logic.js CHANGED
@@ -1,343 +1,343 @@
1
- // Pure helpers shared by the host (where to hang a new branch) and tests.
2
- // The client copies `ringFor` and `buildTurnTree` — keep the two in sync.
3
-
4
- /**
5
- * Walk up through versions of the same turn and stop at the first session
6
- * that is NOT itself an edit of that turn. New siblings hang off that node
7
- * so repeated edits of one message fan out instead of chaining.
8
- */
9
- export function attachParentId(nodesById, sourceId, targetTurn) {
10
- let id = sourceId;
11
- const seen = new Set();
12
- while (id && !seen.has(id)) {
13
- seen.add(id);
14
- const node = nodesById.get(id);
15
- if (!node) return id;
16
- if (node.targetTurn !== targetTurn) return id;
17
- if (!node.parentSessionId) return id;
18
- id = node.parentSessionId;
19
- }
20
- return sourceId;
21
- }
22
-
23
- /**
24
- * The ‹ › ring for the message at `turn` while viewing `sessionId`.
25
- *
26
- * Collects the original message's session plus every version of that same
27
- * turn, even when older builds chained them (A→B→C) instead of fanning
28
- * out as siblings.
29
- */
30
- export function ringFor(versions, sessionId, turn) {
31
- if (!versions) return null;
32
- const byId = new Map(versions.map((v) => [v.sessionId, v]));
33
- let cursor = byId.get(sessionId);
34
- if (!cursor) return null;
35
- while (cursor.parentSessionId && typeof cursor.targetTurn === 'number' && cursor.targetTurn > turn) {
36
- const parent = byId.get(cursor.parentSessionId);
37
- if (!parent) break;
38
- cursor = parent;
39
- }
40
- let fork = cursor;
41
- while (fork.parentSessionId && typeof fork.targetTurn === 'number' && fork.targetTurn === turn) {
42
- const parent = byId.get(fork.parentSessionId);
43
- if (!parent) break;
44
- fork = parent;
45
- }
46
- const walksToFork = (start) => {
47
- let x = start;
48
- const seen = new Set();
49
- while (x && !seen.has(x.sessionId)) {
50
- seen.add(x.sessionId);
51
- if (x.sessionId === fork.sessionId) return true;
52
- if (typeof x.targetTurn !== 'number' || x.targetTurn !== turn) return false;
53
- x = x.parentSessionId ? byId.get(x.parentSessionId) : null;
54
- }
55
- return false;
56
- };
57
- // A deleted (ghost) version still anchors the fork and still bridges the
58
- // parent walks above, but it cannot be opened, so it never appears among
59
- // the alternatives: the ring renumbers over the survivors.
60
- const alternatives = versions
61
- .filter((v) => !v.deleted && (v.sessionId === fork.sessionId || (v.targetTurn === turn && walksToFork(v))))
62
- .sort((a, b) => a.createdAt - b.createdAt || String(a.sessionId).localeCompare(String(b.sessionId)));
63
- if (alternatives.length < 2) return null;
64
- let index = alternatives.findIndex((v) => v.sessionId === cursor.sessionId);
65
- if (index === -1) index = alternatives.findIndex((v) => v.sessionId === sessionId);
66
- if (index === -1) index = 0;
67
- return { alternatives, index };
68
- }
69
-
70
- /**
71
- * The family root for `sessionId`: walk parentSessionId links until one has
72
- * none. Used to key the remembered active path, so every branch of a
73
- * conversation shares one entry.
74
- *
75
- * Mirrors rootOf() in plugin.client.js; kept here so it is testable.
76
- */
77
- export function rootOf(versions, sessionId) {
78
- if (!versions || sessionId === undefined) return undefined;
79
- const byId = new Map(versions.map((v) => [v.sessionId, v]));
80
- let cursor = byId.get(sessionId);
81
- if (!cursor) return undefined;
82
- const seen = new Set();
83
- while (cursor.parentSessionId && !seen.has(cursor.sessionId)) {
84
- seen.add(cursor.sessionId);
85
- const parent = byId.get(cursor.parentSessionId);
86
- if (!parent) break;
87
- cursor = parent;
88
- }
89
- return cursor.sessionId;
90
- }
91
-
92
- /**
93
- * Reconstruct a session's ancestor chain from its own event log, bridging
94
- * ancestors whose sessions were deleted.
95
- *
96
- * A version's seed inherits its parent's events, which include the parent's
97
- * own `message-tree/version` marker — and, transitively, the markers of every
98
- * ancestor back to the root (the root has none). So `markers`, the
99
- * message-tree/version events of ONE session's full log in seq order, lays
100
- * out the ancestry: the last marker is the session's own, the one before it
101
- * belongs to the parent, and so on; each marker's `inverse.sessionId` names
102
- * its owner's parent. The information survives deletion because it lives in
103
- * the descendant's log, not the ancestor's.
104
- *
105
- * @param parentId - the session's `header.parentSession`.
106
- * @param markers - message-tree/version events of the session's log, seq order.
107
- * @returns ancestors nearest-first, each `{ sessionId, marker? }`; the last
108
- * entry is the family root (no marker). Empty for a root session.
109
- */
110
- export function ancestorChainFromLog(parentId, markers) {
111
- const chain = [];
112
- const seen = new Set();
113
- let id = parentId;
114
- let at = markers.length - 2; // markers.at(-1) is the session's own
115
- while (id !== undefined && id !== null && !seen.has(id)) {
116
- seen.add(id);
117
- const marker = at >= 0 ? markers[at] : undefined;
118
- chain.push(marker === undefined ? { sessionId: id } : { sessionId: id, marker });
119
- id = marker === undefined ? undefined
120
- : marker.data && marker.data.inverse ? marker.data.inverse.sessionId : undefined;
121
- at -= 1;
122
- }
123
- return chain;
124
- }
125
-
126
- /**
127
- * Flatten one conversation family from `rootId` downward, bridging deleted
128
- * sessions.
129
- *
130
- * `entries` is every node known to exist — surviving session headers plus
131
- * ghost decorations recovered from descendants' logs — as
132
- * `{ id, parentId?, createdAt, ghost? }`. Edges come from `parentId` and are
133
- * keyed by the raw id, so a surviving child still hangs under its deleted
134
- * parent. A `parentId` with no entry of its own gets a bare ghost entry, so
135
- * a family fragmented by deletion stays one tree with tombstones at the
136
- * holes.
137
- *
138
- * @returns `[{ entry, depth }]` in depth-first order, siblings by createdAt.
139
- */
140
- export function collectFamily(rootId, entries) {
141
- const byId = new Map();
142
- for (const entry of entries) byId.set(entry.id, entry);
143
- for (const entry of entries) {
144
- if (entry.parentId !== undefined && !byId.has(entry.parentId)) {
145
- byId.set(entry.parentId, { id: entry.parentId, ghost: true, createdAt: 0 });
146
- }
147
- }
148
- if (!byId.has(rootId)) byId.set(rootId, { id: rootId, ghost: true, createdAt: 0 });
149
- const childrenByParent = new Map();
150
- for (const entry of byId.values()) {
151
- if (entry.parentId === undefined) continue;
152
- const list = childrenByParent.get(entry.parentId) ?? [];
153
- list.push(entry);
154
- childrenByParent.set(entry.parentId, list);
155
- }
156
- for (const list of childrenByParent.values()) {
157
- list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.id).localeCompare(String(b.id)));
158
- }
159
- const out = [];
160
- const seen = new Set();
161
- const visit = (id, depth) => {
162
- if (seen.has(id)) return;
163
- seen.add(id);
164
- const entry = byId.get(id);
165
- if (entry === undefined) return;
166
- out.push({ entry, depth });
167
- for (const child of childrenByParent.get(id) ?? []) visit(child.id, depth + 1);
168
- };
169
- visit(rootId, 0);
170
- return out;
171
- }
172
-
173
- /**
174
- * Project conversation family versions into a turn-level branching tree.
175
- *
176
- * Each turn in a session becomes a distinct node in the graph:
177
- * - Root session's Turn 1 hangs off the Root Conversation Node.
178
- * - Sibling edits of Turn K hang off Turn K-1 of their parent (or Root if K === 1).
179
- * - Subsequent turns (Turn K+1, K+2...) in the same session hang off their predecessor in that session.
180
- *
181
- * @param versions - The versions array from the host /message-tree payload.
182
- * @param currentSessionId - The session currently being viewed.
183
- * @returns Array of turn nodes with `id`, `sessionId`, `turn`, `parentId`, `title`, `text`, `time`, `onCurrentPath`, etc.
184
- */
185
- export function buildTurnTree(versions, currentSessionId) {
186
- if (!versions || versions.length === 0) return [];
187
- const byId = new Map(versions.map((v) => [v.sessionId, v]));
188
-
189
- // Find family root session
190
- let rootVersion = versions.find((v) => !v.parentSessionId);
191
- if (!rootVersion) {
192
- const rootId = rootOf(versions, currentSessionId) ?? versions[0].sessionId;
193
- rootVersion = byId.get(rootId) ?? versions[0];
194
- }
195
- const rootSessionId = rootVersion.sessionId;
196
-
197
- // Active path of sessions from currentSessionId up to root
198
- const activeSessionPath = new Set();
199
- let cursor = byId.get(currentSessionId);
200
- const seenSessions = new Set();
201
- while (cursor && !seenSessions.has(cursor.sessionId)) {
202
- seenSessions.add(cursor.sessionId);
203
- activeSessionPath.add(cursor.sessionId);
204
- cursor = cursor.parentSessionId ? byId.get(cursor.parentSessionId) : null;
205
- }
206
-
207
- const nodes = [];
208
- const rootNodeId = `${rootSessionId}#root`;
209
- const nodeMap = new Map();
210
-
211
- // 1. Root Conversation Anchor Node
212
- const rootNode = {
213
- id: rootNodeId,
214
- sessionId: rootSessionId,
215
- turn: 0,
216
- isRoot: true,
217
- time: rootVersion.createdAt ?? 0,
218
- current: currentSessionId === rootSessionId && (!rootVersion.turns || rootVersion.turns.length === 0),
219
- onCurrentPath: true,
220
- deleted: !!rootVersion.deleted,
221
- archived: !!rootVersion.archived,
222
- };
223
- nodes.push(rootNode);
224
- nodeMap.set(rootNodeId, rootNode);
225
-
226
- function findParentTurnNodeId(v, turn) {
227
- if (!v.parentSessionId) {
228
- if (turn === 1) return rootNodeId;
229
- return `${v.sessionId}#t${turn - 1}`;
230
- }
231
- if (turn === v.targetTurn) {
232
- if (v.targetTurn === 1) return rootNodeId;
233
- return `${v.parentSessionId}#t${v.targetTurn - 1}`;
234
- }
235
- return `${v.sessionId}#t${turn - 1}`;
236
- }
237
-
238
- for (const v of versions) {
239
- const isCurrentSession = v.sessionId === currentSessionId;
240
- const turns = Array.isArray(v.turns) && v.turns.length > 0 ? v.turns : [];
241
-
242
- if (!v.parentSessionId) {
243
- for (const t of turns) {
244
- const turnNum = t.turn;
245
- const turnNodeId = `${v.sessionId}#t${turnNum}`;
246
- const parentId = findParentTurnNodeId(v, turnNum);
247
- const node = {
248
- id: turnNodeId,
249
- sessionId: v.sessionId,
250
- turn: turnNum,
251
- parentId,
252
- time: t.time ?? v.createdAt,
253
- text: t.text ?? '',
254
- current: isCurrentSession,
255
- onCurrentPath: false,
256
- deleted: !!v.deleted,
257
- archived: !!v.archived,
258
- };
259
- nodes.push(node);
260
- nodeMap.set(turnNodeId, node);
261
- }
262
- } else {
263
- const targetTurn = typeof v.targetTurn === 'number' ? v.targetTurn : 1;
264
- const ownTurns = turns.filter((t) => t.turn >= targetTurn);
265
-
266
- if (ownTurns.length === 0) {
267
- const turnNodeId = `${v.sessionId}#t${targetTurn}`;
268
- const parentId = findParentTurnNodeId(v, targetTurn);
269
- const node = {
270
- id: turnNodeId,
271
- sessionId: v.sessionId,
272
- turn: targetTurn,
273
- parentId,
274
- operation: v.operation ?? 'edit',
275
- text: v.after ?? v.before ?? '',
276
- time: v.createdAt ?? 0,
277
- current: isCurrentSession,
278
- onCurrentPath: false,
279
- deleted: !!v.deleted,
280
- archived: !!v.archived,
281
- };
282
- nodes.push(node);
283
- nodeMap.set(turnNodeId, node);
284
- } else {
285
- for (const t of ownTurns) {
286
- const turnNum = t.turn;
287
- const turnNodeId = `${v.sessionId}#t${turnNum}`;
288
- const parentId = findParentTurnNodeId(v, turnNum);
289
- const isForkTurn = turnNum === targetTurn;
290
- const node = {
291
- id: turnNodeId,
292
- sessionId: v.sessionId,
293
- turn: turnNum,
294
- parentId,
295
- ...isForkTurn && v.operation ? { operation: v.operation } : {},
296
- text: t.text ?? (isForkTurn ? (v.after ?? v.before ?? '') : ''),
297
- time: t.time ?? v.createdAt,
298
- current: isCurrentSession,
299
- onCurrentPath: false,
300
- deleted: !!v.deleted,
301
- archived: !!v.archived,
302
- };
303
- nodes.push(node);
304
- nodeMap.set(turnNodeId, node);
305
- }
306
- }
307
- }
308
- }
309
-
310
- // Ensure any dangling parentId points to rootNodeId
311
- const allIds = new Set(nodes.map((n) => n.id));
312
- for (const n of nodes) {
313
- if (n.parentId && !allIds.has(n.parentId)) {
314
- n.parentId = rootNodeId;
315
- }
316
- }
317
-
318
- // Compute active path by walking parentId links from the latest turn of currentSessionId
319
- const activePathIds = new Set();
320
- let latestNode = null;
321
- for (const n of nodes) {
322
- if (n.sessionId === currentSessionId) {
323
- if (!latestNode || (n.turn ?? 0) >= (latestNode.turn ?? 0)) {
324
- latestNode = n;
325
- }
326
- }
327
- }
328
- let pathCursor = latestNode || nodes[0];
329
- const seenPath = new Set();
330
- while (pathCursor && !seenPath.has(pathCursor.id)) {
331
- seenPath.add(pathCursor.id);
332
- activePathIds.add(pathCursor.id);
333
- pathCursor = pathCursor.parentId ? nodeMap.get(pathCursor.parentId) : null;
334
- }
335
- activePathIds.add(rootNodeId);
336
-
337
- for (const n of nodes) {
338
- n.onCurrentPath = activePathIds.has(n.id);
339
- }
340
-
341
- return nodes;
342
- }
343
-
1
+ // Pure helpers shared by the host (where to hang a new branch) and tests.
2
+ // The client copies `ringFor` and `buildTurnTree` — keep the two in sync.
3
+
4
+ /**
5
+ * Walk up through versions of the same turn and stop at the first session
6
+ * that is NOT itself an edit of that turn. New siblings hang off that node
7
+ * so repeated edits of one message fan out instead of chaining.
8
+ */
9
+ export function attachParentId(nodesById, sourceId, targetTurn) {
10
+ let id = sourceId;
11
+ const seen = new Set();
12
+ while (id && !seen.has(id)) {
13
+ seen.add(id);
14
+ const node = nodesById.get(id);
15
+ if (!node) return id;
16
+ if (node.targetTurn !== targetTurn) return id;
17
+ if (!node.parentSessionId) return id;
18
+ id = node.parentSessionId;
19
+ }
20
+ return sourceId;
21
+ }
22
+
23
+ /**
24
+ * The ‹ › ring for the message at `turn` while viewing `sessionId`.
25
+ *
26
+ * Collects the original message's session plus every version of that same
27
+ * turn, even when older builds chained them (A→B→C) instead of fanning
28
+ * out as siblings.
29
+ */
30
+ export function ringFor(versions, sessionId, turn) {
31
+ if (!versions) return null;
32
+ const byId = new Map(versions.map((v) => [v.sessionId, v]));
33
+ let cursor = byId.get(sessionId);
34
+ if (!cursor) return null;
35
+ while (cursor.parentSessionId && typeof cursor.targetTurn === 'number' && cursor.targetTurn > turn) {
36
+ const parent = byId.get(cursor.parentSessionId);
37
+ if (!parent) break;
38
+ cursor = parent;
39
+ }
40
+ let fork = cursor;
41
+ while (fork.parentSessionId && typeof fork.targetTurn === 'number' && fork.targetTurn === turn) {
42
+ const parent = byId.get(fork.parentSessionId);
43
+ if (!parent) break;
44
+ fork = parent;
45
+ }
46
+ const walksToFork = (start) => {
47
+ let x = start;
48
+ const seen = new Set();
49
+ while (x && !seen.has(x.sessionId)) {
50
+ seen.add(x.sessionId);
51
+ if (x.sessionId === fork.sessionId) return true;
52
+ if (typeof x.targetTurn !== 'number' || x.targetTurn !== turn) return false;
53
+ x = x.parentSessionId ? byId.get(x.parentSessionId) : null;
54
+ }
55
+ return false;
56
+ };
57
+ // A deleted (ghost) version still anchors the fork and still bridges the
58
+ // parent walks above, but it cannot be opened, so it never appears among
59
+ // the alternatives: the ring renumbers over the survivors.
60
+ const alternatives = versions
61
+ .filter((v) => !v.deleted && (v.sessionId === fork.sessionId || (v.targetTurn === turn && walksToFork(v))))
62
+ .sort((a, b) => a.createdAt - b.createdAt || String(a.sessionId).localeCompare(String(b.sessionId)));
63
+ if (alternatives.length < 2) return null;
64
+ let index = alternatives.findIndex((v) => v.sessionId === cursor.sessionId);
65
+ if (index === -1) index = alternatives.findIndex((v) => v.sessionId === sessionId);
66
+ if (index === -1) index = 0;
67
+ return { alternatives, index };
68
+ }
69
+
70
+ /**
71
+ * The family root for `sessionId`: walk parentSessionId links until one has
72
+ * none. Used to key the remembered active path, so every branch of a
73
+ * conversation shares one entry.
74
+ *
75
+ * Mirrors rootOf() in plugin.client.js; kept here so it is testable.
76
+ */
77
+ export function rootOf(versions, sessionId) {
78
+ if (!versions || sessionId === undefined) return undefined;
79
+ const byId = new Map(versions.map((v) => [v.sessionId, v]));
80
+ let cursor = byId.get(sessionId);
81
+ if (!cursor) return undefined;
82
+ const seen = new Set();
83
+ while (cursor.parentSessionId && !seen.has(cursor.sessionId)) {
84
+ seen.add(cursor.sessionId);
85
+ const parent = byId.get(cursor.parentSessionId);
86
+ if (!parent) break;
87
+ cursor = parent;
88
+ }
89
+ return cursor.sessionId;
90
+ }
91
+
92
+ /**
93
+ * Reconstruct a session's ancestor chain from its own event log, bridging
94
+ * ancestors whose sessions were deleted.
95
+ *
96
+ * A version's seed inherits its parent's events, which include the parent's
97
+ * own `message-tree/version` marker — and, transitively, the markers of every
98
+ * ancestor back to the root (the root has none). So `markers`, the
99
+ * message-tree/version events of ONE session's full log in seq order, lays
100
+ * out the ancestry: the last marker is the session's own, the one before it
101
+ * belongs to the parent, and so on; each marker's `inverse.sessionId` names
102
+ * its owner's parent. The information survives deletion because it lives in
103
+ * the descendant's log, not the ancestor's.
104
+ *
105
+ * @param parentId - the session's `header.parentSession`.
106
+ * @param markers - message-tree/version events of the session's log, seq order.
107
+ * @returns ancestors nearest-first, each `{ sessionId, marker? }`; the last
108
+ * entry is the family root (no marker). Empty for a root session.
109
+ */
110
+ export function ancestorChainFromLog(parentId, markers) {
111
+ const chain = [];
112
+ const seen = new Set();
113
+ let id = parentId;
114
+ let at = markers.length - 2; // markers.at(-1) is the session's own
115
+ while (id !== undefined && id !== null && !seen.has(id)) {
116
+ seen.add(id);
117
+ const marker = at >= 0 ? markers[at] : undefined;
118
+ chain.push(marker === undefined ? { sessionId: id } : { sessionId: id, marker });
119
+ id = marker === undefined ? undefined
120
+ : marker.data && marker.data.inverse ? marker.data.inverse.sessionId : undefined;
121
+ at -= 1;
122
+ }
123
+ return chain;
124
+ }
125
+
126
+ /**
127
+ * Flatten one conversation family from `rootId` downward, bridging deleted
128
+ * sessions.
129
+ *
130
+ * `entries` is every node known to exist — surviving session headers plus
131
+ * ghost decorations recovered from descendants' logs — as
132
+ * `{ id, parentId?, createdAt, ghost? }`. Edges come from `parentId` and are
133
+ * keyed by the raw id, so a surviving child still hangs under its deleted
134
+ * parent. A `parentId` with no entry of its own gets a bare ghost entry, so
135
+ * a family fragmented by deletion stays one tree with tombstones at the
136
+ * holes.
137
+ *
138
+ * @returns `[{ entry, depth }]` in depth-first order, siblings by createdAt.
139
+ */
140
+ export function collectFamily(rootId, entries) {
141
+ const byId = new Map();
142
+ for (const entry of entries) byId.set(entry.id, entry);
143
+ for (const entry of entries) {
144
+ if (entry.parentId !== undefined && !byId.has(entry.parentId)) {
145
+ byId.set(entry.parentId, { id: entry.parentId, ghost: true, createdAt: 0 });
146
+ }
147
+ }
148
+ if (!byId.has(rootId)) byId.set(rootId, { id: rootId, ghost: true, createdAt: 0 });
149
+ const childrenByParent = new Map();
150
+ for (const entry of byId.values()) {
151
+ if (entry.parentId === undefined) continue;
152
+ const list = childrenByParent.get(entry.parentId) ?? [];
153
+ list.push(entry);
154
+ childrenByParent.set(entry.parentId, list);
155
+ }
156
+ for (const list of childrenByParent.values()) {
157
+ list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.id).localeCompare(String(b.id)));
158
+ }
159
+ const out = [];
160
+ const seen = new Set();
161
+ const visit = (id, depth) => {
162
+ if (seen.has(id)) return;
163
+ seen.add(id);
164
+ const entry = byId.get(id);
165
+ if (entry === undefined) return;
166
+ out.push({ entry, depth });
167
+ for (const child of childrenByParent.get(id) ?? []) visit(child.id, depth + 1);
168
+ };
169
+ visit(rootId, 0);
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * Project conversation family versions into a turn-level branching tree.
175
+ *
176
+ * Each turn in a session becomes a distinct node in the graph:
177
+ * - Root session's Turn 1 hangs off the Root Conversation Node.
178
+ * - Sibling edits of Turn K hang off Turn K-1 of their parent (or Root if K === 1).
179
+ * - Subsequent turns (Turn K+1, K+2...) in the same session hang off their predecessor in that session.
180
+ *
181
+ * @param versions - The versions array from the host /message-tree payload.
182
+ * @param currentSessionId - The session currently being viewed.
183
+ * @returns Array of turn nodes with `id`, `sessionId`, `turn`, `parentId`, `title`, `text`, `time`, `onCurrentPath`, etc.
184
+ */
185
+ export function buildTurnTree(versions, currentSessionId) {
186
+ if (!versions || versions.length === 0) return [];
187
+ const byId = new Map(versions.map((v) => [v.sessionId, v]));
188
+
189
+ // Find family root session
190
+ let rootVersion = versions.find((v) => !v.parentSessionId);
191
+ if (!rootVersion) {
192
+ const rootId = rootOf(versions, currentSessionId) ?? versions[0].sessionId;
193
+ rootVersion = byId.get(rootId) ?? versions[0];
194
+ }
195
+ const rootSessionId = rootVersion.sessionId;
196
+
197
+ // Active path of sessions from currentSessionId up to root
198
+ const activeSessionPath = new Set();
199
+ let cursor = byId.get(currentSessionId);
200
+ const seenSessions = new Set();
201
+ while (cursor && !seenSessions.has(cursor.sessionId)) {
202
+ seenSessions.add(cursor.sessionId);
203
+ activeSessionPath.add(cursor.sessionId);
204
+ cursor = cursor.parentSessionId ? byId.get(cursor.parentSessionId) : null;
205
+ }
206
+
207
+ const nodes = [];
208
+ const rootNodeId = `${rootSessionId}#root`;
209
+ const nodeMap = new Map();
210
+
211
+ // 1. Root Conversation Anchor Node
212
+ const rootNode = {
213
+ id: rootNodeId,
214
+ sessionId: rootSessionId,
215
+ turn: 0,
216
+ isRoot: true,
217
+ time: rootVersion.createdAt ?? 0,
218
+ current: currentSessionId === rootSessionId && (!rootVersion.turns || rootVersion.turns.length === 0),
219
+ onCurrentPath: true,
220
+ deleted: !!rootVersion.deleted,
221
+ archived: !!rootVersion.archived,
222
+ };
223
+ nodes.push(rootNode);
224
+ nodeMap.set(rootNodeId, rootNode);
225
+
226
+ function findParentTurnNodeId(v, turn) {
227
+ if (!v.parentSessionId) {
228
+ if (turn === 1) return rootNodeId;
229
+ return `${v.sessionId}#t${turn - 1}`;
230
+ }
231
+ if (turn === v.targetTurn) {
232
+ if (v.targetTurn === 1) return rootNodeId;
233
+ return `${v.parentSessionId}#t${v.targetTurn - 1}`;
234
+ }
235
+ return `${v.sessionId}#t${turn - 1}`;
236
+ }
237
+
238
+ for (const v of versions) {
239
+ const isCurrentSession = v.sessionId === currentSessionId;
240
+ const turns = Array.isArray(v.turns) && v.turns.length > 0 ? v.turns : [];
241
+
242
+ if (!v.parentSessionId) {
243
+ for (const t of turns) {
244
+ const turnNum = t.turn;
245
+ const turnNodeId = `${v.sessionId}#t${turnNum}`;
246
+ const parentId = findParentTurnNodeId(v, turnNum);
247
+ const node = {
248
+ id: turnNodeId,
249
+ sessionId: v.sessionId,
250
+ turn: turnNum,
251
+ parentId,
252
+ time: t.time ?? v.createdAt,
253
+ text: t.text ?? '',
254
+ current: isCurrentSession,
255
+ onCurrentPath: false,
256
+ deleted: !!v.deleted,
257
+ archived: !!v.archived,
258
+ };
259
+ nodes.push(node);
260
+ nodeMap.set(turnNodeId, node);
261
+ }
262
+ } else {
263
+ const targetTurn = typeof v.targetTurn === 'number' ? v.targetTurn : 1;
264
+ const ownTurns = turns.filter((t) => t.turn >= targetTurn);
265
+
266
+ if (ownTurns.length === 0) {
267
+ const turnNodeId = `${v.sessionId}#t${targetTurn}`;
268
+ const parentId = findParentTurnNodeId(v, targetTurn);
269
+ const node = {
270
+ id: turnNodeId,
271
+ sessionId: v.sessionId,
272
+ turn: targetTurn,
273
+ parentId,
274
+ operation: v.operation ?? 'edit',
275
+ text: v.after ?? v.before ?? '',
276
+ time: v.createdAt ?? 0,
277
+ current: isCurrentSession,
278
+ onCurrentPath: false,
279
+ deleted: !!v.deleted,
280
+ archived: !!v.archived,
281
+ };
282
+ nodes.push(node);
283
+ nodeMap.set(turnNodeId, node);
284
+ } else {
285
+ for (const t of ownTurns) {
286
+ const turnNum = t.turn;
287
+ const turnNodeId = `${v.sessionId}#t${turnNum}`;
288
+ const parentId = findParentTurnNodeId(v, turnNum);
289
+ const isForkTurn = turnNum === targetTurn;
290
+ const node = {
291
+ id: turnNodeId,
292
+ sessionId: v.sessionId,
293
+ turn: turnNum,
294
+ parentId,
295
+ ...isForkTurn && v.operation ? { operation: v.operation } : {},
296
+ text: t.text ?? (isForkTurn ? (v.after ?? v.before ?? '') : ''),
297
+ time: t.time ?? v.createdAt,
298
+ current: isCurrentSession,
299
+ onCurrentPath: false,
300
+ deleted: !!v.deleted,
301
+ archived: !!v.archived,
302
+ };
303
+ nodes.push(node);
304
+ nodeMap.set(turnNodeId, node);
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ // Ensure any dangling parentId points to rootNodeId
311
+ const allIds = new Set(nodes.map((n) => n.id));
312
+ for (const n of nodes) {
313
+ if (n.parentId && !allIds.has(n.parentId)) {
314
+ n.parentId = rootNodeId;
315
+ }
316
+ }
317
+
318
+ // Compute active path by walking parentId links from the latest turn of currentSessionId
319
+ const activePathIds = new Set();
320
+ let latestNode = null;
321
+ for (const n of nodes) {
322
+ if (n.sessionId === currentSessionId) {
323
+ if (!latestNode || (n.turn ?? 0) >= (latestNode.turn ?? 0)) {
324
+ latestNode = n;
325
+ }
326
+ }
327
+ }
328
+ let pathCursor = latestNode || nodes[0];
329
+ const seenPath = new Set();
330
+ while (pathCursor && !seenPath.has(pathCursor.id)) {
331
+ seenPath.add(pathCursor.id);
332
+ activePathIds.add(pathCursor.id);
333
+ pathCursor = pathCursor.parentId ? nodeMap.get(pathCursor.parentId) : null;
334
+ }
335
+ activePathIds.add(rootNodeId);
336
+
337
+ for (const n of nodes) {
338
+ n.onCurrentPath = activePathIds.has(n.id);
339
+ }
340
+
341
+ return nodes;
342
+ }
343
+