dsh-treecom 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,4171 @@
1
+ /**
2
+ * treecom — Client half.
3
+ *
4
+ * One visible conversation, a tree of its turns. The tree is the only
5
+ * navigation: a leaf click opens that branch, any other click forks at that
6
+ * turn and lands in the new child. The underlying sessions are DSH's normal
7
+ * fork sessions; the panel keeps exactly one of them in the sidebar.
8
+ *
9
+ * Plain JS, React from the page's module table, no build step.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: 'treecom',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const h = React.createElement
16
+ const { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } = React
17
+ let createPortal = null
18
+ try {
19
+ const ReactDOM = require('react-dom')
20
+ if (ReactDOM !== null && typeof ReactDOM.createPortal === 'function') createPortal = ReactDOM.createPortal
21
+ } catch (error) {
22
+ // The smoke test does not mount a DOM; the real page always has react-dom.
23
+ console.warn('[treecom] react-dom is unavailable; hover details are disabled', error)
24
+ }
25
+
26
+ const ROUTE = '/treecom'
27
+ const NS = 'treecom'
28
+ const VIEW_ID = 'treecom'
29
+ const DETAIL_CACHE_MS = 10_000
30
+
31
+ /* --------------------------------------------------------- locale -- */
32
+
33
+ const ZH = {
34
+ 'panel.title': '对话树',
35
+ 'panel.hint': '点击节点跳回该轮;悬停查看详情与操作',
36
+ 'panel.empty': '还没有分叉。继续提问,树上会自动长出节点。',
37
+ 'panel.loading': '正在读取对话树…',
38
+ 'panel.error': '读取对话树失败',
39
+ 'panel.refresh': '刷新',
40
+ 'panel.refreshed': '刷新成功',
41
+ 'panel.refreshFailed': '刷新失败:{message}',
42
+ 'panel.allArchived': '已归档的分支不显示在树里;在左侧取消归档即可恢复。',
43
+ 'panel.turns': '{n} 轮',
44
+ 'panel.branches': '{n} 条分支',
45
+ 'panel.partial': '有分支暂时读不到',
46
+ 'panel.currentTree': '当前对话树',
47
+ 'panel.allTrees': '全部对话树',
48
+ 'panel.allTreesHint': '当前文件夹中的全部对话树',
49
+ 'panel.allTreesEmpty': '这个文件夹里还没有对话树。',
50
+ 'panel.treeUnavailable': '这棵树不在当前对话范围内。',
51
+ 'panel.currentBadge': '当前',
52
+ 'panel.promotedBadge': '新树',
53
+ 'node.promote': '设为根节点',
54
+ 'node.promoteDrop': '拖到这里,设为新树根',
55
+ 'notice.promoted': '已创建新的对话树',
56
+ 'node.youAreHere': '你在这里',
57
+ 'node.blank': '新分支',
58
+ 'node.waiting': '等待提问',
59
+ 'node.askPlaceholder': '输入问题,Enter 发送 · Shift+Enter 换行',
60
+ 'node.askSend': '发送',
61
+ 'node.asking': '发送中…',
62
+ 'node.askHint': '发送后在这里实时显示回答',
63
+ 'node.running': '生成中',
64
+ 'node.childRunning': '存在子节点正在生成中',
65
+ 'node.open': '未完成',
66
+ 'node.rename': '改名',
67
+ 'node.archive': '归档',
68
+ 'node.archiveHint': '归档这个分支',
69
+ 'node.archivedToast': '已归档',
70
+ 'treeDelete.action': '删除对话树',
71
+ 'treeDelete.title': '删除对话树',
72
+ 'treeDelete.message': '确定删除「{title}」吗?该对话树下的 {n} 个会话都会被归档。',
73
+ 'treeDelete.confirm': '删除并归档',
74
+ 'treeDelete.cancel': '取消',
75
+ 'treeDelete.deleting': '正在归档…',
76
+ 'treeDelete.failed': '归档失败:{message}',
77
+ 'treeDelete.partial': '{n} 个会话归档失败',
78
+ 'treeDelete.done': '已归档 {n} 个会话',
79
+ 'node.renamePlaceholder': '给这个分支起个名字',
80
+ 'node.turn': '第 {n} 轮',
81
+ 'node.depth': '分支深度 {n}',
82
+ 'node.continueHint': '从这一轮分叉',
83
+ 'node.jumpHint': '跳转到这一轮',
84
+ 'node.collapse': '收起子节点',
85
+ 'node.expand': '展开子节点',
86
+ 'node.collapsed': '已收起',
87
+ 'node.contextLabel': '上下文',
88
+ 'node.contextTitle': '上下文 {used} / {window}({pct}%)',
89
+ 'node.contextUnknown': '上下文未知',
90
+ 'node.afterShort': '+{n} 轮',
91
+ 'node.afterPathTitle': '当前分支之后还有 {n} 轮',
92
+ 'node.afterBranchTitle': '这条分支之后最多还有 {n} 轮',
93
+ 'node.afterZero': '之后没有更多轮次',
94
+ 'node.questionLabel': '提问',
95
+ 'node.answerLabel': '回答',
96
+ 'node.answerEmpty': '这一轮没有模型回答。',
97
+ 'node.detailWaiting': '等待回答…',
98
+ 'node.detailLoading': '正在读取完整对话…',
99
+ 'node.detailFailed': '详情读取失败:{message}',
100
+ 'node.detailTruncated': '内容较长,已截断。',
101
+ 'node.jump': '回到这一轮',
102
+ 'node.afterLabel': '后续轮数',
103
+ 'node.canvasHint': '拖动空白处平移 · 滚轮缩放 · 点击节点查看详情 · 长按节点拖到顶部设为新树根',
104
+ 'node.center': '回到当前',
105
+ 'node.fit': '居中',
106
+ 'node.selectHint': '点击树上的节点查看详情',
107
+ 'node.rootDetail': '这是这段对话的起点。',
108
+ 'node.hoverHint': '点击查看详情',
109
+ 'node.chainHint': '连续 {n} 轮 · 点击展开',
110
+ 'node.chainAria': '连续 {n} 轮,点击展开',
111
+ 'node.chainCollapse': '折叠 {n} 轮',
112
+ 'node.copy': '复制',
113
+ 'node.copied': '已复制',
114
+ 'node.leafHint': '切到这条分支',
115
+ 'action.branch': '分叉',
116
+ 'action.branchHint': '从这一轮分叉,开一条新的对话分支',
117
+ 'action.busy': '正在分叉…',
118
+ 'header.open': '对话树',
119
+ 'view.title': '对话树',
120
+ 'path.open': '打开对话树',
121
+ 'path.root': '起点',
122
+ 'path.siblings': '兄弟分支',
123
+ 'notice.forkFailed': '分叉失败:{message}',
124
+ 'notice.forkCreating': '正在创建分支…',
125
+ 'notice.forkRefreshing': '分支创建成功,正在刷新对话树…',
126
+ 'notice.forkDone': '分支已创建,对话树已刷新',
127
+ 'notice.forkRefreshFailed': '分支已创建,但刷新对话树失败:{message}',
128
+ 'notice.switchFailed': '切换分支失败:{message}',
129
+ 'notice.running': '当前分支还在生成,等这一轮结束再切换。',
130
+ 'notice.busyFork': '这一轮还在生成,等它结束再分叉。',
131
+ 'notice.noService': '这个环境缺少会话服务,无法分叉。',
132
+ 'notice.askFailed': '提问失败:{message}',
133
+ 'notice.askSent': '已发送,正在等待回答',
134
+ 'notice.noAccess': '没有分叉这个会话的权限。',
135
+ 'notice.renameFailed': '改名失败:{message}',
136
+ 'notice.archiveFailed': '归档失败:{message}',
137
+ }
138
+
139
+ const EN = {
140
+ 'panel.title': 'Conversation tree',
141
+ 'panel.hint': 'Click a node to return to that turn; hover for details and actions',
142
+ 'panel.empty': 'No branches yet. Keep asking and the tree grows by itself.',
143
+ 'panel.loading': 'Reading the conversation tree…',
144
+ 'panel.error': 'Could not read the conversation tree',
145
+ 'panel.refresh': 'Refresh',
146
+ 'panel.refreshed': 'Refreshed',
147
+ 'panel.refreshFailed': 'Refresh failed: {message}',
148
+ 'panel.allArchived': 'Archived branches are hidden from the tree; unarchive one in the sidebar to bring it back.',
149
+ 'panel.turns': '{n} turns',
150
+ 'panel.branches': '{n} branches',
151
+ 'panel.partial': 'Some branches could not be read',
152
+ 'panel.currentTree': 'Current tree',
153
+ 'panel.allTrees': 'All trees',
154
+ 'panel.allTreesHint': 'All conversation trees in this folder',
155
+ 'panel.allTreesEmpty': 'No conversation trees in this folder yet.',
156
+ 'panel.treeUnavailable': 'This tree is outside the current conversation family.',
157
+ 'panel.currentBadge': 'Current',
158
+ 'panel.promotedBadge': 'New tree',
159
+ 'node.promote': 'Make tree root',
160
+ 'node.promoteDrop': 'Drop here to make a new tree root',
161
+ 'notice.promoted': 'New conversation tree created',
162
+ 'node.youAreHere': 'You are here',
163
+ 'node.blank': 'New branch',
164
+ 'node.waiting': 'Waiting for a question',
165
+ 'node.askPlaceholder': 'Ask a question — Enter sends, Shift+Enter adds a line',
166
+ 'node.askSend': 'Send',
167
+ 'node.asking': 'Sending…',
168
+ 'node.askHint': 'The answer will stream here after sending',
169
+ 'node.running': 'Generating',
170
+ 'node.childRunning': 'A child node is generating',
171
+ 'node.open': 'Open',
172
+ 'node.rename': 'Rename',
173
+ 'node.archive': 'Archive',
174
+ 'node.archiveHint': 'Archive this branch',
175
+ 'node.archivedToast': 'Archived',
176
+ 'treeDelete.action': 'Delete tree',
177
+ 'treeDelete.title': 'Delete conversation tree',
178
+ 'treeDelete.message': 'Delete “{title}”? All {n} conversations in this tree will be archived.',
179
+ 'treeDelete.confirm': 'Delete and archive',
180
+ 'treeDelete.cancel': 'Cancel',
181
+ 'treeDelete.deleting': 'Archiving…',
182
+ 'treeDelete.failed': 'Archive failed: {message}',
183
+ 'treeDelete.partial': '{n} conversations could not be archived',
184
+ 'treeDelete.done': 'Archived {n} conversations',
185
+ 'node.renamePlaceholder': 'Name this branch',
186
+ 'node.turn': 'Turn {n}',
187
+ 'node.depth': 'Branch depth {n}',
188
+ 'node.continueHint': 'Branch from this turn',
189
+ 'node.jumpHint': 'Jump to this turn',
190
+ 'node.collapse': 'Collapse children',
191
+ 'node.expand': 'Expand children',
192
+ 'node.collapsed': 'Collapsed',
193
+ 'node.contextLabel': 'Context',
194
+ 'node.contextTitle': 'Context {used} / {window} ({pct}%)',
195
+ 'node.contextUnknown': 'Context unknown',
196
+ 'node.afterShort': '+{n} turns',
197
+ 'node.afterPathTitle': '{n} turns after this on the current branch',
198
+ 'node.afterBranchTitle': 'Up to {n} turns after this on this branch',
199
+ 'node.afterZero': 'No more turns after this',
200
+ 'node.questionLabel': 'Question',
201
+ 'node.answerLabel': 'Answer',
202
+ 'node.answerEmpty': 'No answer for this turn yet.',
203
+ 'node.detailWaiting': 'Waiting for an answer…',
204
+ 'node.detailLoading': 'Reading the full conversation…',
205
+ 'node.detailFailed': 'Could not read details: {message}',
206
+ 'node.detailTruncated': 'Long content truncated.',
207
+ 'node.jump': 'Go to this turn',
208
+ 'node.afterLabel': 'Turns after',
209
+ 'node.canvasHint': 'Drag blank space to pan · scroll to zoom · click a node for details · long-press drag a node to the top to make a new tree root',
210
+ 'node.center': 'Center current',
211
+ 'node.fit': 'Center',
212
+ 'node.selectHint': 'Click a node in the tree to see its details',
213
+ 'node.rootDetail': 'This is where the conversation starts.',
214
+ 'node.hoverHint': 'Click for details',
215
+ 'node.chainHint': '{n} turns · click to expand',
216
+ 'node.chainAria': '{n} consecutive turns; click to expand',
217
+ 'node.chainCollapse': 'Collapse {n} turns',
218
+ 'node.copy': 'Copy',
219
+ 'node.copied': 'Copied',
220
+ 'node.leafHint': 'Switch to this branch',
221
+ 'action.branch': 'Branch',
222
+ 'action.branchHint': 'Branch from this turn into a new conversation branch',
223
+ 'action.busy': 'Branching…',
224
+ 'header.open': 'Conversation tree',
225
+ 'view.title': 'Conversation tree',
226
+ 'path.open': 'Open the conversation tree',
227
+ 'path.root': 'Start',
228
+ 'path.siblings': 'Sibling branches',
229
+ 'notice.forkFailed': 'Branch failed: {message}',
230
+ 'notice.forkCreating': 'Creating branch…',
231
+ 'notice.forkRefreshing': 'Branch created; refreshing the conversation tree…',
232
+ 'notice.forkDone': 'Branch created and the tree is refreshed',
233
+ 'notice.forkRefreshFailed': 'Branch created, but refreshing the tree failed: {message}',
234
+ 'notice.switchFailed': 'Could not switch branch: {message}',
235
+ 'notice.running': 'This branch is still generating; wait for the turn to finish.',
236
+ 'notice.busyFork': 'This turn is still generating; wait for it to finish before branching.',
237
+ 'notice.noService': 'Session services are unavailable here, so branching is disabled.',
238
+ 'notice.askFailed': 'Could not send the question: {message}',
239
+ 'notice.askSent': 'Question sent; waiting for the answer',
240
+ 'notice.noAccess': 'This session cannot be forked.',
241
+ 'notice.renameFailed': 'Rename failed: {message}',
242
+ 'notice.archiveFailed': 'Archive failed: {message}',
243
+ }
244
+
245
+ let appCtx = null
246
+ let translate = (key) => (ZH[key] ?? key)
247
+
248
+ function fmt(template, vars) {
249
+ return String(template).replace(/\{(\w+)\}/g, (match, key) => (
250
+ Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match
251
+ ))
252
+ }
253
+
254
+ const numberFormatter = new Intl.NumberFormat()
255
+ function formatNumber(value) {
256
+ return Number.isFinite(value) ? numberFormatter.format(value) : '0'
257
+ }
258
+
259
+ function formatTime(value) {
260
+ if (typeof value !== 'string' || value === '') return ''
261
+ const date = new Date(value)
262
+ return Number.isNaN(date.getTime()) ? '' : date.toLocaleString()
263
+ }
264
+
265
+ async function copyToClipboard(text) {
266
+ if (text === '') return false
267
+ try {
268
+ if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
269
+ await navigator.clipboard.writeText(text)
270
+ return true
271
+ }
272
+ } catch {
273
+ /* fall through to the legacy path */
274
+ }
275
+ try {
276
+ if (typeof document === 'undefined') return false
277
+ const area = document.createElement('textarea')
278
+ area.value = text
279
+ area.style.position = 'fixed'
280
+ area.style.top = '-1000px'
281
+ document.body.appendChild(area)
282
+ area.select()
283
+ const ok = document.execCommand('copy')
284
+ area.remove()
285
+ return ok === true
286
+ } catch {
287
+ return false
288
+ }
289
+ }
290
+
291
+ const detailCache = new Map()
292
+
293
+ async function fetchDetail(nodeId) {
294
+ const response = await fetch(ROUTE, {
295
+ method: 'POST',
296
+ headers: { 'content-type': 'application/json' },
297
+ body: JSON.stringify({ action: 'detail', nodeId }),
298
+ })
299
+ const body = await response.json().catch(() => null)
300
+ if (!response.ok || body?.ok !== true) throw new Error(body?.error ?? `HTTP ${response.status}`)
301
+ return body.detail
302
+ }
303
+
304
+ function detailPosition(rect) {
305
+ const width = Math.min(360, Math.max(240, window.innerWidth - 24))
306
+ const gap = 12
307
+ const top = Math.max(8, Math.min(rect.top, window.innerHeight - 240))
308
+ const maxHeight = Math.max(200, Math.min(520, window.innerHeight - top - 8))
309
+ let left = rect.left - width - gap
310
+ if (left < 8) left = rect.right + gap
311
+ left = Math.max(8, Math.min(left, window.innerWidth - width - 8))
312
+ return { left, top, width, maxHeight }
313
+ }
314
+
315
+ /* ----------------------------------------------------------- data -- */
316
+
317
+ const inflight = new Map()
318
+ const treeDataCache = new Map()
319
+
320
+ function fetchTree(sessionId, force = false) {
321
+ const requestKey = force ? `${sessionId}:fresh` : sessionId
322
+ const existing = inflight.get(requestKey)
323
+ if (existing !== undefined) return existing
324
+ const promise = (async () => {
325
+ const query = `sessionId=${encodeURIComponent(sessionId)}${force ? '&fresh=1' : ''}`
326
+ const response = await fetch(`${ROUTE}?${query}`, { cache: 'no-store' })
327
+ const body = await response.json().catch(() => null)
328
+ if (!response.ok || body === null || body.ok !== true) {
329
+ throw new Error(body !== null && typeof body.error === 'string' ? body.error : `HTTP ${response.status}`)
330
+ }
331
+ return body
332
+ })()
333
+ inflight.set(requestKey, promise)
334
+ promise.finally(() => {
335
+ if (inflight.get(requestKey) === promise) inflight.delete(requestKey)
336
+ }).catch(() => {})
337
+ return promise
338
+ }
339
+
340
+ async function fetchForest(sessionId) {
341
+ const response = await fetch(ROUTE, {
342
+ method: 'POST',
343
+ headers: { 'content-type': 'application/json' },
344
+ body: JSON.stringify({ action: 'forest', sessionId }),
345
+ })
346
+ const body = await response.json().catch(() => null)
347
+ if (!response.ok || body === null || body.ok !== true) {
348
+ throw new Error(body !== null && typeof body.error === 'string' ? body.error : `HTTP ${response.status}`)
349
+ }
350
+ return body
351
+ }
352
+
353
+ async function fetchLineage(sessionId) {
354
+ const response = await fetch(ROUTE, {
355
+ method: 'POST',
356
+ headers: { 'content-type': 'application/json' },
357
+ body: JSON.stringify({ action: 'lineage', sessionId }),
358
+ })
359
+ const body = await response.json().catch(() => null)
360
+ if (!response.ok || body === null || body.ok !== true) {
361
+ throw new Error(body !== null && typeof body.error === 'string' ? body.error : `HTTP ${response.status}`)
362
+ }
363
+ return body
364
+ }
365
+
366
+ const PROMOTED_KEY = 'treecom:promoted:v1'
367
+ const promotedKey = (cwd) => `${PROMOTED_KEY}:${cwd ?? 'global'}`
368
+ function readPromoted(cwd) {
369
+ try {
370
+ const raw = localStorage.getItem(promotedKey(cwd))
371
+ const value = raw === null ? [] : JSON.parse(raw)
372
+ return Array.isArray(value) ? value.filter((entry) => typeof entry?.nodeId === 'string') : []
373
+ } catch {
374
+ return []
375
+ }
376
+ }
377
+ function writePromoted(cwd, entries) {
378
+ try { localStorage.setItem(promotedKey(cwd), JSON.stringify(entries)) } catch { /* storage unavailable */ }
379
+ }
380
+
381
+ function activateTreeViewTab(attempt = 0) {
382
+ if (typeof document === 'undefined') return
383
+ const tabs = document.querySelectorAll('[data-conversation-tabs] [role="tab"]')
384
+ const label = translate('view.title')
385
+ for (const tab of tabs) {
386
+ if ((tab.textContent ?? '').trim() === label) {
387
+ tab.click()
388
+ return
389
+ }
390
+ }
391
+ if (attempt < 5) setTimeout(() => activateTreeViewTab(attempt + 1), 100)
392
+ }
393
+
394
+ function refreshAll() {
395
+ treeDataCache.clear()
396
+ try {
397
+ window.dispatchEvent(new Event('treecom:refresh'))
398
+ } catch {
399
+ /* a page without CustomEvent/Event is not a real client */
400
+ }
401
+ }
402
+
403
+ function sessionRevision(sessions) {
404
+ if (sessions === null || sessions === undefined) return null
405
+ const values = sessions instanceof Map ? [...sessions.values()] : Object.values(sessions)
406
+ const parts = []
407
+ for (const session of values) {
408
+ if (session === null || session === undefined) continue
409
+ const id = String(session.id ?? '')
410
+ if (session.running === true) parts.push(`${id}:running`)
411
+ else parts.push(`${id}:${session.updatedAt ?? ''}:${session.title ?? ''}:${session.blank === true ? 'blank' : ''}`)
412
+ }
413
+ parts.sort()
414
+ return parts.join('|')
415
+ }
416
+
417
+ function useTree(sessionId, liveRevision, liveRunning) {
418
+ const cachedAtMount = treeDataCache.get(sessionId)
419
+ const [state, setState] = useState(cachedAtMount !== undefined
420
+ ? { sessionId, loading: false, data: cachedAtMount.data, error: null }
421
+ : { sessionId, loading: true, data: null, error: null })
422
+ const generation = useRef(0)
423
+ const revisionRef = useRef(liveRevision)
424
+ useEffect(() => {
425
+ revisionRef.current = liveRevision
426
+ }, [liveRevision])
427
+ const reload = useCallback(async (force = false) => {
428
+ const token = generation.current
429
+ if (force === true) {
430
+ treeDataCache.delete(sessionId)
431
+ inflight.delete(sessionId)
432
+ }
433
+ try {
434
+ const data = await fetchTree(sessionId, force)
435
+ treeDataCache.set(sessionId, { data, revision: revisionRef.current })
436
+ if (generation.current === token) setState({ sessionId, loading: false, data, error: null })
437
+ return data
438
+ } catch (error) {
439
+ if (generation.current === token) {
440
+ setState((previous) => ({
441
+ sessionId,
442
+ loading: false,
443
+ data: previous.sessionId === sessionId ? previous.data : (treeDataCache.get(sessionId)?.data ?? null),
444
+ error: noticeText(error),
445
+ }))
446
+ }
447
+ throw error
448
+ }
449
+ }, [sessionId])
450
+ useEffect(() => {
451
+ generation.current += 1
452
+ const cached = treeDataCache.get(sessionId)
453
+ if (cached !== undefined) {
454
+ setState({ sessionId, loading: false, data: cached.data, error: null })
455
+ if (cached.revision !== 'seed' && liveRevision !== null && liveRevision !== undefined && cached.revision !== liveRevision) {
456
+ reload().catch(() => {})
457
+ }
458
+ } else {
459
+ reload().catch(() => {})
460
+ }
461
+ const onRefresh = () => { reload().catch(() => {}) }
462
+ const onData = (event) => {
463
+ const detail = event.detail
464
+ if (detail === null || detail === undefined || detail.sessionId !== sessionId || detail.data === null || detail.data === undefined) return
465
+ treeDataCache.set(sessionId, { data: detail.data, revision: revisionRef.current })
466
+ setState({ sessionId, loading: false, data: detail.data, error: null })
467
+ }
468
+ window.addEventListener('treecom:refresh', onRefresh)
469
+ window.addEventListener('treecom:data', onData)
470
+ const runningTimer = liveRunning === true
471
+ ? setInterval(() => { reload().catch(() => {}) }, 5_000)
472
+ : null
473
+ return () => {
474
+ generation.current += 1
475
+ window.removeEventListener('treecom:refresh', onRefresh)
476
+ window.removeEventListener('treecom:data', onData)
477
+ if (runningTimer !== null) clearInterval(runningTimer)
478
+ }
479
+ }, [sessionId, liveRevision, liveRunning, reload])
480
+ const visible = state.sessionId === sessionId
481
+ ? state
482
+ : (cachedAtMount !== undefined
483
+ ? { sessionId, loading: false, data: cachedAtMount.data, error: null }
484
+ : { sessionId, loading: true, data: null, error: null })
485
+ return { ...visible, reload }
486
+ }
487
+
488
+ function indexOf(data) {
489
+ const byId = new Map()
490
+ const children = new Map()
491
+ const leaves = new Map()
492
+ for (const node of data.nodes) {
493
+ byId.set(node.id, node)
494
+ if (node.parentId === null || node.parentId === undefined) continue
495
+ const list = children.get(node.parentId) ?? []
496
+ list.push(node)
497
+ children.set(node.parentId, list)
498
+ }
499
+ for (const list of children.values()) list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
500
+ for (const node of data.nodes) {
501
+ if (node.kind === 'root') continue
502
+ const previous = leaves.get(node.sessionId)
503
+ if (previous === undefined || (node.order ?? 0) >= (previous.order ?? 0)) leaves.set(node.sessionId, node)
504
+ }
505
+ const leaf = leaves.get(data.sessionId) ?? null
506
+ const path = new Set()
507
+ const chain = []
508
+ const seen = new Set()
509
+ let cursor = leaf
510
+ while (cursor !== null && cursor !== undefined && !seen.has(cursor.id)) {
511
+ seen.add(cursor.id)
512
+ path.add(cursor.id)
513
+ chain.unshift(cursor)
514
+ cursor = byId.get(cursor.parentId)
515
+ }
516
+ const roots = data.nodes.filter((node) => node.parentId === null || node.parentId === undefined)
517
+ const sessionById = new Map(data.sessions.map((session) => [session.id, session]))
518
+ return { byId, children, leaves, leaf, path, chain, roots, sessionById }
519
+ }
520
+
521
+ function runningNodesOf(index) {
522
+ const generating = new Set()
523
+ const childGenerating = new Set()
524
+ if (index === null) return { generating, childGenerating }
525
+ for (const [sessionId, session] of index.sessionById) {
526
+ if (session.running !== true) continue
527
+ const leaf = index.leaves.get(sessionId)
528
+ if (leaf === undefined) continue
529
+ generating.add(leaf.id)
530
+ let parentId = leaf.parentId
531
+ const seen = new Set()
532
+ while (parentId !== null && parentId !== undefined && !seen.has(parentId)) {
533
+ seen.add(parentId)
534
+ childGenerating.add(parentId)
535
+ parentId = index.byId.get(parentId)?.parentId ?? null
536
+ }
537
+ }
538
+ for (const id of generating) childGenerating.delete(id)
539
+ return { generating, childGenerating }
540
+ }
541
+
542
+ function buildDisplayIndex(index, hiddenIds, expandedChains, prefixCuts, collapsedParents, viewRootId) {
543
+ const display = new Map()
544
+ const chainByRep = new Map()
545
+ const chainByMember = new Map()
546
+ const visibleChildren = (node) => (collapsedParents?.has(node.id) === true ? [] : (index.children.get(node.id) ?? []).filter((child) => hiddenIds.has(child.id) !== true))
547
+ const addNode = (node, parentId) => {
548
+ display.set(node.id, { ...node, parentId })
549
+ }
550
+ const markChainMembers = (members, chainInfo, expanded) => {
551
+ for (let i = 0; i < members.length; i += 1) {
552
+ chainByMember.set(members[i].id, { ...chainInfo, index: i, aboveCount: i, expanded, prefix: false })
553
+ }
554
+ }
555
+ const addSubtree = (node, parentId) => {
556
+ const children = visibleChildren(node)
557
+ if (node.kind !== 'root' && children.length === 1) {
558
+ const members = [node]
559
+ let current = node
560
+ while (visibleChildren(current).length === 1) {
561
+ current = visibleChildren(current)[0]
562
+ members.push(current)
563
+ }
564
+ const key = `${members[0].id}~${current.id}`
565
+ const chainInfo = {
566
+ key,
567
+ count: members.length,
568
+ startId: members[0].id,
569
+ endId: current.id,
570
+ members: members.map((member) => member.id),
571
+ }
572
+ const expanded = expandedChains.has(key) === true
573
+ if (!expanded) {
574
+ addNode(current, parentId)
575
+ chainByRep.set(current.id, { ...chainInfo, expanded: false, prefix: false })
576
+ markChainMembers(members, chainInfo, false)
577
+ for (const child of visibleChildren(current)) addSubtree(child, current.id)
578
+ return
579
+ }
580
+ const rawCut = prefixCuts?.[key] ?? 0
581
+ const cut = Number.isInteger(rawCut) ? Math.max(0, Math.min(members.length, rawCut)) : 0
582
+ if (cut > 0 && cut < members.length) {
583
+ const prefixMembers = members.slice(0, cut)
584
+ const representative = prefixMembers[prefixMembers.length - 1]
585
+ const prefixInfo = {
586
+ key,
587
+ count: prefixMembers.length,
588
+ startId: prefixMembers[0].id,
589
+ endId: representative.id,
590
+ members: prefixMembers.map((member) => member.id),
591
+ prefix: true,
592
+ expanded: true,
593
+ }
594
+ addNode(representative, parentId)
595
+ chainByRep.set(representative.id, prefixInfo)
596
+ for (let i = 0; i < prefixMembers.length; i += 1) {
597
+ chainByMember.set(prefixMembers[i].id, { ...prefixInfo, index: i, aboveCount: i })
598
+ }
599
+ let previous = representative.id
600
+ for (let i = cut; i < members.length; i += 1) {
601
+ const member = members[i]
602
+ addNode(member, previous)
603
+ chainByMember.set(member.id, { ...chainInfo, index: i, aboveCount: i, expanded: true, prefix: false })
604
+ previous = member.id
605
+ }
606
+ for (const child of visibleChildren(current)) addSubtree(child, current.id)
607
+ return
608
+ }
609
+ let previous = parentId
610
+ for (const member of members) {
611
+ addNode(member, previous)
612
+ previous = member.id
613
+ }
614
+ markChainMembers(members, chainInfo, true)
615
+ for (const child of visibleChildren(current)) addSubtree(child, current.id)
616
+ return
617
+ }
618
+ addNode(node, parentId)
619
+ for (const child of children) addSubtree(child, node.id)
620
+ }
621
+ const viewRoot = viewRootId === null || viewRootId === undefined ? null : index.byId.get(viewRootId)
622
+ if (viewRoot !== undefined && viewRoot !== null && !hiddenIds.has(viewRoot.id)) {
623
+ addNode(viewRoot, null)
624
+ for (const child of visibleChildren(viewRoot)) addSubtree(child, viewRoot.id)
625
+ } else {
626
+ for (const root of index.roots) {
627
+ if (hiddenIds.has(root.id)) continue
628
+ addSubtree(root, root.parentId ?? null)
629
+ }
630
+ }
631
+ const byId = new Map(display)
632
+ const children = new Map()
633
+ for (const node of display.values()) {
634
+ if (node.parentId === null || node.parentId === undefined) continue
635
+ const list = children.get(node.parentId) ?? []
636
+ list.push(node)
637
+ children.set(node.parentId, list)
638
+ }
639
+ for (const list of children.values()) list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
640
+ const leaves = new Map()
641
+ for (const node of display.values()) {
642
+ if (node.kind === 'root') continue
643
+ const previous = leaves.get(node.sessionId)
644
+ if (previous === undefined || (node.order ?? 0) >= (previous.order ?? 0)) leaves.set(node.sessionId, node)
645
+ }
646
+ const sessionId = index.leaf?.sessionId ?? null
647
+ const leaf = sessionId === null ? null : (leaves.get(sessionId) ?? null)
648
+ const path = new Set()
649
+ const chain = []
650
+ const seen = new Set()
651
+ let cursor = leaf
652
+ while (cursor !== null && cursor !== undefined && !seen.has(cursor.id)) {
653
+ seen.add(cursor.id)
654
+ path.add(cursor.id)
655
+ chain.unshift(cursor)
656
+ cursor = byId.get(cursor.parentId)
657
+ }
658
+ const roots = [...display.values()].filter((node) => node.parentId === null || node.parentId === undefined)
659
+ return { byId, children, leaves, leaf, path, chain, roots, sessionById: index.sessionById, chainByRep, chainByMember }
660
+ }
661
+
662
+ /**
663
+ * In the all-trees canvas, a promoted node becomes a top-level root and is
664
+ * detached from its original parent edge. This shows each promoted tree as
665
+ * its own tree without duplicating node ids.
666
+ */
667
+ function partitionPromoted(index, entries) {
668
+ if (index === null || !Array.isArray(entries) || entries.length === 0) return index
669
+ const byId = new Map(index.byId)
670
+ const children = new Map()
671
+ for (const [parentId, list] of index.children) children.set(parentId, [...list])
672
+ const roots = [...index.roots]
673
+ for (const entry of entries) {
674
+ const node = byId.get(entry.nodeId)
675
+ if (node === undefined || node.parentId === null || node.parentId === undefined) continue
676
+ const parentChildren = children.get(node.parentId)
677
+ if (parentChildren !== undefined) children.set(node.parentId, parentChildren.filter((child) => child.id !== node.id))
678
+ byId.set(node.id, { ...node, parentId: null })
679
+ if (!roots.some((root) => root.id === node.id)) roots.push(byId.get(node.id))
680
+ }
681
+ return { ...index, byId, children, roots }
682
+ }
683
+
684
+ /* -------------------------------------------------------- actions -- */
685
+
686
+ const pending = { focusSession: null }
687
+ let selectionIntent = null
688
+ const EMPTY_ARCHIVED = Object.freeze([])
689
+ const selectArchived = (snapshot) => (
690
+ snapshot !== undefined && snapshot !== null && Array.isArray(snapshot.archivedSessionIds)
691
+ ? snapshot.archivedSessionIds
692
+ : EMPTY_ARCHIVED
693
+ )
694
+ const selectSessionById = (snapshot) => (
695
+ snapshot !== undefined && snapshot !== null && snapshot.byId !== undefined ? snapshot.byId : null
696
+ )
697
+
698
+ async function switchTo(targetId, focus) {
699
+ const workspace = appCtx?.get('uiWorkspace')
700
+ if (!workspace) throw new Error(translate('notice.noService'))
701
+ if (focus === true) pending.focusSession = targetId
702
+ try {
703
+ await workspace.unarchiveSession(targetId)
704
+ } catch {
705
+ /* already visible */
706
+ }
707
+ workspace.openSession(targetId)
708
+ refreshAll()
709
+ }
710
+
711
+ async function forkAt(node) {
712
+ const sessions = appCtx?.get('sessions')
713
+ if (!sessions) throw new Error(translate('notice.noService'))
714
+ if (!Number.isSafeInteger(node.endSeq)) throw new Error(translate('notice.busyFork'))
715
+ const childId = await sessions.fork({
716
+ sessionId: node.sessionId,
717
+ atSeq: node.endSeq,
718
+ increaseTitle: true,
719
+ })
720
+ /* A fresh fork is already visible. Do not unarchive/open it: both can
721
+ focus the child and make the tree jump away from the current branch. */
722
+ refreshAll()
723
+ return childId
724
+ }
725
+
726
+ /* ----------------------------------------------------------- bits -- */
727
+
728
+ function Icon({ name, size = 14 }) {
729
+ const common = {
730
+ width: size,
731
+ height: size,
732
+ viewBox: '0 0 16 16',
733
+ fill: 'none',
734
+ stroke: 'currentColor',
735
+ strokeWidth: 1.5,
736
+ strokeLinecap: 'round',
737
+ strokeLinejoin: 'round',
738
+ 'aria-hidden': true,
739
+ }
740
+ switch (name) {
741
+ case 'tree':
742
+ return h('svg', common,
743
+ h('path', { d: 'M8 3v4.2M8 7.2c0 2-3 2.2-3 4.2V13M8 7.2c0 2 3 2.2 3 4.2V13' }),
744
+ h('circle', { cx: 8, cy: 2.6, r: 1.4 }),
745
+ h('circle', { cx: 5, cy: 13.4, r: 1.4 }),
746
+ h('circle', { cx: 11, cy: 13.4, r: 1.4 }))
747
+ case 'plus':
748
+ return h('svg', common, h('path', { d: 'M8 3.5v9M3.5 8h9' }))
749
+ case 'pencil':
750
+ return h('svg', common, h('path', { d: 'M10.8 2.9l2.3 2.3-7.3 7.3-3 .7.7-3 7.3-7.3z' }))
751
+ case 'refresh':
752
+ return h('svg', common, h('path', { d: 'M13 8a5 5 0 1 1-1.5-3.6M13 3v3.5H9.5' }))
753
+ case 'left':
754
+ return h('svg', common, h('path', { d: 'M9.5 3.5L5 8l4.5 4.5' }))
755
+ case 'right':
756
+ return h('svg', common, h('path', { d: 'M6.5 3.5L11 8l-4.5 4.5' }))
757
+ case 'down':
758
+ return h('svg', common, h('path', { d: 'M3.5 6.5L8 11l4.5-4.5' }))
759
+ case 'x':
760
+ return h('svg', common, h('path', { d: 'M4 4l8 8M12 4l-8 8' }))
761
+ case 'archive':
762
+ return h('svg', common, h('path', { d: 'M2.5 3.5h11v2.2h-11zM4 5.7v6.8h8V5.7M6.3 8.2h3.4' }))
763
+ case 'trash':
764
+ return h('svg', common,
765
+ h('path', { d: 'M3.5 4.5h9M6 4.5V3.2h4v1.3' }),
766
+ h('path', { d: 'M5 6.6v5.1M8 6.6v5.1M11 6.6v5.1' }),
767
+ h('path', { d: 'M4.2 4.5l.6 8.3h6.4l.6-8.3' }))
768
+ case 'dot':
769
+ return h('svg', common, h('circle', { cx: 8, cy: 8, r: 2.4, fill: 'currentColor', stroke: 'none' }))
770
+ case 'copy':
771
+ return h('svg', common,
772
+ h('rect', { x: 5.5, y: 5.5, width: 7, height: 8, rx: 1 }),
773
+ h('path', { d: 'M3.5 10.5V3.5h7' }))
774
+ case 'check':
775
+ return h('svg', common, h('path', { d: 'M3.5 8.5l3 3 6-7' }))
776
+ default:
777
+ return null
778
+ }
779
+ }
780
+
781
+ function IconButton({ label, name, onClick, disabled, className, spinning }) {
782
+ return h('button', {
783
+ type: 'button',
784
+ className: `tc-icon-btn${className === undefined ? '' : ` ${className}`}`,
785
+ 'aria-label': label,
786
+ title: label,
787
+ disabled: disabled === true,
788
+ 'data-spinning': spinning === true ? 'true' : undefined,
789
+ onClick,
790
+ }, h(Icon, { name }))
791
+ }
792
+
793
+ function noticeText(error) {
794
+ return error instanceof Error ? error.message : String(error)
795
+ }
796
+
797
+ /* ----------------------------------------------------- turn jump -- */
798
+
799
+ /**
800
+ * Land the Chat view on one Turn. The host's own turn rail is the only
801
+ * public control that can load and scroll a turn that is not mounted; a
802
+ * loaded row is the fallback. Polls because the Chat view mounts after a
803
+ * session switch and virtualization removes off-screen turns.
804
+ */
805
+ function jumpToTurn(turn, attempt = 0) {
806
+ if (!Number.isSafeInteger(turn) || turn <= 0 || typeof document === 'undefined') return
807
+ const marks = document.querySelectorAll('button[data-index][aria-label]')
808
+ for (const mark of marks) {
809
+ const match = /(\d+)/.exec(mark.getAttribute('aria-label') || '')
810
+ if (match !== null && Number(match[1]) === turn) {
811
+ mark.click()
812
+ return
813
+ }
814
+ }
815
+ const row = document.querySelector(`[data-chat-turn="${turn}"]`)
816
+ if (row !== null) {
817
+ row.scrollIntoView({ block: 'center', behavior: 'smooth' })
818
+ return
819
+ }
820
+ if (attempt < 24) setTimeout(() => jumpToTurn(turn, attempt + 1), 120)
821
+ }
822
+
823
+ /* ------------------------------------------------------ tree card -- */
824
+
825
+ function NodeCard(props) {
826
+ const { node, activeId, onPath, isLeaf, running, busy, archived, canFork, hasChildren, collapsed, onToggle, onActivate, onFork, onRename, onArchive, after, afterOnPath } = props
827
+ const [renaming, setRenaming] = useState(false)
828
+ const [value, setValue] = useState(node.label)
829
+ const [hovered, setHovered] = useState(false)
830
+ const [detail, setDetail] = useState(null)
831
+ const [detailError, setDetailError] = useState(null)
832
+ const [detailLoading, setDetailLoading] = useState(false)
833
+ const [pos, setPos] = useState(null)
834
+ const cancelled = useRef(false)
835
+ const suppressClick = useRef(false)
836
+ const inputRef = useRef(null)
837
+ const cardRef = useRef(null)
838
+ const openTimer = useRef(null)
839
+ const closeTimer = useRef(null)
840
+ const detailToken = useRef(0)
841
+
842
+ useEffect(() => {
843
+ if (renaming && inputRef.current !== null) {
844
+ inputRef.current.focus()
845
+ inputRef.current.select()
846
+ }
847
+ }, [renaming])
848
+
849
+ useEffect(() => {
850
+ setValue(node.label)
851
+ }, [node.label])
852
+
853
+ useEffect(() => () => {
854
+ if (openTimer.current !== null) clearTimeout(openTimer.current)
855
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current)
856
+ detailToken.current += 1
857
+ }, [])
858
+
859
+ const closeDetail = (immediate = false) => {
860
+ if (openTimer.current !== null) clearTimeout(openTimer.current)
861
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current)
862
+ if (immediate) {
863
+ detailToken.current += 1
864
+ setHovered(false)
865
+ setDetail(null)
866
+ setDetailError(null)
867
+ setDetailLoading(false)
868
+ return
869
+ }
870
+ closeTimer.current = setTimeout(() => {
871
+ closeTimer.current = null
872
+ detailToken.current += 1
873
+ setHovered(false)
874
+ setDetail(null)
875
+ setDetailError(null)
876
+ setDetailLoading(false)
877
+ }, 180)
878
+ }
879
+
880
+ const keepDetail = () => {
881
+ if (openTimer.current !== null) clearTimeout(openTimer.current)
882
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current)
883
+ }
884
+
885
+ const openDetail = () => {
886
+ if (renaming) return
887
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current)
888
+ if (openTimer.current !== null) clearTimeout(openTimer.current)
889
+ if (hovered) {
890
+ const card = cardRef.current
891
+ if (card !== null) setPos(detailPosition(card.getBoundingClientRect()))
892
+ return
893
+ }
894
+ openTimer.current = setTimeout(() => {
895
+ openTimer.current = null
896
+ const card = cardRef.current
897
+ if (card === null) return
898
+ setPos(detailPosition(card.getBoundingClientRect()))
899
+ setHovered(true)
900
+ const token = ++detailToken.current
901
+ const cached = detailCache.get(node.id)
902
+ if (cached !== undefined && Date.now() - cached.at < DETAIL_CACHE_MS) {
903
+ setDetail(cached.detail)
904
+ setDetailError(null)
905
+ setDetailLoading(false)
906
+ return
907
+ }
908
+ setDetail(null)
909
+ setDetailError(null)
910
+ setDetailLoading(true)
911
+ fetchDetail(node.id).then((nextDetail) => {
912
+ if (detailToken.current !== token) return
913
+ detailCache.set(node.id, { at: Date.now(), detail: nextDetail })
914
+ setDetail(nextDetail)
915
+ setDetailLoading(false)
916
+ }).catch((error) => {
917
+ if (detailToken.current !== token) return
918
+ setDetailError(noticeText(error))
919
+ setDetailLoading(false)
920
+ })
921
+ }, 180)
922
+ }
923
+
924
+ useEffect(() => {
925
+ if (!hovered) return undefined
926
+ const updatePosition = () => {
927
+ const card = cardRef.current
928
+ if (card === null) return
929
+ const rect = card.getBoundingClientRect()
930
+ if (rect.bottom < 0 || rect.top > window.innerHeight) {
931
+ closeDetail(true)
932
+ return
933
+ }
934
+ setPos(detailPosition(rect))
935
+ }
936
+ const onKeyDown = (event) => {
937
+ if (event.key === 'Escape') closeDetail(true)
938
+ }
939
+ window.addEventListener('scroll', updatePosition, true)
940
+ window.addEventListener('resize', updatePosition)
941
+ window.addEventListener('keydown', onKeyDown)
942
+ return () => {
943
+ window.removeEventListener('scroll', updatePosition, true)
944
+ window.removeEventListener('resize', updatePosition)
945
+ window.removeEventListener('keydown', onKeyDown)
946
+ }
947
+ }, [hovered])
948
+
949
+ const submit = () => {
950
+ suppressClick.current = true
951
+ setTimeout(() => { suppressClick.current = false }, 0)
952
+ if (cancelled.current) {
953
+ cancelled.current = false
954
+ setRenaming(false)
955
+ setValue(node.label)
956
+ return
957
+ }
958
+ setRenaming(false)
959
+ const next = value.trim()
960
+ if (next !== '' && next !== node.label) onRename(node, next)
961
+ }
962
+
963
+ const mode = node.kind === 'blank'
964
+ ? translate('node.blank')
965
+ : isLeaf
966
+ ? translate('node.leafHint')
967
+ : translate('node.jumpHint')
968
+
969
+ const question = node.prompt !== undefined && node.prompt !== ''
970
+ ? node.prompt
971
+ : (node.kind === 'blank' ? translate('node.blank') : node.label)
972
+ const contextWindow = Number(node.contextWindow)
973
+ const contextTokens = Number(node.contextTokens)
974
+ const hasContext = Number.isFinite(contextWindow) && contextWindow > 0
975
+ const usedContext = Number.isFinite(contextTokens) && contextTokens > 0 ? contextTokens : 0
976
+ const contextPct = hasContext
977
+ ? Math.max(0, Math.min(100, Math.round((usedContext / contextWindow) * 100)))
978
+ : null
979
+ const contextTitle = hasContext
980
+ ? fmt(translate('node.contextTitle'), {
981
+ used: formatNumber(usedContext),
982
+ window: formatNumber(contextWindow),
983
+ pct: contextPct,
984
+ })
985
+ : translate('node.contextUnknown')
986
+ const afterCount = Number.isFinite(after) ? Math.max(0, after) : 0
987
+ const afterTitle = afterCount === 0
988
+ ? translate('node.afterZero')
989
+ : fmt(afterOnPath === true ? translate('node.afterPathTitle') : translate('node.afterBranchTitle'), { n: afterCount })
990
+
991
+ const classes = [
992
+ 'tc-card',
993
+ node.kind === 'blank' ? 'tc-card--blank' : '',
994
+ node.id === activeId ? 'tc-card--active' : '',
995
+ onPath ? 'tc-card--path' : '',
996
+ running ? 'tc-card--running' : '',
997
+ busy ? 'tc-card--busy' : '',
998
+ ].filter(Boolean).join(' ')
999
+
1000
+ const badges = []
1001
+ if (node.id === activeId) {
1002
+ badges.push(h('span', { key: 'here', className: 'tc-badge tc-badge--here' }, translate('node.youAreHere')))
1003
+ }
1004
+ if (node.open === true) {
1005
+ badges.push(h('span', { key: 'open', className: 'tc-badge tc-badge--open' }, translate('node.open')))
1006
+ }
1007
+ if (running) {
1008
+ badges.push(h('span', { key: 'running', className: 'tc-badge tc-badge--running' }, translate('node.running')))
1009
+ }
1010
+
1011
+ const status = h('div', {
1012
+ className: 'tc-status',
1013
+ title: `${contextTitle} · ${afterTitle}`,
1014
+ },
1015
+ h('div', { className: 'tc-ctx' },
1016
+ h('div', { className: 'tc-ctx-top' },
1017
+ h('span', { className: 'tc-ctx-label' }, translate('node.contextLabel')),
1018
+ h('span', { className: 'tc-ctx-pct' }, contextPct === null ? '—' : `${contextPct}%`)),
1019
+ h('div', {
1020
+ className: `tc-ctx-bar${contextPct !== null && contextPct >= 90 ? ' tc-ctx-bar--error' : contextPct !== null && contextPct >= 70 ? ' tc-ctx-bar--warn' : ''}`,
1021
+ }, h('span', { style: { width: `${contextPct ?? 0}%` } }))),
1022
+ h('span', { className: `tc-after${afterCount === 0 ? ' tc-after--none' : ''}` }, fmt(translate('node.afterShort'), { n: afterCount })))
1023
+
1024
+ const detailActions = h('div', { className: 'tc-detail-actions' },
1025
+ archived ? null : h('button', {
1026
+ key: 'archive',
1027
+ type: 'button',
1028
+ className: 'tc-mini-btn',
1029
+ title: running ? translate('node.running') : translate('node.archiveHint'),
1030
+ disabled: running,
1031
+ onClick: (event) => {
1032
+ event.stopPropagation()
1033
+ closeDetail(true)
1034
+ onArchive(node.sessionId)
1035
+ },
1036
+ }, h(Icon, { name: 'archive', size: 12 }), h('span', null, translate('node.archive'))),
1037
+ h('button', {
1038
+ key: 'rename',
1039
+ type: 'button',
1040
+ className: 'tc-mini-btn',
1041
+ title: translate('node.rename'),
1042
+ onClick: (event) => {
1043
+ event.stopPropagation()
1044
+ closeDetail(true)
1045
+ setValue(node.label)
1046
+ setRenaming(true)
1047
+ },
1048
+ }, h(Icon, { name: 'pencil', size: 12 }), h('span', null, translate('node.rename'))),
1049
+ node.kind === 'blank' ? null : h('button', {
1050
+ key: 'fork',
1051
+ type: 'button',
1052
+ className: 'tc-mini-btn',
1053
+ title: running ? translate('node.running') : canFork !== true ? translate('notice.noAccess') : translate('action.branchHint'),
1054
+ disabled: running === true || canFork !== true,
1055
+ onClick: (event) => {
1056
+ event.stopPropagation()
1057
+ closeDetail(true)
1058
+ onFork(node)
1059
+ },
1060
+ }, h(Icon, { name: 'plus', size: 12 }), h('span', null, translate('action.branch'))))
1061
+
1062
+ const fullQuestion = detail?.prompt !== undefined && detail.prompt !== '' ? detail.prompt : question
1063
+ const fullAnswer = detail?.answer ?? ''
1064
+ const detailMeta = []
1065
+ const timeText = formatTime(node.time)
1066
+ if (timeText !== '') detailMeta.push(h('span', { key: 'time' }, timeText))
1067
+ detailMeta.push(h('span', { key: 'context' }, contextTitle))
1068
+ detailMeta.push(h('span', { key: 'after' }, afterTitle))
1069
+
1070
+ const detailNode = hovered && pos !== null && createPortal !== null && typeof document !== 'undefined'
1071
+ ? createPortal(h('div', {
1072
+ className: 'tc-detail',
1073
+ role: 'dialog',
1074
+ 'aria-label': question,
1075
+ style: {
1076
+ left: `${pos.left}px`,
1077
+ top: `${pos.top}px`,
1078
+ width: `${pos.width}px`,
1079
+ maxHeight: `${pos.maxHeight}px`,
1080
+ },
1081
+ onMouseEnter: keepDetail,
1082
+ onMouseLeave: () => closeDetail(),
1083
+ onFocus: keepDetail,
1084
+ onBlur: (event) => {
1085
+ if (event.relatedTarget === null || !event.currentTarget.contains(event.relatedTarget)) closeDetail()
1086
+ },
1087
+ },
1088
+ h('div', { className: 'tc-detail-head' },
1089
+ h('div', { className: 'tc-detail-title' }, node.kind === 'blank' ? translate('node.blank') : fmt(translate('node.turn'), { n: node.turn })),
1090
+ node.label !== question ? h('div', { className: 'tc-detail-name' }, node.label) : null),
1091
+ h('div', { className: 'tc-detail-meta' }, detailMeta),
1092
+ h('div', { className: 'tc-detail-section' },
1093
+ h('div', { className: 'tc-detail-label' }, translate('node.questionLabel')),
1094
+ h('div', { className: 'tc-detail-text' }, fullQuestion === '' ? translate('node.waiting') : fullQuestion)),
1095
+ h('div', { className: 'tc-detail-section' },
1096
+ h('div', { className: 'tc-detail-label' }, translate('node.answerLabel')),
1097
+ detailLoading
1098
+ ? h('div', { className: 'tc-detail-loading' }, translate('node.detailLoading'))
1099
+ : detailError !== null
1100
+ ? h('div', { className: 'tc-detail-error' }, fmt(translate('node.detailFailed'), { message: detailError }))
1101
+ : h('div', { className: 'tc-detail-text' }, fullAnswer !== ''
1102
+ ? fullAnswer
1103
+ : (node.open === true ? translate('node.detailWaiting') : translate('node.answerEmpty')))),
1104
+ detail?.truncated === true ? h('div', { className: 'tc-detail-note' }, translate('node.detailTruncated')) : null,
1105
+ detailActions),
1106
+ document.body)
1107
+ : null
1108
+
1109
+ return h(React.Fragment, null,
1110
+ h('div', {
1111
+ ref: cardRef,
1112
+ className: classes,
1113
+ style: { paddingLeft: '34px' },
1114
+ role: 'treeitem',
1115
+ tabIndex: 0,
1116
+ 'aria-label': question,
1117
+ title: mode,
1118
+ onMouseEnter: openDetail,
1119
+ onMouseLeave: () => closeDetail(),
1120
+ onFocus: () => { if (!renaming) openDetail() },
1121
+ onBlur: () => closeDetail(),
1122
+ onClick: () => {
1123
+ if (suppressClick.current || renaming) return
1124
+ closeDetail(true)
1125
+ onActivate(node)
1126
+ },
1127
+ onKeyDown: (event) => {
1128
+ if (event.target !== null && (event.target.tagName === 'INPUT' || event.target.tagName === 'BUTTON')) return
1129
+ if (event.key === 'Enter' || event.key === ' ') {
1130
+ event.preventDefault()
1131
+ closeDetail(true)
1132
+ onActivate(node)
1133
+ }
1134
+ },
1135
+ },
1136
+ hasChildren
1137
+ ? h('button', {
1138
+ type: 'button',
1139
+ className: 'tc-card-rail',
1140
+ title: collapsed ? translate('node.expand') : translate('node.collapse'),
1141
+ 'aria-label': collapsed ? translate('node.expand') : translate('node.collapse'),
1142
+ 'aria-expanded': collapsed !== true,
1143
+ onClick: (event) => {
1144
+ event.stopPropagation()
1145
+ onToggle(node.id)
1146
+ },
1147
+ }, h(Icon, { name: collapsed ? 'right' : 'down', size: 14 }))
1148
+ : h('span', { className: 'tc-card-rail tc-card-rail--empty' }),
1149
+ h('div', { className: 'tc-card-body' },
1150
+ h('div', { className: 'tc-card-main' },
1151
+ h('div', { className: 'tc-card-head' },
1152
+ h('span', { className: 'tc-card-label' }, renaming
1153
+ ? h('input', {
1154
+ ref: inputRef,
1155
+ className: 'tc-rename',
1156
+ value,
1157
+ placeholder: translate('node.renamePlaceholder'),
1158
+ onClick: (event) => event.stopPropagation(),
1159
+ onChange: (event) => setValue(event.target.value),
1160
+ onKeyDown: (event) => {
1161
+ if (event.key === 'Enter') {
1162
+ event.preventDefault()
1163
+ event.stopPropagation()
1164
+ submit()
1165
+ } else if (event.key === 'Escape') {
1166
+ event.preventDefault()
1167
+ event.stopPropagation()
1168
+ cancelled.current = true
1169
+ suppressClick.current = true
1170
+ setTimeout(() => { suppressClick.current = false }, 0)
1171
+ setRenaming(false)
1172
+ setValue(node.label)
1173
+ }
1174
+ },
1175
+ onBlur: submit,
1176
+ })
1177
+ : h('span', { className: 'tc-card-label-text' }, question))),
1178
+ badges.length > 0 ? h('div', { className: 'tc-card-badges' }, badges) : null),
1179
+ status)),
1180
+ detailNode)
1181
+ }
1182
+
1183
+
1184
+ /* ---------------------------------------------------- canvas tree -- */
1185
+
1186
+ function contextInfo(node) {
1187
+ const contextWindow = Number(node?.contextWindow)
1188
+ const contextTokens = Number(node?.contextTokens)
1189
+ const hasContext = Number.isFinite(contextWindow) && contextWindow > 0 && Number.isFinite(contextTokens) && contextTokens >= 0
1190
+ return {
1191
+ contextWindow,
1192
+ contextTokens,
1193
+ hasContext,
1194
+ contextPct: hasContext ? Math.max(0, Math.min(100, Math.round((contextTokens / contextWindow) * 100))) : null,
1195
+ }
1196
+ }
1197
+
1198
+ /**
1199
+ * Tidy tree, drawn vertically from top to bottom: the root sits at the
1200
+ * top, siblings spread left to right, and the canvas itself can be
1201
+ * panned and zoomed without bounds.
1202
+ */
1203
+ function layoutTree(index, hiddenIds) {
1204
+ const positions = new Map()
1205
+ const edges = []
1206
+ const leafGap = 148
1207
+ const levelGap = 92
1208
+ const padding = 56
1209
+ let cursor = 0
1210
+ let maxDepth = 0
1211
+ const walk = (node, depth) => {
1212
+ const children = (index.children.get(node.id) ?? []).filter((child) => hiddenIds.has(child.id) !== true)
1213
+ let x
1214
+ if (children.length === 0) {
1215
+ x = padding + cursor * leafGap
1216
+ cursor += 1
1217
+ } else {
1218
+ const childXs = []
1219
+ for (const child of children) {
1220
+ const childPosition = walk(child, depth + 1)
1221
+ childXs.push(childPosition.x)
1222
+ edges.push([node.id, child.id])
1223
+ }
1224
+ x = childXs[0] + (childXs[childXs.length - 1] - childXs[0]) / 2
1225
+ }
1226
+ const y = padding + depth * levelGap
1227
+ const position = { x, y, depth }
1228
+ positions.set(node.id, position)
1229
+ if (depth > maxDepth) maxDepth = depth
1230
+ return position
1231
+ }
1232
+ for (const root of index.roots) {
1233
+ if (hiddenIds.has(root.id)) continue
1234
+ walk(root, 0)
1235
+ }
1236
+ const leafCount = Math.max(1, cursor)
1237
+ const width = padding * 2 + Math.max(0, leafCount - 1) * leafGap
1238
+ const height = padding * 2 + maxDepth * levelGap
1239
+ return { positions, edges, width: Math.max(160, width), height: Math.max(160, height), padding, levelGap }
1240
+ }
1241
+
1242
+ /* The canvas belongs to a conversation family, not to whichever fork
1243
+ happens to be active. Keeping its last camera and the set of nodes it
1244
+ has already shown here lets a remount (right sidebar, View tab, or a
1245
+ session switch) continue where the user left it instead of snapping
1246
+ back to the root. */
1247
+ const canvasViewPrefix = 'treecom:canvas:v1:'
1248
+ const canvasSeenByRoot = new Map()
1249
+
1250
+ function canvasViewKey(rootId) {
1251
+ return `${canvasViewPrefix}${rootId}`
1252
+ }
1253
+
1254
+ function readCanvasView(rootId) {
1255
+ try {
1256
+ const raw = localStorage.getItem(canvasViewKey(rootId))
1257
+ if (raw === null) return null
1258
+ const parsed = JSON.parse(raw)
1259
+ if (parsed === null || typeof parsed !== 'object') return null
1260
+ if (!Number.isFinite(parsed.zoom) || !Number.isFinite(parsed.cx) || !Number.isFinite(parsed.cy)) return null
1261
+ return parsed
1262
+ } catch (error) {
1263
+ return null
1264
+ }
1265
+ }
1266
+
1267
+ function writeCanvasView(rootId, view) {
1268
+ try {
1269
+ localStorage.setItem(canvasViewKey(rootId), JSON.stringify(view))
1270
+ } catch (error) {
1271
+ /* Private mode or a full quota: the camera is a nicety, not state. */
1272
+ }
1273
+ }
1274
+
1275
+ /* ------------------------------------------------------ markdown -- */
1276
+
1277
+ function markdownInline(text, keyPrefix) {
1278
+ const source = String(text ?? '')
1279
+ const pattern = /(`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|~~[^~\n]+~~|\*[^*\n]+\*|_[^_\n]+_|\[[^\]\n]+\]\([^)\s]+\))/g
1280
+ const nodes = []
1281
+ let last = 0
1282
+ let index = 0
1283
+ let match
1284
+ while ((match = pattern.exec(source)) !== null) {
1285
+ if (match.index > last) nodes.push(source.slice(last, match.index))
1286
+ const token = match[0]
1287
+ const key = `${keyPrefix}-i${index++}`
1288
+ if (token.startsWith('`')) {
1289
+ nodes.push(h('code', { key }, token.slice(1, -1)))
1290
+ } else if (token.startsWith('**') || token.startsWith('__')) {
1291
+ nodes.push(h('strong', { key }, markdownInline(token.slice(2, -2), key)))
1292
+ } else if (token.startsWith('~~')) {
1293
+ nodes.push(h('del', { key }, markdownInline(token.slice(2, -2), key)))
1294
+ } else if (token.startsWith('*') || token.startsWith('_')) {
1295
+ nodes.push(h('em', { key }, markdownInline(token.slice(1, -1), key)))
1296
+ } else {
1297
+ const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token)
1298
+ if (link !== null) {
1299
+ const href = /^(https?:|mailto:|#|\/)/i.test(link[2]) ? link[2] : '#'
1300
+ nodes.push(h('a', { key, href, target: '_blank', rel: 'noreferrer noopener' }, markdownInline(link[1], key)))
1301
+ } else {
1302
+ nodes.push(token)
1303
+ }
1304
+ }
1305
+ last = match.index + token.length
1306
+ }
1307
+ if (last < source.length) nodes.push(source.slice(last))
1308
+ return nodes.length > 0 ? nodes : [source]
1309
+ }
1310
+
1311
+ function tableCells(line) {
1312
+ return line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((cell) => cell.trim())
1313
+ }
1314
+
1315
+ function tableSeparator(line) {
1316
+ const cells = tableCells(line)
1317
+ return cells.length > 0 && cells.every((cell) => /^:?-{2,}:?$/.test(cell))
1318
+ }
1319
+
1320
+ function markdownBlocks(source) {
1321
+ const lines = String(source ?? '').replace(/\r\n?/g, '\n').split('\n')
1322
+ const blocks = []
1323
+ let i = 0
1324
+ let key = 0
1325
+ const nextKey = () => `md-${key++}`
1326
+ const isBlockStart = (line) => /^\s*(```|#{1,6}\s|>|([-*+]|\d+\.)\s|---+|\*\*\*+|___+)/.test(line)
1327
+ while (i < lines.length) {
1328
+ const line = lines[i]
1329
+ if (/^\s*$/.test(line)) {
1330
+ i += 1
1331
+ continue
1332
+ }
1333
+ const fence = /^\s*```(\S*)\s*$/.exec(line)
1334
+ if (fence !== null) {
1335
+ const code = []
1336
+ i += 1
1337
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
1338
+ code.push(lines[i])
1339
+ i += 1
1340
+ }
1341
+ if (i < lines.length) i += 1
1342
+ blocks.push(h('pre', { key: nextKey(), className: 'tc-md-pre' },
1343
+ h('code', { className: fence[1] !== '' ? `language-${fence[1]}` : undefined }, code.join('\n'))))
1344
+ continue
1345
+ }
1346
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
1347
+ if (heading !== null) {
1348
+ const tag = `h${heading[1].length}`
1349
+ blocks.push(h(tag, { key: nextKey() }, markdownInline(heading[2], nextKey())))
1350
+ i += 1
1351
+ continue
1352
+ }
1353
+ if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) {
1354
+ blocks.push(h('hr', { key: nextKey() }))
1355
+ i += 1
1356
+ continue
1357
+ }
1358
+ if (/^\s*>\s?/.test(line)) {
1359
+ const quote = []
1360
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
1361
+ quote.push(lines[i].replace(/^\s*>\s?/, ''))
1362
+ i += 1
1363
+ }
1364
+ blocks.push(h('blockquote', { key: nextKey() }, markdownBlocks(quote.join('\n'))))
1365
+ continue
1366
+ }
1367
+ const list = /^\s*([-*+]|\d+\.)\s+(.*)$/.exec(line)
1368
+ if (list !== null) {
1369
+ const ordered = /\d+\./.test(list[1])
1370
+ const items = []
1371
+ while (i < lines.length) {
1372
+ const item = /^\s*([-*+]|\d+\.)\s+(.*)$/.exec(lines[i])
1373
+ if (item === null || /\d+\./.test(item[1]) !== ordered) break
1374
+ items.push(item[2])
1375
+ i += 1
1376
+ }
1377
+ blocks.push(h(ordered ? 'ol' : 'ul', { key: nextKey() },
1378
+ items.map((item, itemIndex) => h('li', { key: `${nextKey()}-${itemIndex}` }, markdownInline(item, nextKey())))))
1379
+ continue
1380
+ }
1381
+ if (line.includes('|') && i + 1 < lines.length && tableSeparator(lines[i + 1])) {
1382
+ const headers = tableCells(line)
1383
+ i += 2
1384
+ const rows = []
1385
+ while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) {
1386
+ rows.push(tableCells(lines[i]))
1387
+ i += 1
1388
+ }
1389
+ blocks.push(h('div', { key: nextKey(), className: 'tc-md-table-wrap' },
1390
+ h('table', null,
1391
+ h('thead', null, h('tr', null, headers.map((cell, cellIndex) => h('th', { key: `${nextKey()}-${cellIndex}` }, markdownInline(cell, nextKey()))))),
1392
+ h('tbody', null, rows.map((row, rowIndex) => h('tr', { key: `${nextKey()}-${rowIndex}` }, row.map((cell, cellIndex) => h('td', { key: `${nextKey()}-${cellIndex}` }, markdownInline(cell, nextKey())))))))))
1393
+ continue
1394
+ }
1395
+ const paragraph = []
1396
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !(paragraph.length > 0 && isBlockStart(lines[i]))) {
1397
+ paragraph.push(lines[i])
1398
+ i += 1
1399
+ }
1400
+ if (paragraph.length > 0) {
1401
+ blocks.push(h('p', { key: nextKey() }, markdownInline(paragraph.join('\n'), nextKey())))
1402
+ }
1403
+ }
1404
+ return blocks
1405
+ }
1406
+
1407
+ function MarkdownText(props) {
1408
+ const text = props.text === null || props.text === undefined ? '' : String(props.text)
1409
+ const className = typeof props.className === 'string' && props.className !== '' ? `tc-md ${props.className}` : 'tc-md'
1410
+ return h('div', { className }, markdownBlocks(text))
1411
+ }
1412
+
1413
+ function TreeCanvas(props) {
1414
+ const { index, layoutIndex, hiddenIds, selectedId, activeId, selectedPath, afterFor, chainByRep, chainByMember, onExpand, onCollapse, onSelect, sessionId, rootId, locked, focusNodeId, generatingIds, childGeneratingIds, onPromote } = props
1415
+ const layout = useMemo(() => {
1416
+ const raw = layoutTree(layoutIndex ?? index, hiddenIds)
1417
+ if (layoutIndex === undefined || layoutIndex === null) return raw
1418
+ const displayDepth = new Map()
1419
+ const visit = (node, depth) => {
1420
+ displayDepth.set(node.id, depth)
1421
+ for (const child of index.children.get(node.id) ?? []) visit(child, depth + 1)
1422
+ }
1423
+ for (const root of index.roots) visit(root, 0)
1424
+ const positions = new Map(raw.positions)
1425
+ for (const [id, position] of raw.positions) {
1426
+ const depth = displayDepth.get(id)
1427
+ if (depth !== undefined) positions.set(id, { ...position, y: raw.padding + depth * raw.levelGap })
1428
+ }
1429
+ return { ...raw, positions }
1430
+ }, [index, layoutIndex, hiddenIds])
1431
+ const wrapRef = useRef(null)
1432
+ const [pan, setPan] = useState({ x: 0, y: 0 })
1433
+ const [zoom, setZoom] = useState(1)
1434
+ const [dragging, setDragging] = useState(false)
1435
+ const [tip, setTip] = useState(null)
1436
+ const [tipDetail, setTipDetail] = useState(null)
1437
+ const [tipLoading, setTipLoading] = useState(false)
1438
+ const [tipError, setTipError] = useState(null)
1439
+ const tipCloseTimer = useRef(null)
1440
+ const dragRef = useRef(null)
1441
+ const suppressClick = useRef(false)
1442
+ const tipPointerDown = useRef(false)
1443
+ const initializedRoot = useRef(null)
1444
+ const canvasRootRef = useRef(null)
1445
+ const canvasGrowTimer = useRef(null)
1446
+ const focusedNode = useRef(null)
1447
+ const panRef = useRef(pan)
1448
+ const zoomRef = useRef(1)
1449
+ const [growingIds, setGrowingIds] = useState(() => new Set())
1450
+ const [promote, setPromote] = useState(null)
1451
+ const promoteTimer = useRef(null)
1452
+ const promoteCleanup = useRef(null)
1453
+
1454
+ useEffect(() => () => {
1455
+ if (promoteTimer.current !== null) clearTimeout(promoteTimer.current)
1456
+ if (typeof promoteCleanup.current === 'function') promoteCleanup.current()
1457
+ }, [])
1458
+
1459
+ useLayoutEffect(() => {
1460
+ panRef.current = pan
1461
+ }, [pan])
1462
+
1463
+ useLayoutEffect(() => {
1464
+ if (rootId === null || rootId === undefined) return undefined
1465
+ const current = new Set(layout.positions.keys())
1466
+ const firstMount = canvasRootRef.current === null
1467
+ if (!firstMount && canvasRootRef.current !== rootId) {
1468
+ canvasRootRef.current = rootId
1469
+ if (canvasGrowTimer.current !== null) {
1470
+ clearTimeout(canvasGrowTimer.current)
1471
+ canvasGrowTimer.current = null
1472
+ }
1473
+ canvasSeenByRoot.set(rootId, current)
1474
+ setGrowingIds((existing) => (existing.size === 0 ? existing : new Set()))
1475
+ return undefined
1476
+ }
1477
+ canvasRootRef.current = rootId
1478
+ const previous = canvasSeenByRoot.get(rootId)
1479
+ canvasSeenByRoot.set(rootId, current)
1480
+ if (previous === undefined) return undefined
1481
+ const fresh = new Set()
1482
+ for (const id of current) {
1483
+ if (!previous.has(id)) fresh.add(id)
1484
+ }
1485
+ if (fresh.size === 0) return undefined
1486
+ if (fresh.size > 4) {
1487
+ setGrowingIds((existing) => (existing.size === 0 ? existing : new Set()))
1488
+ return undefined
1489
+ }
1490
+ if (canvasGrowTimer.current !== null) clearTimeout(canvasGrowTimer.current)
1491
+ setGrowingIds(fresh)
1492
+ canvasGrowTimer.current = setTimeout(() => {
1493
+ canvasGrowTimer.current = null
1494
+ setGrowingIds(new Set())
1495
+ }, 700)
1496
+ return undefined
1497
+ }, [layout, rootId])
1498
+
1499
+ useEffect(() => () => {
1500
+ if (canvasGrowTimer.current !== null) {
1501
+ clearTimeout(canvasGrowTimer.current)
1502
+ canvasGrowTimer.current = null
1503
+ }
1504
+ }, [])
1505
+
1506
+ const centerOn = (id, alignTop = false) => {
1507
+ const wrap = wrapRef.current
1508
+ if (wrap === null) return
1509
+ const rect = wrap.getBoundingClientRect()
1510
+ if (rect.width === 0 || rect.height === 0) return
1511
+ const scale = zoomRef.current
1512
+ const anchorY = alignTop ? Math.max(24, rect.height * 0.16) : rect.height / 2
1513
+ const position = id === null ? null : layout.positions.get(id)
1514
+ const nextPan = position === undefined || position === null
1515
+ ? { x: rect.width / 2 - (layout.width * scale) / 2, y: rect.height / 2 - (layout.height * scale) / 2 }
1516
+ : { x: rect.width / 2 - position.x * scale, y: anchorY - position.y * scale }
1517
+ panRef.current = nextPan
1518
+ setPan(nextPan)
1519
+ }
1520
+
1521
+ const fitTree = () => {
1522
+ const wrap = wrapRef.current
1523
+ if (wrap === null) return
1524
+ const rect = wrap.getBoundingClientRect()
1525
+ if (rect.width === 0 || rect.height === 0 || layout.width <= 0 || layout.height <= 0) return
1526
+ const padding = 40
1527
+ const fit = Math.min(
1528
+ (rect.width - padding * 2) / layout.width,
1529
+ (rect.height - padding * 2) / layout.height,
1530
+ )
1531
+ const scale = Math.min(1, Math.max(0.3, fit))
1532
+ let top = null
1533
+ for (const position of layout.positions.values()) {
1534
+ if (top === null || position.y < top.y) top = position
1535
+ }
1536
+ const nextPan = top === null
1537
+ ? {
1538
+ x: (rect.width - layout.width * scale) / 2,
1539
+ y: (rect.height - layout.height * scale) / 2,
1540
+ }
1541
+ : {
1542
+ x: rect.width / 2 - top.x * scale,
1543
+ y: Math.max(24, rect.height * 0.16) - top.y * scale,
1544
+ }
1545
+ zoomRef.current = scale
1546
+ panRef.current = nextPan
1547
+ setZoom(scale)
1548
+ setPan(nextPan)
1549
+ }
1550
+
1551
+ const ensureVisible = (id) => {
1552
+ const wrap = wrapRef.current
1553
+ if (wrap === null) return
1554
+ const position = layout.positions.get(id)
1555
+ if (position === undefined) return
1556
+ const rect = wrap.getBoundingClientRect()
1557
+ if (rect.width === 0 || rect.height === 0) return
1558
+ const scale = zoomRef.current
1559
+ const margin = 56
1560
+ const screenX = panRef.current.x + position.x * scale
1561
+ const screenY = panRef.current.y + position.y * scale
1562
+ let dx = 0
1563
+ let dy = 0
1564
+ if (screenX < margin) dx = margin - screenX
1565
+ else if (screenX > rect.width - margin) dx = (rect.width - margin) - screenX
1566
+ if (screenY < margin) dy = margin - screenY
1567
+ else if (screenY > rect.height - margin) dy = (rect.height - margin) - screenY
1568
+ if (dx === 0 && dy === 0) return
1569
+ const nextPan = { x: panRef.current.x + dx, y: panRef.current.y + dy }
1570
+ panRef.current = nextPan
1571
+ setPan(nextPan)
1572
+ }
1573
+
1574
+ const initView = () => {
1575
+ const wrap = wrapRef.current
1576
+ if (wrap === null || rootId === null || rootId === undefined) return
1577
+ if (wrap.clientWidth < 24 || wrap.clientHeight < 24) return
1578
+ if (initializedRoot.current === rootId) return
1579
+ initializedRoot.current = rootId
1580
+ const stored = readCanvasView(rootId)
1581
+ if (stored !== null) {
1582
+ const nextZoom = Math.min(3, Math.max(0.3, stored.zoom))
1583
+ const nextPan = {
1584
+ x: wrap.clientWidth / 2 - stored.cx * nextZoom,
1585
+ y: wrap.clientHeight / 2 - stored.cy * nextZoom,
1586
+ }
1587
+ zoomRef.current = nextZoom
1588
+ panRef.current = nextPan
1589
+ setZoom(nextZoom)
1590
+ setPan(nextPan)
1591
+ return
1592
+ }
1593
+ zoomRef.current = 1
1594
+ setZoom(1)
1595
+ centerOn(index.roots[0]?.id ?? selectedId ?? activeId ?? null, true)
1596
+ }
1597
+
1598
+ useEffect(() => {
1599
+ if (tipCloseTimer.current !== null) {
1600
+ clearTimeout(tipCloseTimer.current)
1601
+ tipCloseTimer.current = null
1602
+ }
1603
+ setTip(null)
1604
+ }, [sessionId])
1605
+
1606
+ useEffect(() => {
1607
+ const node = tip?.node ?? null
1608
+ if (node === null || node.kind === 'root') {
1609
+ setTipDetail(null)
1610
+ setTipLoading(false)
1611
+ setTipError(null)
1612
+ return undefined
1613
+ }
1614
+ const cacheKey = `${node.id}:${node.endSeq ?? ''}`
1615
+ const cached = detailCache.get(cacheKey)
1616
+ if (cached !== undefined && Date.now() - cached.at < DETAIL_CACHE_MS) {
1617
+ setTipDetail(cached.detail)
1618
+ setTipLoading(false)
1619
+ setTipError(null)
1620
+ return undefined
1621
+ }
1622
+ let cancelled = false
1623
+ setTipDetail(null)
1624
+ setTipLoading(true)
1625
+ setTipError(null)
1626
+ fetchDetail(node.id).then((detail) => {
1627
+ if (cancelled) return
1628
+ detailCache.set(cacheKey, { at: Date.now(), detail })
1629
+ setTipDetail(detail)
1630
+ }).catch((error) => {
1631
+ if (!cancelled) setTipError(noticeText(error))
1632
+ }).finally(() => {
1633
+ if (!cancelled) setTipLoading(false)
1634
+ })
1635
+ return () => { cancelled = true }
1636
+ }, [tip?.node.id, tip?.node.endSeq])
1637
+
1638
+ useEffect(() => () => {
1639
+ if (tipCloseTimer.current !== null) {
1640
+ clearTimeout(tipCloseTimer.current)
1641
+ tipCloseTimer.current = null
1642
+ }
1643
+ }, [])
1644
+
1645
+ useLayoutEffect(() => {
1646
+ initView()
1647
+ }, [rootId, layout])
1648
+
1649
+ useEffect(() => {
1650
+ const wrap = wrapRef.current
1651
+ if (wrap === null || typeof ResizeObserver === 'undefined') return undefined
1652
+ const observer = new ResizeObserver(() => { initView() })
1653
+ observer.observe(wrap)
1654
+ return () => observer.disconnect()
1655
+ }, [rootId, layout])
1656
+
1657
+ useLayoutEffect(() => {
1658
+ if (focusNodeId === null || focusNodeId === undefined) return
1659
+ if (focusedNode.current === focusNodeId) return
1660
+ if (!layout.positions.has(focusNodeId)) return
1661
+ focusedNode.current = focusNodeId
1662
+ ensureVisible(focusNodeId)
1663
+ }, [focusNodeId, layout])
1664
+
1665
+ useEffect(() => {
1666
+ if (rootId === null || rootId === undefined) return undefined
1667
+ const wrap = wrapRef.current
1668
+ if (wrap === null || wrap.clientWidth < 24) return undefined
1669
+ const timer = setTimeout(() => {
1670
+ if (wrap.clientWidth < 24 || wrap.clientHeight < 24) return
1671
+ writeCanvasView(rootId, {
1672
+ zoom,
1673
+ cx: (wrap.clientWidth / 2 - pan.x) / zoom,
1674
+ cy: (wrap.clientHeight / 2 - pan.y) / zoom,
1675
+ })
1676
+ }, 180)
1677
+ return () => clearTimeout(timer)
1678
+ }, [pan, zoom, rootId])
1679
+
1680
+ useEffect(() => {
1681
+ const wrap = wrapRef.current
1682
+ if (wrap === null) return undefined
1683
+ const onWheel = (event) => {
1684
+ if (typeof Element !== 'undefined' && event.target instanceof Element && event.target.closest('.tc-canvas-tip') !== null) return
1685
+ event.preventDefault()
1686
+ const rect = wrap.getBoundingClientRect()
1687
+ if (rect.width === 0 || rect.height === 0) return
1688
+ const factor = Math.exp(-event.deltaY * 0.0015)
1689
+ const next = Math.min(3, Math.max(0.3, zoomRef.current * factor))
1690
+ if (Math.abs(next - zoomRef.current) < 0.0001) return
1691
+ const px = event.clientX - rect.left
1692
+ const py = event.clientY - rect.top
1693
+ const current = panRef.current
1694
+ const worldX = (px - current.x) / zoomRef.current
1695
+ const worldY = (py - current.y) / zoomRef.current
1696
+ const nextPan = { x: px - worldX * next, y: py - worldY * next }
1697
+ zoomRef.current = next
1698
+ panRef.current = nextPan
1699
+ setZoom(next)
1700
+ setPan(nextPan)
1701
+ }
1702
+ wrap.addEventListener('wheel', onWheel, { passive: false })
1703
+ return () => { wrap.removeEventListener('wheel', onWheel) }
1704
+ }, [])
1705
+
1706
+ useEffect(() => {
1707
+ const releaseTipPointer = () => { tipPointerDown.current = false }
1708
+ window.addEventListener('pointerup', releaseTipPointer)
1709
+ window.addEventListener('pointercancel', releaseTipPointer)
1710
+ return () => {
1711
+ window.removeEventListener('pointerup', releaseTipPointer)
1712
+ window.removeEventListener('pointercancel', releaseTipPointer)
1713
+ }
1714
+ }, [])
1715
+
1716
+ const isElement = (value) => typeof Element !== 'undefined' && value instanceof Element
1717
+
1718
+ const clearTipTimer = () => {
1719
+ if (tipCloseTimer.current !== null) {
1720
+ clearTimeout(tipCloseTimer.current)
1721
+ tipCloseTimer.current = null
1722
+ }
1723
+ }
1724
+
1725
+ const hideTipNow = () => {
1726
+ clearTipTimer()
1727
+ setTip(null)
1728
+ }
1729
+ useEffect(() => {
1730
+ if (locked === true) hideTipNow()
1731
+ }, [locked])
1732
+
1733
+ const scheduleHideTip = () => {
1734
+ clearTipTimer()
1735
+ tipCloseTimer.current = setTimeout(() => {
1736
+ tipCloseTimer.current = null
1737
+ setTip(null)
1738
+ }, 180)
1739
+ }
1740
+
1741
+ const cancelHideTip = () => {
1742
+ clearTipTimer()
1743
+ }
1744
+
1745
+ const startDrag = (event) => {
1746
+ if (locked === true || event.button !== 0) return
1747
+ if (isElement(event.target) && event.target.closest('.tc-canvas-node, .tc-canvas-toolbar, .tc-canvas-tip') !== null) return
1748
+ const wrap = wrapRef.current
1749
+ if (wrap === null) return
1750
+ dragRef.current = {
1751
+ pointerId: event.pointerId,
1752
+ startX: event.clientX,
1753
+ startY: event.clientY,
1754
+ panX: panRef.current.x,
1755
+ panY: panRef.current.y,
1756
+ moved: false,
1757
+ }
1758
+ setDragging(true)
1759
+ hideTipNow()
1760
+ try {
1761
+ wrap.setPointerCapture(event.pointerId)
1762
+ } catch {
1763
+ /* pointer capture can be unavailable in tests */
1764
+ }
1765
+ }
1766
+
1767
+ const moveDrag = (event) => {
1768
+ const drag = dragRef.current
1769
+ if (drag === null || drag.pointerId !== event.pointerId) return
1770
+ const dx = event.clientX - drag.startX
1771
+ const dy = event.clientY - drag.startY
1772
+ if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true
1773
+ if (drag.moved) {
1774
+ const nextPan = { x: drag.panX + dx, y: drag.panY + dy }
1775
+ panRef.current = nextPan
1776
+ setPan(nextPan)
1777
+ }
1778
+ }
1779
+
1780
+ const endDrag = (event) => {
1781
+ const drag = dragRef.current
1782
+ if (drag === null || drag.pointerId !== event.pointerId) return
1783
+ if (drag.moved) {
1784
+ suppressClick.current = true
1785
+ setTimeout(() => {
1786
+ suppressClick.current = false
1787
+ }, 0)
1788
+ }
1789
+ dragRef.current = null
1790
+ setDragging(false)
1791
+ }
1792
+
1793
+ const startNodePress = (event, node) => {
1794
+ if (event.button !== 0 || node.kind === 'root' || locked === true) return
1795
+ const startX = event.clientX
1796
+ const startY = event.clientY
1797
+ const gesture = { node, active: false, over: false, pointerId: event.pointerId }
1798
+ const cleanup = () => {
1799
+ if (promoteTimer.current !== null) {
1800
+ clearTimeout(promoteTimer.current)
1801
+ promoteTimer.current = null
1802
+ }
1803
+ window.removeEventListener('pointermove', onMove)
1804
+ window.removeEventListener('pointerup', onUp)
1805
+ window.removeEventListener('pointercancel', onUp)
1806
+ promoteCleanup.current = null
1807
+ setPromote(null)
1808
+ }
1809
+ const onMove = (nextEvent) => {
1810
+ if (nextEvent.pointerId !== gesture.pointerId) return
1811
+ if (!gesture.active) {
1812
+ if (Math.hypot(nextEvent.clientX - startX, nextEvent.clientY - startY) > 7) cleanup()
1813
+ return
1814
+ }
1815
+ const wrap = wrapRef.current
1816
+ if (wrap === null) return
1817
+ const rect = wrap.getBoundingClientRect()
1818
+ gesture.over = nextEvent.clientY < rect.top + 112
1819
+ && nextEvent.clientX > rect.left + 8
1820
+ && nextEvent.clientX < rect.right - 8
1821
+ setPromote({ node, x: nextEvent.clientX, y: nextEvent.clientY, over: gesture.over })
1822
+ }
1823
+ const onUp = () => {
1824
+ if (gesture.active) {
1825
+ if (gesture.over === true && typeof onPromote === 'function') onPromote(node)
1826
+ suppressClick.current = true
1827
+ setTimeout(() => { suppressClick.current = false }, 0)
1828
+ }
1829
+ cleanup()
1830
+ }
1831
+ promoteCleanup.current = cleanup
1832
+ window.addEventListener('pointermove', onMove)
1833
+ window.addEventListener('pointerup', onUp)
1834
+ window.addEventListener('pointercancel', onUp)
1835
+ promoteTimer.current = setTimeout(() => {
1836
+ gesture.active = true
1837
+ suppressClick.current = true
1838
+ setTip(null)
1839
+ setPromote({ node, x: startX, y: startY, over: false })
1840
+ }, 480)
1841
+ }
1842
+
1843
+ const showTip = (node, element) => {
1844
+ const wrap = wrapRef.current
1845
+ if (wrap === null) return
1846
+ cancelHideTip()
1847
+ const wrapRect = wrap.getBoundingClientRect()
1848
+ const rect = element.getBoundingClientRect()
1849
+ const half = Math.min(160, Math.max(80, (wrapRect.width - 24) / 2))
1850
+ const centerX = rect.left - wrapRect.left + rect.width / 2
1851
+ const x = Math.max(half, Math.min(Math.max(half, wrapRect.width - half), centerX))
1852
+ const y = rect.top - wrapRect.top + rect.height / 2
1853
+ const spaceBelow = Math.max(0, wrapRect.height - y - 24)
1854
+ const spaceAbove = Math.max(0, y - 24)
1855
+ const below = spaceBelow >= 160 && (spaceBelow >= spaceAbove || spaceAbove < 160)
1856
+ const maxHeight = Math.max(160, Math.min(420, below ? spaceBelow : spaceAbove))
1857
+ setTip({ node, x, y, after: afterFor(node), below, maxHeight })
1858
+ }
1859
+
1860
+ const nodes = []
1861
+ for (const [id, position] of layout.positions) {
1862
+ const node = index.byId.get(id)
1863
+ if (node === undefined) continue
1864
+ const chain = chainByRep?.get(id) ?? null
1865
+ const classes = [
1866
+ 'tc-canvas-node',
1867
+ node.kind === 'root' ? 'tc-canvas-node--root' : '',
1868
+ node.kind === 'blank' ? 'tc-canvas-node--blank' : '',
1869
+ chain !== null ? 'tc-canvas-node--collapsed' : '',
1870
+ id === selectedId ? 'tc-canvas-node--selected' : '',
1871
+ id === activeId ? 'tc-canvas-node--active' : '',
1872
+ selectedPath.has(id) ? 'tc-canvas-node--path' : '',
1873
+ generatingIds?.has(id) === true ? 'tc-canvas-node--running' : '',
1874
+ childGeneratingIds?.has(id) === true ? 'tc-canvas-node--child-running' : '',
1875
+ promote?.node.id === id ? 'tc-canvas-node--promoting' : '',
1876
+ ].filter((value) => value !== '').join(' ')
1877
+ const question = node.prompt || node.label || translate('node.blank')
1878
+ const growing = growingIds.has(id)
1879
+ const growClasses = growing ? `${classes} tc-canvas-node--grow` : classes
1880
+ const growFrom = node.parentId === null || node.parentId === undefined
1881
+ ? null
1882
+ : layout.positions.get(node.parentId)
1883
+ const nodeStyle = { left: `${position.x}px`, top: `${position.y}px` }
1884
+ if (growing) {
1885
+ nodeStyle['--grow-x'] = `${(growFrom?.x ?? position.x) - position.x}px`
1886
+ nodeStyle['--grow-y'] = `${(growFrom?.y ?? position.y) - position.y}px`
1887
+ }
1888
+ nodes.push(h('button', {
1889
+ key: id,
1890
+ type: 'button',
1891
+ className: growClasses,
1892
+ style: nodeStyle,
1893
+ 'aria-label': chain !== null
1894
+ ? fmt(translate('node.chainAria'), { n: chain.count })
1895
+ : (node.kind === 'root' ? translate('path.root') : question),
1896
+ onClick: (event) => {
1897
+ event.stopPropagation()
1898
+ if (suppressClick.current) return
1899
+ setTip(null)
1900
+ if (chain !== null) onExpand(chain)
1901
+ else onSelect(node)
1902
+ },
1903
+ onPointerDown: (event) => {
1904
+ event.stopPropagation()
1905
+ startNodePress(event, node)
1906
+ },
1907
+ onMouseEnter: (event) => showTip(node, event.currentTarget),
1908
+ onMouseLeave: scheduleHideTip,
1909
+ },
1910
+ h('span', { className: 'tc-canvas-node-halo', 'aria-hidden': 'true' },
1911
+ chain !== null ? h('span', { className: 'tc-canvas-node-count' }, String(chain.count)) : null),
1912
+ h('span', { className: 'tc-canvas-node-dot' }, node.kind === 'root' ? h(Icon, { name: 'tree', size: 11 }) : null)))
1913
+ }
1914
+
1915
+ /* Positions come from the full layout so collapsing never moves an
1916
+ ancestor; edges come from the display tree so a chain representative
1917
+ is still connected to its visible parent. */
1918
+ const edges = []
1919
+ for (const node of index.byId.values()) {
1920
+ const fromId = node.parentId
1921
+ if (fromId === null || fromId === undefined || !index.byId.has(fromId)) continue
1922
+ const from = layout.positions.get(fromId)
1923
+ const to = layout.positions.get(node.id)
1924
+ if (from === undefined || to === undefined) continue
1925
+ const midY = (from.y + to.y) / 2
1926
+ const d = `M ${from.x} ${from.y} C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`
1927
+ const growing = growingIds.has(node.id)
1928
+ edges.push(h('path', {
1929
+ key: `${fromId}>${node.id}`,
1930
+ d,
1931
+ pathLength: growing ? 1 : undefined,
1932
+ className: `tc-canvas-edge${selectedPath.has(node.id) ? ' tc-canvas-edge--path' : ''}${growing ? ' tc-canvas-edge--grow' : ''}`,
1933
+ }))
1934
+ }
1935
+
1936
+ let tipElement = null
1937
+ if (tip !== null) {
1938
+ const tipChain = chainByRep?.get(tip.node.id) ?? chainByMember?.get(tip.node.id) ?? null
1939
+ const tipContext = contextInfo(tip.node)
1940
+ const tipGenerating = generatingIds?.has(tip.node.id) === true
1941
+ const tipChildGenerating = childGeneratingIds?.has(tip.node.id) === true
1942
+ const tipTurn = tip.node.kind === 'root'
1943
+ ? translate('path.root')
1944
+ : tip.node.kind === 'blank'
1945
+ ? translate('node.blank')
1946
+ : fmt(translate('node.turn'), { n: tip.node.turn })
1947
+ const tipQuestion = tip.node.kind === 'root'
1948
+ ? translate('node.rootDetail')
1949
+ : (tipDetail?.prompt ?? tip.node.prompt ?? tip.node.label ?? translate('node.blank'))
1950
+ const tipAnswer = tipDetail?.answer ?? tip.node.answer ?? ''
1951
+ const tipAnswerText = tip.node.kind === 'root'
1952
+ ? ''
1953
+ : tipAnswer !== ''
1954
+ ? tipAnswer
1955
+ : tipLoading
1956
+ ? translate('node.detailLoading')
1957
+ : tip.node.open === true
1958
+ ? translate('node.detailWaiting')
1959
+ : translate('node.answerEmpty')
1960
+ const tipErrorText = tipError === null ? null : fmt(translate('node.detailFailed'), { message: tipError })
1961
+ tipElement = h('div', {
1962
+ className: 'tc-canvas-tip',
1963
+ role: 'tooltip',
1964
+ style: {
1965
+ left: `${tip.x}px`,
1966
+ top: `${tip.below ? tip.y + 20 : tip.y - 14}px`,
1967
+ transform: tip.below ? 'translate(-50%, 0)' : 'translate(-50%, -100%)',
1968
+ maxHeight: `${tip.maxHeight}px`,
1969
+ },
1970
+ onMouseEnter: cancelHideTip,
1971
+ onMouseLeave: () => { if (!tipPointerDown.current) scheduleHideTip() },
1972
+ onPointerDown: (event) => { event.stopPropagation(); tipPointerDown.current = true; cancelHideTip() },
1973
+ onPointerUp: () => { tipPointerDown.current = false },
1974
+ onPointerCancel: () => { tipPointerDown.current = false },
1975
+ onClick: (event) => event.stopPropagation(),
1976
+ },
1977
+ h('div', { className: 'tc-tip-head' },
1978
+ h('span', { className: 'tc-tip-turn' }, tipTurn),
1979
+ tip.node.id === activeId ? h('span', { className: 'tc-tip-here' }, translate('node.youAreHere')) : null,
1980
+ tipGenerating
1981
+ ? h('span', { className: 'tc-tip-badge tc-tip-badge--running' }, translate('node.running'))
1982
+ : tipChildGenerating
1983
+ ? h('span', { className: 'tc-tip-badge tc-tip-badge--child-running' }, translate('node.childRunning'))
1984
+ : null),
1985
+ tipChain !== null
1986
+ ? (tipChain.prefix === true
1987
+ ? h('div', { className: 'tc-tip-chain' }, fmt(translate('node.chainHint'), { n: tipChain.count }))
1988
+ : tipChain.expanded === true
1989
+ ? (tipChain.index > 0
1990
+ ? h('button', {
1991
+ type: 'button',
1992
+ className: 'tc-tip-chain tc-tip-chain--action',
1993
+ onClick: (event) => { event.stopPropagation(); hideTipNow(); onCollapse(tipChain) },
1994
+ }, fmt(translate('node.chainCollapse'), { n: tipChain.index }))
1995
+ : null)
1996
+ : h('div', { className: 'tc-tip-chain' }, fmt(translate('node.chainHint'), { n: tipChain.count })))
1997
+ : null,
1998
+ h('div', { className: 'tc-tip-meta' },
1999
+ tipContext.hasContext ? `${tipContext.contextPct}%` : translate('node.contextUnknown'),
2000
+ ' · ',
2001
+ fmt(translate('node.afterShort'), { n: tip.after.n })),
2002
+ h('div', { className: 'tc-tip-body' },
2003
+ h('div', { className: 'tc-tip-section' },
2004
+ h('div', { className: 'tc-tip-label' }, translate('node.questionLabel')),
2005
+ h('div', { className: 'tc-tip-text' }, h(MarkdownText, { text: tipQuestion, className: 'tc-md--tip' }))),
2006
+ tip.node.kind === 'root' ? null : h('div', { className: 'tc-tip-section' },
2007
+ h('div', { className: 'tc-tip-label' }, translate('node.answerLabel')),
2008
+ h('div', { className: 'tc-tip-text' }, h(MarkdownText, { text: tipAnswerText, className: 'tc-md--tip' }))),
2009
+ tipErrorText !== null ? h('div', { className: 'tc-tip-error' }, tipErrorText) : null,
2010
+ tipDetail?.truncated === true ? h('div', { className: 'tc-tip-note' }, translate('node.detailTruncated')) : null),
2011
+ h('button', {
2012
+ type: 'button',
2013
+ className: 'tc-tip-hint tc-tip-hint--action',
2014
+ onClick: (event) => {
2015
+ event.stopPropagation()
2016
+ let selectedText = ''
2017
+ try { selectedText = window.getSelection()?.toString() ?? '' } catch { /* no selection API */ }
2018
+ if (selectedText.trim() !== '') return
2019
+ hideTipNow()
2020
+ const expandable = chainByRep?.get(tip.node.id) ?? null
2021
+ if (expandable !== null) onExpand(expandable)
2022
+ else onSelect(tip.node)
2023
+ },
2024
+ }, translate('node.hoverHint')))
2025
+ }
2026
+
2027
+ return h('div', {
2028
+ ref: wrapRef,
2029
+ className: `tc-canvas-wrap${dragging ? ' tc-canvas-wrap--dragging' : ''}${locked === true ? ' tc-canvas-wrap--locked' : ''}`,
2030
+ style: { backgroundPosition: `${pan.x}px ${pan.y}px` },
2031
+ onPointerDown: startDrag,
2032
+ onPointerMove: moveDrag,
2033
+ onPointerUp: endDrag,
2034
+ onPointerCancel: endDrag,
2035
+ onClick: () => {
2036
+ if (!suppressClick.current) hideTipNow()
2037
+ },
2038
+ },
2039
+ h('div', {
2040
+ className: 'tc-canvas-world',
2041
+ style: {
2042
+ width: `${layout.width}px`,
2043
+ height: `${layout.height}px`,
2044
+ transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
2045
+ },
2046
+ },
2047
+ h('svg', {
2048
+ className: 'tc-canvas-edges',
2049
+ width: layout.width,
2050
+ height: layout.height,
2051
+ viewBox: `0 0 ${layout.width} ${layout.height}`,
2052
+ 'aria-hidden': 'true',
2053
+ }, edges),
2054
+ nodes),
2055
+ h('div', { className: 'tc-canvas-toolbar' },
2056
+ h('button', {
2057
+ type: 'button',
2058
+ className: 'tc-canvas-btn',
2059
+ title: translate('node.center'),
2060
+ onPointerDown: (event) => event.stopPropagation(),
2061
+ onClick: (event) => {
2062
+ event.stopPropagation()
2063
+ centerOn(activeId ?? selectedId ?? null)
2064
+ },
2065
+ }, translate('node.center')),
2066
+ h('button', {
2067
+ type: 'button',
2068
+ className: 'tc-canvas-btn',
2069
+ title: translate('node.fit'),
2070
+ onPointerDown: (event) => event.stopPropagation(),
2071
+ onClick: (event) => {
2072
+ event.stopPropagation()
2073
+ fitTree()
2074
+ },
2075
+ }, translate('node.fit'))),
2076
+ h('div', { className: 'tc-canvas-hint' }, translate('node.canvasHint')),
2077
+ promote !== null
2078
+ ? h('div', { className: `tc-promote-zone${promote.over === true ? ' tc-promote-zone--over' : ''}` }, translate('node.promoteDrop'))
2079
+ : null,
2080
+ tipElement)
2081
+ }
2082
+
2083
+ function NodeDetailPane(props) {
2084
+ const {
2085
+ node, after, afterOnPath, isHere, isLeaf, running, childRunning, liveRunning, archived,
2086
+ canFork, busy, hasChildren, childCollapsed, displayName, onJump, onFork, onRename, onArchive, onAsk,
2087
+ onToggleChildren, onPromote,
2088
+ } = props
2089
+ const [detail, setDetail] = useState(null)
2090
+ const [loading, setLoading] = useState(false)
2091
+ const [loadError, setLoadError] = useState(null)
2092
+ const [renaming, setRenaming] = useState(false)
2093
+ const [value, setValue] = useState('')
2094
+ const [copied, setCopied] = useState(null)
2095
+ const [askText, setAskText] = useState('')
2096
+ const [sending, setSending] = useState(false)
2097
+ const inputRef = useRef(null)
2098
+ const bodyRef = useRef(null)
2099
+ const renamingRef = useRef(false)
2100
+ const copyTimer = useRef(null)
2101
+ const nodeId = node?.id ?? null
2102
+ const endSeq = node?.endSeq ?? null
2103
+ const cacheKey = nodeId === null ? null : `${nodeId}:${endSeq ?? ''}`
2104
+ const live = node !== null && node.kind === 'turn' && (liveRunning === true || running === true || node.open === true)
2105
+
2106
+ useEffect(() => {
2107
+ renamingRef.current = false
2108
+ setRenaming(false)
2109
+ setValue(node?.label ?? '')
2110
+ setCopied(null)
2111
+ setAskText('')
2112
+ setSending(false)
2113
+ setDetail(null)
2114
+ setLoadError(null)
2115
+ if (bodyRef.current !== null) bodyRef.current.scrollTop = 0
2116
+ }, [nodeId])
2117
+
2118
+ useEffect(() => () => {
2119
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current)
2120
+ }, [])
2121
+
2122
+ useEffect(() => {
2123
+ if (!renaming) return undefined
2124
+ renamingRef.current = true
2125
+ const timer = setTimeout(() => {
2126
+ if (inputRef.current !== null) {
2127
+ inputRef.current.focus()
2128
+ inputRef.current.select()
2129
+ }
2130
+ }, 0)
2131
+ return () => clearTimeout(timer)
2132
+ }, [renaming])
2133
+
2134
+ useEffect(() => {
2135
+ setLoadError(null)
2136
+ if (node === null || node.kind === 'root' || cacheKey === null) {
2137
+ setLoading(false)
2138
+ return undefined
2139
+ }
2140
+ const cached = detailCache.get(cacheKey)
2141
+ if (cached !== undefined && Date.now() - cached.at < DETAIL_CACHE_MS) {
2142
+ setDetail(cached.detail)
2143
+ setLoading(false)
2144
+ return undefined
2145
+ }
2146
+ let cancelled = false
2147
+ setLoading(true)
2148
+ fetchDetail(node.id).then((next) => {
2149
+ if (cancelled) return
2150
+ detailCache.set(cacheKey, { at: Date.now(), detail: next })
2151
+ setDetail(next)
2152
+ }).catch((error) => {
2153
+ if (!cancelled) setLoadError(noticeText(error))
2154
+ }).finally(() => {
2155
+ if (!cancelled) setLoading(false)
2156
+ })
2157
+ return () => {
2158
+ cancelled = true
2159
+ }
2160
+ }, [nodeId, endSeq, node?.kind, cacheKey])
2161
+
2162
+ useEffect(() => {
2163
+ if (!live || node === null || cacheKey === null) return undefined
2164
+ let cancelled = false
2165
+ const poll = () => {
2166
+ fetchDetail(node.id).then((next) => {
2167
+ if (cancelled) return
2168
+ detailCache.set(cacheKey, { at: Date.now(), detail: next })
2169
+ setDetail(next)
2170
+ setLoadError(null)
2171
+ }).catch(() => {})
2172
+ }
2173
+ const timer = setInterval(poll, 800)
2174
+ return () => {
2175
+ cancelled = true
2176
+ clearInterval(timer)
2177
+ }
2178
+ }, [live, nodeId, endSeq, node?.kind, cacheKey])
2179
+
2180
+ const submitRename = () => {
2181
+ if (node === null || !renamingRef.current) return
2182
+ renamingRef.current = false
2183
+ setRenaming(false)
2184
+ const next = value.trim()
2185
+ if (next !== '' && next !== node.label) onRename(node, next)
2186
+ }
2187
+
2188
+ const startRename = () => {
2189
+ if (node === null) return
2190
+ renamingRef.current = true
2191
+ setRenaming(true)
2192
+ setValue(node.label ?? '')
2193
+ }
2194
+
2195
+ const cancelRename = () => {
2196
+ renamingRef.current = false
2197
+ setRenaming(false)
2198
+ setValue(node?.label ?? '')
2199
+ }
2200
+
2201
+ const submitAsk = async () => {
2202
+ if (node === null || node.kind !== 'blank' || busy || sending) return
2203
+ if (typeof onAsk !== 'function') return
2204
+ const text = askText.trim()
2205
+ if (text === '') return
2206
+ setAskText('')
2207
+ setSending(true)
2208
+ try {
2209
+ await onAsk(node, text)
2210
+ } catch {
2211
+ setAskText(text)
2212
+ } finally {
2213
+ setSending(false)
2214
+ }
2215
+ }
2216
+
2217
+ if (node === null) {
2218
+ return h('aside', { className: 'tc-side', 'aria-label': translate('node.selectHint') },
2219
+ h('div', { className: 'tc-side-empty' }, translate('node.selectHint')))
2220
+ }
2221
+
2222
+ const context = contextInfo(node)
2223
+ const question = detail?.prompt ?? node.prompt ?? ''
2224
+ const answer = detail?.answer ?? node.answer ?? ''
2225
+ const answerText = answer !== ''
2226
+ ? answer
2227
+ : loading === true
2228
+ ? translate('node.detailLoading')
2229
+ : node.open === true
2230
+ ? translate('node.detailWaiting')
2231
+ : translate('node.answerEmpty')
2232
+
2233
+ const copySection = async (kind, text) => {
2234
+ if (text === '') return
2235
+ const ok = await copyToClipboard(text)
2236
+ if (!ok) return
2237
+ setCopied(kind)
2238
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current)
2239
+ copyTimer.current = setTimeout(() => {
2240
+ copyTimer.current = null
2241
+ setCopied(null)
2242
+ }, 1500)
2243
+ }
2244
+
2245
+ const copyButton = (kind, text) => h('button', {
2246
+ type: 'button',
2247
+ className: `tc-copy-btn${copied === kind ? ' tc-copy-btn--done' : ''}`,
2248
+ disabled: text === '',
2249
+ title: translate(copied === kind ? 'node.copied' : 'node.copy'),
2250
+ 'aria-label': translate(copied === kind ? 'node.copied' : 'node.copy'),
2251
+ onClick: (event) => {
2252
+ event.stopPropagation()
2253
+ copySection(kind, text)
2254
+ },
2255
+ },
2256
+ h(Icon, { name: copied === kind ? 'check' : 'copy', size: 11 }),
2257
+ copied === kind ? translate('node.copied') : translate('node.copy'))
2258
+
2259
+ const chatHead = (kind, label, text) => h('div', { className: 'tc-chat-head' },
2260
+ h('span', { className: 'tc-chat-label' }, label),
2261
+ copyButton(kind, text))
2262
+ const title = typeof displayName === 'string' && displayName.trim() !== ''
2263
+ ? displayName.trim()
2264
+ : node.kind === 'root'
2265
+ ? translate('path.root')
2266
+ : node.kind === 'blank'
2267
+ ? translate('node.blank')
2268
+ : fmt(translate('node.turn'), { n: node.turn })
2269
+ const subtitle = node.kind === 'root'
2270
+ ? ''
2271
+ : node.kind === 'blank'
2272
+ ? translate('node.waiting')
2273
+ : fmt(translate('node.turn'), { n: node.turn })
2274
+ const afterTitle = after === 0
2275
+ ? translate('node.afterZero')
2276
+ : fmt(translate(afterOnPath ? 'node.afterPathTitle' : 'node.afterBranchTitle'), { n: after })
2277
+ const contextTitle = context.hasContext
2278
+ ? fmt(translate('node.contextTitle'), {
2279
+ used: formatNumber(context.contextTokens),
2280
+ window: formatNumber(context.contextWindow),
2281
+ pct: context.contextPct,
2282
+ })
2283
+ : translate('node.contextUnknown')
2284
+ const barClass = `tc-side-ctx-bar${context.contextPct !== null && context.contextPct >= 90 ? ' tc-side-ctx-bar--error' : context.contextPct !== null && context.contextPct >= 70 ? ' tc-side-ctx-bar--warn' : ''}`
2285
+
2286
+ const badges = []
2287
+ if (isHere) badges.push(h('span', { key: 'here', className: 'tc-side-badge tc-side-badge--here' }, translate('node.youAreHere')))
2288
+ if (running) badges.push(h('span', { key: 'running', className: 'tc-side-badge tc-side-badge--running' }, translate('node.running')))
2289
+ else if (childRunning) badges.push(h('span', { key: 'child-running', className: 'tc-side-badge tc-side-badge--child-running' }, translate('node.childRunning')))
2290
+ else if (node.open === true) badges.push(h('span', { key: 'open', className: 'tc-side-badge' }, translate('node.open')))
2291
+ if (archived) badges.push(h('span', { key: 'archived', className: 'tc-side-badge' }, translate('node.archive')))
2292
+
2293
+ const blocking = running === true || childRunning === true
2294
+ const actions = []
2295
+ if (node.kind === 'turn' || node.kind === 'blank') {
2296
+ actions.push(h('button', {
2297
+ key: 'jump',
2298
+ type: 'button',
2299
+ className: 'tc-side-act',
2300
+ title: translate('node.jumpHint'),
2301
+ disabled: busy,
2302
+ onClick: () => onJump(node),
2303
+ }, h(Icon, { name: 'right', size: 13 }), translate('node.jump')))
2304
+ }
2305
+ if (node.kind === 'turn') {
2306
+ actions.push(h('button', {
2307
+ key: 'fork',
2308
+ type: 'button',
2309
+ className: 'tc-side-act tc-side-act--primary',
2310
+ title: translate('action.branchHint'),
2311
+ disabled: busy || blocking || canFork !== true,
2312
+ onClick: () => onFork(node),
2313
+ }, h(Icon, { name: 'plus', size: 13 }), translate('action.branch')))
2314
+ }
2315
+ if (node.kind !== 'root' && typeof onPromote === 'function') {
2316
+ actions.push(h('button', {
2317
+ key: 'promote',
2318
+ type: 'button',
2319
+ className: 'tc-side-act',
2320
+ disabled: busy,
2321
+ onClick: () => onPromote(node),
2322
+ }, h(Icon, { name: 'tree', size: 13 }), translate('node.promote')))
2323
+ }
2324
+ if (hasChildren === true) {
2325
+ actions.push(h('button', {
2326
+ key: 'children',
2327
+ type: 'button',
2328
+ className: 'tc-side-act',
2329
+ disabled: busy,
2330
+ onClick: () => onToggleChildren(node),
2331
+ }, h(Icon, { name: childCollapsed ? 'down' : 'right', size: 13 }), translate(childCollapsed ? 'node.expand' : 'node.collapse')))
2332
+ }
2333
+ actions.push(h('button', {
2334
+ key: 'rename',
2335
+ type: 'button',
2336
+ className: 'tc-side-act',
2337
+ disabled: busy,
2338
+ onClick: startRename,
2339
+ }, h(Icon, { name: 'pencil', size: 13 }), translate('node.rename')))
2340
+ if (node.kind !== 'root') {
2341
+ actions.push(h('button', {
2342
+ key: 'archive',
2343
+ type: 'button',
2344
+ className: 'tc-side-act',
2345
+ title: translate('node.archiveHint'),
2346
+ disabled: busy || blocking,
2347
+ onClick: () => onArchive(node.sessionId),
2348
+ }, h(Icon, { name: 'archive', size: 13 }), translate('node.archive')))
2349
+ }
2350
+
2351
+ return h('aside', { className: 'tc-side', 'aria-label': title },
2352
+ h('div', { className: 'tc-side-head' },
2353
+ h('div', { className: 'tc-side-title-row' },
2354
+ renaming
2355
+ ? h('input', {
2356
+ ref: inputRef,
2357
+ className: 'tc-side-rename',
2358
+ value,
2359
+ placeholder: translate('node.renamePlaceholder'),
2360
+ onChange: (event) => setValue(event.target.value),
2361
+ onKeyDown: (event) => {
2362
+ if (event.key === 'Enter') {
2363
+ event.preventDefault()
2364
+ event.stopPropagation()
2365
+ submitRename()
2366
+ } else if (event.key === 'Escape') {
2367
+ event.preventDefault()
2368
+ event.stopPropagation()
2369
+ cancelRename()
2370
+ }
2371
+ },
2372
+ onBlur: submitRename,
2373
+ })
2374
+ : h('div', { className: 'tc-side-title', title }, title)),
2375
+ subtitle !== '' && subtitle !== title
2376
+ ? h('div', { className: 'tc-side-sub' }, subtitle)
2377
+ : null,
2378
+ badges.length > 0 ? h('div', { className: 'tc-side-badges' }, badges) : null,
2379
+ actions.length > 0 ? h('div', { className: 'tc-side-actions tc-side-actions--head' }, actions) : null),
2380
+ h('div', { ref: bodyRef, className: 'tc-side-body' },
2381
+ h('div', { className: 'tc-side-stats' },
2382
+ h('div', { className: 'tc-side-stat' },
2383
+ h('div', { className: 'tc-side-stat-label' }, translate('node.contextLabel')),
2384
+ h('div', { className: 'tc-side-stat-value' }, context.hasContext ? `${context.contextPct}%` : '—'),
2385
+ h('div', { className: barClass }, context.hasContext ? h('span', { style: { width: `${context.contextPct}%` } }) : null),
2386
+ h('div', { className: 'tc-side-stat-note', title: contextTitle },
2387
+ context.hasContext
2388
+ ? `${formatNumber(context.contextTokens)} / ${formatNumber(context.contextWindow)}`
2389
+ : translate('node.contextUnknown'))),
2390
+ h('div', { className: 'tc-side-stat' },
2391
+ h('div', { className: 'tc-side-stat-label' }, translate('node.afterLabel')),
2392
+ h('div', { className: 'tc-side-stat-value' }, `+${after}`),
2393
+ h('div', { className: 'tc-side-stat-note', title: afterTitle }, afterTitle))),
2394
+ node.kind === 'root'
2395
+ ? h('div', { className: 'tc-side-empty' }, translate('node.rootDetail'))
2396
+ : h('div', { className: 'tc-chat' },
2397
+ loadError !== null ? h('div', { className: 'tc-side-error' }, fmt(translate('node.detailFailed'), { message: loadError })) : null,
2398
+ chatHead('question', translate('node.questionLabel'), question),
2399
+ node.kind === 'blank'
2400
+ ? h('div', { className: 'tc-branch-ask' },
2401
+ h('textarea', {
2402
+ className: 'tc-branch-ask-input',
2403
+ rows: 2,
2404
+ value: askText,
2405
+ placeholder: translate('node.askPlaceholder'),
2406
+ disabled: busy || sending,
2407
+ onChange: (event) => setAskText(event.target.value),
2408
+ onKeyDown: (event) => {
2409
+ if (event.key === 'Enter' && event.shiftKey !== true && event.isComposing !== true) {
2410
+ event.preventDefault()
2411
+ submitAsk()
2412
+ }
2413
+ },
2414
+ }),
2415
+ h('div', { className: 'tc-branch-ask-foot' },
2416
+ h('button', {
2417
+ type: 'button',
2418
+ className: 'tc-branch-ask-send',
2419
+ disabled: busy || sending || askText.trim() === '',
2420
+ onClick: submitAsk,
2421
+ }, sending ? translate('node.asking') : translate('node.askSend'))))
2422
+ : h('div', { className: 'tc-chat-bubble tc-chat-bubble--user' }, question !== '' ? h(MarkdownText, { text: question }) : h('div', { className: 'tc-md-placeholder' }, translate('node.waiting'))),
2423
+ chatHead('answer', translate('node.answerLabel'), answer),
2424
+ h('div', { className: `tc-chat-bubble tc-chat-bubble--assistant${loading ? ' tc-chat-bubble--loading' : ''}` }, h(MarkdownText, { text: answerText })),
2425
+ detail?.truncated === true ? h('div', { className: 'tc-side-note' }, translate('node.detailTruncated')) : null,
2426
+ detail?.time ? h('div', { className: 'tc-side-note' }, formatTime(detail.time)) : null)))
2427
+ }
2428
+
2429
+ /* ---------------------------------------------------------- panel -- */
2430
+
2431
+ function TreePanel(props) {
2432
+ const { sessionId, variant, useAccess, useSession, useWorkspaces, useSessions } = props
2433
+ const [busy, setBusy] = useState(false)
2434
+ const [notice, setNotice] = useState(null)
2435
+ const [overrides, setOverrides] = useState({})
2436
+ const [refreshing, setRefreshing] = useState(false)
2437
+ const [toast, setToast] = useState(null)
2438
+ const [forestSeq, setForestSeq] = useState(0)
2439
+ const archiveTreeRef = useRef(null)
2440
+ const [forkPhase, setForkPhase] = useState(null)
2441
+ const [asking, setAsking] = useState(false)
2442
+ const [focusNodeId, setFocusNodeId] = useState(null)
2443
+ const [selectedId, setSelectedId] = useState(null)
2444
+ const [collapsed, setCollapsed] = useState(() => new Set())
2445
+ const [expandedChains, setExpandedChains] = useState(() => new Set())
2446
+ const [prefixCuts, setPrefixCuts] = useState({})
2447
+ const [collapsedParents, setCollapsedParents] = useState(() => new Set())
2448
+ const [treeMode, setTreeMode] = useState('current')
2449
+ const [viewRootId, setViewRootId] = useState(null)
2450
+ const [forest, setForest] = useState(null)
2451
+ const [forestLoading, setForestLoading] = useState(false)
2452
+ const [forestError, setForestError] = useState(null)
2453
+ const [promoted, setPromoted] = useState([])
2454
+ const toastTimer = useRef(null)
2455
+ const canFork = useAccess ? useAccess('session/fork') : true
2456
+ const liveArchived = useWorkspaces ? useWorkspaces(selectArchived) : null
2457
+ const liveSessions = useSessions ? useSessions(selectSessionById) : null
2458
+ const runningActive = useSession ? useSession((snapshot) => snapshot.running) : (liveSessions?.[sessionId]?.running === true)
2459
+ const liveRevision = useMemo(() => sessionRevision(liveSessions), [liveSessions])
2460
+ const { data, error, reload } = useTree(sessionId, liveRevision, runningActive)
2461
+ const index = useMemo(() => (data === null ? null : indexOf(data)), [data])
2462
+ const forestIndex = useMemo(() => (forest !== null && Array.isArray(forest.nodes) ? indexOf(forest) : null), [forest])
2463
+ const forestDisplayIndex = useMemo(() => partitionPromoted(forestIndex, promoted), [forestIndex, promoted])
2464
+ const runningInfo = useMemo(() => runningNodesOf(index), [index])
2465
+ const selectedNodeId = selectedId !== null && index !== null && index.byId.has(selectedId)
2466
+ ? selectedId
2467
+ : (index?.leaf?.id ?? null)
2468
+ const archivedIds = useMemo(() => {
2469
+ const set = new Set()
2470
+ if (Array.isArray(liveArchived)) {
2471
+ for (const id of liveArchived) set.add(id)
2472
+ } else if (data !== null) {
2473
+ for (const session of data.sessions) if (session.archived === true) set.add(session.id)
2474
+ }
2475
+ return set
2476
+ }, [liveArchived, data])
2477
+
2478
+ const showToast = (text) => {
2479
+ setToast(text)
2480
+ if (toastTimer.current !== null) clearTimeout(toastTimer.current)
2481
+ toastTimer.current = setTimeout(() => {
2482
+ toastTimer.current = null
2483
+ setToast(null)
2484
+ }, 2200)
2485
+ }
2486
+
2487
+ useEffect(() => () => {
2488
+ if (toastTimer.current !== null) clearTimeout(toastTimer.current)
2489
+ }, [])
2490
+
2491
+ useEffect(() => {
2492
+ if (notice === null) return undefined
2493
+ const timer = setTimeout(() => setNotice(null), 7000)
2494
+ return () => clearTimeout(timer)
2495
+ }, [notice])
2496
+
2497
+ useEffect(() => {
2498
+ setExpandedChains(new Set())
2499
+ setPrefixCuts({})
2500
+ setCollapsedParents(new Set())
2501
+ setFocusNodeId(null)
2502
+ }, [data?.rootId])
2503
+
2504
+ useEffect(() => {
2505
+ setPromoted(readPromoted(data?.cwd ?? null))
2506
+ }, [data?.cwd])
2507
+
2508
+ useEffect(() => {
2509
+ setTreeMode('current')
2510
+ setViewRootId(null)
2511
+ }, [sessionId])
2512
+
2513
+ useEffect(() => {
2514
+ if (treeMode !== 'all' || data === null) return undefined
2515
+ let cancelled = false
2516
+ setForestLoading(true)
2517
+ setForestError(null)
2518
+ fetchForest(sessionId).then((next) => {
2519
+ if (!cancelled) {
2520
+ setForest(next)
2521
+ setForestLoading(false)
2522
+ }
2523
+ }).catch((error) => {
2524
+ if (!cancelled) {
2525
+ setForestError(noticeText(error))
2526
+ setForestLoading(false)
2527
+ }
2528
+ })
2529
+ return () => { cancelled = true }
2530
+ }, [treeMode, sessionId, data?.rootId, forestSeq])
2531
+
2532
+ useEffect(() => {
2533
+ setOverrides({})
2534
+ }, [data?.rootId])
2535
+
2536
+ useEffect(() => {
2537
+ if (data === null || index === null || liveSessions === null) return
2538
+ setOverrides((current) => {
2539
+ let changed = false
2540
+ const next = { ...current }
2541
+ for (const [nodeId, label] of Object.entries(current)) {
2542
+ const node = index.byId.get(nodeId)
2543
+ if (node === undefined) continue
2544
+ const isLeaf = index.leaves.get(node.sessionId)?.id === node.id
2545
+ if (!isLeaf) continue
2546
+ const live = liveSessions[node.sessionId]?.title
2547
+ if (typeof live === 'string' && live.trim() === label) {
2548
+ delete next[nodeId]
2549
+ changed = true
2550
+ }
2551
+ }
2552
+ return changed ? next : current
2553
+ })
2554
+ }, [data, index, liveSessions])
2555
+
2556
+ const refreshNow = async () => {
2557
+ if (refreshing || busy || forkPhase !== null) return
2558
+ setRefreshing(true)
2559
+ try {
2560
+ await reload(true)
2561
+ showToast(translate('panel.refreshed'))
2562
+ } catch (refreshError) {
2563
+ showToast(fmt(translate('panel.refreshFailed'), { message: noticeText(refreshError) }))
2564
+ } finally {
2565
+ setRefreshing(false)
2566
+ }
2567
+ }
2568
+
2569
+ const targetSessionFor = (node) => {
2570
+ if (index.path.has(node.id)) return sessionId
2571
+ const queue = [node]
2572
+ const seen = new Set()
2573
+ while (queue.length > 0) {
2574
+ const current = queue.shift()
2575
+ if (seen.has(current.id)) continue
2576
+ seen.add(current.id)
2577
+ const leaf = index.leaves.get(current.sessionId)
2578
+ if (leaf?.id === current.id && !archivedIds.has(current.sessionId)) return current.sessionId
2579
+ for (const child of index.children.get(current.id) ?? []) queue.push(child)
2580
+ }
2581
+ return node.sessionId
2582
+ }
2583
+
2584
+ const activate = (node) => {
2585
+ if (busy) return
2586
+ if (node.kind === 'root') return
2587
+ const targetSession = targetSessionFor(node)
2588
+ setSelectedId(node.id)
2589
+ if (targetSession !== sessionId) {
2590
+ selectionIntent = { sessionId: targetSession, nodeId: node.id }
2591
+ setBusy(true)
2592
+ switchTo(targetSession, node.kind === 'blank').then(() => {
2593
+ if (node.turn > 0) jumpToTurn(node.turn)
2594
+ }).catch((switchError) => {
2595
+ setNotice(fmt(translate('notice.switchFailed'), { message: noticeText(switchError) }))
2596
+ }).finally(() => setBusy(false))
2597
+ return
2598
+ }
2599
+ selectionIntent = null
2600
+ if (node.turn > 0) jumpToTurn(node.turn)
2601
+ }
2602
+
2603
+ const forkFromNode = (node) => {
2604
+ if (busy) return
2605
+ if (node.kind === 'blank' || !Number.isSafeInteger(node.endSeq)) {
2606
+ setNotice(translate('notice.busyFork'))
2607
+ return
2608
+ }
2609
+ if (canFork !== true) {
2610
+ setNotice(translate('notice.noAccess'))
2611
+ return
2612
+ }
2613
+ if (runningActive === true) {
2614
+ setNotice(translate('notice.running'))
2615
+ return
2616
+ }
2617
+ if (index.sessionById.get(node.sessionId)?.running === true) {
2618
+ setNotice(translate('notice.busyFork'))
2619
+ return
2620
+ }
2621
+ setBusy(true)
2622
+ setForkPhase('creating')
2623
+ let created = false
2624
+ forkAt(node).then(async (childId) => {
2625
+ created = true
2626
+ setForkPhase('refreshing')
2627
+ const nextData = await reload(true)
2628
+ const nextIndex = indexOf(nextData)
2629
+ const childNode = nextData.nodes.find((candidate) => candidate.sessionId === childId && candidate.kind === 'blank')
2630
+ ?? nextData.nodes.find((candidate) => candidate.sessionId === childId)
2631
+ if (childNode !== undefined && nextIndex.byId.has(childNode.id)) {
2632
+ setSelectedId(childNode.id)
2633
+ setFocusNodeId(childNode.id)
2634
+ }
2635
+ setForkPhase(null)
2636
+ showToast(translate('notice.forkDone'))
2637
+ }).catch((forkError) => {
2638
+ setForkPhase(null)
2639
+ setNotice(fmt(translate(created ? 'notice.forkRefreshFailed' : 'notice.forkFailed'), { message: noticeText(forkError) }))
2640
+ }).finally(() => {
2641
+ setForkPhase(null)
2642
+ setBusy(false)
2643
+ })
2644
+ }
2645
+
2646
+ const askBranch = async (node, text) => {
2647
+ if (busy || node.kind !== 'blank') return
2648
+ const sessions = appCtx?.get('sessions')
2649
+ if (!sessions || typeof sessions.using !== 'function') {
2650
+ const error = new Error(translate('notice.noService'))
2651
+ setNotice(fmt(translate('notice.askFailed'), { message: noticeText(error) }))
2652
+ throw error
2653
+ }
2654
+ setBusy(true)
2655
+ setAsking(true)
2656
+ try {
2657
+ const result = await sessions.using(node.sessionId, { source: 'workspaceOperation' }, (reference) => (
2658
+ reference.binding.session.prompt([{ type: 'text', text }], 'queue')
2659
+ ))
2660
+ if (result === undefined || result.ok !== true) {
2661
+ throw new Error(result?.error?.message ?? translate('node.asking'))
2662
+ }
2663
+ /* The prompt has been accepted. Warm the child's tree cache while
2664
+ the parent is still mounted, then switch. The child shares the
2665
+ same root/family as the parent, so this makes the tree appear
2666
+ immediately instead of showing a loading state after the switch. */
2667
+ /* Seed the child session with the current family tree so switching
2668
+ never waits on the full /treecom rebuild. The real child payload
2669
+ is fetched in parallel and pushed into useTree when ready. */
2670
+ treeDataCache.set(node.sessionId, { data, revision: 'seed' })
2671
+ const warm = fetchTree(node.sessionId).then((next) => {
2672
+ treeDataCache.set(node.sessionId, { data: next, revision: null })
2673
+ try {
2674
+ window.dispatchEvent(new CustomEvent('treecom:data', { detail: { sessionId: node.sessionId, data: next } }))
2675
+ } catch { /* no CustomEvent in a non-browser harness */ }
2676
+ }).catch(() => {
2677
+ setTimeout(() => {
2678
+ try { window.dispatchEvent(new Event('treecom:refresh')) } catch { /* ignore */ }
2679
+ }, 1200)
2680
+ })
2681
+ void warm
2682
+ selectionIntent = null
2683
+ const workspace = appCtx?.get('uiWorkspace')
2684
+ if (workspace) {
2685
+ workspace.openSession(node.sessionId)
2686
+ }
2687
+ /* A session remembers its last Conversation view; the fork child
2688
+ normally starts on Chat. Re-select the tree tab after the switch. */
2689
+ activateTreeViewTab()
2690
+ setTimeout(activateTreeViewTab, 120)
2691
+ setTimeout(activateTreeViewTab, 320)
2692
+ showToast(translate('notice.askSent'))
2693
+ } catch (error) {
2694
+ setNotice(fmt(translate('notice.askFailed'), { message: noticeText(error) }))
2695
+ throw error
2696
+ } finally {
2697
+ setAsking(false)
2698
+ setBusy(false)
2699
+ }
2700
+ }
2701
+
2702
+ const renameSessionTitle = async (targetSessionId, title) => {
2703
+ const sessions = appCtx?.get('sessions')
2704
+ if (sessions === undefined) throw new Error(translate('notice.noService'))
2705
+ await sessions.using(targetSessionId, { source: 'workspaceOperation' }, async (reference) => {
2706
+ const result = await reference.binding.session.rename(title)
2707
+ if (result !== null && result !== undefined && result.ok === false) {
2708
+ throw new Error(result.error?.message ?? 'rename failed')
2709
+ }
2710
+ })
2711
+ }
2712
+
2713
+ const rename = (node, label) => {
2714
+ setOverrides((current) => ({ ...current, [node.id]: label }))
2715
+ renameSessionTitle(node.sessionId, label)
2716
+ .then(() => fetch(ROUTE, {
2717
+ method: 'POST',
2718
+ headers: { 'content-type': 'application/json' },
2719
+ body: JSON.stringify({ action: 'label', nodeId: node.id, label }),
2720
+ }))
2721
+ .then(async (response) => {
2722
+ const body = await response.json().catch(() => null)
2723
+ if (!response.ok || body?.ok !== true) throw new Error(body?.error ?? `HTTP ${response.status}`)
2724
+ refreshAll()
2725
+ })
2726
+ .catch((renameError) => {
2727
+ setOverrides((current) => {
2728
+ const next = { ...current }
2729
+ delete next[node.id]
2730
+ return next
2731
+ })
2732
+ const message = fmt(translate('notice.renameFailed'), { message: noticeText(renameError) })
2733
+ setNotice(message)
2734
+ showToast(message)
2735
+ })
2736
+ }
2737
+
2738
+ const archiveBranch = async (targetSessionId) => {
2739
+ const workspace = appCtx?.get('uiWorkspace')
2740
+ if (!workspace) {
2741
+ setNotice(translate('notice.noService'))
2742
+ return
2743
+ }
2744
+ try {
2745
+ await workspace.archiveSession(targetSessionId)
2746
+ showToast(translate('node.archivedToast'))
2747
+ refreshAll()
2748
+ } catch (archiveError) {
2749
+ setNotice(fmt(translate('notice.archiveFailed'), { message: noticeText(archiveError) }))
2750
+ }
2751
+ }
2752
+
2753
+ const archiveTree = async (ids) => {
2754
+ const workspace = appCtx?.get('uiWorkspace')
2755
+ if (!workspace) throw new Error(translate('notice.noService'))
2756
+ const unique = [...new Set((ids ?? []).filter((id) => typeof id === 'string' && id !== ''))]
2757
+ if (unique.length === 0) return
2758
+ setBusy(true)
2759
+ try {
2760
+ const failed = []
2761
+ for (const id of unique) {
2762
+ try {
2763
+ await workspace.archiveSession(id)
2764
+ } catch {
2765
+ failed.push(id)
2766
+ }
2767
+ }
2768
+ setForestSeq((value) => value + 1)
2769
+ refreshAll()
2770
+ if (failed.length > 0) {
2771
+ throw new Error(fmt(translate('treeDelete.partial'), { n: failed.length }))
2772
+ }
2773
+ showToast(fmt(translate('treeDelete.done'), { n: unique.length }))
2774
+ } finally {
2775
+ setBusy(false)
2776
+ }
2777
+ }
2778
+ archiveTreeRef.current = archiveTree
2779
+
2780
+ useEffect(() => {
2781
+ const handler = (root, title, ids) => {
2782
+ showTreeDeleteConfirm({
2783
+ title,
2784
+ count: ids.length,
2785
+ onConfirm: () => archiveTreeRef.current?.(ids),
2786
+ })
2787
+ }
2788
+ sidebarTreeDeleteHandler = handler
2789
+ return () => {
2790
+ if (sidebarTreeDeleteHandler === handler) sidebarTreeDeleteHandler = null
2791
+ }
2792
+ }, [])
2793
+
2794
+ const toggleCollapsed = (id) => {
2795
+ setCollapsed((current) => {
2796
+ const next = new Set(current)
2797
+ if (next.has(id)) next.delete(id)
2798
+ else next.add(id)
2799
+ return next
2800
+ })
2801
+ }
2802
+
2803
+ const sessionTitleOf = (targetSessionId) => {
2804
+ const live = liveSessions?.[targetSessionId]?.title
2805
+ if (typeof live === 'string' && live.trim() !== '') return live.trim()
2806
+ const host = index?.sessionById.get(targetSessionId)?.title
2807
+ return typeof host === 'string' && host.trim() !== '' ? host.trim() : null
2808
+ }
2809
+
2810
+ const labelOf = (node) => {
2811
+ const isLeaf = index !== null && index.leaves.get(node.sessionId)?.id === node.id
2812
+ if (isLeaf) {
2813
+ const override = overrides[node.id]
2814
+ if (override !== undefined) return override
2815
+ const title = sessionTitleOf(node.sessionId)
2816
+ if (title !== null) return title
2817
+ }
2818
+ return overrides[node.id] ?? node.label
2819
+ }
2820
+
2821
+ const nodeNameOf = (node) => {
2822
+ if (node === null || node === undefined) return ''
2823
+ const custom = overrides[node.id] ?? data?.labels?.[node.id]
2824
+ if (typeof custom === 'string' && custom.trim() !== '') return custom.trim()
2825
+ if (node.kind === 'root') {
2826
+ const rootName = data?.treeTitle ?? node.label
2827
+ return typeof rootName === 'string' && rootName.trim() !== '' ? rootName.trim() : translate('path.root')
2828
+ }
2829
+ const source = typeof node.prompt === 'string' && node.prompt.trim() !== ''
2830
+ ? node.prompt.replace(/\s+/g, ' ').trim()
2831
+ : (typeof node.label === 'string' ? node.label.trim() : '')
2832
+ if (source === '') return translate('node.blank')
2833
+ return source.length > 10 ? `${source.slice(0, 10)}…` : source
2834
+ }
2835
+
2836
+ const hiddenIds = useMemo(() => {
2837
+ const hidden = new Set()
2838
+ if (data === null || index === null || archivedIds.size === 0) return hidden
2839
+ const required = new Set()
2840
+ for (const session of data.sessions) {
2841
+ if (archivedIds.has(session.id)) continue
2842
+ let node = index.leaves.get(session.id)
2843
+ const guard = new Set()
2844
+ while (node !== undefined && node !== null && !guard.has(node.id)) {
2845
+ guard.add(node.id)
2846
+ required.add(node.id)
2847
+ node = index.byId.get(node.parentId)
2848
+ }
2849
+ }
2850
+ for (const node of data.nodes) {
2851
+ if (node.kind === 'root') continue
2852
+ if (archivedIds.has(node.sessionId) && !required.has(node.id)) hidden.add(node.id)
2853
+ }
2854
+ return hidden
2855
+ }, [data, index, archivedIds])
2856
+
2857
+ const displayIndex = useMemo(() => (
2858
+ index === null ? null : buildDisplayIndex(index, hiddenIds, expandedChains, prefixCuts, collapsedParents, viewRootId)
2859
+ ), [index, hiddenIds, expandedChains, prefixCuts, collapsedParents, viewRootId])
2860
+
2861
+ const activeLeafId = index?.leaf?.id ?? null
2862
+ const effectiveSelectedId = selectedNodeId !== null && !hiddenIds.has(selectedNodeId)
2863
+ ? selectedNodeId
2864
+ : (activeLeafId !== null && !hiddenIds.has(activeLeafId) ? activeLeafId : null)
2865
+ const selectedPath = useMemo(() => {
2866
+ const path = new Set()
2867
+ if (index === null || effectiveSelectedId === null) return path
2868
+ let node = index.byId.get(effectiveSelectedId)
2869
+ const guard = new Set()
2870
+ while (node !== undefined && node !== null && !guard.has(node.id)) {
2871
+ guard.add(node.id)
2872
+ path.add(node.id)
2873
+ node = index.byId.get(node.parentId)
2874
+ }
2875
+ return path
2876
+ }, [index, effectiveSelectedId])
2877
+
2878
+ const resolveDisplayId = (id) => {
2879
+ if (displayIndex === null || id === null) return null
2880
+ if (displayIndex.byId.has(id)) return id
2881
+ return displayIndex.chainByMember.get(id)?.endId ?? null
2882
+ }
2883
+ const displaySelectedId = resolveDisplayId(effectiveSelectedId)
2884
+ const displayActiveId = resolveDisplayId(activeLeafId)
2885
+
2886
+ /* Switching branches must not collapse the camera, but it also must not
2887
+ leave the active leaf buried inside a collapsed chain. Expand only the
2888
+ chain that contains the newly active leaf, once per session switch:
2889
+ manual collapses stay collapsed while that branch stays active. */
2890
+ const autoExpandedSession = useRef(null)
2891
+ useLayoutEffect(() => {
2892
+ if (data === null || index === null || data.sessionId !== sessionId) return
2893
+ if (activeLeafId === null) return
2894
+ if (autoExpandedSession.current === sessionId) return
2895
+ autoExpandedSession.current = sessionId
2896
+ if (displayIndex === null) return
2897
+ if (displayIndex.byId.has(activeLeafId)) return
2898
+ const member = displayIndex.chainByMember.get(activeLeafId)
2899
+ if (member === undefined) return
2900
+ if (member.expanded === false) {
2901
+ setExpandedChains((current) => {
2902
+ if (current.has(member.key)) return current
2903
+ const next = new Set(current)
2904
+ next.add(member.key)
2905
+ return next
2906
+ })
2907
+ } else if (member.prefix === true) {
2908
+ setPrefixCuts((current) => {
2909
+ if (current[member.key] === undefined) return current
2910
+ const next = { ...current }
2911
+ delete next[member.key]
2912
+ return next
2913
+ })
2914
+ } else {
2915
+ return
2916
+ }
2917
+ setFocusNodeId(activeLeafId)
2918
+ }, [data, index, sessionId, activeLeafId, displayIndex])
2919
+
2920
+ const expandChain = (chain) => {
2921
+ if (chain === null || chain === undefined) return
2922
+ setPrefixCuts((current) => {
2923
+ if (current[chain.key] === undefined) return current
2924
+ const next = { ...current }
2925
+ delete next[chain.key]
2926
+ return next
2927
+ })
2928
+ if (chain.prefix === true) {
2929
+ if (chain.endId !== undefined) setSelectedId(chain.endId)
2930
+ return
2931
+ }
2932
+ setExpandedChains((current) => {
2933
+ if (current.has(chain.key)) return current
2934
+ const next = new Set(current)
2935
+ next.add(chain.key)
2936
+ return next
2937
+ })
2938
+ if (chain.endId !== undefined) setSelectedId(chain.endId)
2939
+ }
2940
+
2941
+ const collapseAbove = (chain) => {
2942
+ if (chain === null || chain === undefined) return
2943
+ if (!Number.isInteger(chain.index) || chain.index <= 0) return
2944
+ setPrefixCuts((current) => ({ ...current, [chain.key]: chain.index }))
2945
+ }
2946
+
2947
+ const toggleChildren = (node) => {
2948
+ if (node === null || node === undefined) return
2949
+ setCollapsedParents((current) => {
2950
+ const next = new Set(current)
2951
+ if (next.has(node.id)) next.delete(node.id)
2952
+ else next.add(node.id)
2953
+ return next
2954
+ })
2955
+ setSelectedId(node.id)
2956
+ }
2957
+
2958
+ const promoteNode = (node) => {
2959
+ if (node === null || node === undefined || node.kind === 'root') return
2960
+ const entry = { nodeId: node.id, sessionId: node.sessionId, label: nodeNameOf(node) }
2961
+ setPromoted((current) => {
2962
+ if (current.some((item) => item.nodeId === entry.nodeId)) return current
2963
+ const next = [...current, entry]
2964
+ writePromoted(data?.cwd ?? null, next)
2965
+ return next
2966
+ })
2967
+ setViewRootId(node.id)
2968
+ setTreeMode('current')
2969
+ setSelectedId(node.id)
2970
+ setFocusNodeId(node.id)
2971
+ showToast(translate('notice.promoted'))
2972
+ }
2973
+
2974
+ const openTreeRoot = (rootId) => {
2975
+ if (rootId === data?.rootId) {
2976
+ setViewRootId(null)
2977
+ setTreeMode('current')
2978
+ return
2979
+ }
2980
+ const workspace = appCtx?.get('uiWorkspace')
2981
+ if (!workspace) {
2982
+ setNotice(translate('notice.noService'))
2983
+ return
2984
+ }
2985
+ workspace.openSession(rootId)
2986
+ setViewRootId(null)
2987
+ setTreeMode('current')
2988
+ setTimeout(activateTreeViewTab, 120)
2989
+ setTimeout(activateTreeViewTab, 320)
2990
+ }
2991
+
2992
+ const openPromoted = (entry) => {
2993
+ if (index === null || !index.byId.has(entry.nodeId)) {
2994
+ setNotice(translate('panel.treeUnavailable'))
2995
+ return
2996
+ }
2997
+ setViewRootId(entry.nodeId)
2998
+ setTreeMode('current')
2999
+ setSelectedId(entry.nodeId)
3000
+ setFocusNodeId(entry.nodeId)
3001
+ }
3002
+
3003
+ const afterInfo = useMemo(() => {
3004
+ const pathAfter = new Map()
3005
+ const maxAfter = new Map()
3006
+ const activePath = new Set()
3007
+ if (index !== null) {
3008
+ const path = []
3009
+ let current = activeLeafId !== null ? index.byId.get(activeLeafId) : null
3010
+ const guard = new Set()
3011
+ while (current !== undefined && current !== null && !guard.has(current.id)) {
3012
+ guard.add(current.id)
3013
+ path.push(current)
3014
+ current = index.byId.get(current.parentId)
3015
+ }
3016
+ path.reverse()
3017
+ for (const node of path) activePath.add(node.id)
3018
+ let turnsAfter = 0
3019
+ for (let i = path.length - 1; i >= 0; i -= 1) {
3020
+ const node = path[i]
3021
+ pathAfter.set(node.id, turnsAfter)
3022
+ if (node.kind === 'turn') turnsAfter += 1
3023
+ }
3024
+ const visit = (node) => {
3025
+ let best = 0
3026
+ for (const child of index.children.get(node.id) ?? []) {
3027
+ const value = (child.kind === 'turn' ? 1 : 0) + visit(child)
3028
+ if (value > best) best = value
3029
+ }
3030
+ maxAfter.set(node.id, best)
3031
+ return best
3032
+ }
3033
+ for (const root of index.roots) visit(root)
3034
+ }
3035
+ return { pathAfter, maxAfter, activePath }
3036
+ }, [index, activeLeafId])
3037
+
3038
+ const afterFor = (node) => {
3039
+ if (afterInfo.activePath.has(node.id)) {
3040
+ return { n: afterInfo.pathAfter.get(node.id) ?? 0, onPath: true }
3041
+ }
3042
+ return { n: afterInfo.maxAfter.get(node.id) ?? 0, onPath: false }
3043
+ }
3044
+
3045
+ useEffect(() => {
3046
+ if (selectionIntent === null) setSelectedId(null)
3047
+ }, [sessionId])
3048
+
3049
+ useEffect(() => {
3050
+ if (data === null || index === null || data.sessionId !== sessionId) return
3051
+ if (selectionIntent === null || selectionIntent.sessionId !== sessionId) return
3052
+ const intent = selectionIntent
3053
+ selectionIntent = null
3054
+ setSelectedId(intent.nodeId !== null && index.byId.has(intent.nodeId) ? intent.nodeId : null)
3055
+ }, [sessionId, data, index])
3056
+
3057
+ useEffect(() => {
3058
+ if (index === null || effectiveSelectedId === null) return
3059
+ const ancestors = new Set()
3060
+ let node = index.byId.get(effectiveSelectedId)
3061
+ const guard = new Set()
3062
+ while (node !== undefined && node !== null && !guard.has(node.id)) {
3063
+ guard.add(node.id)
3064
+ ancestors.add(node.id)
3065
+ node = index.byId.get(node.parentId)
3066
+ }
3067
+ setCollapsed((current) => {
3068
+ let changed = false
3069
+ const next = new Set(current)
3070
+ for (const id of ancestors) if (next.delete(id)) changed = true
3071
+ return changed ? next : current
3072
+ })
3073
+ // Only a selection change auto-expands its path; a background refresh
3074
+ // must not undo a collapse the user chose.
3075
+ }, [effectiveSelectedId])
3076
+
3077
+ const renderBranch = (node, depth) => {
3078
+ if (hiddenIds.has(node.id)) return null
3079
+ const children = index.children.get(node.id) ?? []
3080
+ const hasChildren = children.length > 0
3081
+ const collapsedNow = collapsed.has(node.id)
3082
+ const isRoot = node.kind === 'root'
3083
+ const isLeaf = index.leaves.get(node.sessionId)?.id === node.id
3084
+ const onPath = selectedPath.has(node.id)
3085
+ const after = afterFor(node)
3086
+ return h('div', { key: node.id, className: 'tc-branch' },
3087
+ isRoot
3088
+ ? h('div', { className: 'tc-root' },
3089
+ hasChildren
3090
+ ? h('button', {
3091
+ type: 'button',
3092
+ className: 'tc-collapse-btn',
3093
+ title: collapsedNow ? translate('node.expand') : translate('node.collapse'),
3094
+ 'aria-label': collapsedNow ? translate('node.expand') : translate('node.collapse'),
3095
+ 'aria-expanded': collapsedNow !== true,
3096
+ onClick: (event) => {
3097
+ event.stopPropagation()
3098
+ toggleCollapsed(node.id)
3099
+ },
3100
+ }, h(Icon, { name: collapsedNow ? 'right' : 'down', size: 14 }))
3101
+ : h('span', { className: 'tc-collapse-spacer' }),
3102
+ h('span', { className: 'tc-root-mark' }, h(Icon, { name: 'tree', size: 13 })),
3103
+ h('span', { className: 'tc-root-title' }, translate('path.root')),
3104
+ collapsedNow ? h('span', { className: 'tc-badge tc-badge--collapsed' }, translate('node.collapsed')) : null)
3105
+ : h('div', { className: 'tc-row' },
3106
+ h('div', {
3107
+ className: `tc-gutter${onPath ? ' tc-gutter--path' : ''}${node.id === effectiveSelectedId ? ' tc-gutter--active' : ''}`,
3108
+ title: fmt(translate('node.depth'), { n: depth }),
3109
+ 'aria-hidden': 'true',
3110
+ }, h('span', { className: 'tc-depth' }, depth === 0 ? '·' : String(depth))),
3111
+ h(NodeCard, {
3112
+ node: { ...node, label: labelOf(node) },
3113
+ activeId: effectiveSelectedId,
3114
+ onPath,
3115
+ isLeaf,
3116
+ running: index.sessionById.get(node.sessionId)?.running === true,
3117
+ archived: archivedIds.has(node.sessionId),
3118
+ canFork,
3119
+ hasChildren,
3120
+ collapsed: collapsedNow,
3121
+ busy,
3122
+ onToggle: toggleCollapsed,
3123
+ onActivate: activate,
3124
+ onFork: forkFromNode,
3125
+ onRename: rename,
3126
+ onArchive: archiveBranch,
3127
+ after: after.n,
3128
+ afterOnPath: after.onPath,
3129
+ })),
3130
+ children.length === 0 || collapsedNow
3131
+ ? null
3132
+ : h('div', { className: `tc-children${children.length > 1 ? ' tc-children--fork' : ''}` },
3133
+ children.map((child) => renderBranch(child, depth + (children.length > 1 ? 1 : 0)))))
3134
+ }
3135
+
3136
+ const toastNode = forkPhase === null
3137
+ ? (toast === null ? null : h('div', { className: 'tc-toast', role: 'status' }, toast))
3138
+ : h('div', { className: 'tc-toast tc-toast--busy', role: 'status' },
3139
+ h('span', { className: 'tc-spinner', 'aria-hidden': 'true' }),
3140
+ forkPhase === 'creating' ? translate('notice.forkCreating') : translate('notice.forkRefreshing'))
3141
+ const treeLocked = busy || refreshing || forkPhase !== null
3142
+
3143
+ if (data === null) {
3144
+ return h('div', { className: `tc-panel tc-panel--${variant}` },
3145
+ h('div', { className: 'tc-head' },
3146
+ h('span', { className: 'tc-title' }, translate('panel.title')),
3147
+ h('span', { className: 'tc-spacer' }),
3148
+ h(IconButton, {
3149
+ label: translate('panel.refresh'),
3150
+ name: 'refresh',
3151
+ onClick: refreshNow,
3152
+ disabled: treeLocked,
3153
+ spinning: refreshing,
3154
+ })),
3155
+ toastNode,
3156
+ h('div', { className: 'tc-empty' }, error === null ? translate('panel.loading') : `${translate('panel.error')}:${error}`))
3157
+ }
3158
+
3159
+ const visibleNodes = data.nodes.filter((node) => !hiddenIds.has(node.id))
3160
+ const turns = visibleNodes.filter((node) => node.kind === 'turn').length
3161
+ const branches = data.sessions.filter((session) => !archivedIds.has(session.id)).length
3162
+ const allArchived = data.sessions.length > 0 && branches === 0
3163
+ const selectedNode = effectiveSelectedId !== null && index !== null
3164
+ ? (index.byId.get(effectiveSelectedId) ?? null)
3165
+ : null
3166
+ const paneNode = selectedNode === null ? null : { ...selectedNode, label: labelOf(selectedNode) }
3167
+ const paneAfter = selectedNode === null ? { n: 0, onPath: false } : afterFor(selectedNode)
3168
+ const paneIsHere = selectedNode !== null && activeLeafId === selectedNode.id
3169
+ const paneIsLeaf = selectedNode !== null && index.leaves.get(selectedNode.sessionId)?.id === selectedNode.id
3170
+ const paneRunning = selectedNode !== null && runningInfo.generating.has(selectedNode.id)
3171
+ const paneChildRunning = selectedNode !== null && runningInfo.childGenerating.has(selectedNode.id)
3172
+ const paneLiveRunning = selectedNode !== null && liveSessions?.[selectedNode.sessionId]?.running === true
3173
+ const paneArchived = selectedNode !== null && archivedIds.has(selectedNode.sessionId)
3174
+ const treeRootNode = index !== null ? (index.byId.get(viewRootId ?? data.rootId) ?? null) : null
3175
+ const treeName = treeMode === 'all'
3176
+ ? translate('panel.allTrees')
3177
+ : (treeRootNode !== null ? nodeNameOf(treeRootNode) : data.treeTitle)
3178
+ const modeSwitch = h('div', { className: 'tc-mode' },
3179
+ h('button', {
3180
+ type: 'button',
3181
+ className: `tc-mode-btn${treeMode === 'current' ? ' tc-mode-btn--active' : ''}`,
3182
+ onClick: () => { setTreeMode('current'); setViewRootId(null) },
3183
+ }, translate('panel.currentTree')),
3184
+ h('button', {
3185
+ type: 'button',
3186
+ className: `tc-mode-btn${treeMode === 'all' ? ' tc-mode-btn--active' : ''}`,
3187
+ onClick: () => setTreeMode('all'),
3188
+ }, translate('panel.allTrees')))
3189
+ const rootItems = (forest?.roots ?? []).filter((root) => !archivedIds.has(root.id))
3190
+ const promotedItems = []
3191
+ const allTreesNode = h('div', { className: 'tc-all-trees' },
3192
+ h('div', { className: 'tc-all-hint' }, translate('panel.allTreesHint')),
3193
+ forestLoading === true ? h('div', { className: 'tc-all-loading' }, translate('panel.loading')) : null,
3194
+ forestError !== null ? h('div', { className: 'tc-side-error' }, `${translate('panel.error')}:${forestError}`) : null,
3195
+ h('div', { className: 'tc-all-list' },
3196
+ rootItems.map((root) => {
3197
+ const treeSessionIds = Array.isArray(root.sessionIds) && root.sessionIds.length > 0 ? root.sessionIds : [root.id]
3198
+ return h('div', { key: `root:${root.id}`, className: 'tc-all-tree-row' },
3199
+ h('button', {
3200
+ type: 'button',
3201
+ className: `tc-all-tree${root.id === data.rootId ? ' tc-all-tree--current' : ''}`,
3202
+ onClick: () => openTreeRoot(root.id),
3203
+ },
3204
+ h('span', { className: 'tc-all-tree-name' }, root.title),
3205
+ h('span', { className: 'tc-all-tree-meta' }, root.id === data.rootId ? translate('panel.currentBadge') : '')),
3206
+ h('button', {
3207
+ type: 'button',
3208
+ className: 'tc-all-tree-delete',
3209
+ title: translate('treeDelete.action'),
3210
+ 'aria-label': translate('treeDelete.action'),
3211
+ onClick: (event) => {
3212
+ event.stopPropagation()
3213
+ showTreeDeleteConfirm({
3214
+ title: root.title,
3215
+ count: treeSessionIds.length,
3216
+ onConfirm: () => archiveTree(treeSessionIds),
3217
+ })
3218
+ },
3219
+ }, h(Icon, { name: 'trash', size: 13 })))
3220
+ }),
3221
+ promotedItems.map((entry) => h('button', {
3222
+ key: `promoted:${entry.nodeId}`,
3223
+ type: 'button',
3224
+ className: `tc-all-tree tc-all-tree--promoted${entry.nodeId === viewRootId ? ' tc-all-tree--current' : ''}`,
3225
+ onClick: () => openPromoted(entry),
3226
+ },
3227
+ h('span', { className: 'tc-all-tree-name' }, entry.label),
3228
+ h('span', { className: 'tc-all-tree-meta' }, translate('panel.promotedBadge'))))),
3229
+ rootItems.length === 0 && promotedItems.length === 0 && forestLoading !== true
3230
+ ? h('div', { className: 'tc-empty' }, translate('panel.allTreesEmpty'))
3231
+ : null)
3232
+ return h('div', { className: `tc-panel tc-panel--${variant}`, 'aria-busy': treeLocked },
3233
+ h('div', { className: 'tc-head' },
3234
+ h('div', { className: 'tc-head-text' },
3235
+ h('div', { className: 'tc-title', title: treeName }, treeName || translate('panel.title')),
3236
+ h('div', { className: 'tc-sub' }, `${fmt(translate('panel.turns'), { n: turns })} · ${fmt(translate('panel.branches'), { n: branches })}`)),
3237
+ modeSwitch,
3238
+ h('span', { className: 'tc-spacer' }),
3239
+ h(IconButton, {
3240
+ label: translate('panel.refresh'),
3241
+ name: 'refresh',
3242
+ onClick: refreshNow,
3243
+ disabled: treeLocked,
3244
+ spinning: refreshing,
3245
+ })),
3246
+ busy && asking !== true ? h('div', { className: 'tc-notice tc-notice--busy' }, translate('action.busy')) : null,
3247
+ notice === null ? null : h('div', { className: 'tc-notice' }, notice),
3248
+ error === null ? null : h('div', { className: 'tc-notice' }, `${translate('panel.error')}:${error}`),
3249
+ data.errors !== undefined && data.errors.length > 0 ? h('div', { className: 'tc-warn' }, translate('panel.partial')) : null,
3250
+ toastNode,
3251
+ h('div', { className: 'tc-body', 'aria-label': translate('panel.title') },
3252
+ treeMode === 'all'
3253
+ ? allTreesNode
3254
+ : visibleNodes.length === 0
3255
+ ? h('div', { className: 'tc-empty' }, allArchived ? translate('panel.allArchived') : translate('panel.empty'))
3256
+ : h('div', { className: 'tc-workspace' },
3257
+ h(TreeCanvas, {
3258
+ index: displayIndex ?? index,
3259
+ layoutIndex: index,
3260
+ hiddenIds,
3261
+ selectedId: displaySelectedId,
3262
+ activeId: displayActiveId,
3263
+ selectedPath,
3264
+ afterFor,
3265
+ chainByRep: displayIndex?.chainByRep ?? null,
3266
+ chainByMember: displayIndex?.chainByMember ?? null,
3267
+ onExpand: expandChain,
3268
+ onCollapse: collapseAbove,
3269
+ onSelect: (node) => setSelectedId(node.id),
3270
+ sessionId,
3271
+ rootId: viewRootId ?? data.rootId,
3272
+ locked: treeLocked,
3273
+ onPromote: promoteNode,
3274
+ focusNodeId,
3275
+ generatingIds: runningInfo.generating,
3276
+ childGeneratingIds: runningInfo.childGenerating,
3277
+ }),
3278
+ h(NodeDetailPane, {
3279
+ node: paneNode,
3280
+ after: paneAfter.n,
3281
+ afterOnPath: paneAfter.onPath,
3282
+ isHere: paneIsHere,
3283
+ isLeaf: paneIsLeaf,
3284
+ displayName: selectedNode !== null ? nodeNameOf(selectedNode) : '',
3285
+ hasChildren: selectedNode !== null && (index.children.get(selectedNode.id)?.length ?? 0) > 0,
3286
+ childCollapsed: selectedNode !== null && collapsedParents.has(selectedNode.id),
3287
+ onToggleChildren: toggleChildren,
3288
+ onPromote: promoteNode,
3289
+ running: paneRunning,
3290
+ childRunning: paneChildRunning,
3291
+ liveRunning: paneLiveRunning,
3292
+ archived: paneArchived,
3293
+ canFork,
3294
+ busy,
3295
+ onAsk: askBranch,
3296
+ onJump: activate,
3297
+ onFork: forkFromNode,
3298
+ onRename: rename,
3299
+ onArchive: archiveBranch,
3300
+ }))))
3301
+ }
3302
+
3303
+ function ViewTree(props) {
3304
+ useEffect(() => {
3305
+ if (typeof document === 'undefined') return undefined
3306
+ document.body.classList.add('tc-view-tree-open')
3307
+ return () => {
3308
+ document.body.classList.remove('tc-view-tree-open')
3309
+ }
3310
+ }, [])
3311
+ return h(TreePanel, { ...props, variant: 'view' })
3312
+ }
3313
+
3314
+ function SidebarTree(props) {
3315
+ return h('div', { className: 'tc-sidebar-body' },
3316
+ h(TreePanel, { ...props, variant: 'sidebar' }))
3317
+ }
3318
+
3319
+ /**
3320
+ * Self-contained floating tree. It is root-scoped on purpose: the right
3321
+ * sidebar controller is not always reachable from a dynamic Client plugin,
3322
+ * and a conversation View tab steals the Chat surface. This panel floats
3323
+ * over the frame, hides the composer while open, and follows the active branch.
3324
+ */
3325
+ /* ------------------------------------------------- header opener -- */
3326
+
3327
+ function OpenButton(props) {
3328
+ const { sessionId, inputActions } = props
3329
+ useEffect(() => {
3330
+ if (pending.focusSession === sessionId) {
3331
+ pending.focusSession = null
3332
+ setTimeout(() => {
3333
+ try {
3334
+ inputActions?.focus?.()
3335
+ } catch (focusError) {
3336
+ console.warn('[treecom] focus failed', focusError)
3337
+ }
3338
+ }, 280)
3339
+ }
3340
+ }, [sessionId, inputActions])
3341
+
3342
+ const openSidebar = () => {
3343
+ const sidebar = appCtx?.get('sidebarRight')
3344
+ if (sidebar === undefined || typeof sidebar.openTab !== 'function') {
3345
+ console.warn('[treecom] sidebarRight is unavailable')
3346
+ return
3347
+ }
3348
+ try {
3349
+ sidebar.openTab('treecom')
3350
+ } catch (error) {
3351
+ console.warn('[treecom] opening the right sidebar failed', error)
3352
+ }
3353
+ }
3354
+
3355
+ return h('button', {
3356
+ type: 'button',
3357
+ className: 'tc-open-btn',
3358
+ title: translate('header.open'),
3359
+ 'aria-label': translate('header.open'),
3360
+ onClick: openSidebar,
3361
+ }, h(Icon, { name: 'tree', size: 14 }), h('span', null, translate('header.open')))
3362
+ }
3363
+
3364
+ /* ---------------------------------------------------------- styles -- */
3365
+
3366
+ const CSS = `
3367
+ .tc-panel{--tc-run-blue:#4aa8ff;position:relative;height:100%;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:12px;line-height:1.45}
3368
+ .tc-head{display:flex;align-items:center;gap:8px;padding:10px 12px 8px;border-bottom:1px solid var(--dsw-alias-border-l1)}
3369
+ .tc-head-text{min-width:0}
3370
+ .tc-title{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3371
+ .tc-sub{color:var(--dsw-alias-label-tertiary);font-size:11px;margin-top:1px}
3372
+ .tc-spacer{flex:1}
3373
+ .tc-hint{padding:7px 12px 0;color:var(--dsw-alias-label-tertiary);font-size:11px}
3374
+ .tc-body{flex:1;min-height:0;overflow:hidden;padding:0}
3375
+ .tc-panel--view .tc-body{max-width:none;width:100%;margin:0;padding:0;box-sizing:border-box}
3376
+ .tc-empty{padding:18px 12px;color:var(--dsw-alias-label-tertiary);font-size:12px}
3377
+ .tc-notice{margin:8px 12px 0;padding:6px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-label-primary);font-size:11px}
3378
+ .tc-notice--busy{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
3379
+ .tc-warn{margin:8px 12px 0;color:var(--dsw-alias-state-warn-label);font-size:11px}
3380
+ .tc-branch{min-width:0}
3381
+ /* Every card stays full width; hierarchy lives in a fixed-width left gutter.
3382
+ A long linear conversation therefore never loses horizontal room. */
3383
+ .tc-children{min-width:0}
3384
+ .tc-row{display:flex;align-items:stretch;gap:6px;min-width:0}
3385
+ .tc-row > .tc-card{flex:1 1 0%;min-width:0;box-sizing:border-box}
3386
+ .tc-gutter{position:relative;flex:0 0 22px;min-width:22px;display:flex;align-items:flex-start;justify-content:center;pointer-events:none}
3387
+ .tc-gutter::before{content:"";position:absolute;top:0;bottom:0;left:10px;width:1px;background:var(--dsw-alias-border-l1)}
3388
+ .tc-gutter--path::before{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,var(--dsw-alias-border-l1))}
3389
+ .tc-gutter--active::before{background:var(--dsw-alias-state-business-primary)}
3390
+ .tc-depth{position:relative;z-index:1;margin-top:7px;display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 3px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:1;box-sizing:border-box}
3391
+ .tc-gutter--path .tc-depth{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,var(--dsw-alias-bg-layer-1))}
3392
+ .tc-gutter--active .tc-depth{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 14%,var(--dsw-alias-bg-layer-1))}
3393
+ .tc-root{display:flex;align-items:center;gap:6px;padding:4px 4px 6px 2px;color:var(--dsw-alias-label-tertiary);font-size:11px;min-width:0}
3394
+ .tc-root-mark{display:inline-flex}
3395
+ .tc-root-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3396
+ .tc-card{position:relative;overflow:hidden;margin:4px 0;padding:7px 8px 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);cursor:pointer;transition:border-color .12s,background-color .12s;min-width:0}
3397
+ .tc-card:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-2)}
3398
+ .tc-card:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
3399
+ .tc-card--blank{border-style:dashed;background:transparent}
3400
+ .tc-card--path{background:color-mix(in srgb, var(--dsw-alias-state-business-primary) 5%, var(--dsw-alias-bg-layer-1));border-color:var(--dsw-alias-state-business-primary)}
3401
+ .tc-card--active{background:color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, var(--dsw-alias-bg-layer-1));border-color:var(--dsw-alias-state-business-primary);box-shadow:inset 3px 0 0 var(--dsw-alias-state-business-primary),0 0 0 1px var(--dsw-alias-state-business-primary)}
3402
+ .tc-card--active .tc-card-label{color:var(--dsw-alias-state-business-primary)}
3403
+ .tc-card--path:hover{background:color-mix(in srgb, var(--dsw-alias-state-business-primary) 5%, var(--dsw-alias-bg-layer-1));border-color:var(--dsw-alias-state-business-primary)}
3404
+ .tc-card--active:hover{background:color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, var(--dsw-alias-bg-layer-1));border-color:var(--dsw-alias-state-business-primary)}
3405
+ .tc-card--running{opacity:.85}
3406
+ .tc-card--busy{cursor:progress}
3407
+ .tc-card-body{display:flex;align-items:stretch;gap:8px;min-width:0}
3408
+ .tc-card-main{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1 1 auto}
3409
+ .tc-status{flex:0 0 64px;width:64px;min-width:64px;box-sizing:border-box;display:flex;flex-direction:column;justify-content:center;gap:4px;padding-left:7px;border-left:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary)}
3410
+ .tc-ctx{display:flex;flex-direction:column;gap:2px;min-width:0}
3411
+ .tc-ctx-top{display:flex;align-items:baseline;justify-content:space-between;gap:2px;font-size:9px;line-height:1.2}
3412
+ .tc-ctx-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3413
+ .tc-ctx-pct{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);font-weight:600}
3414
+ .tc-ctx-bar{height:3px;border-radius:2px;background:var(--dsw-alias-border-l1);overflow:hidden}
3415
+ .tc-ctx-bar > span{display:block;height:100%;border-radius:2px;background:var(--dsw-alias-state-business-primary,#4d6bfe);transition:width .2s}
3416
+ .tc-ctx-bar--warn > span{background:var(--dsw-alias-state-warn-primary,#d97706)}
3417
+ .tc-ctx-bar--error > span{background:var(--dsw-alias-state-error-primary,#dc2626)}
3418
+ .tc-after{font-size:9px;line-height:1.2;white-space:nowrap;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}
3419
+ .tc-after--none{opacity:.55}
3420
+ .tc-card-head{display:flex;align-items:flex-start;gap:6px}
3421
+ .tc-card-label{flex:1;min-width:0;font-weight:500;font-size:12px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
3422
+ .tc-card-label-text{pointer-events:none}
3423
+ .tc-card-badges{display:flex;flex-wrap:wrap;gap:3px;justify-content:flex-start;max-width:100%;margin-top:1px}
3424
+ .tc-badge{padding:0 4px;border-radius:4px;border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:15px;white-space:nowrap}
3425
+ .tc-badge--here{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);font-weight:600}
3426
+ .tc-badge--running{color:var(--tc-run-blue);border-color:var(--tc-run-blue)}
3427
+ .tc-badge--open{color:var(--dsw-alias-state-warn-label);border-color:var(--dsw-alias-state-warn-primary)}
3428
+ .tc-detail{position:fixed;z-index:2147483002;box-sizing:border-box;display:flex;flex-direction:column;gap:7px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel,0 12px 36px rgba(0,0,0,.24));font-size:12px;line-height:1.45;overflow:auto;overscroll-behavior:contain;pointer-events:auto}
3429
+ .tc-detail-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0}
3430
+ .tc-detail-title{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3431
+ .tc-detail-name{font-size:10px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
3432
+ .tc-detail-meta{display:flex;flex-wrap:wrap;gap:4px 8px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.3}
3433
+ .tc-detail-section{display:flex;flex-direction:column;gap:3px;min-width:0}
3434
+ .tc-detail-label{font-size:10px;font-weight:600;color:var(--dsw-alias-label-secondary);letter-spacing:.02em}
3435
+ .tc-detail-text{white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-primary)}
3436
+ .tc-detail-loading,.tc-detail-note{color:var(--dsw-alias-label-tertiary);font-size:11px}
3437
+ .tc-detail-error{color:var(--dsw-alias-state-error-primary);font-size:11px}
3438
+ .tc-detail-note{font-size:10px}
3439
+ .tc-detail-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1)}
3440
+ .tc-card-rail{position:absolute;left:0;top:0;bottom:0;width:26px;display:flex;align-items:center;justify-content:center;padding:0;border:0;border-right:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;transition:background-color .12s,color .12s}
3441
+ .tc-card-rail:hover{background:rgba(0,0,0,.12);color:var(--dsw-alias-label-primary)}
3442
+ .tc-card-rail--empty{cursor:default;pointer-events:none;background:transparent;border-right-color:transparent}
3443
+ .tc-card-rail--empty:hover{background:transparent}
3444
+ .tc-collapse-btn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;flex-shrink:0}
3445
+ .tc-collapse-btn:hover{background:rgba(0,0,0,.12);color:var(--dsw-alias-label-primary)}
3446
+ .tc-collapse-spacer{width:22px;flex-shrink:0}
3447
+ .tc-badge--collapsed{color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l1)}
3448
+ .tc-mini-btn{display:inline-flex;align-items:center;gap:3px;height:20px;padding:0 5px;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:10px;cursor:pointer}
3449
+ .tc-mini-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}
3450
+ .tc-mini-btn:disabled{opacity:.45;cursor:not-allowed}
3451
+ .tc-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}
3452
+ .tc-icon-btn:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1)}
3453
+ .tc-icon-btn:disabled{opacity:.4;cursor:not-allowed}
3454
+ .tc-icon-btn--tiny{width:18px;height:18px}
3455
+ .tc-icon-btn[data-spinning="true"] svg{animation:tc-spin .8s linear infinite}
3456
+ @keyframes tc-spin{to{transform:rotate(360deg)}}
3457
+ .tc-rename{width:100%;box-sizing:border-box;height:22px;padding:0 4px;border:1px solid var(--dsw-alias-state-business-primary);border-radius:4px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;outline:none}
3458
+ .tc-path{display:flex;align-items:center;gap:5px;min-height:22px;padding:1px 2px 4px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:15px;min-width:0;overflow:hidden}
3459
+ .tc-path-open{display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 5px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;white-space:nowrap}
3460
+ .tc-path-open:hover{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}
3461
+ .tc-path-chain{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow:hidden;white-space:nowrap}
3462
+ .tc-path-part{display:inline-flex;align-items:center;gap:4px;min-width:0}
3463
+ .tc-path-open,.tc-path-siblings,.tc-path-error{flex-shrink:0}
3464
+ .tc-path-crumb{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
3465
+ .tc-path-crumb:hover{color:var(--dsw-alias-label-primary)}
3466
+ .tc-path-current{color:var(--dsw-alias-label-primary);font-weight:500}
3467
+ .tc-path-dots{opacity:.7}
3468
+ .tc-path-sep{opacity:.5}
3469
+ .tc-path-siblings{display:inline-flex;align-items:center;gap:1px;margin-left:2px}
3470
+ .tc-path-count{font-size:10px;min-width:22px;text-align:center;color:var(--dsw-alias-label-tertiary)}
3471
+ .tc-path-error{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-base);font-size:10px;font-weight:700;cursor:help}
3472
+ .tc-branch-action{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 7px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;white-space:nowrap}
3473
+ .tc-branch-action:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}
3474
+ .tc-branch-action:disabled{opacity:.45;cursor:not-allowed}
3475
+ .tc-action-error{margin-left:2px;color:var(--dsw-alias-state-error-primary);font-weight:700;cursor:help}
3476
+ body.tc-view-tree-open [data-composer-seat],body.tc-view-tree-open [data-conversation-region="composer"],body.tc-view-tree-open [data-composer-card],body.tc-view-tree-open [data-composer-input],body.tc-view-tree-open [data-composer-dock],body.tc-view-tree-open [data-composer-overlay],body.tc-view-tree-open [data-conversation-composer-overlay],body.tc-view-tree-open [contenteditable="true"],body.tc-view-tree-open textarea:not(.tc-branch-ask-input){display:none!important}
3477
+ body.tc-view-tree-open [data-width-handle]{pointer-events:none!important;cursor:default!important}
3478
+ body.tc-view-tree-open [data-width-handle]::after{display:none!important}
3479
+ .tc-sidebar-body{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden}
3480
+ .tc-sidebar-body .tc-panel{flex:1;min-height:0}
3481
+ .tc-sidebar-body .tc-workspace{flex-direction:column}
3482
+ .tc-sidebar-body .tc-canvas-wrap{flex:1 1 auto;min-height:240px}
3483
+ .tc-sidebar-body .tc-side{flex:0 0 46%;width:100%;max-width:none;max-height:52%;border-left:0;border-top:1px solid var(--dsw-alias-border-l1)}
3484
+ .tc-sidebar-body .tc-chat-bubble,.tc-sidebar-body .tc-chat-bubble--user{max-height:92px}
3485
+ .tc-sidebar-body .tc-chat-bubble--assistant{max-height:168px}
3486
+ .tc-mode{display:flex;align-items:center;gap:2px;flex:0 0 auto;padding:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-1)}
3487
+ .tc-mode-btn{border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:10.5px;line-height:1.4;padding:3px 7px;border-radius:5px;cursor:pointer;white-space:nowrap}
3488
+ .tc-mode-btn:hover{color:var(--dsw-alias-label-primary)}
3489
+ .tc-mode-btn--active{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
3490
+ .tc-all-trees{flex:1;min-height:0;overflow:auto;padding:14px 16px 20px;display:flex;flex-direction:column;gap:10px}
3491
+ .tc-all-hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.45}
3492
+ .tc-all-loading{color:var(--dsw-alias-label-tertiary);font-size:11px}
3493
+ .tc-all-list{display:flex;flex-direction:column;gap:6px}
3494
+ .tc-all-tree-row{display:flex;align-items:stretch;gap:4px;min-width:0}
3495
+ .tc-all-tree-row .tc-all-tree{flex:1 1 auto;min-width:0}
3496
+ .tc-all-tree-delete{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;padding:0;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:.6}
3497
+ .tc-all-tree-delete:hover{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626);opacity:1}
3498
+ .tc-all-tree{display:flex;align-items:center;justify-content:space-between;gap:10px;text-align:left;width:100%;padding:9px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer}
3499
+ .tc-all-tree:hover{border-color:var(--dsw-alias-state-business-primary)}
3500
+ .tc-all-tree--current{border-color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,var(--dsw-alias-bg-layer-1))}
3501
+ .tc-all-tree--promoted{border-style:dashed}
3502
+ .tc-all-tree-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600}
3503
+ .tc-all-tree-meta{flex:0 0 auto;color:var(--dsw-alias-state-business-primary);font-size:10px;font-weight:600}
3504
+ .tc-promote-zone{position:absolute;left:50%;top:68px;transform:translateX(-50%);z-index:7;pointer-events:none;padding:8px 14px;border:1px dashed var(--dsw-alias-state-business-primary);border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-bg-base) 88%,var(--dsw-alias-state-business-primary));color:var(--dsw-alias-state-business-primary);font-size:11px;font-weight:600;white-space:nowrap;box-shadow:0 6px 20px rgba(0,0,0,.18)}
3505
+ .tc-promote-zone--over{background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-bg-base)}
3506
+ .tc-canvas-node--promoting .tc-canvas-node-dot{box-shadow:0 0 0 3px var(--dsw-alias-state-business-primary)}
3507
+
3508
+ .tc-toast{position:absolute;top:46px;left:50%;transform:translateX(-50%);z-index:5;padding:5px 10px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel);font-size:11px;white-space:nowrap;pointer-events:none;animation:tc-toast-in .15s ease-out}
3509
+ .tc-toast--busy{display:inline-flex;align-items:center;gap:6px;max-width:calc(100% - 20px);white-space:normal;text-align:center}
3510
+ .tc-spinner{flex:0 0 auto;width:10px;height:10px;border:2px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-state-business-primary);border-radius:50%;display:inline-block;animation:tc-spin .8s linear infinite}
3511
+ .tc-canvas-wrap--locked{pointer-events:none;cursor:progress}
3512
+ @keyframes tc-toast-in{from{opacity:0;transform:translate(-50%,-4px)}to{opacity:1;transform:translate(-50%,0)}}
3513
+ .tc-open-btn{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
3514
+ .tc-open-btn:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
3515
+ /* --------------------------------------------------- canvas workspace -- */
3516
+ .tc-workspace{display:flex;height:100%;min-height:0;width:100%}
3517
+ .tc-canvas-wrap{position:relative;flex:1 1 auto;min-width:0;min-height:0;height:100%;overflow:hidden;background-color:var(--dsw-alias-bg-base);background-image:radial-gradient(circle,var(--dsw-alias-border-l1) 1px,transparent 1px);background-size:18px 18px;cursor:grab;touch-action:none;user-select:none}
3518
+ .tc-canvas-wrap--dragging{cursor:grabbing}
3519
+ .tc-canvas-world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}
3520
+ .tc-canvas-edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
3521
+ .tc-canvas-edge{fill:none;stroke:var(--dsw-alias-border-l2);stroke-width:1.4;stroke-linecap:round}
3522
+ .tc-canvas-edge--path{stroke:var(--dsw-alias-state-business-primary);stroke-width:2;opacity:.8}
3523
+ .tc-canvas-edge--grow{stroke-dasharray:1;stroke-dashoffset:1;animation:tc-edge-grow .62s ease-out .03s both}
3524
+ .tc-canvas-node--grow{animation:tc-node-grow .62s cubic-bezier(.22,.9,.28,1) both}
3525
+ @keyframes tc-node-grow{0%{opacity:0;transform:translate(calc(-50% + var(--grow-x,0px)),calc(-50% + var(--grow-y,0px))) scale(.3)}55%{opacity:1}80%{transform:translate(-50%,-50%) scale(1.04)}100%{opacity:1;transform:translate(-50%,-50%) scale(1)}}
3526
+ @keyframes tc-edge-grow{to{stroke-dashoffset:0}}
3527
+ @media (prefers-reduced-motion:reduce){.tc-canvas-node--grow,.tc-canvas-edge--grow{animation:none}}
3528
+ .tc-canvas-node{position:absolute;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;outline:none;transition:left .26s cubic-bezier(.2,.9,.2,1),top .26s cubic-bezier(.2,.9,.2,1)}
3529
+ @media (prefers-reduced-motion:reduce){.tc-canvas-node{transition:none}}
3530
+ .tc-canvas-node-halo{position:absolute;inset:2px;border-radius:50%;background:transparent;transition:background-color .12s,box-shadow .12s}
3531
+ .tc-canvas-node-dot{position:relative;display:flex;align-items:center;justify-content:center;width:12px;height:12px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-tertiary);transition:background-color .12s,border-color .12s,transform .12s}
3532
+ .tc-canvas-node:hover .tc-canvas-node-halo{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent)}
3533
+ .tc-canvas-node:hover .tc-canvas-node-dot{transform:scale(1.12)}
3534
+ .tc-canvas-node:focus-visible .tc-canvas-node-halo{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
3535
+ .tc-canvas-node--root .tc-canvas-node-dot{width:22px;height:22px;border-color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,var(--dsw-alias-bg-base));color:var(--dsw-alias-state-business-primary)}
3536
+ .tc-canvas-node--blank .tc-canvas-node-dot{border-style:dashed;background:transparent}
3537
+ .tc-canvas-node--collapsed .tc-canvas-node-dot{box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}
3538
+ .tc-canvas-node-count{position:absolute;left:50%;top:50%;transform:translate(8px,-50%);min-width:17px;height:15px;padding:0 4px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:9px;line-height:13px;text-align:center;font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
3539
+ .tc-canvas-node--path .tc-canvas-node-dot{border-color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-base))}
3540
+ .tc-canvas-node--selected .tc-canvas-node-halo{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 16%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
3541
+ .tc-canvas-node--selected .tc-canvas-node-dot{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-bg-base)}
3542
+ .tc-canvas-node--active .tc-canvas-node-halo{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 18%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-success-primary) 20%,transparent)}
3543
+ .tc-canvas-node--active .tc-canvas-node-dot{border-color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-bg-base)}
3544
+ .tc-canvas-node--running .tc-canvas-node-dot{border-color:var(--tc-run-blue);animation:tc-node-pulse 1.6s ease-in-out infinite}
3545
+ .tc-canvas-node--active.tc-canvas-node--running .tc-canvas-node-dot{background:var(--tc-run-blue);border-color:var(--tc-run-blue);color:var(--dsw-alias-bg-base)}
3546
+ .tc-canvas-node--child-running .tc-canvas-node-dot{background:var(--tc-run-blue);border-color:var(--tc-run-blue);color:var(--dsw-alias-bg-base);box-shadow:0 0 0 2px color-mix(in srgb,var(--tc-run-blue) 20%,transparent)}
3547
+ @keyframes tc-node-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--tc-run-blue) 0%,transparent)}50%{box-shadow:0 0 0 6px color-mix(in srgb,var(--tc-run-blue) 24%,transparent)}}
3548
+ .tc-canvas-toolbar{position:absolute;left:12px;top:10px;z-index:6;display:flex;gap:6px}
3549
+ .tc-canvas-btn{height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;white-space:nowrap}
3550
+ .tc-canvas-btn:hover{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}
3551
+ .tc-canvas-hint{position:absolute;left:12px;bottom:10px;right:12px;z-index:5;color:var(--dsw-alias-label-tertiary);font-size:10px;pointer-events:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3552
+ .tc-canvas-tip{position:absolute;z-index:30;width:320px;max-width:calc(100% - 24px);max-height:calc(100% - 16px);box-sizing:border-box;padding:9px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-panel,0 10px 28px rgba(0,0,0,.22));pointer-events:auto;font-size:11px;line-height:1.45;display:flex;flex-direction:column;cursor:default;user-select:text}
3553
+ .tc-tip-head{display:flex;align-items:center;gap:6px;min-width:0}
3554
+ .tc-tip-turn{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3555
+ .tc-tip-here{margin-left:auto;padding:0 4px;border:1px solid var(--dsw-alias-state-success-primary);border-radius:4px;color:var(--dsw-alias-state-success-primary);font-size:10px;line-height:15px;white-space:nowrap}
3556
+ .tc-tip-q{margin-top:4px;color:var(--dsw-alias-label-secondary);font-size:11px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
3557
+ .tc-tip-body{margin-top:6px;min-height:0;overflow-y:auto;overscroll-behavior:contain;display:flex;flex-direction:column;gap:7px;padding-right:2px}
3558
+ .tc-tip-section{min-width:0}
3559
+ .tc-tip-label{margin-bottom:2px;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:600;letter-spacing:.02em}
3560
+ .tc-tip-text{white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.5;cursor:text;user-select:text}
3561
+ .tc-tip-text *{cursor:text}
3562
+ .tc-tip-text a{cursor:pointer}
3563
+ .tc-tip-error{color:var(--dsw-alias-state-error-label);font-size:10px}
3564
+ .tc-tip-note{color:var(--dsw-alias-label-tertiary);font-size:10px}
3565
+ .tc-tip-meta{margin-top:5px;color:var(--dsw-alias-label-tertiary);font-size:10px;font-variant-numeric:tabular-nums}
3566
+ .tc-tip-hint{margin-top:3px;padding:0;border:0;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:10px;text-align:left;cursor:pointer}
3567
+ .tc-tip-hint--action:hover{color:var(--dsw-alias-state-business-primary);text-decoration:underline}
3568
+ .tc-tip-chain{margin-top:5px;color:var(--dsw-alias-state-business-primary);font-size:10px;font-weight:500}
3569
+ .tc-tip-chain--action{padding:0;border:0;background:none;font:inherit;font-size:10px;font-weight:500;text-align:left;cursor:pointer}
3570
+ .tc-tip-chain--action:hover{text-decoration:underline}
3571
+ .tc-md{min-width:0;color:inherit;line-height:1.65;font-size:12px;overflow-wrap:anywhere;white-space:pre-wrap}
3572
+ .tc-md > :first-child{margin-top:0}
3573
+ .tc-md > :last-child{margin-bottom:0}
3574
+ .tc-md p{margin:0 0 8px}
3575
+ .tc-md h1,.tc-md h2,.tc-md h3,.tc-md h4,.tc-md h5,.tc-md h6{margin:10px 0 6px;color:var(--dsw-alias-label-primary);font-weight:600;line-height:1.35;letter-spacing:0}
3576
+ .tc-md h1{font-size:16px}
3577
+ .tc-md h2{font-size:15px}
3578
+ .tc-md h3{font-size:14px}
3579
+ .tc-md h4,.tc-md h5,.tc-md h6{font-size:13px}
3580
+ .tc-md ul,.tc-md ol{margin:0 0 8px;padding-left:20px}
3581
+ .tc-md li{margin:2px 0}
3582
+ .tc-md li > p{margin:0}
3583
+ .tc-md code{padding:1px 4px;border-radius:4px;background:var(--dsw-alias-bg-layer-3);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
3584
+ .tc-md pre{margin:0 0 8px;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-layer-3);overflow:auto;max-height:260px;white-space:pre}
3585
+ .tc-md pre code{padding:0;background:transparent;font-size:11px;white-space:pre}
3586
+ .tc-md blockquote{margin:0 0 8px;padding:2px 0 2px 10px;border-left:2px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
3587
+ .tc-md a{color:var(--dsw-alias-state-business-primary);text-decoration:none}
3588
+ .tc-md a:hover{text-decoration:underline}
3589
+ .tc-md hr{margin:10px 0;border:0;border-top:1px solid var(--dsw-alias-border-l1)}
3590
+ .tc-md .tc-md-table-wrap{margin:0 0 8px;max-width:100%;overflow-x:auto}
3591
+ .tc-md table{border-collapse:collapse;font-size:11px;min-width:0}
3592
+ .tc-md th,.tc-md td{padding:4px 7px;border:1px solid var(--dsw-alias-border-l1);text-align:left;vertical-align:top}
3593
+ .tc-md th{background:var(--dsw-alias-bg-layer-2);font-weight:600;color:var(--dsw-alias-label-primary)}
3594
+ .tc-md--tip{font-size:11px;line-height:1.55}
3595
+ .tc-md--tip h1,.tc-md--tip h2,.tc-md--tip h3,.tc-md--tip h4{font-size:12px}
3596
+ .tc-md--tip pre{max-height:180px}
3597
+ .tc-md-placeholder{color:var(--dsw-alias-label-tertiary)}
3598
+ .tc-side{flex:0 0 360px;width:360px;min-width:0;height:100%;min-height:0;display:flex;flex-direction:column;border-left:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base)}
3599
+ .tc-panel--view .tc-side{flex-basis:380px;width:380px}
3600
+ .tc-side-head{padding:14px 16px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-shrink:0}
3601
+ .tc-side-title-row{display:flex;align-items:center;gap:8px;min-width:0}
3602
+ .tc-side-title{flex:1;min-width:0;font-weight:600;font-size:14px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3603
+ .tc-side-sub{margin-top:3px;color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3604
+ .tc-side-badges{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px}
3605
+ .tc-side-badge{padding:0 4px;border:1px solid var(--dsw-alias-border-l1);border-radius:4px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:15px;white-space:nowrap}
3606
+ .tc-side-badge--here{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);font-weight:600}
3607
+ .tc-side-badge--running{border-color:var(--tc-run-blue);color:var(--tc-run-blue)}
3608
+ .tc-side-badge--child-running{border-color:var(--tc-run-blue);color:var(--tc-run-blue);font-weight:500;opacity:.95}
3609
+ .tc-tip-badge{border:1px solid var(--tc-run-blue);border-radius:999px;padding:0 6px;font-size:10px;line-height:17px;color:var(--tc-run-blue);margin-left:6px}
3610
+ .tc-tip-badge--child-running{font-weight:500;opacity:.95}
3611
+ .tc-side-body{flex:1 1 auto;min-height:0;overflow:auto;padding:12px 16px 18px;display:flex;flex-direction:column;gap:12px;overscroll-behavior:contain}
3612
+ .tc-side-empty{padding:18px 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}
3613
+ .tc-side-stats{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px}
3614
+ .tc-side-stat{min-width:0;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}
3615
+ .tc-side-stat-label{color:var(--dsw-alias-label-tertiary);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3616
+ .tc-side-stat-value{margin-top:2px;font-size:18px;font-weight:600;line-height:1.2;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}
3617
+ .tc-side-stat-note{margin-top:3px;color:var(--dsw-alias-label-tertiary);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
3618
+ .tc-side-ctx-bar{height:4px;margin-top:5px;border-radius:2px;background:var(--dsw-alias-border-l1);overflow:hidden}
3619
+ .tc-side-ctx-bar > span{display:block;height:100%;border-radius:2px;background:var(--dsw-alias-state-business-primary,#4d6bfe);transition:width .2s}
3620
+ .tc-side-ctx-bar--warn > span{background:var(--dsw-alias-state-warn-primary,#d97706)}
3621
+ .tc-side-ctx-bar--error > span{background:var(--dsw-alias-state-error-primary,#dc2626)}
3622
+ .tc-chat{display:flex;flex-direction:column;gap:6px;min-width:0}
3623
+ .tc-chat-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:2px}
3624
+ .tc-chat-head .tc-chat-label{margin-top:0}
3625
+ .tc-chat-label{margin-top:2px;color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:600;letter-spacing:.02em}
3626
+ .tc-copy-btn{display:inline-flex;align-items:center;gap:3px;padding:1px 5px;border:1px solid transparent;border-radius:4px;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:10px;line-height:16px;cursor:pointer;opacity:.8}
3627
+ .tc-copy-btn:hover:not(:disabled){color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);opacity:1}
3628
+ .tc-copy-btn:disabled{opacity:.35;cursor:default}
3629
+ .tc-copy-btn--done{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);opacity:1}
3630
+ .tc-chat-bubble{max-height:220px;flex:0 0 auto;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-width:thin;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere}
3631
+ .tc-chat-bubble--user{max-height:116px}
3632
+ .tc-chat-bubble::-webkit-scrollbar{width:6px}
3633
+ .tc-chat-bubble::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2);border-radius:3px}
3634
+ .tc-branch-ask{display:flex;flex-direction:column;gap:6px;margin-top:2px}
3635
+ .tc-branch-ask-input{width:100%;box-sizing:border-box;min-height:58px;max-height:160px;resize:vertical;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5;outline:none}
3636
+ .tc-branch-ask-input:focus{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-business-primary) 16%,transparent)}
3637
+ .tc-branch-ask-input:disabled{opacity:.6}
3638
+ .tc-branch-ask-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px}
3639
+ .tc-branch-ask-send{flex:0 0 auto;padding:5px 12px;border:1px solid var(--dsw-alias-state-business-primary);border-radius:7px;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-bg-base);font:inherit;font-size:11.5px;font-weight:600;cursor:pointer}
3640
+ .tc-branch-ask-send:hover:not(:disabled){filter:brightness(1.08)}
3641
+ .tc-branch-ask-send:disabled{cursor:default;opacity:.45}
3642
+ .tc-chat-bubble--user{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 34%,var(--dsw-alias-border-l1));background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 6%,var(--dsw-alias-bg-layer-1))}
3643
+ .tc-chat-bubble--assistant{max-height:220px;background:var(--dsw-alias-bg-layer-1)}
3644
+ .tc-chat-bubble--loading{color:var(--dsw-alias-label-tertiary);font-style:italic}
3645
+ .tc-side-note{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.45}
3646
+ .tc-side-error{padding:6px 8px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:6px;color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:1.45}
3647
+ .tc-side-actions{display:flex;flex-wrap:wrap;gap:6px;padding:10px 16px;border-top:1px solid var(--dsw-alias-border-l1);flex-shrink:0}
3648
+ .tc-side-actions--head{padding:9px 0 0;border-top:0;background:transparent}
3649
+ .tc-side-act{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;white-space:nowrap}
3650
+ .tc-side-act:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}
3651
+ .tc-side-act:disabled{opacity:.45;cursor:not-allowed}
3652
+ .tc-side-act--primary{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}
3653
+ .tc-side-act--primary:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}
3654
+ .tc-side-rename{width:100%;box-sizing:border-box;height:28px;padding:0 6px;border:1px solid var(--dsw-alias-state-business-primary);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;outline:none}
3655
+ @media (max-width:980px){.tc-side,.tc-panel--view .tc-side{flex-basis:320px;width:320px}}
3656
+ @media (max-width:760px){.tc-workspace{flex-direction:column}.tc-canvas-wrap{flex:1 1 auto;min-height:220px}.tc-side,.tc-panel--view .tc-side{flex:0 0 45%;width:100%;border-left:0;border-top:1px solid var(--dsw-alias-border-l1)}}
3657
+
3658
+ .tc-left-tree-head{display:flex;align-items:center;gap:6px;min-width:0;margin:1px 4px 2px;padding:5px 6px;border-radius:6px;color:var(--dsw-alias-label-secondary);font:inherit;font-size:14px;font-weight:600;line-height:20px;cursor:pointer;user-select:none;outline:none}
3659
+ .tc-left-tree-head:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
3660
+ .tc-left-tree-head:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
3661
+ .tc-left-tree-head--current{color:var(--dsw-alias-label-primary)}
3662
+ .tc-left-tree-chevron{flex:none;display:inline-flex;align-items:center;justify-content:center;width:13px;height:20px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1;transform:rotate(90deg);transform-origin:50% 50%;transition:transform .15s ease}
3663
+ .tc-left-tree-head--collapsed .tc-left-tree-chevron{transform:rotate(0)}
3664
+ .tc-left-tree-head:not(.tc-left-tree-head--collapsed) .tc-left-tree-folder{color:var(--dsw-alias-state-business-primary)}
3665
+ .tc-left-tree-folder{flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-secondary)}
3666
+ .tc-left-tree-folder svg{width:15px;height:15px}
3667
+ .tc-left-tree-delete{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:.5}
3668
+ .tc-left-tree-delete:hover{color:var(--dsw-alias-state-error-primary,#dc2626);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#dc2626) 12%,transparent);opacity:1}
3669
+ .tc-left-tree-delete:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px;opacity:1}
3670
+ .tc-left-tree-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3671
+ .tc-left-tree-count{flex:none;margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:10.5px;font-weight:500;font-variant-numeric:tabular-nums}
3672
+ .tc-left-tree-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-success-primary) 18%,transparent)}
3673
+ .tc-left-tree-dot--running{animation:tc-left-tree-dot-pulse 1.1s ease-in-out infinite}
3674
+ @keyframes tc-left-tree-dot-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.72)}}
3675
+ @media (prefers-reduced-motion:reduce){.tc-left-tree-dot--running{animation:none;opacity:.55}}
3676
+ .tc-confirm-backdrop{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(0,0,0,.42)}
3677
+ .tc-confirm{width:min(380px,92vw);box-sizing:border-box;padding:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);box-shadow:0 18px 50px rgba(0,0,0,.35);color:var(--dsw-alias-label-primary)}
3678
+ .tc-confirm-title{font-size:14px;font-weight:700;line-height:1.4}
3679
+ .tc-confirm-message{margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.65;word-break:break-word}
3680
+ .tc-confirm-error{margin-top:8px;color:var(--dsw-alias-state-error-primary,#dc2626);font-size:11px;line-height:1.5}
3681
+ .tc-confirm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
3682
+ .tc-confirm-btn{padding:6px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer}
3683
+ .tc-confirm-btn:hover:not(:disabled){border-color:var(--dsw-alias-label-tertiary)}
3684
+ .tc-confirm-btn--danger{border-color:var(--dsw-alias-state-error-primary,#dc2626);background:var(--dsw-alias-state-error-primary,#dc2626);color:#fff;font-weight:600}
3685
+ .tc-confirm-btn:disabled{cursor:default;opacity:.55}
3686
+ [data-treecom-left-row="true"]{margin-left:16px;width:calc(100% - 16px);box-sizing:border-box}
3687
+ [data-treecom-left-row="true"].tc-left-tree-row--collapsed{display:none!important}
3688
+
3689
+ `
3690
+
3691
+ /* ------------------------------------------- grouped left sidebar -- */
3692
+
3693
+ const SIDEBAR_COLLAPSED_KEY = 'treecom:sidebar-trees-collapsed'
3694
+
3695
+ let sidebarTreeDeleteHandler = null
3696
+ let activeTreeConfirm = null
3697
+
3698
+ function closeTreeDeleteConfirm() {
3699
+ if (activeTreeConfirm === null) return
3700
+ const close = activeTreeConfirm
3701
+ activeTreeConfirm = null
3702
+ close()
3703
+ }
3704
+
3705
+ /** DOM-level confirmation so both the injected left rows and React all-trees can use it. */
3706
+ function showTreeDeleteConfirm({ title, count, onConfirm }) {
3707
+ closeTreeDeleteConfirm()
3708
+ if (typeof document === 'undefined') return
3709
+ const backdrop = document.createElement('div')
3710
+ backdrop.className = 'tc-confirm-backdrop'
3711
+ const dialog = document.createElement('div')
3712
+ dialog.className = 'tc-confirm'
3713
+ dialog.setAttribute('role', 'dialog')
3714
+ dialog.setAttribute('aria-modal', 'true')
3715
+ dialog.setAttribute('aria-labelledby', 'tc-confirm-title')
3716
+ const heading = document.createElement('div')
3717
+ heading.className = 'tc-confirm-title'
3718
+ heading.id = 'tc-confirm-title'
3719
+ heading.textContent = translate('treeDelete.title')
3720
+ const message = document.createElement('div')
3721
+ message.className = 'tc-confirm-message'
3722
+ message.textContent = fmt(translate('treeDelete.message'), { title, n: count })
3723
+ const error = document.createElement('div')
3724
+ error.className = 'tc-confirm-error'
3725
+ error.hidden = true
3726
+ const actions = document.createElement('div')
3727
+ actions.className = 'tc-confirm-actions'
3728
+ const cancel = document.createElement('button')
3729
+ cancel.type = 'button'
3730
+ cancel.className = 'tc-confirm-btn'
3731
+ cancel.textContent = translate('treeDelete.cancel')
3732
+ const confirm = document.createElement('button')
3733
+ confirm.type = 'button'
3734
+ confirm.className = 'tc-confirm-btn tc-confirm-btn--danger'
3735
+ confirm.textContent = translate('treeDelete.confirm')
3736
+ actions.append(cancel, confirm)
3737
+ dialog.append(heading, message, error, actions)
3738
+ backdrop.append(dialog)
3739
+ document.body.append(backdrop)
3740
+ let busy = false
3741
+ let closed = false
3742
+ const close = () => {
3743
+ if (closed) return
3744
+ closed = true
3745
+ if (activeTreeConfirm === close) activeTreeConfirm = null
3746
+ document.removeEventListener('keydown', onKeyDown, true)
3747
+ backdrop.remove()
3748
+ }
3749
+ activeTreeConfirm = close
3750
+ const onKeyDown = (event) => {
3751
+ if (event.key === 'Escape') {
3752
+ if (busy) return
3753
+ event.preventDefault()
3754
+ close()
3755
+ }
3756
+ }
3757
+ backdrop.addEventListener('click', (event) => {
3758
+ if (busy || event.target !== backdrop) return
3759
+ close()
3760
+ })
3761
+ cancel.addEventListener('click', () => {
3762
+ if (!busy) close()
3763
+ })
3764
+ confirm.addEventListener('click', async () => {
3765
+ if (busy) return
3766
+ busy = true
3767
+ cancel.disabled = true
3768
+ confirm.disabled = true
3769
+ confirm.textContent = translate('treeDelete.deleting')
3770
+ try {
3771
+ await onConfirm()
3772
+ close()
3773
+ } catch (errorValue) {
3774
+ error.hidden = false
3775
+ error.textContent = fmt(translate('treeDelete.failed'), {
3776
+ message: errorValue instanceof Error ? errorValue.message : String(errorValue),
3777
+ })
3778
+ busy = false
3779
+ cancel.disabled = false
3780
+ confirm.disabled = false
3781
+ confirm.textContent = translate('treeDelete.confirm')
3782
+ }
3783
+ })
3784
+ document.addEventListener('keydown', onKeyDown, true)
3785
+ cancel.focus()
3786
+ }
3787
+
3788
+ function fallbackTreeDelete(root, title, ids) {
3789
+ const unique = [...new Set((ids ?? []).filter((id) => typeof id === 'string' && id !== ''))]
3790
+ if (unique.length === 0) return
3791
+ showTreeDeleteConfirm({
3792
+ title,
3793
+ count: unique.length,
3794
+ onConfirm: async () => {
3795
+ const workspace = appCtx?.get('uiWorkspace')
3796
+ if (workspace === undefined || typeof workspace.archiveSession !== 'function') {
3797
+ throw new Error(translate('notice.noService'))
3798
+ }
3799
+ for (const id of unique) await workspace.archiveSession(id)
3800
+ if (typeof showToast !== 'function') return
3801
+ showToast(fmt(translate('treeDelete.done'), { n: unique.length }))
3802
+ },
3803
+ })
3804
+ }
3805
+
3806
+ function installSidebarTreeGrouping(sessions) {
3807
+ if (typeof document === 'undefined' || typeof window === 'undefined') return () => {}
3808
+ const list = sessions?.list
3809
+ if (!list || typeof list.getSnapshot !== 'function') return () => {}
3810
+ let disposed = false
3811
+ let scheduled = false
3812
+ let applying = false
3813
+ let collapsed = new Set()
3814
+ let rootBySession = new Map()
3815
+ let titleByRoot = new Map()
3816
+ let lineageTimer = null
3817
+ let lineageToken = 0
3818
+ try {
3819
+ const raw = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
3820
+ const value = raw === null ? [] : JSON.parse(raw)
3821
+ if (Array.isArray(value)) collapsed = new Set(value.filter((id) => typeof id === 'string'))
3822
+ } catch { /* private mode */ }
3823
+
3824
+ const rootOf = (id, byId) => {
3825
+ let current = id
3826
+ const seen = new Set()
3827
+ for (;;) {
3828
+ if (seen.has(current)) return current
3829
+ seen.add(current)
3830
+ const parent = byId[current]?.parentId
3831
+ if (typeof parent !== 'string' || parent === current || byId[parent] === undefined) return current
3832
+ current = parent
3833
+ }
3834
+ }
3835
+ const selectedSessionId = () => {
3836
+ const selected = document.querySelector('[data-row-key^="session:"][aria-selected="true"]')
3837
+ return selected?.getAttribute('data-row-key')?.slice('session:'.length) ?? null
3838
+ }
3839
+ const selectedRoot = (byId) => {
3840
+ const id = selectedSessionId()
3841
+ if (typeof id !== 'string') return null
3842
+ return rootBySession.get(id) ?? (byId[id] !== undefined ? rootOf(id, byId) : null)
3843
+ }
3844
+ const requestLineage = () => {
3845
+ if (disposed) return
3846
+ if (lineageTimer !== null) clearTimeout(lineageTimer)
3847
+ lineageTimer = setTimeout(() => {
3848
+ lineageTimer = null
3849
+ const snapshot = list.getSnapshot()
3850
+ const byId = snapshot?.byId ?? {}
3851
+ const id = selectedSessionId() ?? snapshot?.ids?.[0]
3852
+ if (typeof id !== 'string' || id === '') return
3853
+ const token = ++lineageToken
3854
+ fetchLineage(id).then((body) => {
3855
+ if (disposed || token !== lineageToken) return
3856
+ const nextRoots = new Map()
3857
+ const nextTitles = new Map()
3858
+ for (const row of body.sessions ?? []) {
3859
+ if (typeof row?.id !== 'string' || typeof row?.rootId !== 'string') continue
3860
+ nextRoots.set(row.id, row.rootId)
3861
+ if (row.id === row.rootId && typeof row.title === 'string' && row.title.trim() !== '') {
3862
+ nextTitles.set(row.rootId, row.title.trim())
3863
+ }
3864
+ }
3865
+ for (const [id, session] of Object.entries(byId)) {
3866
+ if (!nextRoots.has(id)) nextRoots.set(id, rootOf(id, byId))
3867
+ }
3868
+ rootBySession = nextRoots
3869
+ titleByRoot = nextTitles
3870
+ schedule()
3871
+ }).catch(() => {})
3872
+ }, 150)
3873
+ }
3874
+ const saveCollapsed = () => {
3875
+ try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify([...collapsed])) } catch { /* private mode */ }
3876
+ }
3877
+ const folderSvg = () => '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><circle cx="8" cy="3.2" r="1.35"/><path d="M8 4.6v2.2M8 6.8H4.2v2.2M8 6.8h3.8v2.2"/><circle cx="4.2" cy="10.5" r="1.2"/><circle cx="11.8" cy="10.5" r="1.2"/></svg>'
3878
+ const deleteSvg = () => '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true"><path d="M3.5 4.5h9M6 4.5V3.2h4v1.3M5 6.6v5.1M8 6.6v5.1M11 6.6v5.1M4.2 4.5l.6 8.3h6.4l.6-8.3"/></svg>'
3879
+
3880
+ const apply = () => {
3881
+ if (disposed) return
3882
+ applying = true
3883
+ try {
3884
+ const snapshot = list.getSnapshot()
3885
+ const byId = snapshot?.byId ?? {}
3886
+ for (const old of document.querySelectorAll('[data-treecom-left-head]')) old.remove()
3887
+ const rows = [...document.querySelectorAll('[data-row-key^="session:"]')]
3888
+ for (const row of rows) {
3889
+ row.removeAttribute('data-treecom-left-row')
3890
+ row.removeAttribute('data-treecom-left-root')
3891
+ row.classList.remove('tc-left-tree-row--collapsed')
3892
+ }
3893
+ const currentRoot = selectedRoot(byId)
3894
+ const runningRoots = new Set()
3895
+ for (const [id, session] of Object.entries(byId)) {
3896
+ if (session?.running !== true) continue
3897
+ runningRoots.add(rootBySession.get(id) ?? rootOf(id, byId))
3898
+ }
3899
+ const workspaceRows = [...document.querySelectorAll('[data-row-key^="workspace:"]')]
3900
+ for (const workspaceRow of workspaceRows) {
3901
+ let section = workspaceRow.parentElement
3902
+ while (section !== null && section.querySelector('[data-row-key^="session:"]') === null) {
3903
+ section = section.parentElement
3904
+ }
3905
+ if (section !== null && section !== document.body && section !== document.documentElement) {
3906
+ section.setAttribute('data-treecom-workspace-section', '')
3907
+ }
3908
+ }
3909
+ const bySection = new Map()
3910
+ for (const row of rows) {
3911
+ const section = row.closest('[data-treecom-workspace-section]')
3912
+ if (section === null) continue
3913
+ const sectionRows = bySection.get(section) ?? []
3914
+ sectionRows.push(row)
3915
+ bySection.set(section, sectionRows)
3916
+ }
3917
+ for (const [section, sectionRows] of bySection) {
3918
+ let scope = sectionRows[0]?.parentElement ?? null
3919
+ while (scope !== null && !sectionRows.every((row) => scope.contains(row))) scope = scope.parentElement
3920
+ if (scope === null || !sectionRows.every((row) => scope.contains(row))) continue
3921
+ const itemOf = (row) => {
3922
+ let item = row
3923
+ while (item.parentElement !== null && item.parentElement !== scope) item = item.parentElement
3924
+ return item.parentElement === scope ? item : null
3925
+ }
3926
+ const groups = new Map()
3927
+ const firstItems = []
3928
+ for (const row of sectionRows) {
3929
+ const id = row.getAttribute('data-row-key')?.slice('session:'.length)
3930
+ if (typeof id !== 'string' || byId[id] === undefined) continue
3931
+ const root = rootBySession.get(id) ?? rootOf(id, byId)
3932
+ const item = itemOf(row)
3933
+ if (item === null) continue
3934
+ if (!firstItems.includes(item)) firstItems.push(item)
3935
+ const group = groups.get(root) ?? { root, items: [], rows: [] }
3936
+ if (!group.items.includes(item)) group.items.push(item)
3937
+ group.rows.push(row)
3938
+ groups.set(root, group)
3939
+ }
3940
+ if (groups.size === 0) continue
3941
+ const ordered = [...groups.values()].sort((left, right) => {
3942
+ if (left.root === currentRoot) return -1
3943
+ if (right.root === currentRoot) return 1
3944
+ return (byId[right.root]?.updatedAt ?? 0) - (byId[left.root]?.updatedAt ?? 0)
3945
+ })
3946
+ const first = firstItems[0]
3947
+ if (first === undefined) continue
3948
+ const anchor = document.createComment('treecom-left-anchor')
3949
+ scope.insertBefore(anchor, first)
3950
+ const fragment = document.createDocumentFragment()
3951
+ for (const group of ordered) {
3952
+ const rootSession = byId[group.root]
3953
+ const title = rootSession?.displayTitle || rootSession?.title || titleByRoot.get(group.root) || group.root
3954
+ const isCollapsed = collapsed.has(group.root)
3955
+ const head = document.createElement('div')
3956
+ head.className = `tc-left-tree-head${group.root === currentRoot ? ' tc-left-tree-head--current' : ''}${isCollapsed ? ' tc-left-tree-head--collapsed' : ''}`
3957
+ head.setAttribute('data-treecom-left-head', group.root)
3958
+ head.setAttribute('role', 'button')
3959
+ head.setAttribute('tabindex', '0')
3960
+ head.title = title
3961
+ const chevron = document.createElement('span')
3962
+ chevron.className = 'tc-left-tree-chevron'
3963
+ chevron.textContent = '▸'
3964
+ const folder = document.createElement('span')
3965
+ folder.className = 'tc-left-tree-folder'
3966
+ folder.innerHTML = folderSvg()
3967
+ const name = document.createElement('span')
3968
+ name.className = 'tc-left-tree-name'
3969
+ name.textContent = title
3970
+ const count = document.createElement('span')
3971
+ count.className = 'tc-left-tree-count'
3972
+ count.textContent = String(group.rows.length)
3973
+ head.append(chevron)
3974
+ if (group.root === currentRoot) {
3975
+ const running = runningRoots.has(group.root)
3976
+ const dot = document.createElement('span')
3977
+ dot.className = `tc-left-tree-dot${running ? ' tc-left-tree-dot--running' : ''}`
3978
+ dot.title = running ? translate('node.running') : translate('node.youAreHere')
3979
+ head.append(dot)
3980
+ }
3981
+ const remove = document.createElement('button')
3982
+ remove.type = 'button'
3983
+ remove.className = 'tc-left-tree-delete'
3984
+ remove.title = translate('treeDelete.action')
3985
+ remove.setAttribute('aria-label', translate('treeDelete.action'))
3986
+ remove.innerHTML = deleteSvg()
3987
+ remove.addEventListener('click', (event) => {
3988
+ event.preventDefault()
3989
+ event.stopPropagation()
3990
+ const ids = group.rows
3991
+ .map((row) => row.getAttribute('data-row-key') ?? '')
3992
+ .filter((key) => key.startsWith('session:'))
3993
+ .map((key) => key.slice(8))
3994
+ const handler = typeof sidebarTreeDeleteHandler === 'function' ? sidebarTreeDeleteHandler : fallbackTreeDelete
3995
+ handler(group.root, title, ids)
3996
+ })
3997
+ head.append(folder, name, count, remove)
3998
+ const toggle = (event) => {
3999
+ if (event !== undefined) { event.preventDefault(); event.stopPropagation() }
4000
+ if (collapsed.has(group.root)) collapsed.delete(group.root)
4001
+ else collapsed.add(group.root)
4002
+ saveCollapsed()
4003
+ const nowCollapsed = collapsed.has(group.root)
4004
+ head.classList.toggle('tc-left-tree-head--collapsed', nowCollapsed)
4005
+ for (const row of document.querySelectorAll('[data-treecom-left-root]')) {
4006
+ if (row.getAttribute('data-treecom-left-root') === group.root) {
4007
+ row.classList.toggle('tc-left-tree-row--collapsed', nowCollapsed)
4008
+ }
4009
+ }
4010
+ }
4011
+ head.addEventListener('click', toggle)
4012
+ head.addEventListener('keydown', (event) => {
4013
+ if (event.key === 'Enter' || event.key === ' ') toggle(event)
4014
+ })
4015
+ fragment.append(head)
4016
+ for (const row of group.rows) {
4017
+ row.setAttribute('data-treecom-left-row', 'true')
4018
+ row.setAttribute('data-treecom-left-root', group.root)
4019
+ row.classList.toggle('tc-left-tree-row--collapsed', isCollapsed)
4020
+ }
4021
+ for (const item of group.items) fragment.append(item)
4022
+ }
4023
+ scope.insertBefore(fragment, anchor)
4024
+ anchor.remove()
4025
+ }
4026
+ /* Exactly one folder carries the current-tree dot. */
4027
+ const liveCurrentRoot = selectedRoot(byId)
4028
+ for (const head of document.querySelectorAll('.tc-left-tree-head')) {
4029
+ const current = head.getAttribute('data-treecom-left-head') === liveCurrentRoot
4030
+ head.classList.toggle('tc-left-tree-head--current', current)
4031
+ const dot = head.querySelector('.tc-left-tree-dot')
4032
+ if (current && dot === null) {
4033
+ const running = runningRoots.has(liveCurrentRoot)
4034
+ const next = document.createElement('span')
4035
+ next.className = `tc-left-tree-dot${running ? ' tc-left-tree-dot--running' : ''}`
4036
+ next.title = running ? translate('node.running') : translate('node.youAreHere')
4037
+ const folder = head.querySelector('.tc-left-tree-folder')
4038
+ head.insertBefore(next, folder)
4039
+ } else if (!current && dot !== null) {
4040
+ dot.remove()
4041
+ } else if (current && dot !== null) {
4042
+ const running = runningRoots.has(liveCurrentRoot)
4043
+ dot.classList.toggle('tc-left-tree-dot--running', running)
4044
+ dot.title = running ? translate('node.running') : translate('node.youAreHere')
4045
+ }
4046
+ }
4047
+ } finally {
4048
+ if (typeof requestAnimationFrame === 'function') {
4049
+ requestAnimationFrame(() => { applying = false })
4050
+ } else {
4051
+ setTimeout(() => { applying = false }, 0)
4052
+ }
4053
+ }
4054
+ }
4055
+
4056
+ const schedule = () => {
4057
+ if (disposed || scheduled) return
4058
+ scheduled = true
4059
+ const run = () => {
4060
+ scheduled = false
4061
+ apply()
4062
+ }
4063
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
4064
+ else setTimeout(run, 0)
4065
+ }
4066
+
4067
+ const observer = new MutationObserver((mutations) => {
4068
+ if (applying) return
4069
+ for (const mutation of mutations) {
4070
+ if (mutation.type === 'attributes' && mutation.attributeName === 'aria-selected') {
4071
+ requestLineage()
4072
+ schedule()
4073
+ return
4074
+ }
4075
+ if (mutation.type === 'childList') {
4076
+ const rows = (node) => node instanceof Element
4077
+ && (node.matches('[data-row-key^="session:"]') || node.querySelector('[data-row-key^="session:"]') !== null)
4078
+ const changed = [...mutation.addedNodes, ...mutation.removedNodes].some(rows)
4079
+ if (changed) schedule()
4080
+ return
4081
+ }
4082
+ }
4083
+ })
4084
+ observer.observe(document.body, {
4085
+ subtree: true,
4086
+ childList: true,
4087
+ attributes: true,
4088
+ attributeFilter: ['aria-selected'],
4089
+ })
4090
+ const unsubscribe = typeof list.subscribe === 'function' ? list.subscribe(() => { requestLineage(); schedule() }) : () => {}
4091
+ requestLineage()
4092
+ schedule()
4093
+ return () => {
4094
+ disposed = true
4095
+ if (lineageTimer !== null) clearTimeout(lineageTimer)
4096
+ observer.disconnect()
4097
+ unsubscribe()
4098
+ for (const old of document.querySelectorAll('[data-treecom-left-head]')) old.remove()
4099
+ for (const row of document.querySelectorAll('[data-treecom-left-row]')) {
4100
+ row.removeAttribute('data-treecom-left-row')
4101
+ row.removeAttribute('data-treecom-left-root')
4102
+ row.classList.remove('tc-left-tree-row--collapsed')
4103
+ }
4104
+ }
4105
+ }
4106
+
4107
+ /* ----------------------------------------------------------- apply -- */
4108
+
4109
+ return {
4110
+ inject: ['slots'],
4111
+ apply(ctx) {
4112
+ appCtx = ctx
4113
+ const slots = ctx.get('slots')
4114
+ if (!slots) throw new Error('treecom: slots service missing')
4115
+ const locale = ctx.get('locale')
4116
+ if (locale) {
4117
+ ctx.effect(() => locale.register(NS, { zh: ZH, en: EN }), 'treecom: locale')
4118
+ translate = locale.bind(NS)
4119
+ }
4120
+
4121
+ ctx.effect(() => {
4122
+ const style = document.createElement('style')
4123
+ style.setAttribute('data-treecom-style', '')
4124
+ style.textContent = CSS
4125
+ document.head.appendChild(style)
4126
+ return () => style.remove()
4127
+ }, 'treecom: styles')
4128
+
4129
+ ctx.inject(['sessions'], (sessionScope) => {
4130
+ const sessionService = sessionScope.get('sessions')
4131
+ if (!sessionService) return
4132
+ ctx.effect(() => installSidebarTreeGrouping(sessionService), 'treecom: grouped session sidebar')
4133
+ })
4134
+
4135
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
4136
+ name: 'conversation.view',
4137
+ id: VIEW_ID,
4138
+ order: 30,
4139
+ label: () => translate('view.title'),
4140
+ }, ViewTree))
4141
+
4142
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
4143
+ name: 'sidebar.right.pane.tab',
4144
+ key: 'treecom',
4145
+ inject: (sessionId) => ({ sessionId }),
4146
+ }, SidebarTree))
4147
+
4148
+ if (typeof ctx.inject === 'function') {
4149
+ ctx.inject(['sidebarRightTabs'], (scope) => {
4150
+ const tabs = scope.get('sidebarRightTabs')
4151
+ if (tabs === undefined || typeof tabs.register !== 'function') return
4152
+ ctx.effect(() => tabs.register({
4153
+ id: 'treecom',
4154
+ kind: 'treecom',
4155
+ keepMounted: true,
4156
+ priority: 'extension',
4157
+ title: () => translate('panel.title'),
4158
+ }), 'treecom: right sidebar tab')
4159
+ })
4160
+ }
4161
+
4162
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
4163
+ name: 'conversation.session.header.utilities',
4164
+ id: 'treecom-open',
4165
+ order: 20,
4166
+ label: () => translate('header.open'),
4167
+ }, OpenButton))
4168
+ },
4169
+ }
4170
+ },
4171
+ })