dsh-ask-from-text 0.0.0-stage → 0.1.13

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/client.js ADDED
@@ -0,0 +1,2684 @@
1
+ /**
2
+ * Inline follow-up: select text in a finalized answer, open a floating branch,
3
+ * fork the session at the selected event, and render the branch with the shared
4
+ * Conversation Factory.
5
+ *
6
+ * The plugin deliberately keeps the branch content in real DSH sessions. The
7
+ * store here only owns UI metadata (tree, anchors, colors, panel state).
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-ask-from-text',
11
+ factory(require) {
12
+ const React = require('react')
13
+ const h = React.createElement
14
+ const {
15
+ useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,
16
+ } = React
17
+
18
+ const NS = 'inlineFollowup'
19
+ const STORAGE_KEY = 'dsh.inline-followup.v1'
20
+ const MAX_QUOTE = 4000
21
+ const PANEL_WIDTH = 420
22
+ const COLORS = [
23
+ { light: '#C25A3A', lightTint: '#F7E6DF', dark: '#F08A68', darkTint: 'rgba(240,138,104,.16)' },
24
+ { light: '#207F73', lightTint: '#DFF1ED', dark: '#5FC9B8', darkTint: 'rgba(95,201,184,.15)' },
25
+ { light: '#5B63C4', lightTint: '#E7E8FA', dark: '#9AA4FF', darkTint: 'rgba(154,164,255,.15)' },
26
+ { light: '#A66B12', lightTint: '#F7EBD8', dark: '#E3B05E', darkTint: 'rgba(227,176,94,.15)' },
27
+ { light: '#B0446E', lightTint: '#F7E3EC', dark: '#E889B0', darkTint: 'rgba(232,137,176,.15)' },
28
+ { light: '#3D6FA8', lightTint: '#E3EDF8', dark: '#7FB2E8', darkTint: 'rgba(127,178,232,.15)' },
29
+ { light: '#7A4FA8', lightTint: '#EEE4F8', dark: '#C29BE8', darkTint: 'rgba(194,155,232,.15)' },
30
+ { light: '#5F7A2E', lightTint: '#EAF0DC', dark: '#A9C46A', darkTint: 'rgba(169,196,106,.15)' },
31
+ ]
32
+
33
+ const DICT = {
34
+ zh: {
35
+ chip: '追问',
36
+ chipHint: '针对选中文字创建独立分支',
37
+ branch: '分支',
38
+ branchN: '分支',
39
+ basedOn: '基于',
40
+ main: '主对话',
41
+ independent: '独立分支',
42
+ draft: '草稿',
43
+ sending: '建立中',
44
+ running: '运行中',
45
+ ready: '已创建',
46
+ failed: '失败',
47
+ send: '发送',
48
+ sendingAction: '发送中…',
49
+ quote: '已引用',
50
+ defaultQuestion: '请针对这段内容继续说明。',
51
+ empty: '在这里补充你的问题…',
52
+ sendHint: 'Enter 发送 · Shift + Enter 换行',
53
+ collapse: '收起',
54
+ input: '输入',
55
+ deleteDraft: '删除草稿',
56
+ close: '关闭',
57
+ expand: '放大',
58
+ restore: '还原为浮窗',
59
+ back: '后退',
60
+ forward: '前进',
61
+ stageHint: '后退 / 前进只切换正在查看的分支,不改变模型上下文。',
62
+ path: '路径',
63
+ all: '全部',
64
+ tree: '分支树',
65
+ current: '当前',
66
+ dock: '已收起的分支',
67
+ open: '打开',
68
+ retry: '重试',
69
+ error: '发送失败',
70
+ forkError: '无法创建分支',
71
+ noSession: '当前会话不可用',
72
+ tooLong: '选择过长,请缩短后再试',
73
+ content: '正文',
74
+ quoteLabel: '引用自',
75
+ showQuote: '展开引用',
76
+ hideQuote: '收起引用',
77
+ switchBranch: '切换到此分支',
78
+ stages: '个节点',
79
+ collapseDock: '收起列表',
80
+ expandDock: '展开列表',
81
+ },
82
+ en: {
83
+ chip: 'Ask',
84
+ chipHint: 'Ask about the selected text in an independent branch',
85
+ branch: 'Branch',
86
+ branchN: 'Branch',
87
+ basedOn: 'based on',
88
+ main: 'Main chat',
89
+ independent: 'Independent branch',
90
+ draft: 'Draft',
91
+ sending: 'Opening',
92
+ running: 'Running',
93
+ ready: 'Ready',
94
+ failed: 'Failed',
95
+ send: 'Send',
96
+ sendingAction: 'Sending…',
97
+ quote: 'Quoted',
98
+ defaultQuestion: 'Please explain this passage further.',
99
+ empty: 'Add your question…',
100
+ sendHint: 'Enter to send · Shift + Enter for a new line',
101
+ collapse: 'Collapse',
102
+ input: 'Input',
103
+ deleteDraft: 'Delete draft',
104
+ close: 'Close',
105
+ expand: 'Expand',
106
+ restore: 'Restore panel',
107
+ back: 'Back',
108
+ forward: 'Forward',
109
+ stageHint: 'Back / forward only changes the branch you are viewing; it does not change model context.',
110
+ path: 'Path',
111
+ all: 'All',
112
+ tree: 'Branches',
113
+ current: 'Current',
114
+ dock: 'Collapsed branches',
115
+ open: 'Open',
116
+ retry: 'Retry',
117
+ error: 'Send failed',
118
+ forkError: 'Could not create branch',
119
+ noSession: 'Session unavailable',
120
+ tooLong: 'Selection is too long',
121
+ content: 'Text',
122
+ quoteLabel: 'Quoted from',
123
+ showQuote: 'Show quote',
124
+ hideQuote: 'Hide quote',
125
+ switchBranch: 'Switch to this branch',
126
+ stages: 'nodes',
127
+ collapseDock: 'Collapse list',
128
+ expandDock: 'Expand list',
129
+ },
130
+ }
131
+
132
+ let app = null
133
+
134
+ // ---------------------------------------------------------------------
135
+ // small helpers
136
+ // ---------------------------------------------------------------------
137
+
138
+ function clamp(value, min, max) {
139
+ return Math.max(min, Math.min(max, value))
140
+ }
141
+
142
+ function defaultInputBox(panel) {
143
+ const width = Math.min(panel.w || 440, Math.max(240, window.innerWidth - 24))
144
+ const height = 168
145
+ let x = clamp(panel.x, 8, Math.max(8, window.innerWidth - width - 8))
146
+ let y = panel.y + (panel.h || 360) + 8
147
+ if (y + height > window.innerHeight - 8) y = panel.y - height - 8
148
+ if (y < 8) y = Math.max(8, window.innerHeight - height - 8)
149
+ return { x, y, w: width, h: height }
150
+ }
151
+
152
+ function snippet(text, length) {
153
+ const value = String(text || '').replace(/\s+/g, ' ').trim()
154
+ return value.length > length ? `${value.slice(0, length - 1)}…` : value
155
+ }
156
+
157
+ function cssEscape(value) {
158
+ return String(value).replace(/["\\]/g, '\\$&')
159
+ }
160
+
161
+ function isEditable(element) {
162
+ if (element === null || element === undefined) return false
163
+ if (!(element instanceof Element)) return false
164
+ return element.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
165
+ }
166
+
167
+ function textNodesOf(root) {
168
+ const nodes = []
169
+ if (!root) return nodes
170
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
171
+ acceptNode(node) {
172
+ if (node.nodeValue === null || node.nodeValue.length === 0) return NodeFilter.FILTER_REJECT
173
+ const parent = node.parentElement
174
+ if (parent && parent.closest('script, style, [hidden], [aria-hidden="true"]')) return NodeFilter.FILTER_REJECT
175
+ return NodeFilter.FILTER_ACCEPT
176
+ },
177
+ })
178
+ let current = walker.nextNode()
179
+ while (current) {
180
+ nodes.push(current)
181
+ current = walker.nextNode()
182
+ }
183
+ return nodes
184
+ }
185
+
186
+ function sourceText(root) {
187
+ return textNodesOf(root).map(node => node.nodeValue).join('')
188
+ }
189
+
190
+ function rangeFromOffsets(root, start, end) {
191
+ const nodes = textNodesOf(root)
192
+ let position = 0
193
+ let startNode = null
194
+ let startOffset = 0
195
+ let endNode = null
196
+ let endOffset = 0
197
+ for (const node of nodes) {
198
+ const length = node.nodeValue.length
199
+ const next = position + length
200
+ if (startNode === null && start >= position && start <= next) {
201
+ startNode = node
202
+ startOffset = clamp(start - position, 0, length)
203
+ }
204
+ if (endNode === null && end >= position && end <= next) {
205
+ endNode = node
206
+ endOffset = clamp(end - position, 0, length)
207
+ }
208
+ position = next
209
+ }
210
+ if (startNode === null || endNode === null) return null
211
+ const range = document.createRange()
212
+ try {
213
+ range.setStart(startNode, startOffset)
214
+ range.setEnd(endNode, endOffset)
215
+ return range
216
+ } catch (_error) {
217
+ return null
218
+ }
219
+ }
220
+
221
+ function offsetAt(root, container, offset) {
222
+ if (!root.contains(container)) return null
223
+ try {
224
+ const range = document.createRange()
225
+ range.selectNodeContents(root)
226
+ range.setEnd(container, offset)
227
+ return range.toString().length
228
+ } catch (_error) {
229
+ return null
230
+ }
231
+ }
232
+
233
+ function sourceIndexInNode(node, source) {
234
+ const all = node.querySelectorAll('[data-text-interaction-source]')
235
+ for (let i = 0; i < all.length; i += 1) {
236
+ if (all[i] === source) return i
237
+ }
238
+ return 0
239
+ }
240
+
241
+ function seqOfChatNode(node) {
242
+ if (!node) return -1
243
+ const finalSeq = node.data && node.data.finalNode && node.data.finalNode.seq
244
+ if (typeof finalSeq === 'number') return finalSeq
245
+ return typeof node.anchorSeq === 'number' ? node.anchorSeq : -1
246
+ }
247
+
248
+ function hideInheritedRows(host, sessionId, cutSeq) {
249
+ if (!host || !app || !Number.isFinite(cutSeq)) return
250
+ const chat = app.chats.get(sessionId)
251
+ if (!chat || !chat.nodes) return
252
+ const rows = host.querySelectorAll('[data-chat-node-key]')
253
+ for (const row of rows) {
254
+ const key = row.getAttribute('data-chat-node-key')
255
+ if (!key) continue
256
+ const node = chat.nodes.get(key)
257
+ const seq = seqOfChatNode(node)
258
+ if (seq >= 0 && seq <= cutSeq && row.style.display !== 'none') row.style.display = 'none'
259
+ }
260
+ const wrappers = host.querySelectorAll('[data-chat-anchor-key]')
261
+ for (const wrapper of wrappers) {
262
+ const inner = wrapper.querySelectorAll('[data-chat-node-key]')
263
+ if (inner.length === 0) continue
264
+ let visible = false
265
+ for (const row of inner) {
266
+ if (row.style.display !== 'none') { visible = true; break }
267
+ }
268
+ if (visible) {
269
+ if (wrapper.dataset.ifuHidden === '1') {
270
+ wrapper.style.display = wrapper.dataset.ifuPrevDisplay || ''
271
+ delete wrapper.dataset.ifuHidden
272
+ delete wrapper.dataset.ifuPrevDisplay
273
+ }
274
+ } else if (wrapper.dataset.ifuHidden !== '1') {
275
+ wrapper.dataset.ifuPrevDisplay = wrapper.style.display
276
+ wrapper.style.display = 'none'
277
+ wrapper.dataset.ifuHidden = '1'
278
+ }
279
+ }
280
+ }
281
+
282
+ function mainChatFlow(anchor) {
283
+ const flows = Array.from(document.querySelectorAll('[data-chat-flow]'))
284
+ .filter(flow => !flow.closest('[data-ifu-root]'))
285
+ let candidates = flows
286
+ if (anchor && anchor.nodeKey) {
287
+ const selector = `[data-chat-node-key="${cssEscape(anchor.nodeKey)}"]`
288
+ const containing = flows.filter(flow => flow.querySelector(selector))
289
+ if (containing.length > 0) candidates = containing
290
+ }
291
+ const visible = candidates.find((flow) => {
292
+ const rect = flow.getBoundingClientRect()
293
+ return rect.width > 0 && rect.height > 0
294
+ })
295
+ return visible || candidates[0] || null
296
+ }
297
+
298
+ function branchChatFlow(sessionId) {
299
+ if (!sessionId) return null
300
+ const selector = `[data-ifu-session="${cssEscape(sessionId)}"] [data-chat-flow]`
301
+ return document.querySelector(selector)
302
+ }
303
+
304
+ function treeOrder(state, branches) {
305
+ const ids = new Set(branches.map(branch => branch.id))
306
+ const byParent = new Map()
307
+ for (const branch of branches) {
308
+ const parentId = branch.parentBranchId && ids.has(branch.parentBranchId)
309
+ ? branch.parentBranchId
310
+ : null
311
+ const list = byParent.get(parentId) || []
312
+ list.push(branch)
313
+ byParent.set(parentId, list)
314
+ }
315
+ const rows = []
316
+ const seen = new Set()
317
+ const walk = (parentId, depth) => {
318
+ for (const branch of byParent.get(parentId) || []) {
319
+ if (seen.has(branch.id)) continue
320
+ seen.add(branch.id)
321
+ rows.push({ branch, depth })
322
+ walk(branch.id, depth + 1)
323
+ }
324
+ }
325
+ walk(null, 0)
326
+ for (const branch of branches) {
327
+ if (!seen.has(branch.id)) rows.push({ branch, depth: 0 })
328
+ }
329
+ return rows
330
+ }
331
+
332
+ function pathOf(state, branch) {
333
+ const parts = []
334
+ let current = branch
335
+ const seen = new Set()
336
+ while (current && !seen.has(current.id)) {
337
+ seen.add(current.id)
338
+ parts.unshift(current)
339
+ current = current.parentBranchId ? state.branches[current.parentBranchId] : null
340
+ }
341
+ return parts
342
+ }
343
+
344
+ function pathLabel(state, branch) {
345
+ return `${DICT.zh.main}${pathOf(state, branch).map(item => ` › #${item.number}`).join('')}`
346
+ }
347
+
348
+ function branchBasis(state, branch) {
349
+ if (!branch.parentBranchId) return `${DICT.zh.basedOn} ${DICT.zh.main}`
350
+ const parent = state.branches[branch.parentBranchId]
351
+ return parent ? `${DICT.zh.basedOn} #${parent.number}` : `${DICT.zh.basedOn} ${DICT.zh.main}`
352
+ }
353
+
354
+ function statusLabel(branch) {
355
+ if (branch.status === 'sending') return DICT.zh.sending
356
+ if (branch.status === 'running') return DICT.zh.running
357
+ if (branch.status === 'error') return DICT.zh.failed
358
+ if (branch.prompted) return DICT.zh.ready
359
+ return DICT.zh.draft
360
+ }
361
+
362
+ function colorVars(color) {
363
+ const index = Number.isFinite(color) ? color % COLORS.length : 0
364
+ return {
365
+ '--ifu-c': `var(--ifu-c${index})`,
366
+ '--ifu-t': `var(--ifu-t${index})`,
367
+ '--ifu-h': `var(--ifu-h${index})`,
368
+ }
369
+ }
370
+
371
+ function trackPointer(event, onMove, onEnd) {
372
+ if (!event || event.button !== 0) return
373
+ event.preventDefault()
374
+ const startX = event.clientX
375
+ const startY = event.clientY
376
+ const move = (moveEvent) => onMove(moveEvent.clientX - startX, moveEvent.clientY - startY)
377
+ const finish = () => {
378
+ window.removeEventListener('pointermove', move)
379
+ window.removeEventListener('pointerup', finish)
380
+ window.removeEventListener('pointercancel', finish)
381
+ if (onEnd) onEnd()
382
+ }
383
+ window.addEventListener('pointermove', move)
384
+ window.addEventListener('pointerup', finish, { once: true })
385
+ window.addEventListener('pointercancel', finish, { once: true })
386
+ }
387
+
388
+ // ---------------------------------------------------------------------
389
+ // app store
390
+ // ---------------------------------------------------------------------
391
+
392
+ function createApp(ctx) {
393
+ const state = {
394
+ version: 0,
395
+ mainSessionId: null,
396
+ branches: {},
397
+ order: [],
398
+ nextNumber: 1,
399
+ chip: null,
400
+ stageOpen: false,
401
+ stageStack: [],
402
+ stageIndex: -1,
403
+ sidebarCollapsed: false,
404
+ notice: null,
405
+ dockOpen: true,
406
+ dock: null,
407
+ treeOpen: false,
408
+ }
409
+ const refs = new Map()
410
+ const chats = new Map()
411
+ const listeners = new Set()
412
+ let main = { sessionId: null, nodes: null, timeline: null }
413
+ let persistTimer = null
414
+ let projectFrame = null
415
+ let noticeTimer = null
416
+ let selectionTimer = null
417
+ let mutationObserver = null
418
+ let installed = false
419
+
420
+ const app = {
421
+ state,
422
+ refs,
423
+ chats,
424
+ get main() { return main },
425
+ t(key) { return (ctx.locale && ctx.locale.bind(NS)(key)) || DICT.zh[key] || key },
426
+ subscribe(listener) {
427
+ listeners.add(listener)
428
+ return () => { listeners.delete(listener) }
429
+ },
430
+ getVersion() { return state.version },
431
+ emit,
432
+ load,
433
+ persist,
434
+ install,
435
+ setMainChat,
436
+ setBranchChat,
437
+ setBranchRunning,
438
+ scheduleProject,
439
+ projectHighlights,
440
+ highlightRects,
441
+ markItems,
442
+ openDraft,
443
+ send,
444
+ setDraftText,
445
+ setInputText,
446
+ sendFollowup,
447
+ openPanel,
448
+ revealBranch,
449
+ collapse,
450
+ deleteDraft,
451
+ promote,
452
+ stageGo,
453
+ stageRestore,
454
+ stageClose,
455
+ closeDock,
456
+ toggleDock,
457
+ toggleTree,
458
+ toggleSidebarBranches,
459
+ startPanelDrag,
460
+ startPanelResize,
461
+ startDockDrag,
462
+ startDockResize,
463
+ notify,
464
+ clearChip,
465
+ branchBySession,
466
+ }
467
+
468
+ function emit() {
469
+ state.version += 1
470
+ for (const listener of listeners) {
471
+ try { listener() } catch (_error) { /* one bad listener must not stop others */ }
472
+ }
473
+ schedulePersist()
474
+ }
475
+
476
+ function notify(text) {
477
+ state.notice = text
478
+ emit()
479
+ if (noticeTimer !== null) clearTimeout(noticeTimer)
480
+ noticeTimer = setTimeout(() => {
481
+ state.notice = null
482
+ emit()
483
+ }, 4200)
484
+ }
485
+
486
+ function schedulePersist() {
487
+ if (persistTimer !== null) return
488
+ persistTimer = setTimeout(() => {
489
+ persistTimer = null
490
+ persist()
491
+ }, 250)
492
+ }
493
+
494
+ function persist() {
495
+ try {
496
+ const branches = {}
497
+ for (const [id, branch] of Object.entries(state.branches)) {
498
+ branches[id] = {
499
+ id: branch.id,
500
+ number: branch.number,
501
+ color: branch.color,
502
+ rootSessionId: branch.rootSessionId,
503
+ parentSessionId: branch.parentSessionId,
504
+ parentBranchId: branch.parentBranchId,
505
+ sessionId: branch.sessionId,
506
+ anchor: branch.anchor,
507
+ question: branch.question,
508
+ prompted: branch.prompted,
509
+ status: branch.status === 'running' ? 'idle' : branch.status,
510
+ error: branch.error,
511
+ panel: branch.panel,
512
+ input: branch.input,
513
+ inputText: branch.inputText,
514
+ createdAt: branch.createdAt,
515
+ }
516
+ }
517
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
518
+ version: 1,
519
+ branches,
520
+ order: state.order,
521
+ nextNumber: state.nextNumber,
522
+ dock: state.dock,
523
+ sidebarCollapsed: state.sidebarCollapsed,
524
+ }))
525
+ } catch (_error) {
526
+ // localStorage may be unavailable; the session logs still exist.
527
+ }
528
+ }
529
+
530
+ function load() {
531
+ try {
532
+ const raw = localStorage.getItem(STORAGE_KEY)
533
+ if (!raw) return
534
+ const data = JSON.parse(raw)
535
+ if (!data || data.version !== 1 || !data.branches || !Array.isArray(data.order)) return
536
+ let maxNumber = 0
537
+ for (const id of data.order) {
538
+ const branch = data.branches[id]
539
+ if (!branch || !branch.id || !branch.anchor || !branch.anchor.sessionId) continue
540
+ if (!Array.isArray(branch.anchor.ranges) || branch.anchor.ranges.length === 0) continue
541
+ branch.mode = 'collapsed'
542
+ branch.loading = false
543
+ branch.status = branch.status === 'sending' ? 'error' : branch.status
544
+ branch.panel = { x: Number(branch.panel?.x) || 24, y: Number(branch.panel?.y) || 80, w: Number(branch.panel?.w) || 440, h: Number(branch.panel?.h) || 360 }
545
+ if (!branch.prompted && !branch.input && branch.panel.h > 520) branch.panel.h = 360
546
+ branch.input = {
547
+ x: Number(branch.input?.x) || defaultInputBox(branch.panel).x,
548
+ y: Number(branch.input?.y) || defaultInputBox(branch.panel).y,
549
+ w: Number(branch.input?.w) || 440,
550
+ h: Number(branch.input?.h) || 168,
551
+ }
552
+ branch.inputText = String(branch.inputText || '')
553
+ state.branches[id] = branch
554
+ state.order.push(id)
555
+ maxNumber = Math.max(maxNumber, Number(branch.number) || 0)
556
+ }
557
+ state.nextNumber = Math.max(Number(data.nextNumber) || 1, maxNumber + 1)
558
+ if (data.dock && Number.isFinite(data.dock.x) && Number.isFinite(data.dock.y)) state.dock = { x: data.dock.x, y: data.dock.y, w: Number(data.dock.w) || 286, h: Number(data.dock.h) || 260 }
559
+ state.sidebarCollapsed = Boolean(data.sidebarCollapsed)
560
+ } catch (_error) {
561
+ // Ignore a corrupt or incompatible tree; sessions remain available.
562
+ }
563
+ }
564
+
565
+ function branchBySession(sessionId) {
566
+ for (const id of state.order) {
567
+ const branch = state.branches[id]
568
+ if (branch.sessionId === sessionId) return branch
569
+ }
570
+ return null
571
+ }
572
+
573
+ function setMainChat(sessionId, nodes, timeline) {
574
+ if (!sessionId) return
575
+ const changed = state.mainSessionId !== sessionId
576
+ main = { sessionId, nodes, timeline }
577
+ if (!changed) {
578
+ scheduleProject()
579
+ return
580
+ }
581
+ state.mainSessionId = sessionId
582
+ state.chip = null
583
+ state.stageOpen = false
584
+ state.stageStack = []
585
+ state.stageIndex = -1
586
+ state.treeOpen = false
587
+ for (const branch of Object.values(state.branches)) {
588
+ if (branch.mode === 'panel' || branch.mode === 'stage') branch.mode = 'collapsed'
589
+ }
590
+ emit()
591
+ }
592
+
593
+ function setBranchChat(sessionId, nodes, timeline) {
594
+ if (!sessionId) return
595
+ chats.set(sessionId, { nodes, timeline })
596
+ scheduleProject()
597
+ }
598
+
599
+ function setBranchRunning(branchId, running) {
600
+ const branch = state.branches[branchId]
601
+ if (!branch) return
602
+ const next = running ? 'running' : (branch.prompted ? 'idle' : (branch.status === 'error' ? 'error' : 'draft'))
603
+ if (branch.status !== next) {
604
+ branch.status = next
605
+ emit()
606
+ }
607
+ }
608
+
609
+ function scheduleProject() {
610
+ if (projectFrame !== null) return
611
+ projectFrame = requestAnimationFrame(() => {
612
+ projectFrame = null
613
+ projectHighlights()
614
+ renderBadges()
615
+ renderSidebarBranches()
616
+ })
617
+ }
618
+
619
+ function projectHighlights() {
620
+ if (typeof CSS === 'undefined' || !CSS.highlights || typeof Highlight === 'undefined') return
621
+ for (let i = 0; i < COLORS.length; i += 1) CSS.highlights.delete(`ifu-c${i}`)
622
+ if (!state.mainSessionId) return
623
+ const groups = Array.from({ length: COLORS.length }, () => [])
624
+ for (const id of state.order) {
625
+ const branch = state.branches[id]
626
+ if (!branch || branch.rootSessionId !== state.mainSessionId) continue
627
+ if (!branch.anchor.ranges || branch.anchor.ranges.length === 0) continue
628
+ const inThread = branch.anchor.sessionId !== state.mainSessionId
629
+ const root = inThread
630
+ ? branchChatFlow(branch.anchor.sessionId)
631
+ : mainChatFlow(branch.anchor.ranges[0])
632
+ if (!root) continue
633
+ for (const anchor of branch.anchor.ranges) {
634
+ const range = restoreAnchorRange(root, anchor)
635
+ if (range) groups[branch.color % COLORS.length].push(range)
636
+ }
637
+ }
638
+ for (let i = 0; i < groups.length; i += 1) {
639
+ if (groups[i].length > 0) CSS.highlights.set(`ifu-c${i}`, new Highlight(...groups[i]))
640
+ }
641
+ }
642
+
643
+ function decorationLayerFor(column) {
644
+ let layer = column.querySelector(':scope > [data-ifu-badge-layer]')
645
+ if (layer) return layer
646
+ layer = document.createElement('div')
647
+ layer.className = 'ifu-badge-layer'
648
+ layer.setAttribute('data-ifu-badge-layer', '')
649
+ layer.setAttribute('aria-hidden', 'true')
650
+ layer.addEventListener('click', (event) => {
651
+ const target = event.target instanceof Element ? event.target.closest('[data-ifu-branch-id]') : null
652
+ if (!target) return
653
+ event.preventDefault()
654
+ event.stopPropagation()
655
+ openPanel(target.getAttribute('data-ifu-branch-id'))
656
+ })
657
+ column.appendChild(layer)
658
+ return layer
659
+ }
660
+
661
+ function renderBadges() {
662
+ if (!state.mainSessionId || state.stageOpen) {
663
+ for (const layer of document.querySelectorAll('[data-ifu-badge-layer]')) layer.remove()
664
+ return
665
+ }
666
+ const active = []
667
+ let column = null
668
+ for (const id of state.order) {
669
+ const branch = state.branches[id]
670
+ if (!branch || branch.rootSessionId !== state.mainSessionId) continue
671
+ if (branch.anchor.sessionId !== state.mainSessionId) continue
672
+ if (!branch.anchor.ranges || branch.anchor.ranges.length === 0) continue
673
+ const flow = mainChatFlow(branch.anchor.ranges[0])
674
+ if (!flow) continue
675
+ column = flow
676
+ const range = restoreAnchorRange(flow, branch.anchor.ranges[0])
677
+ if (!range) continue
678
+ const rect = range.getBoundingClientRect()
679
+ if (rect.width < 1 || rect.height < 1) continue
680
+ active.push({ branch, flow, rect })
681
+ }
682
+ if (!column || active.length === 0) {
683
+ for (const layer of document.querySelectorAll('[data-ifu-badge-layer]')) layer.remove()
684
+ return
685
+ }
686
+ const layer = decorationLayerFor(column)
687
+ const columnRect = column.getBoundingClientRect()
688
+ const fragment = document.createDocumentFragment()
689
+ for (const item of active) {
690
+ const button = document.createElement('button')
691
+ button.type = 'button'
692
+ button.className = 'ifu-badge'
693
+ button.setAttribute('data-ifu-branch-id', item.branch.id)
694
+ button.setAttribute('title', `#${item.branch.number} ${snippet(item.branch.anchor.quote, 48)}`)
695
+ button.style.left = `${Math.round(item.rect.right - columnRect.left + 5)}px`
696
+ button.style.top = `${Math.round(item.rect.top - columnRect.top - 2)}px`
697
+ button.style.setProperty('--ifu-c', `var(--ifu-c${item.branch.color % COLORS.length})`)
698
+ const dot = document.createElement('span')
699
+ dot.className = 'ifu-dot'
700
+ const label = document.createElement('span')
701
+ label.textContent = `#${item.branch.number}`
702
+ button.append(dot, label)
703
+ fragment.appendChild(button)
704
+ }
705
+ layer.replaceChildren(fragment)
706
+ }
707
+
708
+ function highlightRects(thread) {
709
+ if (!state.mainSessionId) return []
710
+ const output = []
711
+ for (const id of state.order) {
712
+ const branch = state.branches[id]
713
+ if (!branch || branch.rootSessionId !== state.mainSessionId) continue
714
+ if (!branch.anchor.ranges || branch.anchor.ranges.length === 0) continue
715
+ const inThread = branch.anchor.sessionId !== state.mainSessionId
716
+ if (Boolean(thread) !== inThread) continue
717
+ const root = inThread
718
+ ? branchChatFlow(branch.anchor.sessionId)
719
+ : mainChatFlow(branch.anchor.ranges[0])
720
+ if (!root) continue
721
+ const scroller = root.closest('[data-conversation-scroll]') || root.closest('[data-ifu-session]') || root
722
+ const clip = scroller.getBoundingClientRect()
723
+ const clipRight = state.stageOpen && state.treeOpen
724
+ ? Math.min(clip.right, window.innerWidth - 300)
725
+ : clip.right
726
+ let rangeIndex = 0
727
+ for (const anchor of branch.anchor.ranges) {
728
+ const range = restoreAnchorRange(root, anchor)
729
+ if (!range) { rangeIndex += 1; continue }
730
+ let rectIndex = 0
731
+ for (const rect of range.getClientRects()) {
732
+ const left = Math.max(rect.left, clip.left)
733
+ const right = Math.min(rect.right, clipRight)
734
+ const top = Math.max(rect.top, clip.top)
735
+ const bottom = Math.min(rect.bottom, clip.bottom)
736
+ if (right - left < 1 || bottom - top < 1) { rectIndex += 1; continue }
737
+ output.push({
738
+ id: `${branch.id}-${rangeIndex}-${rectIndex}`,
739
+ color: branch.color,
740
+ x: left,
741
+ y: top,
742
+ w: right - left,
743
+ h: bottom - top,
744
+ })
745
+ rectIndex += 1
746
+ if (output.length > 260) return output
747
+ }
748
+ rangeIndex += 1
749
+ }
750
+ }
751
+ return output
752
+ }
753
+
754
+ function markItems() {
755
+ if (!state.mainSessionId || state.stageOpen) return []
756
+ const items = []
757
+ for (const id of state.order) {
758
+ const branch = state.branches[id]
759
+ if (!branch || branch.rootSessionId !== state.mainSessionId) continue
760
+ if (branch.anchor.sessionId !== state.mainSessionId) continue
761
+ if (!branch.anchor.ranges || branch.anchor.ranges.length === 0) continue
762
+ const root = mainChatFlow(branch.anchor.ranges[0])
763
+ if (!root) continue
764
+ const range = restoreAnchorRange(root, branch.anchor.ranges[0])
765
+ if (!range) continue
766
+ const rect = range.getBoundingClientRect()
767
+ if (rect.width < 1 || rect.height < 1) continue
768
+ const scroller = root.closest('[data-conversation-scroll]') || root
769
+ const clip = scroller.getBoundingClientRect()
770
+ if (rect.bottom < clip.top || rect.top > clip.bottom) continue
771
+ const composerSeat = scroller.querySelector('[data-composer-seat]')
772
+ const composerTop = composerSeat ? composerSeat.getBoundingClientRect().top : clip.bottom
773
+ const visibleBottom = Math.min(clip.bottom, composerTop)
774
+ const badgeY = Math.round(rect.top - 2)
775
+ // Do not paint a marker over the composer: the visible chat area ends at its top edge.
776
+ if (badgeY + 20 > visibleBottom || badgeY < clip.top) continue
777
+ items.push({
778
+ id: branch.id,
779
+ number: branch.number,
780
+ color: branch.color,
781
+ x: clamp(Math.round(rect.right + 5), 8, window.innerWidth - 38),
782
+ y: badgeY,
783
+ quote: branch.anchor.quote,
784
+ })
785
+ }
786
+ return items
787
+ }
788
+
789
+ function restoreAnchorRange(root, anchor) {
790
+ const trySource = (source) => {
791
+ const text = sourceText(source)
792
+ let start = anchor.start
793
+ let end = anchor.end
794
+ if (start < 0 || end > text.length || start >= end || text.slice(start, end) !== anchor.quote) {
795
+ const index = anchor.quote ? text.indexOf(anchor.quote) : -1
796
+ if (index < 0) return null
797
+ start = index
798
+ end = index + anchor.quote.length
799
+ }
800
+ return rangeFromOffsets(source, start, end)
801
+ }
802
+ const node = root.querySelector(`[data-chat-node-key="${cssEscape(anchor.nodeKey)}"]`)
803
+ if (node) {
804
+ const sources = node.querySelectorAll('[data-text-interaction-source]')
805
+ const source = sources[anchor.sourceIndex] || sources[0]
806
+ if (source) {
807
+ const range = trySource(source)
808
+ if (range) return range
809
+ }
810
+ }
811
+ if (anchor.quote) {
812
+ for (const source of root.querySelectorAll('[data-text-interaction-source]')) {
813
+ const range = trySource(source)
814
+ if (range) return range
815
+ }
816
+ }
817
+ return null
818
+ }
819
+
820
+ // -------------------------------------------------------------------
821
+ // selection -> chip -> draft
822
+ // -------------------------------------------------------------------
823
+
824
+ function clearChip() {
825
+ if (state.chip !== null) {
826
+ state.chip = null
827
+ emit()
828
+ }
829
+ }
830
+
831
+ function suggestSelection() {
832
+ if (!installed) return
833
+ const selection = window.getSelection()
834
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
835
+ clearChip()
836
+ return
837
+ }
838
+ const range = selection.getRangeAt(0)
839
+ const startElement = range.startContainer.nodeType === Node.ELEMENT_NODE
840
+ ? range.startContainer
841
+ : range.startContainer.parentElement
842
+ const endElement = range.endContainer.nodeType === Node.ELEMENT_NODE
843
+ ? range.endContainer
844
+ : range.endContainer.parentElement
845
+ if (!startElement || !endElement) { clearChip(); return }
846
+ if (startElement.closest('[data-ifu-no-select], .ifu-compose, .ifu-stage-bar, .ifu-dock, .ifu-chip')) {
847
+ clearChip()
848
+ return
849
+ }
850
+ const startNode = startElement.closest('[data-chat-node-key]')
851
+ const endNode = endElement.closest('[data-chat-node-key]')
852
+ if (!startNode || !endNode || startNode !== endNode) {
853
+ clearChip()
854
+ return
855
+ }
856
+ if (startNode.getAttribute('data-chat-flow-kind') !== 'assistant-step') {
857
+ clearChip()
858
+ return
859
+ }
860
+ const flow = startNode.closest('[data-chat-flow]')
861
+ if (!flow) { clearChip(); return }
862
+ const holder = flow.closest('[data-ifu-session]')
863
+ const sessionId = holder ? holder.getAttribute('data-ifu-session') : state.mainSessionId
864
+ if (!sessionId) { clearChip(); return }
865
+ const chat = sessionId === main.sessionId ? main : chats.get(sessionId)
866
+ if (!chat || !chat.nodes) { clearChip(); return }
867
+ const nodeKey = startNode.getAttribute('data-chat-node-key')
868
+ const chatNode = chat.nodes.get(nodeKey)
869
+ if (!chatNode || seqOfChatNode(chatNode) < 0) { clearChip(); return }
870
+ const sourceElements = Array.from(startNode.querySelectorAll('[data-text-interaction-source]'))
871
+ const ranges = []
872
+ for (const source of sourceElements) {
873
+ if (!range.intersectsNode(source)) continue
874
+ let start = 0
875
+ let end = sourceText(source).length
876
+ if (source.contains(range.startContainer)) {
877
+ const value = offsetAt(source, range.startContainer, range.startOffset)
878
+ if (value !== null) start = value
879
+ }
880
+ if (source.contains(range.endContainer)) {
881
+ const value = offsetAt(source, range.endContainer, range.endOffset)
882
+ if (value !== null) end = value
883
+ }
884
+ if (end <= start) continue
885
+ const part = rangeFromOffsets(source, start, end)
886
+ if (!part) continue
887
+ const quote = part.toString()
888
+ if (quote.trim() === '') continue
889
+ const full = sourceText(source)
890
+ ranges.push({
891
+ nodeKey,
892
+ flowKind: 'assistant-step',
893
+ sourceIndex: sourceIndexInNode(startNode, source),
894
+ start,
895
+ end,
896
+ quote,
897
+ prefix: full.slice(Math.max(0, start - 32), start),
898
+ suffix: full.slice(end, end + 32),
899
+ })
900
+ }
901
+ if (ranges.length === 0) { clearChip(); return }
902
+ const quote = ranges.map(part => part.quote).join('\n').trim()
903
+ if (!quote) { clearChip(); return }
904
+ if (quote.length > MAX_QUOTE) {
905
+ notify(DICT.zh.tooLong)
906
+ clearChip()
907
+ return
908
+ }
909
+ if (sessionId === state.mainSessionId && branchBySession(sessionId)) { clearChip(); return }
910
+ const parentBranch = sessionId === state.mainSessionId ? null : branchBySession(sessionId)
911
+ if (sessionId !== state.mainSessionId && !parentBranch) { clearChip(); return }
912
+ const rootSessionId = parentBranch ? parentBranch.rootSessionId : state.mainSessionId
913
+ if (!rootSessionId) { clearChip(); return }
914
+ const atSeq = Math.max(...ranges.map(part => seqOfChatNode(chat.nodes.get(part.nodeKey))))
915
+ if (!Number.isFinite(atSeq) || atSeq < 0 || !Number.isInteger(atSeq)) { clearChip(); return }
916
+ // Text inherited from the parent branch is read-only context here.
917
+ if (parentBranch && atSeq <= parentBranch.anchor.atSeq) { clearChip(); return }
918
+ const rect = range.getBoundingClientRect()
919
+ const anchor = {
920
+ sessionId,
921
+ atSeq,
922
+ quote,
923
+ ranges,
924
+ parentBranchId: parentBranch ? parentBranch.id : null,
925
+ parentSessionId: sessionId,
926
+ rootSessionId,
927
+ }
928
+ const left = clamp(rect.right - 88, 8, window.innerWidth - 100)
929
+ const top = rect.bottom + 8 + 28 > window.innerHeight - 8
930
+ ? Math.max(8, rect.top - 36)
931
+ : rect.bottom + 8
932
+ state.chip = { x: left, y: top, anchor }
933
+ emit()
934
+ }
935
+
936
+ function scheduleSelection() {
937
+ if (selectionTimer !== null) clearTimeout(selectionTimer)
938
+ selectionTimer = setTimeout(() => {
939
+ selectionTimer = null
940
+ if (document.activeElement && document.activeElement.matches('button, input, textarea, select, [contenteditable="true"]')) {
941
+ clearChip()
942
+ return
943
+ }
944
+ try { suggestSelection() } catch (_error) { clearChip() }
945
+ }, 90)
946
+ }
947
+
948
+ function onPointerDown(event) {
949
+ if (event.target instanceof Element && event.target.closest('[data-ifu-chip]')) return
950
+ if (state.chip !== null) clearChip()
951
+ }
952
+
953
+ function onPointerUp(event) {
954
+ if (event.button !== 0) return
955
+ if (!(event.target instanceof Element)) return
956
+ if (event.target.closest('[data-ifu-chip], button, input, textarea, select, [contenteditable="true"], [data-ifu-no-select]')) return
957
+ scheduleSelection()
958
+ }
959
+
960
+ function onKeyUp(event) {
961
+ if (event.key !== 'Shift' && event.shiftKey === false && !event.key.startsWith('Arrow')) return
962
+ scheduleSelection()
963
+ }
964
+
965
+ function onSelectionChange() {
966
+ const selection = window.getSelection()
967
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
968
+ clearChip()
969
+ return
970
+ }
971
+ scheduleSelection()
972
+ }
973
+
974
+ function onScroll() {
975
+ if (state.chip !== null) clearChip()
976
+ }
977
+
978
+ function onKeyDown(event) {
979
+ if (event.key !== 'Escape') return
980
+ if (state.chip !== null) {
981
+ clearChip()
982
+ return
983
+ }
984
+ if (state.stageOpen && !isEditable(document.activeElement)) {
985
+ event.preventDefault()
986
+ stageClose()
987
+ }
988
+ }
989
+
990
+ function install() {
991
+ if (installed) return () => {}
992
+ installed = true
993
+ const style = document.createElement('style')
994
+ style.setAttribute('data-ifu-style', '')
995
+ style.textContent = STYLES
996
+ ;(document.head || document.body || document.documentElement).appendChild(style)
997
+ document.addEventListener('pointerdown', onPointerDown, true)
998
+ document.addEventListener('pointerup', onPointerUp, true)
999
+ document.addEventListener('keyup', onKeyUp, true)
1000
+ document.addEventListener('selectionchange', onSelectionChange)
1001
+ document.addEventListener('keydown', onKeyDown, true)
1002
+ window.addEventListener('scroll', onScroll, true)
1003
+ const onResize = () => scheduleProject()
1004
+ window.addEventListener('resize', onResize)
1005
+ if (document.body) {
1006
+ mutationObserver = new MutationObserver((records) => {
1007
+ const own = records.every((record) => {
1008
+ const target = record.target instanceof Element ? record.target : record.target.parentElement
1009
+ return target !== null && target !== undefined
1010
+ && (target.closest('[data-ifu-badge-layer]') !== null
1011
+ || target.closest('.ifu-hl') !== null
1012
+ || target.closest('[data-ifu-sidebar-group]') !== null)
1013
+ })
1014
+ if (!own) scheduleProject()
1015
+ })
1016
+ mutationObserver.observe(document.body, { childList: true, subtree: true, characterData: true })
1017
+ }
1018
+ scheduleProject()
1019
+ return () => {
1020
+ installed = false
1021
+ document.removeEventListener('pointerdown', onPointerDown, true)
1022
+ document.removeEventListener('pointerup', onPointerUp, true)
1023
+ document.removeEventListener('keyup', onKeyUp, true)
1024
+ document.removeEventListener('selectionchange', onSelectionChange)
1025
+ document.removeEventListener('keydown', onKeyDown, true)
1026
+ window.removeEventListener('scroll', onScroll, true)
1027
+ window.removeEventListener('resize', onResize)
1028
+ if (mutationObserver) mutationObserver.disconnect()
1029
+ mutationObserver = null
1030
+ if (projectFrame !== null) cancelAnimationFrame(projectFrame)
1031
+ projectFrame = null
1032
+ if (persistTimer !== null) clearTimeout(persistTimer)
1033
+ persistTimer = null
1034
+ if (noticeTimer !== null) clearTimeout(noticeTimer)
1035
+ noticeTimer = null
1036
+ if (selectionTimer !== null) clearTimeout(selectionTimer)
1037
+ selectionTimer = null
1038
+ style.remove()
1039
+ for (const layer of document.querySelectorAll('[data-ifu-badge-layer]')) layer.remove()
1040
+ for (const header of document.querySelectorAll('[data-ifu-sidebar-group]')) header.remove()
1041
+ for (const row of document.querySelectorAll('.ifu-sidebar-hidden')) row.classList.remove('ifu-sidebar-hidden')
1042
+ for (let i = 0; i < COLORS.length; i += 1) {
1043
+ if (typeof CSS !== 'undefined' && CSS.highlights) CSS.highlights.delete(`ifu-c${i}`)
1044
+ }
1045
+ for (const reference of refs.values()) {
1046
+ try { reference.release() } catch (_error) { /* already released */ }
1047
+ }
1048
+ refs.clear()
1049
+ persist()
1050
+ }
1051
+ }
1052
+
1053
+ // -------------------------------------------------------------------
1054
+ // branch actions
1055
+ // -------------------------------------------------------------------
1056
+
1057
+ function nextNumber() {
1058
+ const number = state.nextNumber
1059
+ state.nextNumber += 1
1060
+ return number
1061
+ }
1062
+
1063
+ function openDraft(anchor, x, y) {
1064
+ const number = nextNumber()
1065
+ const id = `ifu-${number}-${Date.now()}`
1066
+ const branch = {
1067
+ id,
1068
+ number,
1069
+ color: (number - 1) % COLORS.length,
1070
+ rootSessionId: anchor.rootSessionId,
1071
+ parentSessionId: anchor.parentSessionId,
1072
+ parentBranchId: anchor.parentBranchId,
1073
+ sessionId: null,
1074
+ anchor,
1075
+ question: '',
1076
+ prompted: false,
1077
+ status: 'draft',
1078
+ error: null,
1079
+ mode: 'panel',
1080
+ loading: false,
1081
+ panel: {
1082
+ x: clamp(x + ((number - 1) % 3) * 18, 12, Math.max(12, window.innerWidth - 452)),
1083
+ y: clamp(y + ((number - 1) % 3) * 18, 12, Math.max(12, window.innerHeight - 580)),
1084
+ w: 440,
1085
+ h: 360,
1086
+ },
1087
+ createdAt: Date.now(),
1088
+ }
1089
+ branch.input = { x: branch.panel.x, y: branch.panel.y, w: 440, h: 168 }
1090
+ state.branches[id] = branch
1091
+ state.order.push(id)
1092
+ state.chip = null
1093
+ try { window.getSelection()?.removeAllRanges() } catch (_error) { /* selection may be gone */ }
1094
+ emit()
1095
+ }
1096
+
1097
+ function serialize(quote, question) {
1098
+ const body = String(quote || '').trim().split(/\r?\n/).map(line => `> ${line}`).join('\n')
1099
+ const text = String(question || '').trim() || DICT.zh.defaultQuestion
1100
+ return `${body}\n\n${text}`
1101
+ }
1102
+
1103
+ function forgetReference(branchId) {
1104
+ const reference = refs.get(branchId)
1105
+ if (!reference) return
1106
+ refs.delete(branchId)
1107
+ try { reference.release() } catch (_error) { /* already released */ }
1108
+ }
1109
+
1110
+ function setDraftText(branchId, text) {
1111
+ const branch = state.branches[branchId]
1112
+ if (!branch || branch.prompted) return
1113
+ branch.question = String(text)
1114
+ schedulePersist()
1115
+ }
1116
+
1117
+ function setInputText(branchId, text) {
1118
+ const branch = state.branches[branchId]
1119
+ if (!branch) return
1120
+ branch.inputText = String(text)
1121
+ if (!branch.prompted) branch.question = branch.inputText
1122
+ schedulePersist()
1123
+ }
1124
+
1125
+ async function ensureReference(branch) {
1126
+ if (refs.has(branch.id)) return refs.get(branch.id)
1127
+ if (!branch.sessionId) return null
1128
+ const reference = ctx.sessions.retain(branch.sessionId, { source: 'inlineFollowup' })
1129
+ refs.set(branch.id, reference)
1130
+ await reference.ready
1131
+ return reference
1132
+ }
1133
+
1134
+ async function send(branchId, question) {
1135
+ const branch = state.branches[branchId]
1136
+ if (!branch || branch.status === 'sending') return false
1137
+ branch.question = String(question || '')
1138
+ branch.status = 'sending'
1139
+ branch.error = null
1140
+ emit()
1141
+ try {
1142
+ if (!branch.sessionId) {
1143
+ const childId = await ctx.sessions.fork({
1144
+ sessionId: branch.parentSessionId,
1145
+ atSeq: branch.anchor.atSeq,
1146
+ increaseTitle: false,
1147
+ })
1148
+ branch.sessionId = childId
1149
+ const reference = ctx.sessions.retain(childId, { source: 'inlineFollowup' })
1150
+ refs.set(branch.id, reference)
1151
+ await reference.ready
1152
+ void reference.binding.session.rename(`↳ #${branch.number} ${snippet(branch.anchor.quote, 18)}`).catch(() => {})
1153
+ } else {
1154
+ await ensureReference(branch)
1155
+ }
1156
+ const reference = refs.get(branch.id)
1157
+ if (!reference) throw new Error(DICT.zh.noSession)
1158
+ const text = serialize(branch.anchor.quote, branch.question)
1159
+ const echo = reference.binding.session.beginSubmission({
1160
+ mode: 'queue',
1161
+ text,
1162
+ attachments: [],
1163
+ })
1164
+ const result = await reference.binding.session.prompt(
1165
+ [{ type: 'text', text }],
1166
+ 'queue',
1167
+ undefined,
1168
+ echo.requestId,
1169
+ )
1170
+ if (!result.ok) {
1171
+ branch.status = 'error'
1172
+ branch.error = result.error?.message || DICT.zh.error
1173
+ emit()
1174
+ return false
1175
+ }
1176
+ branch.status = 'running'
1177
+ branch.prompted = true
1178
+ branch.mode = 'panel'
1179
+ if (branch.panel.h < 480) branch.panel.h = 560
1180
+ emit()
1181
+ return true
1182
+ } catch (error) {
1183
+ branch.status = 'error'
1184
+ branch.error = error instanceof Error ? error.message : String(error)
1185
+ forgetReference(branch.id)
1186
+ emit()
1187
+ return false
1188
+ }
1189
+ }
1190
+
1191
+ async function sendFollowup(branchId, question) {
1192
+ const branch = state.branches[branchId]
1193
+ const text = String(question || '').trim()
1194
+ if (!branch || !branch.sessionId || !branch.prompted || !text || branch.status === 'sending') return false
1195
+ branch.status = 'sending'
1196
+ branch.error = null
1197
+ emit()
1198
+ try {
1199
+ await ensureReference(branch)
1200
+ const reference = refs.get(branch.id)
1201
+ if (!reference) throw new Error(DICT.zh.noSession)
1202
+ const echo = reference.binding.session.beginSubmission({
1203
+ mode: 'queue',
1204
+ text,
1205
+ attachments: [],
1206
+ })
1207
+ const result = await reference.binding.session.prompt(
1208
+ [{ type: 'text', text }],
1209
+ 'queue',
1210
+ undefined,
1211
+ echo.requestId,
1212
+ )
1213
+ if (!result.ok) {
1214
+ branch.status = 'error'
1215
+ branch.error = result.error?.message || DICT.zh.error
1216
+ emit()
1217
+ return false
1218
+ }
1219
+ branch.status = 'running'
1220
+ emit()
1221
+ return true
1222
+ } catch (error) {
1223
+ branch.status = 'error'
1224
+ branch.error = error instanceof Error ? error.message : String(error)
1225
+ emit()
1226
+ return false
1227
+ }
1228
+ }
1229
+
1230
+ function openPanel(branchId) {
1231
+ const branch = state.branches[branchId]
1232
+ if (!branch) return
1233
+ if (state.stageOpen) {
1234
+ const active = activeStageBranch()
1235
+ if (active && active.id === branch.id) return
1236
+ }
1237
+ branch.mode = 'panel'
1238
+ if (branch.sessionId && !refs.has(branch.id)) {
1239
+ branch.loading = true
1240
+ emit()
1241
+ ensureReference(branch)
1242
+ .then(() => { branch.loading = false; emit() })
1243
+ .catch((error) => {
1244
+ branch.loading = false
1245
+ branch.status = 'error'
1246
+ branch.error = error instanceof Error ? error.message : String(error)
1247
+ forgetReference(branch.id)
1248
+ emit()
1249
+ })
1250
+ return
1251
+ }
1252
+ emit()
1253
+ }
1254
+
1255
+ function toggleSidebarBranches() {
1256
+ state.sidebarCollapsed = !state.sidebarCollapsed
1257
+ renderSidebarBranches()
1258
+ emit()
1259
+ }
1260
+
1261
+ function renderSidebarBranches() {
1262
+ if (!installed) return
1263
+ const ids = new Set(state.order
1264
+ .map(id => state.branches[id]?.sessionId)
1265
+ .filter(Boolean))
1266
+ const parents = new Map()
1267
+ for (const id of ids) {
1268
+ for (const row of document.querySelectorAll(`[data-row-key="session:${cssEscape(id)}"]`)) {
1269
+ if (row.closest('[data-ifu-root]')) continue
1270
+ const parent = row.parentElement
1271
+ if (!parent) continue
1272
+ const list = parents.get(parent) || []
1273
+ list.push(row)
1274
+ parents.set(parent, list)
1275
+ }
1276
+ }
1277
+ const activeParents = new Set(parents.keys())
1278
+ for (const header of document.querySelectorAll('[data-ifu-sidebar-group]')) {
1279
+ if (!activeParents.has(header.parentElement)) header.remove()
1280
+ }
1281
+ for (const [parent, rows] of parents) {
1282
+ let header = parent.querySelector(':scope > [data-ifu-sidebar-group]')
1283
+ if (!header) {
1284
+ header = document.createElement('button')
1285
+ header.type = 'button'
1286
+ header.className = 'ifu-sidebar-group'
1287
+ header.setAttribute('data-ifu-sidebar-group', '')
1288
+ header.addEventListener('click', (event) => {
1289
+ event.preventDefault()
1290
+ event.stopPropagation()
1291
+ toggleSidebarBranches()
1292
+ })
1293
+ }
1294
+ const first = rows[0]
1295
+ if (header.nextSibling !== first) parent.insertBefore(header, first)
1296
+ const label = `${state.sidebarCollapsed ? '▸' : '▾'} 追问分支 · ${rows.length}`
1297
+ if (header.textContent !== label) header.textContent = label
1298
+ for (const row of rows) row.classList.toggle('ifu-sidebar-hidden', state.sidebarCollapsed)
1299
+ // Keep the main conversation row above its branch rows even when the
1300
+ // host sort places a recently used branch first. Moving the row at
1301
+ // the React-owned DOM level is deliberate: the Session list is not a
1302
+ // flex/order container, and the collapsible group header sits here.
1303
+ const mainId = state.mainSessionId
1304
+ const mainRow = mainId
1305
+ ? parent.querySelector(`:scope > [data-row-key="session:${cssEscape(mainId)}"]`)
1306
+ : null
1307
+ if (mainRow && mainRow !== header
1308
+ && (mainRow.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) {
1309
+ parent.insertBefore(mainRow, header)
1310
+ }
1311
+ }
1312
+ // Keep row classes correct even when no parent could be found this pass.
1313
+ for (const id of ids) {
1314
+ for (const row of document.querySelectorAll(`[data-row-key="session:${cssEscape(id)}"]`)) {
1315
+ if (!row.closest('[data-ifu-root]')) row.classList.toggle('ifu-sidebar-hidden', state.sidebarCollapsed)
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ function alignRowInScrollers(row) {
1321
+ if (!row || !row.isConnected) return
1322
+ let element = row.parentElement
1323
+ while (element && element !== document.documentElement) {
1324
+ const style = getComputedStyle(element)
1325
+ if (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 2) {
1326
+ const rowRect = row.getBoundingClientRect()
1327
+ const box = element.getBoundingClientRect()
1328
+ if (rowRect.top < box.top + 8 || rowRect.bottom > box.bottom - 8) {
1329
+ const next = element.scrollTop + rowRect.top - box.top - (element.clientHeight - rowRect.height) / 2
1330
+ element.scrollTop = Math.max(0, Math.min(next, element.scrollHeight - element.clientHeight))
1331
+ }
1332
+ }
1333
+ element = element.parentElement
1334
+ }
1335
+ }
1336
+
1337
+ function centerRangeInScrollers(range) {
1338
+ if (!range) return
1339
+ let element = range.startContainer.nodeType === Node.ELEMENT_NODE
1340
+ ? range.startContainer
1341
+ : range.startContainer.parentElement
1342
+ while (element && element !== document.documentElement) {
1343
+ const style = getComputedStyle(element)
1344
+ if (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 2) {
1345
+ const rect = range.getBoundingClientRect()
1346
+ const box = element.getBoundingClientRect()
1347
+ if (rect.height > 0) {
1348
+ const next = element.scrollTop + rect.top - box.top - (element.clientHeight - rect.height) / 2
1349
+ element.scrollTop = Math.max(0, Math.min(next, element.scrollHeight - element.clientHeight))
1350
+ }
1351
+ }
1352
+ element = element.parentElement
1353
+ }
1354
+ }
1355
+
1356
+ function scrollBranchIntoView(branch, attempt = 0) {
1357
+ const sessionId = branch.anchor.sessionId
1358
+ const first = branch.anchor.ranges && branch.anchor.ranges[0]
1359
+ const root = sessionId === state.mainSessionId
1360
+ ? mainChatFlow(first)
1361
+ : branchChatFlow(sessionId)
1362
+ const range = root && first ? restoreAnchorRange(root, first) : null
1363
+ const row = root && first?.nodeKey
1364
+ ? root.querySelector(`[data-chat-node-key="${cssEscape(first.nodeKey)}"]`)
1365
+ : null
1366
+ const chatNodes = sessionId === state.mainSessionId ? main.nodes : chats.get(sessionId)?.nodes
1367
+ const chatNode = chatNodes && first?.nodeKey ? chatNodes.get(first.nodeKey) : null
1368
+ const turn = chatNode?.location?.turn?.turn
1369
+ const content = root ? root.closest('[data-conversation-content]') : document.querySelector('[data-conversation-content]')
1370
+ const navigator = Number.isSafeInteger(turn) && content
1371
+ ? Array.from(content.querySelectorAll('button[data-index][aria-label]')).find((button) => {
1372
+ const label = button.getAttribute('aria-label') || ''
1373
+ return new RegExp(`(?:^|\\D)${turn}(?:\\D|$)`).test(label)
1374
+ }) || null
1375
+ : null
1376
+ if (!root || (!range && !row)) {
1377
+ // An off-screen historical Turn may not be mounted at all. The turn
1378
+ // rail is the ChatView's own loader/scroll entry, so use it before
1379
+ // giving up on this attempt.
1380
+ if (navigator && attempt === 0) navigator.click()
1381
+ if (attempt < 14) setTimeout(() => scrollBranchIntoView(branch, attempt + 1), 100)
1382
+ else notify(`#${branch.number} 的原文当前不可见,可能已被折叠或尚未加载`)
1383
+ return
1384
+ }
1385
+ if (navigator && attempt === 0) navigator.click()
1386
+ const target = row || range.startContainer.parentElement?.closest('[data-chat-node-key]')
1387
+ if (target) {
1388
+ // scrollIntoView is deliberate here: DSH's ChatView owns a nested
1389
+ // scrollport and may still be in follow-tail mode. A raw scrollTop
1390
+ // write can be swallowed by that controller; scrollIntoView emits the
1391
+ // native scroll event that switches it to reader mode.
1392
+ target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' })
1393
+ alignRowInScrollers(target)
1394
+ try {
1395
+ const previous = document.activeElement
1396
+ target.setAttribute('tabindex', '-1')
1397
+ target.focus({ preventScroll: false })
1398
+ if (previous instanceof HTMLElement) setTimeout(() => previous.focus({ preventScroll: true }), 0)
1399
+ } catch (_error) { /* row focus is only a scrolling assist */ }
1400
+ }
1401
+ if (range) {
1402
+ centerRangeInScrollers(range)
1403
+ const scroller = root.closest('[data-conversation-scroll]') || root
1404
+ const rect = range.getBoundingClientRect()
1405
+ const viewport = scroller.getBoundingClientRect()
1406
+ const top = scroller.scrollTop + rect.top - viewport.top - scroller.clientHeight / 2 + rect.height / 2
1407
+ if (!target) {
1408
+ try {
1409
+ scroller.scrollTo({ top: Math.max(0, top), behavior: 'auto' })
1410
+ } catch (_error) {
1411
+ scroller.scrollTop = Math.max(0, top)
1412
+ }
1413
+ }
1414
+ const settle = () => {
1415
+ if (!root.isConnected) return
1416
+ const liveRange = restoreAnchorRange(root, first)
1417
+ if (liveRange) {
1418
+ centerRangeInScrollers(liveRange)
1419
+ return
1420
+ }
1421
+ const live = root.querySelector(`[data-chat-node-key="${cssEscape(first.nodeKey)}"]`) || row
1422
+ if (live) alignRowInScrollers(live)
1423
+ }
1424
+ setTimeout(settle, 80)
1425
+ setTimeout(settle, 260)
1426
+ }
1427
+ if (attempt === 0) {
1428
+ setTimeout(() => {
1429
+ const live = sessionId === state.mainSessionId
1430
+ ? mainChatFlow(first)
1431
+ : branchChatFlow(sessionId)
1432
+ const liveRange = live && first ? restoreAnchorRange(live, first) : null
1433
+ if (liveRange) liveRange.startContainer.parentElement?.closest('[data-chat-node-key]')?.animate?.(
1434
+ [{ opacity: 0.55 }, { opacity: 1 }], { duration: 260, easing: 'ease-out' },
1435
+ )
1436
+ }, 280)
1437
+ }
1438
+ }
1439
+
1440
+ function revealBranch(branchId) {
1441
+ const branch = state.branches[branchId]
1442
+ if (!branch) return
1443
+ const parent = (branch.parentBranchId ? state.branches[branch.parentBranchId] : null)
1444
+ || (branch.anchor.sessionId !== state.mainSessionId ? branchBySession(branch.anchor.sessionId) : null)
1445
+ if (parent && parent.sessionId) {
1446
+ const active = activeStageBranch()
1447
+ if (!state.stageOpen || !active || active.id !== parent.id) {
1448
+ promote(parent.id)
1449
+ }
1450
+ }
1451
+ openPanel(branchId)
1452
+ scrollBranchIntoView(branch, 0)
1453
+ }
1454
+
1455
+ function collapse(branchId) {
1456
+ const branch = state.branches[branchId]
1457
+ if (!branch) return
1458
+ branch.mode = 'collapsed'
1459
+ emit()
1460
+ }
1461
+
1462
+ function deleteDraft(branchId) {
1463
+ const branch = state.branches[branchId]
1464
+ if (!branch || branch.sessionId || branch.prompted) return
1465
+ if (state.order.some(id => state.branches[id]?.parentBranchId === branchId)) return
1466
+ delete state.branches[branchId]
1467
+ state.order = state.order.filter(id => id !== branchId)
1468
+ const reference = refs.get(branchId)
1469
+ if (reference) {
1470
+ refs.delete(branchId)
1471
+ try { reference.release() } catch (_error) { /* already released */ }
1472
+ }
1473
+ if (state.stageIndex >= 0) {
1474
+ state.stageStack = state.stageStack.filter(id => id !== branchId)
1475
+ if (state.stageStack.length === 0) {
1476
+ state.stageOpen = false
1477
+ state.stageIndex = -1
1478
+ } else {
1479
+ state.stageIndex = Math.min(state.stageIndex, state.stageStack.length - 1)
1480
+ }
1481
+ }
1482
+ scheduleProject()
1483
+ emit()
1484
+ }
1485
+
1486
+ function promote(branchId) {
1487
+ const branch = state.branches[branchId]
1488
+ if (!branch || !branch.prompted || !branch.sessionId) return
1489
+ for (const item of Object.values(state.branches)) {
1490
+ if (item.mode === 'panel' || item.mode === 'stage') item.mode = 'collapsed'
1491
+ }
1492
+ branch.mode = 'stage'
1493
+ if (!state.stageOpen) {
1494
+ state.stageStack = [branchId]
1495
+ state.stageIndex = 0
1496
+ } else {
1497
+ const stack = state.stageStack.slice(0, state.stageIndex + 1)
1498
+ stack.push(branchId)
1499
+ state.stageStack = stack
1500
+ state.stageIndex = stack.length - 1
1501
+ }
1502
+ state.stageOpen = true
1503
+ if (branch.sessionId && !refs.has(branch.id)) {
1504
+ branch.loading = true
1505
+ ensureReference(branch)
1506
+ .then(() => { branch.loading = false; emit() })
1507
+ .catch((error) => {
1508
+ branch.loading = false
1509
+ branch.status = 'error'
1510
+ branch.error = error instanceof Error ? error.message : String(error)
1511
+ forgetReference(branch.id)
1512
+ emit()
1513
+ })
1514
+ }
1515
+ emit()
1516
+ }
1517
+
1518
+ function activeStageBranch() {
1519
+ if (!state.stageOpen || state.stageIndex < 0) return null
1520
+ const id = state.stageStack[state.stageIndex]
1521
+ return state.branches[id] || null
1522
+ }
1523
+
1524
+ function stageGo(delta) {
1525
+ const next = state.stageIndex + delta
1526
+ if (next < 0 || next >= state.stageStack.length) return
1527
+ const previous = activeStageBranch()
1528
+ if (previous) previous.mode = 'collapsed'
1529
+ state.stageIndex = next
1530
+ const branch = activeStageBranch()
1531
+ if (!branch) return
1532
+ branch.mode = 'stage'
1533
+ if (branch.sessionId && !refs.has(branch.id)) {
1534
+ branch.loading = true
1535
+ ensureReference(branch)
1536
+ .then(() => { branch.loading = false; emit() })
1537
+ .catch((error) => {
1538
+ branch.loading = false
1539
+ branch.status = 'error'
1540
+ branch.error = error instanceof Error ? error.message : String(error)
1541
+ forgetReference(branch.id)
1542
+ emit()
1543
+ })
1544
+ }
1545
+ emit()
1546
+ }
1547
+
1548
+ function stageRestore() {
1549
+ const branch = activeStageBranch()
1550
+ if (branch) branch.mode = 'panel'
1551
+ state.stageOpen = false
1552
+ state.stageStack = []
1553
+ state.stageIndex = -1
1554
+ state.treeOpen = false
1555
+ emit()
1556
+ }
1557
+
1558
+ function stageClose() {
1559
+ const branch = activeStageBranch()
1560
+ if (branch) branch.mode = 'collapsed'
1561
+ state.stageOpen = false
1562
+ state.stageStack = []
1563
+ state.stageIndex = -1
1564
+ state.treeOpen = false
1565
+ emit()
1566
+ }
1567
+
1568
+ function closeDock() {
1569
+ state.dockOpen = false
1570
+ emit()
1571
+ }
1572
+
1573
+ function toggleDock() {
1574
+ state.dockOpen = !state.dockOpen
1575
+ emit()
1576
+ }
1577
+
1578
+ function toggleTree() {
1579
+ state.treeOpen = !state.treeOpen
1580
+ emit()
1581
+ }
1582
+
1583
+ function startPanelDrag(branchId, event, target = 'panel') {
1584
+ const branch = state.branches[branchId]
1585
+ const box = branch && branch[target]
1586
+ if (!branch || !box || !event || event.button !== 0) return
1587
+ if (event.target instanceof Element && event.target.closest('button, .ifu-resize')) return
1588
+ const width = box.w || (target === 'input' ? 440 : 360)
1589
+ const startX = box.x
1590
+ const startY = box.y
1591
+ trackPointer(event, (dx, dy) => {
1592
+ box.x = clamp(startX + dx, 8, Math.max(8, window.innerWidth - width - 8))
1593
+ box.y = clamp(startY + dy, 8, Math.max(8, window.innerHeight - 60))
1594
+ emit()
1595
+ }, persist)
1596
+ }
1597
+
1598
+ function startPanelResize(branchId, event, direction, target = 'panel') {
1599
+ const branch = state.branches[branchId]
1600
+ const box = branch && branch[target]
1601
+ if (!branch || !box || !event || event.button !== 0) return
1602
+ event.stopPropagation()
1603
+ const start = { ...box }
1604
+ start.w = start.w || (target === 'input' ? 440 : 360)
1605
+ start.h = start.h || (target === 'input' ? 168 : 360)
1606
+ const minW = target === 'input' ? 260 : 300
1607
+ const minH = target === 'input' ? 120 : 220
1608
+ trackPointer(event, (dx, dy) => {
1609
+ let x = start.x
1610
+ let y = start.y
1611
+ let w = start.w
1612
+ let h = start.h
1613
+ if (direction.includes('e')) w = clamp(start.w + dx, minW, Math.max(minW, window.innerWidth - start.x - 8))
1614
+ if (direction.includes('s')) h = clamp(start.h + dy, minH, Math.max(minH, window.innerHeight - start.y - 8))
1615
+ if (direction.includes('w')) {
1616
+ x = clamp(start.x + dx, 8, start.x + start.w - minW)
1617
+ w = start.w + (start.x - x)
1618
+ }
1619
+ if (direction.includes('n')) {
1620
+ y = clamp(start.y + dy, 8, start.y + start.h - minH)
1621
+ h = start.h + (start.y - y)
1622
+ }
1623
+ box.x = x
1624
+ box.y = y
1625
+ box.w = w
1626
+ box.h = h
1627
+ emit()
1628
+ }, persist)
1629
+ }
1630
+
1631
+ function startDockDrag(event) {
1632
+ if (!event || event.button !== 0) return
1633
+ if (event.target instanceof Element && event.target.closest('button, .ifu-resize')) return
1634
+ const dock = document.querySelector('.ifu-dock')
1635
+ if (!dock) return
1636
+ if (!state.dock) {
1637
+ const rect = dock.getBoundingClientRect()
1638
+ state.dock = { x: rect.left, y: rect.top, w: rect.width, h: rect.height }
1639
+ emit()
1640
+ }
1641
+ const start = { ...state.dock }
1642
+ trackPointer(event, (dx, dy) => {
1643
+ state.dock.x = clamp(start.x + dx, 8, Math.max(8, window.innerWidth - start.w - 8))
1644
+ state.dock.y = clamp(start.y + dy, 8, Math.max(8, window.innerHeight - 60))
1645
+ emit()
1646
+ }, persist)
1647
+ }
1648
+
1649
+ function startDockResize(event, direction) {
1650
+ if (!event || event.button !== 0) return
1651
+ event.stopPropagation()
1652
+ const dock = document.querySelector('.ifu-dock')
1653
+ if (!dock) return
1654
+ if (!state.dock) {
1655
+ const rect = dock.getBoundingClientRect()
1656
+ state.dock = { x: rect.left, y: rect.top, w: rect.width, h: rect.height }
1657
+ emit()
1658
+ }
1659
+ const start = { ...state.dock }
1660
+ const minW = 220
1661
+ const minH = 120
1662
+ trackPointer(event, (dx, dy) => {
1663
+ let x = start.x
1664
+ let y = start.y
1665
+ let w = start.w
1666
+ let h = start.h
1667
+ if (direction.includes('e')) w = clamp(start.w + dx, minW, Math.max(minW, window.innerWidth - start.x - 8))
1668
+ if (direction.includes('s')) h = clamp(start.h + dy, minH, Math.max(minH, window.innerHeight - start.y - 8))
1669
+ if (direction.includes('w')) {
1670
+ x = clamp(start.x + dx, 8, start.x + start.w - minW)
1671
+ w = start.w + (start.x - x)
1672
+ }
1673
+ if (direction.includes('n')) {
1674
+ y = clamp(start.y + dy, 8, start.y + start.h - minH)
1675
+ h = start.h + (start.y - y)
1676
+ }
1677
+ state.dock = { x, y, w, h }
1678
+ emit()
1679
+ }, persist)
1680
+ }
1681
+
1682
+ return app
1683
+ }
1684
+
1685
+ // ---------------------------------------------------------------------
1686
+ // React components
1687
+ // ---------------------------------------------------------------------
1688
+
1689
+ function findAppFrame() {
1690
+ let element = document.querySelector('[data-ifu-root]')
1691
+ while (element) {
1692
+ if (element instanceof HTMLElement && element.style && element.style.gridTemplateColumns) return element
1693
+ element = element.parentElement
1694
+ }
1695
+ return null
1696
+ }
1697
+
1698
+ function readSidebarWidth(frame) {
1699
+ if (!frame) return 0
1700
+ const value = frame.style.gridTemplateColumns || getComputedStyle(frame).gridTemplateColumns
1701
+ const first = String(value || '').trim().split(/\s+/)[0]
1702
+ const width = Number.parseFloat(first)
1703
+ return Number.isFinite(width) ? width : 0
1704
+ }
1705
+
1706
+ function useFrameLeft() {
1707
+ const [left, setLeft] = useState(() => readSidebarWidth(findAppFrame()))
1708
+ useLayoutEffect(() => {
1709
+ const frame = findAppFrame()
1710
+ if (!frame) return
1711
+ const update = () => setLeft(readSidebarWidth(frame))
1712
+ update()
1713
+ const observer = new MutationObserver(update)
1714
+ observer.observe(frame, { attributes: true, attributeFilter: ['style'] })
1715
+ window.addEventListener('resize', update)
1716
+ return () => {
1717
+ observer.disconnect()
1718
+ window.removeEventListener('resize', update)
1719
+ }
1720
+ }, [])
1721
+ return left
1722
+ }
1723
+
1724
+ function useVersion() {
1725
+ return useSyncExternalStore(app.subscribe, app.getVersion, app.getVersion)
1726
+ }
1727
+
1728
+ function SessionProbe(props) {
1729
+ const nodes = props.useChat(snapshot => snapshot.nodes)
1730
+ const timeline = props.useChat(snapshot => snapshot.timeline)
1731
+ useEffect(() => {
1732
+ if (app) app.setMainChat(props.sessionId, nodes, timeline)
1733
+ }, [props.sessionId, nodes, timeline])
1734
+ return null
1735
+ }
1736
+
1737
+ function FixedChatView(props) {
1738
+ return h(React.Fragment, null, props.renderSlot('conversation.session', { view: 'chat' }))
1739
+ }
1740
+
1741
+ function FollowupThread(props) {
1742
+ const nodes = props.useChat(snapshot => snapshot.nodes)
1743
+ const timeline = props.useChat(snapshot => snapshot.timeline)
1744
+ const running = props.useSession(snapshot => snapshot.running)
1745
+ const hostRef = useRef(null)
1746
+ useLayoutEffect(() => {
1747
+ if (app) app.setBranchChat(props.sessionId, nodes, timeline)
1748
+ }, [props.sessionId, nodes, timeline])
1749
+ const branchRecord = app ? app.branchBySession(props.sessionId) : null
1750
+ const branchId = props.branchId || (branchRecord ? branchRecord.id : null)
1751
+ const cutSeq = Number.isFinite(props.cutSeq) ? props.cutSeq : (branchRecord ? branchRecord.anchor.atSeq : undefined)
1752
+ useEffect(() => {
1753
+ if (app && branchId) app.setBranchRunning(branchId, running)
1754
+ }, [branchId, running])
1755
+ useLayoutEffect(() => {
1756
+ const host = hostRef.current
1757
+ if (!host) return
1758
+ const apply = () => hideInheritedRows(host, props.sessionId, cutSeq)
1759
+ apply()
1760
+ const observer = new MutationObserver(apply)
1761
+ observer.observe(host, { childList: true, subtree: true, characterData: true })
1762
+ const timer = setTimeout(() => {
1763
+ apply()
1764
+ const scroller = host.querySelector('[data-conversation-scroll]')
1765
+ if (scroller) scroller.scrollTop = scroller.scrollHeight
1766
+ }, 160)
1767
+ return () => {
1768
+ observer.disconnect()
1769
+ clearTimeout(timer)
1770
+ }
1771
+ }, [props.sessionId, cutSeq])
1772
+ return h('div', {
1773
+ ref: hostRef,
1774
+ className: 'ifu-thread',
1775
+ 'data-ifu-session': props.sessionId,
1776
+ }, props.renderFactorySlot('conversation.content', {
1777
+ variant: 'embedded',
1778
+ phase: 'active',
1779
+ hero: false,
1780
+ }, {
1781
+ slots: { views: FixedChatView },
1782
+ }))
1783
+ }
1784
+
1785
+ function IconButton(props) {
1786
+ return h('button', {
1787
+ type: 'button',
1788
+ className: `ifu-icon ${props.className || ''}`,
1789
+ title: props.title,
1790
+ 'aria-label': props.title,
1791
+ onClick: props.onClick,
1792
+ disabled: props.disabled,
1793
+ }, props.children)
1794
+ }
1795
+
1796
+ function Compose(props) {
1797
+ const { branch } = props
1798
+ const [text, setText] = useState(branch.question || '')
1799
+ const areaRef = useRef(null)
1800
+ useEffect(() => {
1801
+ if (areaRef.current) areaRef.current.focus()
1802
+ }, [])
1803
+ const sending = branch.status === 'sending'
1804
+ const submit = () => {
1805
+ if (sending) return
1806
+ app.send(branch.id, text)
1807
+ }
1808
+ return h('div', { className: 'ifu-compose' },
1809
+ h('div', { className: 'ifu-quote', title: branch.anchor.quote },
1810
+ h('div', { className: 'ifu-quote-label' }, `${DICT.zh.quote} · ${DICT.zh.basedOn} ${branch.parentBranchId ? `#${app.state.branches[branch.parentBranchId]?.number ?? ''}` : DICT.zh.main}`),
1811
+ h('div', { className: 'ifu-quote-text' }, branch.anchor.quote.length > 220 ? `${branch.anchor.quote.slice(0, 219)}…` : branch.anchor.quote),
1812
+ ),
1813
+ h('textarea', {
1814
+ ref: areaRef,
1815
+ className: 'ifu-textarea',
1816
+ value: text,
1817
+ placeholder: DICT.zh.empty,
1818
+ rows: 3,
1819
+ onChange: event => { setText(event.target.value); app.setDraftText(branch.id, event.target.value) },
1820
+ onKeyDown: event => {
1821
+ if (event.key !== 'Enter' || event.shiftKey) return
1822
+ if (event.nativeEvent && (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)) return
1823
+ event.preventDefault()
1824
+ submit()
1825
+ },
1826
+ }),
1827
+ branch.error ? h('div', { className: 'ifu-error', role: 'alert' }, branch.error) : null,
1828
+ h('div', { className: 'ifu-compose-foot' },
1829
+ h('span', { className: 'ifu-hint' }, DICT.zh.sendHint),
1830
+ h('div', { className: 'ifu-grow' }),
1831
+ h('button', {
1832
+ type: 'button',
1833
+ className: 'ifu-send',
1834
+ disabled: sending,
1835
+ onClick: submit,
1836
+ }, sending ? DICT.zh.sendingAction : (branch.error ? DICT.zh.retry : DICT.zh.send)),
1837
+ ),
1838
+ )
1839
+ }
1840
+
1841
+ function ThreadHost(props) {
1842
+ const { branch, SessionProvider, renderSlot } = props
1843
+ const reference = app.refs.get(branch.id)
1844
+ if (!reference) {
1845
+ return h('div', { className: 'ifu-loading' }, branch.error || DICT.zh.sending)
1846
+ }
1847
+ return h(SessionProvider, { session: reference },
1848
+ h('div', {
1849
+ className: 'ifu-thread-host',
1850
+ 'data-ifu-session': branch.sessionId,
1851
+ }, renderSlot('followup.thread', { branchId: branch.id, cutSeq: branch.anchor.atSeq })),
1852
+ )
1853
+ }
1854
+
1855
+ function InputWindow(props) {
1856
+ const { branch } = props
1857
+ useVersion()
1858
+ const initial = branch.question || ''
1859
+ const [text, setText] = useState(initial)
1860
+ const areaRef = useRef(null)
1861
+ useEffect(() => {
1862
+ if (areaRef.current) areaRef.current.focus()
1863
+ }, [])
1864
+ const box = branch.input || defaultInputBox(branch.panel)
1865
+ const width = Math.min(box.w || 440, Math.max(220, window.innerWidth - 24))
1866
+ const height = Math.min(box.h || 168, Math.max(120, window.innerHeight - 24))
1867
+ const left = clamp(box.x, 12, Math.max(12, window.innerWidth - width - 12))
1868
+ const top = clamp(box.y, 12, Math.max(12, window.innerHeight - height - 12))
1869
+ const sending = branch.status === 'sending'
1870
+ const submit = () => {
1871
+ if (sending) return
1872
+ const value = text.trim()
1873
+ if (!value) return
1874
+ const pending = app.send(branch.id, value)
1875
+ Promise.resolve(pending).then((ok) => {
1876
+ if (!ok) return
1877
+ setText('')
1878
+ app.setInputText(branch.id, '')
1879
+ })
1880
+ }
1881
+ return h('section', {
1882
+ className: 'ifu-panel ifu-input-panel',
1883
+ 'data-ifu-input-panel': '',
1884
+ style: {
1885
+ left: `${left}px`,
1886
+ top: `${top}px`,
1887
+ width: `${width}px`,
1888
+ height: `${height}px`,
1889
+ zIndex: app.state.stageOpen ? 351 : 101,
1890
+ ...colorVars(branch.color),
1891
+ },
1892
+ role: 'dialog',
1893
+ 'aria-label': `${DICT.zh.branchN} #${branch.number} ${DICT.zh.input}`,
1894
+ },
1895
+ h('header', { className: 'ifu-head', onPointerDown: event => app.startPanelDrag(branch.id, event, 'input') },
1896
+ h('span', { className: 'ifu-dot' }),
1897
+ h('div', { className: 'ifu-title-wrap' },
1898
+ h('div', { className: 'ifu-title' }, `${DICT.zh.input} · #${branch.number}`),
1899
+ h('div', { className: 'ifu-sub' }, statusLabel(branch)),
1900
+ ),
1901
+ h('div', { className: 'ifu-grow' }),
1902
+ h(IconButton, { title: DICT.zh.collapse, onClick: () => app.collapse(branch.id) }, '—'),
1903
+ branch.sessionId ? null : h(IconButton, {
1904
+ title: DICT.zh.deleteDraft,
1905
+ className: 'ifu-danger',
1906
+ onClick: () => app.deleteDraft(branch.id),
1907
+ }, '⌫'),
1908
+ ),
1909
+ h('div', { className: 'ifu-input-body' },
1910
+ h('textarea', {
1911
+ ref: areaRef,
1912
+ className: 'ifu-textarea',
1913
+ value: text,
1914
+ placeholder: DICT.zh.empty,
1915
+ rows: 3,
1916
+ onChange: event => { setText(event.target.value); app.setInputText(branch.id, event.target.value) },
1917
+ onKeyDown: event => {
1918
+ if (event.key !== 'Enter' || event.shiftKey) return
1919
+ if (event.nativeEvent && (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)) return
1920
+ event.preventDefault()
1921
+ submit()
1922
+ },
1923
+ }),
1924
+ branch.error ? h('div', { className: 'ifu-error', role: 'alert' }, branch.error) : null,
1925
+ h('div', { className: 'ifu-compose-foot' },
1926
+ h('span', { className: 'ifu-hint' }, DICT.zh.sendHint),
1927
+ h('div', { className: 'ifu-grow' }),
1928
+ h('button', {
1929
+ type: 'button',
1930
+ className: 'ifu-send',
1931
+ disabled: sending,
1932
+ onClick: submit,
1933
+ }, sending ? DICT.zh.sendingAction : DICT.zh.send),
1934
+ ),
1935
+ ),
1936
+ ...['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].map(direction => h('div', {
1937
+ key: direction,
1938
+ className: `ifu-resize ifu-${direction}`,
1939
+ onPointerDown: event => app.startPanelResize(branch.id, event, direction, 'input'),
1940
+ })),
1941
+ )
1942
+ }
1943
+
1944
+ function BranchPanel(props) {
1945
+ const { branch, SessionProvider, renderSlot } = props
1946
+ useVersion()
1947
+ if (!branch.prompted) return h(InputWindow, { key: `${branch.id}-input`, branch })
1948
+ const parent = branch.parentBranchId ? app.state.branches[branch.parentBranchId] : null
1949
+ const basis = parent ? `${DICT.zh.basedOn} #${parent.number}` : `${DICT.zh.basedOn} ${DICT.zh.main}`
1950
+ const reference = app.refs.get(branch.id)
1951
+ const showThread = branch.prompted && reference && !branch.loading
1952
+ const showDraft = !branch.prompted
1953
+ const panelWidth = Math.min(branch.panel.w || 440, Math.max(200, window.innerWidth - 24))
1954
+ const panelHeight = Math.min(branch.panel.h || 360, Math.max(220, window.innerHeight - 24))
1955
+ const panelLeft = clamp(branch.panel.x, 12, Math.max(12, window.innerWidth - panelWidth - 12))
1956
+ const panelTop = clamp(branch.panel.y, 12, Math.max(12, window.innerHeight - panelHeight - 12))
1957
+ return h(React.Fragment, null,
1958
+ h('section', {
1959
+ className: 'ifu-panel',
1960
+ 'data-ifu-panel': '',
1961
+ style: {
1962
+ left: `${panelLeft}px`,
1963
+ top: `${panelTop}px`,
1964
+ width: `${panelWidth}px`,
1965
+ height: `${panelHeight}px`,
1966
+ zIndex: app.state.stageOpen ? 350 : 100,
1967
+ ...colorVars(branch.color),
1968
+ },
1969
+ role: 'dialog',
1970
+ 'aria-label': `${DICT.zh.branchN} #${branch.number}`,
1971
+ },
1972
+ h('header', { className: 'ifu-head', onPointerDown: event => app.startPanelDrag(branch.id, event) },
1973
+ h('span', { className: 'ifu-dot' }),
1974
+ h('div', { className: 'ifu-title-wrap' },
1975
+ h('div', { className: 'ifu-title' }, `${DICT.zh.branchN} #${branch.number}`),
1976
+ h('div', { className: 'ifu-sub' }, `${basis} · ${statusLabel(branch)}`),
1977
+ ),
1978
+ h('div', { className: 'ifu-grow' }),
1979
+ h(IconButton, { title: DICT.zh.expand, onClick: () => app.promote(branch.id), disabled: !branch.prompted || !branch.sessionId }, '↗'),
1980
+ h(IconButton, { title: DICT.zh.collapse, onClick: () => app.collapse(branch.id) }, '—'),
1981
+ branch.sessionId || branch.prompted
1982
+ ? h(IconButton, { title: DICT.zh.close, onClick: () => app.collapse(branch.id) }, '✕')
1983
+ : h(IconButton, { title: DICT.zh.deleteDraft, className: 'ifu-danger', onClick: () => app.deleteDraft(branch.id) }, '⌫'),
1984
+ ),
1985
+ h('div', { className: 'ifu-panel-body' },
1986
+ showThread
1987
+ ? h(ThreadHost, { key: branch.id, branch, SessionProvider, renderSlot })
1988
+ : showDraft
1989
+ ? h('div', { className: 'ifu-compose-center' },
1990
+ h('div', { className: 'ifu-draft-quote' },
1991
+ h('div', { className: 'ifu-quote-label' }, `${DICT.zh.quote} · ${basis}`),
1992
+ h('div', { className: 'ifu-quote-text' }, branch.anchor.quote),
1993
+ h('div', { className: 'ifu-draft-wait' }, '在下方输入浮窗中提问'),
1994
+ ),
1995
+ )
1996
+ : h('div', { className: 'ifu-loading' }, branch.error || DICT.zh.sending),
1997
+ ),
1998
+ ...['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].map(direction => h('div', {
1999
+ key: direction,
2000
+ className: `ifu-resize ifu-${direction}`,
2001
+ onPointerDown: event => app.startPanelResize(branch.id, event, direction),
2002
+ })),
2003
+ ),
2004
+
2005
+ )
2006
+ }
2007
+
2008
+ function StageQuote(props) {
2009
+ const { branch } = props
2010
+ const parent = branch.parentBranchId ? app.state.branches[branch.parentBranchId] : null
2011
+ const basis = parent ? `#${parent.number}` : DICT.zh.main
2012
+ return h('div', { className: 'ifu-stage-quote', title: branch.anchor.quote },
2013
+ h('div', { className: 'ifu-stage-quote-label' },
2014
+ h('span', { className: 'ifu-dot' }),
2015
+ `${DICT.zh.quote} · ${DICT.zh.basedOn} ${basis}`,
2016
+ ),
2017
+ h('div', { className: 'ifu-stage-quote-text' }, branch.anchor.quote),
2018
+ )
2019
+ }
2020
+
2021
+ function StageThread(props) {
2022
+ const { branch, SessionProvider, renderSlot } = props
2023
+ const reference = app.refs.get(branch.id)
2024
+ const hostRef = useRef(null)
2025
+ useLayoutEffect(() => {
2026
+ const host = hostRef.current
2027
+ if (!host) return undefined
2028
+ const mark = () => {
2029
+ const quote = host.querySelector('[data-chat-flow-kind="user"] blockquote')
2030
+ if (quote) quote.classList.add('ifu-stage-native-quote')
2031
+ }
2032
+ mark()
2033
+ const observer = new MutationObserver(mark)
2034
+ observer.observe(host, { childList: true, subtree: true, characterData: true })
2035
+ return () => observer.disconnect()
2036
+ }, [branch.id])
2037
+ if (!reference) return h('div', { className: 'ifu-loading' }, branch.error || DICT.zh.sending)
2038
+ return h(SessionProvider, { session: reference },
2039
+ h('div', {
2040
+ ref: hostRef,
2041
+ className: 'ifu-thread-host ifu-stage-thread',
2042
+ 'data-ifu-session': branch.sessionId,
2043
+ }, renderSlot('followup.thread', { branchId: branch.id, cutSeq: branch.anchor.atSeq })),
2044
+ )
2045
+ }
2046
+
2047
+ function BranchTree() {
2048
+ useVersion()
2049
+ const [scope, setScope] = useState('all')
2050
+ const state = app.state
2051
+ const branches = state.order
2052
+ .map(id => state.branches[id])
2053
+ .filter(branch => branch && branch.rootSessionId === state.mainSessionId)
2054
+ if (!state.treeOpen) return null
2055
+ const activeId = state.stageOpen && state.stageIndex >= 0 ? state.stageStack[state.stageIndex] : null
2056
+ const activeBranch = activeId ? state.branches[activeId] : null
2057
+ const path = activeBranch ? pathOf(state, activeBranch) : []
2058
+ const pathIds = new Set(path.map(branch => branch.id))
2059
+ const rows = treeOrder(state, branches)
2060
+ const visibleRows = scope === 'path' && activeBranch
2061
+ ? rows.filter(row => pathIds.has(row.branch.id))
2062
+ : rows
2063
+ const contextText = path.length > 0
2064
+ ? `${DICT.zh.main}${path.map(branch => ` → #${branch.number}`).join('')}`
2065
+ : DICT.zh.main
2066
+ return h('aside', { className: 'ifu-tree', 'data-ifu-no-select': '' },
2067
+ h('div', { className: 'ifu-tree-head' },
2068
+ h('span', null, DICT.zh.tree),
2069
+ h('div', { className: 'ifu-tree-tabs' },
2070
+ h('button', {
2071
+ type: 'button',
2072
+ className: `ifu-tree-tab ${scope === 'path' ? 'is-active' : ''}`,
2073
+ onClick: () => setScope('path'),
2074
+ }, '路径'),
2075
+ h('button', {
2076
+ type: 'button',
2077
+ className: `ifu-tree-tab ${scope === 'all' ? 'is-active' : ''}`,
2078
+ onClick: () => setScope('all'),
2079
+ }, '全部'),
2080
+ ),
2081
+ h('button', { type: 'button', className: 'ifu-icon', title: DICT.zh.close, onClick: () => app.toggleTree() }, '✕'),
2082
+ ),
2083
+ h('div', { className: 'ifu-tree-list' },
2084
+ h('div', {
2085
+ className: 'ifu-tree-root',
2086
+ role: 'button',
2087
+ tabIndex: 0,
2088
+ onClick: () => app.stageClose(),
2089
+ onKeyDown: (event) => {
2090
+ if (event.key !== 'Enter' && event.key !== ' ') return
2091
+ event.preventDefault()
2092
+ app.stageClose()
2093
+ },
2094
+ },
2095
+ h('span', { className: 'ifu-dot' }),
2096
+ h('span', { className: 'ifu-tree-root-name' }, DICT.zh.main),
2097
+ h('span', { className: 'ifu-tree-root-tag' }, 'ROOT'),
2098
+ h('span', { className: 'ifu-tree-root-meta' }, '上下文起点'),
2099
+ ),
2100
+ visibleRows.map(({ branch, depth }) => {
2101
+ const isDraft = !branch.sessionId && !branch.prompted
2102
+ const parent = branch.parentBranchId ? state.branches[branch.parentBranchId] : null
2103
+ const parentLabel = parent ? `${DICT.zh.main} · #${parent.number}` : DICT.zh.main
2104
+ const title = branch.question || branch.anchor.quote || '待提问'
2105
+ const inPath = pathIds.has(branch.id)
2106
+ return h('div', {
2107
+ key: branch.id,
2108
+ role: 'button',
2109
+ tabIndex: 0,
2110
+ className: `ifu-tree-row ${branch.id === activeId ? 'is-current' : ''} ${inPath ? 'is-path' : ''}`,
2111
+ style: { paddingLeft: `${10 + depth * 14}px`, ...colorVars(branch.color) },
2112
+ onClick: () => {
2113
+ if (branch.id === activeId) return
2114
+ if (branch.parentBranchId && state.branches[branch.parentBranchId]) {
2115
+ app.revealBranch(branch.id)
2116
+ if (state.treeOpen) app.toggleTree()
2117
+ } else if (branch.prompted && branch.sessionId) app.promote(branch.id)
2118
+ else app.openPanel(branch.id)
2119
+ },
2120
+ onKeyDown: (event) => {
2121
+ if (event.target !== event.currentTarget) return
2122
+ if (event.key !== 'Enter' && event.key !== ' ') return
2123
+ event.preventDefault()
2124
+ if (branch.parentBranchId && state.branches[branch.parentBranchId]) {
2125
+ app.revealBranch(branch.id)
2126
+ if (state.treeOpen) app.toggleTree()
2127
+ } else if (branch.prompted && branch.sessionId) app.promote(branch.id)
2128
+ else app.openPanel(branch.id)
2129
+ },
2130
+ title: `${DICT.zh.switchBranch} #${branch.number}`,
2131
+ },
2132
+ h('div', { className: 'ifu-tree-top' },
2133
+ h('span', { className: 'ifu-dot' }),
2134
+ h('span', { className: 'ifu-tree-num' }, `#${branch.number}`),
2135
+ h('span', { className: 'ifu-tree-title' }, snippet(title, 36)),
2136
+ branch.id === activeId
2137
+ ? h('span', { className: 'ifu-tree-tag' }, DICT.zh.current)
2138
+ : inPath
2139
+ ? h('span', { className: 'ifu-tree-tag is-context' }, '上下文')
2140
+ : null,
2141
+ isDraft ? h('button', {
2142
+ type: 'button',
2143
+ className: 'ifu-tree-delete',
2144
+ title: DICT.zh.deleteDraft,
2145
+ onClick: (event) => { event.stopPropagation(); app.deleteDraft(branch.id) },
2146
+ }, '⌫') : null,
2147
+ ),
2148
+ h('div', { className: 'ifu-tree-quote' }, snippet(branch.anchor.quote, 72)),
2149
+ h('div', { className: 'ifu-tree-meta' },
2150
+ h('span', null, `${DICT.zh.basedOn} ${parentLabel}`),
2151
+ h('span', null, statusLabel(branch)),
2152
+ ),
2153
+ )
2154
+ }),
2155
+ ),
2156
+ h('div', { className: 'ifu-tree-note' },
2157
+ `当前模型上下文:${contextText}。兄弟分支不在其中。`,
2158
+ ),
2159
+ )
2160
+ }
2161
+
2162
+ function Stage(props) {
2163
+ const { branch, SessionProvider, renderSlot } = props
2164
+ useVersion()
2165
+ const frameLeft = useFrameLeft()
2166
+ const state = app.state
2167
+ const path = pathOf(state, branch)
2168
+ const index = state.stageIndex
2169
+ const total = state.stageStack.length
2170
+ const reference = app.refs.get(branch.id)
2171
+ return h('div', {
2172
+ className: 'ifu-stage',
2173
+ 'data-ifu-stage': '',
2174
+ style: { left: `${frameLeft}px`, ...colorVars(branch.color) },
2175
+ },
2176
+ h('header', { className: 'ifu-stage-bar' },
2177
+ h('button', {
2178
+ type: 'button',
2179
+ className: 'ifu-stage-back',
2180
+ onClick: () => index <= 0 ? app.stageClose() : app.stageGo(-1),
2181
+ title: index <= 0 ? DICT.zh.main : DICT.zh.back,
2182
+ }, `← ${DICT.zh.back}`),
2183
+ h('span', { className: 'ifu-stage-count' }, `${index + 1}/${total}`),
2184
+ h('button', {
2185
+ type: 'button',
2186
+ className: 'ifu-stage-back',
2187
+ disabled: index >= total - 1,
2188
+ onClick: () => app.stageGo(1),
2189
+ }, DICT.zh.forward, ' →'),
2190
+ h('span', { className: 'ifu-stage-divider' }),
2191
+ h('div', { className: 'ifu-crumbs' },
2192
+ h('button', {
2193
+ type: 'button',
2194
+ className: 'ifu-crumb-btn',
2195
+ onClick: () => app.stageClose(),
2196
+ title: DICT.zh.main,
2197
+ }, DICT.zh.main),
2198
+ ...path.flatMap(item => [
2199
+ h('span', { key: `sep-${item.id}`, className: 'ifu-crumb-sep' }, '›'),
2200
+ h('button', {
2201
+ key: `crumb-${item.id}`,
2202
+ type: 'button',
2203
+ className: `ifu-crumb-btn ${item.id === branch.id ? 'ifu-crumb-current' : ''}`,
2204
+ onClick: () => {
2205
+ if (item.id === branch.id) return
2206
+ app.promote(item.id)
2207
+ },
2208
+ title: `${DICT.zh.switchBranch} #${item.number}`,
2209
+ }, `#${item.number}`),
2210
+ ]),
2211
+ ),
2212
+ h('span', { className: 'ifu-context-chip' }, `上下文 · ${path.length} ${DICT.zh.stages}`),
2213
+ h('div', { className: 'ifu-grow' }),
2214
+ h('span', { className: 'ifu-stage-status' }, statusLabel(branch)),
2215
+ h('button', { type: 'button', className: 'ifu-stage-action', onClick: () => app.toggleTree() }, '▤ ', DICT.zh.tree),
2216
+ h('button', { type: 'button', className: 'ifu-stage-action', onClick: () => app.stageRestore() }, '↘ ', DICT.zh.restore),
2217
+ h('button', { type: 'button', className: 'ifu-stage-action', onClick: () => app.stageClose() }, '✕ ', DICT.zh.close),
2218
+ ),
2219
+ h('div', { className: 'ifu-stage-tip' }, DICT.zh.stageHint),
2220
+ h('div', { className: 'ifu-stage-body' },
2221
+ h(StageQuote, { branch }),
2222
+ reference && !branch.loading
2223
+ ? h(StageThread, { key: branch.id, branch, SessionProvider, renderSlot })
2224
+ : h('div', { className: 'ifu-loading' }, branch.error || DICT.zh.sending),
2225
+ ),
2226
+ h(BranchTree),
2227
+ )
2228
+ }
2229
+
2230
+ function HighlightOverlay(props) {
2231
+ if (typeof CSS !== 'undefined' && CSS.highlights && typeof Highlight !== 'undefined') return null
2232
+ return h(HighlightOverlayFallback, props)
2233
+ }
2234
+
2235
+ function HighlightOverlayFallback(props) {
2236
+ const version = useVersion()
2237
+ const [rects, setRects] = useState([])
2238
+ const compute = useCallback(() => {
2239
+ if (!app) return
2240
+ const next = app.highlightRects(Boolean(props.thread))
2241
+ setRects(previous => {
2242
+ if (previous.length !== next.length) return next
2243
+ for (let i = 0; i < next.length; i += 1) {
2244
+ const a = previous[i]
2245
+ const b = next[i]
2246
+ if (a.id !== b.id || a.x !== b.x || a.y !== b.y || a.w !== b.w || a.h !== b.h) return next
2247
+ }
2248
+ return previous
2249
+ })
2250
+ }, [props.thread])
2251
+ useEffect(() => {
2252
+ compute()
2253
+ }, [compute, version])
2254
+ useEffect(() => {
2255
+ let frame = null
2256
+ const onMove = () => {
2257
+ if (frame !== null) return
2258
+ frame = requestAnimationFrame(() => { frame = null; compute() })
2259
+ }
2260
+ window.addEventListener('scroll', onMove, true)
2261
+ document.addEventListener('scroll', onMove, true)
2262
+ window.addEventListener('resize', onMove)
2263
+ const observer = new MutationObserver(onMove)
2264
+ if (document.body) observer.observe(document.body, { childList: true, subtree: true, characterData: true })
2265
+ return () => {
2266
+ window.removeEventListener('scroll', onMove, true)
2267
+ document.removeEventListener('scroll', onMove, true)
2268
+ window.removeEventListener('resize', onMove)
2269
+ observer.disconnect()
2270
+ if (frame !== null) cancelAnimationFrame(frame)
2271
+ }
2272
+ }, [compute])
2273
+ return rects.map(rect => h('div', {
2274
+ key: rect.id,
2275
+ className: `ifu-hl ${props.thread ? 'ifu-hl-thread' : 'ifu-hl-main'}`,
2276
+ style: {
2277
+ left: `${rect.x}px`,
2278
+ top: `${rect.y}px`,
2279
+ width: `${rect.w}px`,
2280
+ height: `${rect.h}px`,
2281
+ ...colorVars(rect.color),
2282
+ },
2283
+ }))
2284
+ }
2285
+
2286
+ function MarkBadges() {
2287
+ const version = useVersion()
2288
+ const [items, setItems] = useState([])
2289
+ const [scrolling, setScrolling] = useState(false)
2290
+ const compute = useCallback(() => {
2291
+ if (!app) return
2292
+ const next = app.markItems()
2293
+ setItems(previous => {
2294
+ if (previous.length !== next.length) return next
2295
+ for (let i = 0; i < next.length; i += 1) {
2296
+ if (previous[i].id !== next[i].id || previous[i].x !== next[i].x || previous[i].y !== next[i].y) return next
2297
+ }
2298
+ return previous
2299
+ })
2300
+ }, [])
2301
+ useEffect(() => {
2302
+ compute()
2303
+ let frame = null
2304
+ let hideTimer = null
2305
+ const scheduleCompute = () => {
2306
+ if (frame !== null) return
2307
+ frame = requestAnimationFrame(() => { frame = null; compute() })
2308
+ }
2309
+ const onScroll = () => {
2310
+ setScrolling(true)
2311
+ if (hideTimer !== null) clearTimeout(hideTimer)
2312
+ hideTimer = setTimeout(() => {
2313
+ hideTimer = null
2314
+ setScrolling(false)
2315
+ compute()
2316
+ }, 140)
2317
+ }
2318
+ const onResize = () => scheduleCompute()
2319
+ window.addEventListener('scroll', onScroll, true)
2320
+ document.addEventListener('scroll', onScroll, true)
2321
+ window.addEventListener('resize', onResize)
2322
+ const observer = new MutationObserver(scheduleCompute)
2323
+ if (document.body) observer.observe(document.body, { childList: true, subtree: true, characterData: true })
2324
+ return () => {
2325
+ window.removeEventListener('scroll', onScroll, true)
2326
+ document.removeEventListener('scroll', onScroll, true)
2327
+ window.removeEventListener('resize', onResize)
2328
+ observer.disconnect()
2329
+ if (frame !== null) cancelAnimationFrame(frame)
2330
+ if (hideTimer !== null) clearTimeout(hideTimer)
2331
+ }
2332
+ }, [compute])
2333
+ useEffect(() => {
2334
+ compute()
2335
+ }, [compute, version])
2336
+ if (scrolling) return null
2337
+ return items.map(item => h('button', {
2338
+ key: item.id,
2339
+ type: 'button',
2340
+ className: 'ifu-badge',
2341
+ style: { left: `${item.x}px`, top: `${item.y}px`, ...colorVars(item.color) },
2342
+ title: `#${item.number} ${snippet(item.quote, 48)}`,
2343
+ 'data-ifu-no-select': '',
2344
+ onClick: () => app.openPanel(item.id),
2345
+ }, h('span', { className: 'ifu-dot' }), `#${item.number}`))
2346
+ }
2347
+
2348
+ function BranchDock() {
2349
+ const state = app.state
2350
+ useVersion()
2351
+ const branches = state.order
2352
+ .map(id => state.branches[id])
2353
+ .filter(branch => branch && branch.rootSessionId === state.mainSessionId)
2354
+ if (branches.length === 0 || state.stageOpen) return null
2355
+ if (!state.dockOpen) {
2356
+ return h('button', {
2357
+ type: 'button',
2358
+ className: 'ifu-dock-collapsed',
2359
+ 'data-ifu-dock': '',
2360
+ onClick: () => app.toggleDock(),
2361
+ }, `◉ ${DICT.zh.dock} · ${branches.length}`)
2362
+ }
2363
+ const dockStyle = state.dock
2364
+ ? {
2365
+ left: `${clamp(state.dock.x, 8, Math.max(8, window.innerWidth - (state.dock.w || 286) - 8))}px`,
2366
+ top: `${clamp(state.dock.y, 8, Math.max(8, window.innerHeight - (state.dock.h || 260) - 8))}px`,
2367
+ width: `${state.dock.w || 286}px`,
2368
+ height: `${state.dock.h || 260}px`,
2369
+ right: 'auto',
2370
+ bottom: 'auto',
2371
+ }
2372
+ : {}
2373
+ return h('aside', { className: 'ifu-dock', 'data-ifu-dock': '', style: dockStyle },
2374
+ h('div', { className: 'ifu-dock-head', onPointerDown: event => app.startDockDrag(event) },
2375
+ h('span', null, `${DICT.zh.dock} · ${branches.length}`),
2376
+ h('button', {
2377
+ type: 'button',
2378
+ className: 'ifu-dock-toggle',
2379
+ title: DICT.zh.collapseDock,
2380
+ onClick: () => app.closeDock(),
2381
+ }, '—'),
2382
+ ),
2383
+ h('div', { className: 'ifu-dock-list' },
2384
+ branches.map(branch => {
2385
+ const isDraft = !branch.sessionId && !branch.prompted
2386
+ return h('div', {
2387
+ key: branch.id,
2388
+ role: 'button',
2389
+ tabIndex: 0,
2390
+ className: `ifu-dock-row ${branch.mode === 'panel' ? 'is-open' : ''}`,
2391
+ onClick: () => app.revealBranch(branch.id),
2392
+ onKeyDown: (event) => {
2393
+ if (event.target !== event.currentTarget) return
2394
+ if (event.key !== 'Enter' && event.key !== ' ') return
2395
+ event.preventDefault()
2396
+ app.revealBranch(branch.id)
2397
+ },
2398
+ title: `${DICT.zh.switchBranch}: #${branch.number}`,
2399
+ },
2400
+ h('span', { className: 'ifu-dot', style: colorVars(branch.color) }),
2401
+ h('span', { className: 'ifu-dock-num' }, `#${branch.number}`),
2402
+ h('span', { className: 'ifu-dock-text' }, snippet(branch.question || branch.anchor.quote, 42)),
2403
+ h('span', { className: 'ifu-dock-status' }, statusLabel(branch)),
2404
+ isDraft ? h('button', {
2405
+ type: 'button',
2406
+ className: 'ifu-dock-delete',
2407
+ title: DICT.zh.deleteDraft,
2408
+ onClick: (event) => { event.stopPropagation(); app.deleteDraft(branch.id) },
2409
+ }, '⌫') : null,
2410
+ )
2411
+ }),
2412
+ ),
2413
+ ...['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].map(direction => h('div', {
2414
+ key: direction,
2415
+ className: `ifu-resize ifu-${direction}`,
2416
+ onPointerDown: event => app.startDockResize(event, direction),
2417
+ })),
2418
+ )
2419
+ }
2420
+
2421
+ function SelectionChip() {
2422
+ const chip = app.state.chip
2423
+ if (!chip) return null
2424
+ return h('button', {
2425
+ type: 'button',
2426
+ className: 'ifu-chip',
2427
+ 'data-ifu-chip': '',
2428
+ style: { left: `${chip.x}px`, top: `${chip.y}px` },
2429
+ title: DICT.zh.chipHint,
2430
+ onMouseDown: event => event.preventDefault(),
2431
+ onClick: () => {
2432
+ const x = clamp(chip.x - 120, 12, window.innerWidth - PANEL_WIDTH - 12)
2433
+ const y = clamp(chip.y + 38, 12, window.innerHeight - 320)
2434
+ app.openDraft(chip.anchor, x, y)
2435
+ },
2436
+ }, '✦ ', DICT.zh.chip)
2437
+ }
2438
+
2439
+ function Notice() {
2440
+ useVersion()
2441
+ if (!app || !app.state.notice) return null
2442
+ return h('div', { className: 'ifu-notice', role: 'status' }, app.state.notice)
2443
+ }
2444
+
2445
+ function FollowupLayer(props) {
2446
+ useVersion()
2447
+ const state = app.state
2448
+ const branches = state.order.map(id => state.branches[id]).filter(Boolean)
2449
+ const panels = branches.filter(branch => branch.mode === 'panel')
2450
+ const stageBranch = state.stageOpen && state.stageIndex >= 0
2451
+ ? state.branches[state.stageStack[state.stageIndex]]
2452
+ : null
2453
+ useEffect(() => {
2454
+ app.scheduleProject()
2455
+ })
2456
+ return h('div', { className: 'ifu-root', 'data-ifu-root': '' },
2457
+ stageBranch
2458
+ ? h(Stage, {
2459
+ branch: stageBranch,
2460
+ SessionProvider: props.SessionProvider,
2461
+ renderSlot: props.renderSlot,
2462
+ })
2463
+ : null,
2464
+ panels.map(branch => h(BranchPanel, {
2465
+ key: branch.id,
2466
+ branch,
2467
+ SessionProvider: props.SessionProvider,
2468
+ renderSlot: props.renderSlot,
2469
+ })),
2470
+ h(HighlightOverlay, { thread: false }),
2471
+ h(HighlightOverlay, { thread: true }),
2472
+ h(BranchDock),
2473
+ h(SelectionChip),
2474
+ h(Notice),
2475
+ )
2476
+ }
2477
+
2478
+ // ---------------------------------------------------------------------
2479
+ // styles
2480
+ // ---------------------------------------------------------------------
2481
+
2482
+ const STYLES = `
2483
+ :root {
2484
+ --ifu-c0:#C25A3A; --ifu-t0:#F7E6DF; --ifu-c1:#207F73; --ifu-t1:#DFF1ED;
2485
+ --ifu-c2:#5B63C4; --ifu-t2:#E7E8FA; --ifu-c3:#A66B12; --ifu-t3:#F7EBD8;
2486
+ --ifu-c4:#B0446E; --ifu-t4:#F7E3EC; --ifu-c5:#3D6FA8; --ifu-t5:#E3EDF8;
2487
+ --ifu-c6:#7A4FA8; --ifu-t6:#EEE4F8; --ifu-c7:#5F7A2E; --ifu-t7:#EAF0DC;
2488
+ --ifu-h0:rgba(194,90,58,.20); --ifu-h1:rgba(32,127,115,.20); --ifu-h2:rgba(91,99,196,.20); --ifu-h3:rgba(166,107,18,.20);
2489
+ --ifu-h4:rgba(176,68,110,.20); --ifu-h5:rgba(61,111,168,.20); --ifu-h6:rgba(122,79,168,.20); --ifu-h7:rgba(95,122,46,.20);
2490
+ }
2491
+ body[data-ds-dark-theme] {
2492
+ --ifu-c0:#F08A68; --ifu-t0:rgba(240,138,104,.16); --ifu-c1:#5FC9B8; --ifu-t1:rgba(95,201,184,.15);
2493
+ --ifu-c2:#9AA4FF; --ifu-t2:rgba(154,164,255,.15); --ifu-c3:#E3B05E; --ifu-t3:rgba(227,176,94,.15);
2494
+ --ifu-c4:#E889B0; --ifu-t4:rgba(232,137,176,.15); --ifu-c5:#7FB2E8; --ifu-t5:rgba(127,178,232,.15);
2495
+ --ifu-c6:#C29BE8; --ifu-t6:rgba(194,155,232,.15); --ifu-c7:#A9C46A; --ifu-t7:rgba(169,196,106,.15);
2496
+ --ifu-h0:rgba(240,138,104,.24); --ifu-h1:rgba(95,201,184,.24); --ifu-h2:rgba(154,164,255,.24); --ifu-h3:rgba(227,176,94,.24);
2497
+ --ifu-h4:rgba(232,137,176,.24); --ifu-h5:rgba(127,178,232,.24); --ifu-h6:rgba(194,155,232,.24); --ifu-h7:rgba(169,196,106,.24);
2498
+ }
2499
+ ::highlight(ifu-c0){ background-color:var(--ifu-t0,#F7E6DF); text-decoration:underline; text-decoration-color:var(--ifu-c0,#C25A3A); text-decoration-thickness:2px; text-underline-offset:3px; }
2500
+ ::highlight(ifu-c1){ background-color:var(--ifu-t1,#DFF1ED); text-decoration:underline; text-decoration-color:var(--ifu-c1,#207F73); text-decoration-thickness:2px; text-underline-offset:3px; }
2501
+ ::highlight(ifu-c2){ background-color:var(--ifu-t2,#E7E8FA); text-decoration:underline; text-decoration-color:var(--ifu-c2,#5B63C4); text-decoration-thickness:2px; text-underline-offset:3px; }
2502
+ ::highlight(ifu-c3){ background-color:var(--ifu-t3,#F7EBD8); text-decoration:underline; text-decoration-color:var(--ifu-c3,#A66B12); text-decoration-thickness:2px; text-underline-offset:3px; }
2503
+ ::highlight(ifu-c4){ background-color:var(--ifu-t4,#F7E3EC); text-decoration:underline; text-decoration-color:var(--ifu-c4,#B0446E); text-decoration-thickness:2px; text-underline-offset:3px; }
2504
+ ::highlight(ifu-c5){ background-color:var(--ifu-t5,#E3EDF8); text-decoration:underline; text-decoration-color:var(--ifu-c5,#3D6FA8); text-decoration-thickness:2px; text-underline-offset:3px; }
2505
+ ::highlight(ifu-c6){ background-color:var(--ifu-t6,#EEE4F8); text-decoration:underline; text-decoration-color:var(--ifu-c6,#7A4FA8); text-decoration-thickness:2px; text-underline-offset:3px; }
2506
+ ::highlight(ifu-c7){ background-color:var(--ifu-t7,#EAF0DC); text-decoration:underline; text-decoration-color:var(--ifu-c7,#5F7A2E); text-decoration-thickness:2px; text-underline-offset:3px; }
2507
+ /* Literal colors: a highlight pseudo-element may not resolve custom properties reliably. */
2508
+ ::highlight(ifu-c0){ background-color:rgba(194,90,58,.20); text-decoration-color:#C25A3A; }
2509
+ ::highlight(ifu-c1){ background-color:rgba(32,127,115,.20); text-decoration-color:#207F73; }
2510
+ ::highlight(ifu-c2){ background-color:rgba(91,99,196,.20); text-decoration-color:#5B63C4; }
2511
+ ::highlight(ifu-c3){ background-color:rgba(166,107,18,.20); text-decoration-color:#A66B12; }
2512
+ ::highlight(ifu-c4){ background-color:rgba(176,68,110,.20); text-decoration-color:#B0446E; }
2513
+ ::highlight(ifu-c5){ background-color:rgba(61,111,168,.20); text-decoration-color:#3D6FA8; }
2514
+ ::highlight(ifu-c6){ background-color:rgba(122,79,168,.20); text-decoration-color:#7A4FA8; }
2515
+ ::highlight(ifu-c7){ background-color:rgba(95,122,46,.20); text-decoration-color:#5F7A2E; }
2516
+ body[data-ds-dark-theme] ::highlight(ifu-c0){ background-color:rgba(240,138,104,.24); text-decoration-color:#F08A68; }
2517
+ body[data-ds-dark-theme] ::highlight(ifu-c1){ background-color:rgba(95,201,184,.24); text-decoration-color:#5FC9B8; }
2518
+ body[data-ds-dark-theme] ::highlight(ifu-c2){ background-color:rgba(154,164,255,.24); text-decoration-color:#9AA4FF; }
2519
+ body[data-ds-dark-theme] ::highlight(ifu-c3){ background-color:rgba(227,176,94,.24); text-decoration-color:#E3B05E; }
2520
+ body[data-ds-dark-theme] ::highlight(ifu-c4){ background-color:rgba(232,137,176,.24); text-decoration-color:#E889B0; }
2521
+ body[data-ds-dark-theme] ::highlight(ifu-c5){ background-color:rgba(127,178,232,.24); text-decoration-color:#7FB2E8; }
2522
+ body[data-ds-dark-theme] ::highlight(ifu-c6){ background-color:rgba(194,155,232,.24); text-decoration-color:#C29BE8; }
2523
+ body[data-ds-dark-theme] ::highlight(ifu-c7){ background-color:rgba(169,196,106,.24); text-decoration-color:#A9C46A; }
2524
+ ::highlight(ifu-c0), ::highlight(ifu-c1), ::highlight(ifu-c2), ::highlight(ifu-c3), ::highlight(ifu-c4), ::highlight(ifu-c5), ::highlight(ifu-c6), ::highlight(ifu-c7) { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:3px; }
2525
+ .ifu-sidebar-group { display:flex; align-items:center; width:100%; box-sizing:border-box; padding:5px 8px; border:0; background:transparent; color:var(--dsw-alias-label-tertiary,#61666b); font:9.5px/1.4 var(--dsw-font-family-text,sans-serif); cursor:pointer; text-align:left; }
2526
+ .ifu-sidebar-group:hover { background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)); }
2527
+ .ifu-sidebar-hidden { display:none !important; }
2528
+ .ifu-root { position:fixed; inset:0; pointer-events:none; z-index:60; }
2529
+ .ifu-hl { position:fixed; z-index:65; pointer-events:none; border-radius:2px; background:var(--ifu-h); border-bottom:2px solid var(--ifu-c); }
2530
+ .ifu-hl-thread { z-index:360; }
2531
+ .ifu-grow { flex:1; }
2532
+ .ifu-chip { position:fixed; z-index:380; -webkit-app-region:no-drag; pointer-events:auto; height:28px; padding:0 10px; border:0; border-radius:8px; background:var(--dsw-alias-label-primary,#17191d); color:var(--dsw-alias-bg-layer-1,#fff); font:500 11.5px/1 var(--dsw-font-family-text,-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif); box-shadow:0 8px 22px rgba(15,17,21,.22); cursor:pointer; }
2533
+ .ifu-panel { position:fixed; z-index:100; -webkit-app-region:no-drag; box-sizing:border-box; width:${PANEL_WIDTH}px; max-width:calc(100vw - 24px); max-height:calc(100vh - 16px); pointer-events:auto; display:flex; flex-direction:column; overflow:hidden; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); border-radius:16px; background:var(--dsw-alias-bg-layer-1,#fff); color:var(--dsw-alias-label-primary,#0f1115); box-shadow:0 24px 64px rgba(15,17,21,.16),0 6px 18px rgba(15,17,21,.08); }
2534
+ .ifu-head { height:44px; flex:0 0 44px; display:flex; align-items:center; gap:8px; padding:0 8px 0 12px; border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.06)); cursor:grab; user-select:none; }
2535
+ .ifu-head:active { cursor:grabbing; }
2536
+ .ifu-dot { width:8px; height:8px; border-radius:50%; background:var(--ifu-c); box-shadow:0 0 0 3px var(--ifu-t); flex:0 0 8px; }
2537
+ .ifu-title-wrap { min-width:0; }
2538
+ .ifu-title { font-size:12px; font-weight:600; }
2539
+ .ifu-sub { font-size:10px; color:var(--dsw-alias-label-tertiary,#61666b); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
2540
+ .ifu-icon { width:26px; height:26px; flex:0 0 26px; border:0; border-radius:6px; background:transparent; color:var(--dsw-alias-label-secondary,#3c4043); font:13px/1 var(--dsw-font-family-text,sans-serif); cursor:pointer; }
2541
+ .ifu-icon:hover { background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)); }
2542
+ .ifu-icon:focus-visible, .ifu-send:focus-visible, .ifu-chip:focus-visible, .ifu-stage-back:focus-visible, .ifu-stage-action:focus-visible { outline:2px solid var(--dsw-focus-ring-color,#4176e6); outline-offset:2px; }
2543
+ .ifu-icon:disabled { opacity:.35; cursor:default; }
2544
+ .ifu-icon.ifu-danger { color:var(--dsw-alias-state-error-primary,#ef4444); }
2545
+ .ifu-panel-body { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; overflow:hidden; }
2546
+ .ifu-compose-center { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; overflow-y:auto; overflow-x:hidden; }
2547
+ .ifu-compose { box-sizing:border-box; width:100%; max-width:100%; margin:auto 0; padding:11px 12px 0; display:flex; flex-direction:column; min-width:0; min-height:0; }
2548
+ .ifu-draft-quote { box-sizing:border-box; width:100%; max-width:100%; margin:auto 0; padding:11px 12px; }
2549
+ .ifu-draft-wait { margin-top:10px; font-size:10px; color:var(--dsw-alias-label-caption,#979da6); }
2550
+ .ifu-input-panel { display:flex; flex-direction:column; }
2551
+ .ifu-input-body { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; padding:11px 12px 0; overflow:auto; }
2552
+ .ifu-input-body .ifu-textarea { flex:1; min-height:52px; resize:none; }
2553
+ .ifu-quote { box-sizing:border-box; max-width:100%; border-left:2.5px solid var(--ifu-c); background:var(--ifu-t); border-radius:2px 9px 9px 2px; padding:8px 10px; color:var(--dsw-alias-label-secondary,#3c4043); font-size:11.5px; line-height:1.55; }
2554
+ .ifu-quote-label { font-size:9px; color:var(--dsw-alias-label-tertiary,#61666b); margin-bottom:3px; }
2555
+ .ifu-quote-text { max-height:88px; overflow:auto; }
2556
+ .ifu-textarea { box-sizing:border-box; width:100%; max-width:100%; margin-top:10px; min-height:86px; max-height:180px; resize:vertical; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); border-radius:10px; background:var(--dsw-alias-bg-base,#fff); color:var(--dsw-alias-label-primary,#0f1115); font:13.5px/1.6 var(--dsw-font-family-text,-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif); padding:9px 10px; }
2557
+ .ifu-textarea:focus { outline:2px solid var(--dsw-focus-ring-color,#4176e6); outline-offset:1px; }
2558
+ .ifu-error { margin-top:8px; color:var(--dsw-alias-state-error-primary,#ef4444); font-size:10.5px; line-height:1.5; }
2559
+ .ifu-compose-foot { display:flex; align-items:center; gap:8px; padding:8px 0 10px; }
2560
+ .ifu-hint { font-size:9px; color:var(--dsw-alias-label-caption,#979da6); }
2561
+ .ifu-send { height:29px; padding:0 13px; border:0; border-radius:8px; background:var(--dsw-alias-label-primary,#17191d); color:var(--dsw-alias-bg-layer-1,#fff); font:500 12px/1 var(--dsw-font-family-text,sans-serif); cursor:pointer; }
2562
+ .ifu-send:disabled { opacity:.55; cursor:default; }
2563
+ .ifu-thread-host { display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; width:100%; max-width:100%; height:auto; overflow:hidden; box-sizing:border-box; background:var(--dsw-alias-bg-base,#fff); }
2564
+ .ifu-thread { flex:1; min-width:0; min-height:0; width:100%; max-width:100%; display:flex; flex-direction:column; overflow-x:hidden; box-sizing:border-box; }
2565
+ .ifu-thread [data-conversation-content], .ifu-thread [data-conversation-scroll] { max-width:100%; box-sizing:border-box; }
2566
+ .ifu-thread > * { flex:1; min-height:0; }
2567
+ .ifu-loading { padding:28px 16px; color:var(--dsw-alias-label-tertiary,#61666b); font-size:12px; text-align:center; }
2568
+ .ifu-stage { position:fixed; top:var(--dsh-frame-top-clearance,0px); left:0; right:0; bottom:0; z-index:300; pointer-events:auto; -webkit-app-region:no-drag; display:flex; flex-direction:column; background:var(--dsw-alias-bg-base,#fff); color:var(--dsw-alias-label-primary,#0f1115); }
2569
+ .ifu-stage-bar { pointer-events:auto; position:relative; z-index:12; -webkit-app-region:no-drag; height:56px; flex:0 0 56px; display:flex; align-items:center; gap:8px; padding:0 14px; border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); background:var(--dsw-specific-sidebar-fill,#f9fafb); }
2570
+ .ifu-stage-back { pointer-events:auto; position:relative; z-index:13; -webkit-app-region:no-drag; height:28px; padding:0 8px; border:0; border-radius:6px; background:transparent; color:var(--dsw-alias-label-secondary,#3c4043); font:11px/1 var(--dsw-font-family-text,sans-serif); cursor:pointer; }
2571
+ .ifu-stage-back:hover { background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)); }
2572
+ .ifu-stage-back:disabled { opacity:.35; cursor:default; background:transparent; }
2573
+ .ifu-stage-count { font:9px/1 var(--dsw-font-family-code,monospace); color:var(--dsw-alias-label-caption,#979da6); padding:0 5px; }
2574
+ .ifu-stage-divider { width:1px; height:18px; background:var(--dsw-alias-border-l2,rgba(0,0,0,.12)); margin:0 2px; }
2575
+ .ifu-crumbs { display:flex; align-items:center; gap:5px; font-size:11px; color:var(--dsw-alias-label-tertiary,#61666b); min-width:0; overflow:hidden; white-space:nowrap; }
2576
+ .ifu-crumb-sep { color:var(--dsw-alias-label-caption,#979da6); }
2577
+ .ifu-crumb-btn { pointer-events:auto; -webkit-app-region:no-drag; border:0; background:transparent; padding:2px 3px; border-radius:4px; color:var(--dsw-alias-label-tertiary,#61666b); font:11px/1.4 var(--dsw-font-family-text,sans-serif); cursor:pointer; }
2578
+ .ifu-crumb-btn:hover { background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)); color:var(--dsw-alias-label-secondary,#3c4043); }
2579
+ .ifu-crumb-current { color:var(--dsw-alias-label-primary,#0f1115); font-weight:600; }
2580
+ .ifu-crumb-btn.ifu-crumb-current { cursor:default; }
2581
+ .ifu-crumb-btn.ifu-crumb-current:hover { background:transparent; }
2582
+ .ifu-context-chip { font-size:9.5px; color:var(--dsw-alias-label-tertiary,#61666b); background:var(--dsw-alias-bg-module-platform,#f1f3f5); padding:3px 7px; border-radius:999px; white-space:nowrap; }
2583
+ .ifu-stage-status { font-size:9.5px; color:var(--dsw-alias-state-success-primary,#2f8f62); background:var(--dsw-alias-bg-module-platform,#f1f3f5); padding:3px 7px; border-radius:999px; white-space:nowrap; }
2584
+ .ifu-stage-action { pointer-events:auto; position:relative; z-index:13; -webkit-app-region:no-drag; height:28px; padding:0 8px; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); border-radius:6px; background:var(--dsw-alias-bg-layer-1,#fff); color:var(--dsw-alias-label-secondary,#3c4043); font:10px/1 var(--dsw-font-family-text,sans-serif); cursor:pointer; }
2585
+ .ifu-stage-action:hover { background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)); }
2586
+ .ifu-stage-tip { position:relative; z-index:11; flex:0 0 30px; display:flex; align-items:center; padding:0 18px; background:var(--dsw-alias-bg-module-platform,#f7f9fc); border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.06)); color:var(--dsw-alias-label-tertiary,#61666b); font-size:10px; }
2587
+ .ifu-stage-body { position:relative; z-index:1; flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; overflow-x:hidden; }
2588
+ .ifu-stage-quote { flex:0 0 auto; box-sizing:border-box; width:min(calc(100% - 36px), var(--dsh-chat-content-width,760px)); margin:10px auto 0; padding:8px 11px; border:1px solid color-mix(in srgb, var(--ifu-c) 30%, transparent); border-left:3px solid var(--ifu-c); border-radius:8px; background:color-mix(in srgb, var(--ifu-c) 9%, var(--dsw-alias-bg-base,#fff)); color:var(--dsw-alias-label-primary,#0f1115); }
2589
+ .ifu-stage-quote-label { display:flex; align-items:center; gap:5px; margin-bottom:4px; font-size:9px; color:var(--dsw-alias-label-tertiary,#61666b); }
2590
+ .ifu-stage-quote-label .ifu-dot { flex:0 0 6px; }
2591
+ .ifu-stage-quote-text { max-height:150px; overflow:auto; font-size:11px; line-height:1.55; white-space:pre-wrap; word-break:break-word; }
2592
+ .ifu-stage-thread { flex:1; min-width:0; min-height:0; height:auto; max-width:100%; overflow-x:hidden; }
2593
+ .ifu-stage-thread .ifu-stage-native-quote { display:none !important; }
2594
+ .ifu-resize { position:absolute; z-index:6; touch-action:none; }
2595
+ .ifu-e { right:-3px; top:0; bottom:0; width:7px; cursor:ew-resize; }
2596
+ .ifu-w { left:-3px; top:0; bottom:0; width:7px; cursor:ew-resize; }
2597
+ .ifu-n { left:0; right:0; top:-3px; height:7px; cursor:ns-resize; }
2598
+ .ifu-s { left:0; right:0; bottom:-3px; height:7px; cursor:ns-resize; }
2599
+ .ifu-ne { right:-4px; top:-4px; width:14px; height:14px; cursor:nesw-resize; }
2600
+ .ifu-nw { left:-4px; top:-4px; width:14px; height:14px; cursor:nwse-resize; }
2601
+ .ifu-se { right:-4px; bottom:-4px; width:16px; height:16px; cursor:nwse-resize; }
2602
+ .ifu-sw { left:-4px; bottom:-4px; width:14px; height:14px; cursor:nesw-resize; }
2603
+ .ifu-se::after { content:""; position:absolute; right:4px; bottom:4px; width:7px; height:7px; border-right:2px solid var(--dsw-alias-label-caption,#979da6); border-bottom:2px solid var(--dsw-alias-label-caption,#979da6); border-radius:0 0 2px 0; opacity:.6; }
2604
+ .ifu-tree { pointer-events:auto; position:absolute; -webkit-app-region:no-drag; top:86px; right:0; bottom:0; z-index:20; width:300px; display:flex; flex-direction:column; background:var(--dsw-specific-sidebar-fill,#fbfbfc); border-left:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); box-shadow:-12px 0 30px rgba(15,17,21,.06); }
2605
+ .ifu-tree-head { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.06)); font-size:10px; color:var(--dsw-alias-label-tertiary,#61666b); }
2606
+ .ifu-tree-head .ifu-icon { margin-left:0; }
2607
+ .ifu-tree-tabs { margin-left:auto; display:flex; gap:2px; padding:1px; border-radius:6px; background:var(--dsw-alias-bg-module-platform,#f1f3f5); }
2608
+ .ifu-tree-tab { border:0; border-radius:5px; background:transparent; color:var(--dsw-alias-label-tertiary,#61666b); font:9px/1.4 var(--dsw-font-family-text,sans-serif); padding:3px 6px; cursor:pointer; }
2609
+ .ifu-tree-tab.is-active { background:var(--dsw-alias-bg-layer-1,#fff); color:var(--dsw-alias-label-primary,#0f1115); box-shadow:0 1px 2px rgba(0,0,0,.06); }
2610
+ .ifu-tree-list { flex:1; min-height:0; overflow:auto; padding:8px; display:flex; flex-direction:column; gap:6px; }
2611
+ .ifu-tree-root { display:flex; align-items:center; gap:6px; box-sizing:border-box; padding:8px; border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.06)); border-radius:8px; background:var(--dsw-alias-bg-module-platform,#f1f3f5); cursor:pointer; }
2612
+ .ifu-tree-root-name { font-size:10px; color:var(--dsw-alias-label-primary,#0f1115); }
2613
+ .ifu-tree-root-tag { margin-left:auto; font:8px/1 var(--dsw-font-family-code,monospace); color:var(--dsw-alias-label-caption,#979da6); }
2614
+ .ifu-tree-root-meta { font-size:8px; color:var(--dsw-alias-label-caption,#979da6); }
2615
+ .ifu-tree-row { pointer-events:auto; width:100%; box-sizing:border-box; display:flex; flex-direction:column; align-items:stretch; gap:4px; border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.06)); border-radius:8px; background:var(--dsw-alias-bg-layer-1,#fff); padding:8px; color:var(--dsw-alias-label-primary,#0f1115); text-align:left; cursor:pointer; }
2616
+ .ifu-tree-row:hover { border-color:color-mix(in srgb, var(--ifu-c) 36%, var(--dsw-alias-border-l2,rgba(0,0,0,.12))); }
2617
+ .ifu-tree-row.is-current { border-color:var(--ifu-c); background:color-mix(in srgb, var(--ifu-c) 7%, var(--dsw-alias-bg-layer-1,#fff)); box-shadow:0 1px 3px rgba(0,0,0,.06); }
2618
+ .ifu-tree-top { display:flex; align-items:center; gap:6px; min-width:0; }
2619
+ .ifu-tree-num { font:600 9px/1 var(--dsw-font-family-code,monospace); color:var(--dsw-alias-label-secondary,#3c4043); flex:0 0 auto; }
2620
+ .ifu-tree-title { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; color:var(--dsw-alias-label-primary,#0f1115); }
2621
+ .ifu-tree-quote { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-left:6px; border-left:2px solid var(--ifu-c); font-size:9px; color:var(--dsw-alias-label-tertiary,#61666b); }
2622
+ .ifu-tree-meta { display:flex; align-items:center; justify-content:space-between; gap:6px; font-size:8px; color:var(--dsw-alias-label-caption,#979da6); }
2623
+ .ifu-tree-tag { flex:0 0 auto; margin-left:auto; font-size:7.5px; color:var(--dsw-alias-state-success-primary,#2f8f62); }
2624
+ .ifu-tree-tag.is-context { color:var(--ifu-c); }
2625
+ .ifu-tree-note { padding:10px 12px; border-top:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.06)); font-size:8.5px; line-height:1.5; color:var(--dsw-alias-label-caption,#979da6); }
2626
+ .ifu-badge-layer { position:absolute; inset:0; pointer-events:none; z-index:3; }
2627
+ .ifu-badge { position:absolute; z-index:3; pointer-events:auto; display:inline-flex; align-items:center; gap:4px; height:18px; padding:0 5px 0 4px; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); border-radius:999px; background:var(--dsw-alias-bg-layer-1,#fff); color:var(--dsw-alias-label-tertiary,#61666b); font:600 8.5px/1 var(--dsw-font-family-code,monospace); box-shadow:0 2px 6px rgba(15,17,21,.08); cursor:pointer; }
2628
+ .ifu-badge .ifu-dot { width:6px; height:6px; box-shadow:none; }
2629
+ .ifu-dock { position:fixed; -webkit-app-region:no-drag; right:18px; bottom:96px; z-index:80; width:286px; height:260px; min-width:220px; min-height:120px; max-width:calc(100vw - 16px); max-height:calc(100vh - 16px); pointer-events:auto; display:flex; flex-direction:column; overflow:hidden; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); border-radius:12px; background:var(--dsw-alias-bg-layer-1,#fff); color:var(--dsw-alias-label-primary,#0f1115); box-shadow:0 12px 32px rgba(15,17,21,.12); }
2630
+ .ifu-dock-head { display:flex; align-items:center; gap:6px; padding:8px 10px; border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.06)); font-size:9.5px; color:var(--dsw-alias-label-tertiary,#61666b); cursor:grab; user-select:none; }
2631
+ .ifu-dock-head:active { cursor:grabbing; }
2632
+ .ifu-dock-toggle { margin-left:auto; border:0; background:transparent; color:var(--dsw-alias-label-tertiary,#61666b); cursor:pointer; font-size:13px; line-height:1; }
2633
+ .ifu-dock-list { flex:1; min-height:0; overflow:auto; padding:4px; }
2634
+ .ifu-dock-row { width:100%; box-sizing:border-box; display:flex; align-items:center; gap:6px; border:0; border-radius:7px; background:transparent; padding:6px 7px; color:var(--dsw-alias-label-primary,#0f1115); text-align:left; cursor:pointer; }
2635
+ .ifu-dock-row:hover, .ifu-dock-row.is-open { background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)); }
2636
+ .ifu-dock-num { font:600 9.5px/1 var(--dsw-font-family-code,monospace); color:var(--dsw-alias-label-secondary,#3c4043); flex:0 0 auto; }
2637
+ .ifu-dock-text { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; color:var(--dsw-alias-label-tertiary,#61666b); }
2638
+ .ifu-dock-status { margin-left:auto; font-size:8px; color:var(--dsw-alias-label-caption,#979da6); flex:0 0 auto; }
2639
+ .ifu-dock-delete, .ifu-tree-delete { width:20px; height:20px; flex:0 0 20px; border:0; border-radius:5px; background:transparent; color:var(--dsw-alias-state-error-primary,#ef4444); font:12px/1 var(--dsw-font-family-text,sans-serif); cursor:pointer; }
2640
+ .ifu-dock-delete:hover, .ifu-tree-delete:hover { background:color-mix(in srgb, var(--dsw-alias-state-error-primary,#ef4444) 12%, transparent); }
2641
+ .ifu-tree-delete { margin-left:auto; }
2642
+ .ifu-dock-collapsed { position:fixed; right:18px; bottom:96px; z-index:80; pointer-events:auto; height:26px; padding:0 9px; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12)); border-radius:999px; background:var(--dsw-alias-bg-layer-1,#fff); color:var(--dsw-alias-label-secondary,#3c4043); font-size:9.5px; box-shadow:0 6px 18px rgba(15,17,21,.1); cursor:pointer; }
2643
+ .ifu-notice { position:fixed; left:50%; bottom:22px; transform:translateX(-50%); z-index:500; pointer-events:none; max-width:min(520px,80vw); padding:8px 13px; border-radius:9px; background:var(--dsw-alias-hovercard-bg,#353A40); color:var(--dsw-alias-hovercard-label-primary,#fff); font-size:11px; box-shadow:0 10px 28px rgba(0,0,0,.2); }
2644
+ .ifu-root, .ifu-panel, .ifu-stage, .ifu-dock, .ifu-chip { font-family:var(--dsw-font-family-text,-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif); }
2645
+ html[data-fullscreen] .ifu-stage { top:0; }
2646
+ @media (max-width: 760px) {
2647
+ .ifu-panel { left:12px !important; right:12px; width:auto; top:12px !important; max-height:78vh; }
2648
+ .ifu-dock { right:12px; bottom:84px; width:calc(100vw - 24px); }
2649
+ .ifu-stage-bar { gap:4px; padding:0 8px; }
2650
+ .ifu-context-chip { display:none; }
2651
+ }
2652
+ `
2653
+
2654
+ // ---------------------------------------------------------------------
2655
+ // plugin entry
2656
+ // ---------------------------------------------------------------------
2657
+
2658
+ return {
2659
+ name: 'inline-followup',
2660
+ inject: ['slots', 'locale', 'sessions'],
2661
+ apply(ctx) {
2662
+ app = createApp(ctx)
2663
+ app.load()
2664
+ ctx.effect(() => ctx.locale.register(NS, { zh: DICT.zh, en: DICT.en }), 'inline-followup: locale')
2665
+ ctx.effect(() => app.install(), 'inline-followup: lifetime')
2666
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
2667
+ name: 'shell.overlay',
2668
+ id: 'inline-followup',
2669
+ children: {
2670
+ 'followup.thread': { kind: 'single', scope: 'session' },
2671
+ },
2672
+ }, FollowupLayer))
2673
+ ctx.slots.inject('followup.thread', () => ctx.slots.register({
2674
+ name: 'followup.thread',
2675
+ }, FollowupThread))
2676
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
2677
+ name: 'conversation.session.header.utilities',
2678
+ id: 'inline-followup-probe',
2679
+ order: 40,
2680
+ }, SessionProbe))
2681
+ },
2682
+ }
2683
+ },
2684
+ })