dsh-treecom 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/assets/runtime.webp +0 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +14 -0
- package/index.js +857 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +56 -4
- package/plugin.client.js +4171 -0
package/plugin.client.js
ADDED
|
@@ -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
|
+
})
|