dsh-synapse 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js ADDED
@@ -0,0 +1,1252 @@
1
+ const app = document.querySelector('#app')
2
+ if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
3
+ const LEGACY_CARD_POSITIONS_KEY = 'dsh-synapse:card-positions'
4
+ const CARD_POSITIONS_KEY = 'dsh-synapse:card-positions:v3'
5
+ const COLLAPSED_CARDS_KEY = 'dsh-synapse:collapsed-cards:v1'
6
+ const savedBranchAnchors = (() => {
7
+ try {
8
+ const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
9
+ return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && typeof item[1] === 'string') : []
10
+ } catch { return [] }
11
+ })()
12
+ const savedCardPositions = (() => {
13
+ try {
14
+ // Drop formats that were never persisted; the current key stores drags.
15
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
16
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
17
+ const value = JSON.parse(localStorage.getItem(CARD_POSITIONS_KEY) ?? '[]')
18
+ return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && item[1] !== null && Number.isFinite(item[1].x) && Number.isFinite(item[1].y)) : []
19
+ } catch { return [] }
20
+ })()
21
+ const savedCollapsedCards = (() => {
22
+ try {
23
+ const value = JSON.parse(localStorage.getItem(COLLAPSED_CARDS_KEY) ?? '[]')
24
+ return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []
25
+ } catch { return [] }
26
+ })()
27
+ const CARD_WIDTH = 310
28
+ const CARD_HEIGHT = 276
29
+ const CARD_GAP_Y = 42
30
+ const CAMERA_INSET_X = 56
31
+ const CAMERA_INSET_Y = 56
32
+ const state = {
33
+ summaries: [], workspace: null, activeId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
34
+ dshWorkspaces: [], selectedDshWorkspaceId: null,
35
+ historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
36
+ draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards),
37
+ dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 },
38
+ expandedMessageIds: new Set(),
39
+ }
40
+
41
+ const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character]))
42
+ const formatTime = value => new Date(value).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
43
+ const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null
44
+ const threadListTitle = thread => thread.dshSessionTitle ?? thread.title ?? questionFor(thread)
45
+
46
+ function rememberBranchAnchor(sessionId, cardId) {
47
+ state.branchAnchors.set(sessionId, cardId)
48
+ try { localStorage.setItem('dsh-synapse:branch-anchors', JSON.stringify([...state.branchAnchors])) } catch { /* Private browsing may disable local storage. */ }
49
+ }
50
+
51
+ function persistCardPositions() {
52
+ try { localStorage.setItem(CARD_POSITIONS_KEY, JSON.stringify([...state.cardPositions])) } catch { /* Private browsing may disable local storage. */ }
53
+ }
54
+
55
+ function persistCollapsedCards() {
56
+ try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
57
+ }
58
+
59
+ function rememberCardPosition(cardId, position, aliases = []) {
60
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
61
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
62
+ persistCardPositions()
63
+ }
64
+
65
+ function resetCardPositions() {
66
+ state.cardPositions.clear()
67
+ persistCardPositions()
68
+ try {
69
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
70
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
71
+ } catch { /* Private browsing may disable local storage. */ }
72
+ }
73
+
74
+ function resetCanvasCamera() {
75
+ state.canvasViewInitialized = false
76
+ state.canvasCamera = { x: 0, y: 0 }
77
+ }
78
+
79
+ async function api(path, options = {}) {
80
+ const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) } })
81
+ const body = await response.json().catch(() => ({}))
82
+ if (!response.ok) throw new Error(body.error ?? '请求失败')
83
+ return body
84
+ }
85
+
86
+ function post(type, payload = {}) {
87
+ if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin)
88
+ }
89
+
90
+ function dshRpc(type, payload = {}) {
91
+ if (window.parent === window) return Promise.reject(new Error('请从 DSH 页面打开 Synapse 后再操作会话'))
92
+ const requestId = crypto.randomUUID()
93
+ post(type, { requestId, ...payload })
94
+ return new Promise((resolve, reject) => {
95
+ const timer = window.setTimeout(() => {
96
+ state.pendingRpc.delete(requestId)
97
+ reject(new Error('DSH 未在规定时间内响应'))
98
+ }, 20_000)
99
+ state.pendingRpc.set(requestId, { resolve, reject, timer })
100
+ })
101
+ }
102
+
103
+ function settleRpc(requestId, value, error) {
104
+ const pending = state.pendingRpc.get(requestId)
105
+ if (pending === undefined) return
106
+ state.pendingRpc.delete(requestId)
107
+ window.clearTimeout(pending.timer)
108
+ if (error === undefined) pending.resolve(value)
109
+ else pending.reject(error instanceof Error ? error : new Error(String(error)))
110
+ }
111
+
112
+ function setError(error = '') { state.error = error instanceof Error ? error.message : error; render() }
113
+
114
+ function messagesFromEvents(events) {
115
+ if (!Array.isArray(events)) return []
116
+ return events.flatMap(event => {
117
+ const content = event?.data?.message?.content ?? event?.data?.content
118
+ const text = Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).filter(Boolean).join('\n') : ''
119
+ if (event?.type === 'user/message' && text && !text.startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')) return [{ kind: 'user', text, at: event.time, sourceSeq: event.seq }]
120
+ if (event?.type === 'assistant/message' && text) return [{ kind: 'assistant', text, at: event.time, sourceSeq: event.seq }]
121
+ return []
122
+ })
123
+ }
124
+
125
+ async function loadThreadHistory() {}
126
+
127
+ function canReplaceView() {
128
+ return state.draft === null && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea')
129
+ }
130
+
131
+ function deferCanvasRefresh(delay = 700) {
132
+ state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay)
133
+ }
134
+
135
+ function currentDshWorkspace() {
136
+ const id = state.currentDsh?.id
137
+ return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined
138
+ }
139
+
140
+ function selectedDshWorkspace() {
141
+ return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId)
142
+ }
143
+
144
+ function currentDshThread(threads = state.workspace?.threads ?? []) {
145
+ const id = state.currentDsh?.id
146
+ return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined
147
+ }
148
+
149
+ function workspaceChoices() {
150
+ if (state.dshWorkspaces.length > 0) return state.dshWorkspaces.map(workspace => ({ ...workspace, source: 'dsh' }))
151
+ return state.summaries.map(workspace => ({ id: workspace.id, title: workspace.title, path: workspace.cwd, sessionIds: [], source: 'projection' }))
152
+ }
153
+
154
+ async function threadsForDshWorkspace(workspace) {
155
+ if (workspace.sessionIds.length === 0) return []
156
+ const requested = new Set(workspace.sessionIds)
157
+ const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`)))
158
+ return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
159
+ }
160
+
161
+ async function openDshWorkspace(id, { renderAfter = true } = {}) {
162
+ const workspace = state.dshWorkspaces.find(item => item.id === id)
163
+ if (workspace === undefined) return false
164
+ const load = ++state.workspaceLoad
165
+ state.selectedDshWorkspaceId = id
166
+ const threads = await threadsForDshWorkspace(workspace)
167
+ if (load !== state.workspaceLoad) return true
168
+ const nextWorkspaceId = `dsh:${workspace.id}`
169
+ if (state.workspace?.id !== nextWorkspaceId) resetCanvasCamera()
170
+ state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
171
+ const currentThread = currentDshThread(state.workspace.threads)
172
+ state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
173
+ if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id)
174
+ if (renderAfter && canReplaceView()) render()
175
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
176
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
177
+ return true
178
+ }
179
+
180
+ async function openCurrentWorkspace() {
181
+ const workspace = currentDshWorkspace()
182
+ if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
183
+ return openDshWorkspace(workspace.id)
184
+ }
185
+
186
+ async function refreshSummaries({ renderAfter = true } = {}) {
187
+ const before = JSON.stringify(state.summaries)
188
+ const body = await api('/synapse/api/workspaces')
189
+ state.summaries = body.workspaces
190
+ const changed = before !== JSON.stringify(state.summaries)
191
+ const current = state.workspace?.id
192
+ if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null
193
+ const selected = selectedDshWorkspace()
194
+ if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter })
195
+ else if (state.workspace === null && state.summaries.length > 0) await openWorkspace(state.summaries[0].id)
196
+ else if (renderAfter && changed && canReplaceView()) render()
197
+ return changed
198
+ }
199
+
200
+ async function openWorkspace(id, { renderAfter = true } = {}) {
201
+ const load = ++state.workspaceLoad
202
+ const body = await api(`/synapse/api/workspaces/${id}`)
203
+ if (load !== state.workspaceLoad) return
204
+ if (state.workspace?.id !== body.workspace.id) resetCanvasCamera()
205
+ state.workspace = body.workspace
206
+ state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null
207
+ if (renderAfter && canReplaceView()) render()
208
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
209
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
210
+ }
211
+
212
+ async function refreshProjection() {
213
+ const summariesChanged = await refreshSummaries({ renderAfter: false })
214
+ if (!summariesChanged || state.workspace === null || !canReplaceView()) return summariesChanged
215
+ if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId)
216
+ else await openWorkspace(state.workspace.id)
217
+ return true
218
+ }
219
+
220
+ function openNewSession() {
221
+ if (state.draft !== null) return
222
+ state.mode = 'canvas'
223
+ state.activeId = null
224
+ state.draft = { kind: 'new', text: '', sending: false }
225
+ state.error = ''
226
+ resetCanvasCamera()
227
+ render()
228
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
229
+ }
230
+
231
+ async function archiveThread(thread) {
232
+ if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
233
+ await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
234
+ state.historyBySession.delete(thread.dshSessionId)
235
+ if (state.workspace !== null) {
236
+ const removed = new Set([thread.id])
237
+ for (let changed = true; changed;) {
238
+ changed = false
239
+ for (const item of state.workspace.threads) {
240
+ if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) {
241
+ removed.add(item.id)
242
+ changed = true
243
+ }
244
+ }
245
+ }
246
+ state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id))
247
+ for (const key of [...state.cardPositions.keys()]) {
248
+ if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key)
249
+ }
250
+ let collapsedChanged = false
251
+ for (const key of [...state.collapsedCardIds]) {
252
+ if ([...removed].some(id => key.startsWith(`${id}:`))) {
253
+ state.collapsedCardIds.delete(key)
254
+ collapsedChanged = true
255
+ }
256
+ }
257
+ if (collapsedChanged) persistCollapsedCards()
258
+ state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId)
259
+ ? state.activeId
260
+ : state.workspace.threads[0]?.id ?? null
261
+ render()
262
+ } else {
263
+ state.activeId = null
264
+ }
265
+ await refreshSummaries()
266
+ }
267
+
268
+ function openContinue(parent, anchorId = undefined) {
269
+ if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
270
+ state.activeId = parent.id
271
+ state.draft = { kind: 'continue', parentId: parent.id, anchorId, text: '', sending: false }
272
+ render()
273
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
274
+ }
275
+
276
+ function openBranch(parent, atSeq = undefined, anchorId = undefined) {
277
+ if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
278
+ state.activeId = parent.id
279
+ state.draft = { kind: 'branch', parentId: parent.id, atSeq, anchorId, text: '', sending: false }
280
+ render()
281
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
282
+ }
283
+
284
+ async function sendMessage(thread, text) {
285
+ if (thread.dshSessionId === null) throw new Error('该节点没有关联的 DSH 会话')
286
+ if (state.pendingReplies.has(thread.dshSessionId)) throw new Error('该会话正在回复,请稍后再发送')
287
+ state.pendingReplies.set(thread.dshSessionId, { text, at: Date.now() })
288
+ state.error = ''
289
+ render()
290
+ try {
291
+ await dshRpc('synapse:send-message', { sessionId: thread.dshSessionId, text })
292
+ void loadThreadHistory(thread)
293
+ } catch (error) {
294
+ state.pendingReplies.delete(thread.dshSessionId)
295
+ render()
296
+ throw error
297
+ }
298
+ }
299
+
300
+ async function submitDraft() {
301
+ const draft = state.draft
302
+ const text = draft?.text.trim()
303
+ if (draft === null || !text) return
304
+ const branchPosition = draft.kind === 'branch' && state.workspace !== null ? draftPlacement(conversationCards(state.workspace.threads))?.position : undefined
305
+ draft.sending = true
306
+ state.error = ''
307
+ render()
308
+ try {
309
+ if (draft.kind === 'new') {
310
+ const session = await dshRpc('synapse:create-session', { workspaceId: state.selectedDshWorkspaceId, cwd: state.currentDsh?.cwd })
311
+ await dshRpc('synapse:send-message', { sessionId: session.id, text })
312
+ state.draft = null
313
+ render()
314
+ window.setTimeout(() => {
315
+ void refreshProjection().catch(() => {})
316
+ }, 150)
317
+ return
318
+ }
319
+ const parent = state.workspace?.threads.find(thread => thread.id === draft.parentId)
320
+ if (parent === undefined) throw new Error('来源会话不存在')
321
+ if (draft.kind === 'continue') {
322
+ state.draft = null
323
+ await sendMessage(parent, text)
324
+ return
325
+ }
326
+ const session = await dshRpc('synapse:fork-session', { sessionId: parent.dshSessionId, atSeq: draft.atSeq })
327
+ if (draft.anchorId !== undefined) rememberBranchAnchor(session.id, draft.anchorId)
328
+ const result = await api(`/synapse/api/threads/${parent.id}/branch`, { method: 'POST', body: JSON.stringify({ title: text.slice(0, 42), dshSessionId: session.id, dshSessionTitle: session.title, position: branchPosition }) })
329
+ if (state.workspace !== null && !state.workspace.threads.some(thread => thread.id === result.thread.id || thread.dshSessionId === result.thread.dshSessionId)) state.workspace.threads.push(result.thread)
330
+ state.activeId = result.thread.id
331
+ state.draft = null
332
+ state.pendingReplies.set(result.thread.dshSessionId, { text, at: Date.now() })
333
+ render()
334
+ await dshRpc('synapse:send-message', { sessionId: result.thread.dshSessionId, text })
335
+ void loadThreadHistory(result.thread)
336
+ await refreshProjection()
337
+ } catch (error) {
338
+ if (draft.kind === 'branch') {
339
+ state.pendingReplies.delete(state.workspace?.threads.find(thread => thread.id === state.activeId)?.dshSessionId)
340
+ if (state.draft !== null) state.draft = { ...draft, sending: false }
341
+ } else {
342
+ state.draft = { ...draft, sending: false }
343
+ }
344
+ setError(error)
345
+ }
346
+ }
347
+
348
+ function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
349
+ function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
350
+
351
+ function pendingUserIndex(messages, pending) {
352
+ return messages.findLastIndex(message => message.kind === 'user' && message.text === pending.text && new Date(message.at).getTime() >= pending.at - 2_000)
353
+ }
354
+
355
+ function settlePendingReply(thread, messages) {
356
+ const pending = state.pendingReplies.get(thread.dshSessionId)
357
+ if (pending === undefined) return false
358
+ const userIndex = pendingUserIndex(messages, pending)
359
+ if (userIndex === -1 || !messages.slice(userIndex + 1).some(message => message.kind === 'assistant')) return false
360
+ state.pendingReplies.delete(thread.dshSessionId)
361
+ return true
362
+ }
363
+
364
+ function messagesFor(thread) {
365
+ // A runtime-context snapshot is internal DSH state, never a user turn.
366
+ // Filter here as well as during persistence so existing saved workspaces
367
+ // immediately render one question and its answer as one card.
368
+ const messages = persistedMessagesFor(thread).filter(message => !(message.kind === 'user' && typeof message.text === 'string' && message.text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')))
369
+ const pending = state.pendingReplies.get(thread.dshSessionId)
370
+ if (pending === undefined) return messages
371
+ if (settlePendingReply(thread, messages)) {
372
+ state.liveReplies.delete(thread.dshSessionId)
373
+ return messages
374
+ }
375
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
376
+ const liveAssistant = liveReply?.running ? { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() } : { kind: 'assistant', text: '', pending: true, at: new Date().toISOString() }
377
+ const userIndex = pendingUserIndex(messages, pending)
378
+ if (userIndex !== -1) return [...messages, liveAssistant]
379
+ return [...messages, { kind: 'user', text: pending.text, pending: true, at: new Date(pending.at).toISOString() }, liveAssistant]
380
+ }
381
+
382
+ function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) }
383
+ function questionFor(thread) { return latestMessage(thread, 'user')?.text ?? thread.dshSessionTitle ?? '等待用户提问' }
384
+ function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null }
385
+
386
+ function inlineMarkdown(text) {
387
+ return escapeHtml(text)
388
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
389
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
390
+ .replace(/~~([^~]+)~~/g, '<s>$1</s>')
391
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
392
+ }
393
+
394
+ const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
395
+
396
+ const isTableDelimiter = line => {
397
+ const cells = tableCells(line)
398
+ return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell))
399
+ }
400
+
401
+ function markdownBlock(text) {
402
+ const lines = text.split('\n')
403
+ const output = []
404
+ for (let index = 0; index < lines.length;) {
405
+ const line = lines[index]
406
+ if (line.trim() === '') { index++; continue }
407
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line)
408
+ if (heading !== null) {
409
+ const level = heading[1].length
410
+ output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`)
411
+ index++
412
+ continue
413
+ }
414
+ const unordered = /^[-*+]\s+(.+)$/.exec(line)
415
+ const ordered = /^\d+[.)]\s+(.+)$/.exec(line)
416
+ if (unordered !== null || ordered !== null) {
417
+ const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/
418
+ const items = []
419
+ while (index < lines.length) {
420
+ const item = matcher.exec(lines[index])
421
+ if (item === null) break
422
+ items.push(`<li>${inlineMarkdown(item[1])}</li>`)
423
+ index++
424
+ }
425
+ output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}</${unordered === null ? 'ol' : 'ul'}>`)
426
+ continue
427
+ }
428
+ // GFM table: a leading-pipe header row followed by a |-delimiter row,
429
+ // then any number of leading-pipe body rows.
430
+ if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) {
431
+ const header = line
432
+ const body = []
433
+ index += 2
434
+ while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
435
+ body.push(lines[index])
436
+ index++
437
+ }
438
+ output.push(`<table><thead><tr>${tableCells(header).map(cell => `<th>${inlineMarkdown(cell)}</th>`).join('')}</tr></thead><tbody>${body.map(row => `<tr>${tableCells(row).map(cell => `<td>${inlineMarkdown(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`)
439
+ continue
440
+ }
441
+ const paragraph = []
442
+ while (index < lines.length && lines[index].trim() !== '' && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index]) && !/^\d+[.)]\s+/.test(lines[index])) paragraph.push(lines[index++])
443
+ // A marker-only line such as PowerShell's "+ " diagnostic is neither a
444
+ // list item nor paragraph content under the rules above. Consume it so
445
+ // the parser always makes progress.
446
+ if (paragraph.length === 0) paragraph.push(lines[index++])
447
+ output.push(`<p>${paragraph.map(inlineMarkdown).join('<br>')}</p>`)
448
+ }
449
+ return output.join('')
450
+ }
451
+
452
+ // Markdown parsing is pure CPU and repeats for every card on every canvas
453
+ // rebuild; cache the rendered HTML by input text so stable answers are never
454
+ // re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
455
+ const markdownCache = new Map()
456
+ const MARKDOWN_CACHE_LIMIT = 500
457
+ function renderMarkdown(text) {
458
+ const key = String(text)
459
+ const cached = markdownCache.get(key)
460
+ if (cached !== undefined) return cached
461
+ const parts = key.split(/```/)
462
+ const rendered = parts.map((part, index) => index % 2 === 1
463
+ ? `<pre><code>${escapeHtml(part.replace(/^\w*\n/, ''))}</code></pre>`
464
+ : markdownBlock(part)).join('')
465
+ if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value)
466
+ markdownCache.set(key, rendered)
467
+ return rendered
468
+ }
469
+
470
+ function overlapsCard(position, other) {
471
+ return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x
472
+ && position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y
473
+ }
474
+
475
+ function firstAvailableCardPosition(position, occupied) {
476
+ const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) }
477
+ while (true) {
478
+ const collisions = occupied.filter(other => overlapsCard(candidate, other))
479
+ if (collisions.length === 0) return candidate
480
+ candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y))
481
+ }
482
+ }
483
+
484
+ function connectorPath(fromPosition, toPosition) {
485
+ const fromX = fromPosition.x + CARD_WIDTH
486
+ const fromY = fromPosition.y + CARD_HEIGHT / 2
487
+ const toX = toPosition.x
488
+ const toY = toPosition.y + CARD_HEIGHT / 2
489
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
490
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
491
+ }
492
+
493
+ function connectorPathFromElements(fromCard, toCard) {
494
+ const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH
495
+ const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2
496
+ const toX = Number.parseFloat(toCard.style.left)
497
+ const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2
498
+ if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null
499
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
500
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
501
+ }
502
+
503
+ function selectorValue(value) {
504
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
505
+ }
506
+
507
+ function refreshCardConnectors(cardId) {
508
+ const viewport = document.querySelector('.canvas-viewport')
509
+ if (!(viewport instanceof HTMLElement)) return
510
+ const id = selectorValue(cardId)
511
+ for (const path of viewport.querySelectorAll(`.connectors path[data-from="${id}"], .connectors path[data-to="${id}"]`)) {
512
+ const fromId = path.getAttribute('data-from')
513
+ const toId = path.getAttribute('data-to')
514
+ if (fromId === null || toId === null) continue
515
+ const fromCard = viewport.querySelector(`[data-card-id="${selectorValue(fromId)}"]`)
516
+ const toCard = viewport.querySelector(`[data-card-id="${selectorValue(toId)}"]`)
517
+ if (!(fromCard instanceof HTMLElement) || !(toCard instanceof HTMLElement)) continue
518
+ const nextPath = connectorPathFromElements(fromCard, toCard)
519
+ if (nextPath !== null) path.setAttribute('d', nextPath)
520
+ }
521
+ }
522
+
523
+ function initialCanvasCamera(cards) {
524
+ const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
525
+ const active = state.activeId === null ? undefined : cards.find(card => card.dshThreadId === state.activeId)
526
+ const focus = draft ?? active ?? cards[0]
527
+ const position = focus?.position
528
+ if (position === undefined) return { x: 0, y: 0 }
529
+ return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom }
530
+ }
531
+
532
+ function placeConversationCards(cards) {
533
+ const saved = new Map(cards.flatMap(card => {
534
+ if (card.positionLocked !== true) return []
535
+ const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey)
536
+ return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]]
537
+ }))
538
+ const occupied = []
539
+ for (const card of cards) {
540
+ const position = saved.get(card.id)
541
+ if (position !== undefined) {
542
+ card.position = position
543
+ continue
544
+ }
545
+ card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied)
546
+ occupied.push(card.position)
547
+ }
548
+ return cards
549
+ }
550
+
551
+ function layoutConversationGraph(cards, threads) {
552
+ const childrenByThread = new Map()
553
+ for (const thread of threads) {
554
+ if (thread.parentId === null) continue
555
+ const children = childrenByThread.get(thread.parentId) ?? []
556
+ children.push(thread.id)
557
+ childrenByThread.set(thread.parentId, children)
558
+ }
559
+ const laneByThread = new Map()
560
+ const visitThread = threadId => {
561
+ if (laneByThread.has(threadId)) return
562
+ laneByThread.set(threadId, laneByThread.size)
563
+ for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId)
564
+ }
565
+ for (const thread of threads) if (thread.parentId === null) visitThread(thread.id)
566
+ for (const thread of threads) visitThread(thread.id)
567
+
568
+ const byId = new Map(cards.map(card => [card.id, card]))
569
+ const positioned = new Map()
570
+ const positionFor = (card, visiting = new Set()) => {
571
+ if (positioned.has(card.id)) return positioned.get(card.id)
572
+ if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) }
573
+ visiting.add(card.id)
574
+ const parent = card.parentId === null ? undefined : byId.get(card.parentId)
575
+ const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting)
576
+ const position = {
577
+ x: parentPosition === undefined ? 86 : parentPosition.x + 365,
578
+ y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y),
579
+ }
580
+ visiting.delete(card.id)
581
+ positioned.set(card.id, position)
582
+ return position
583
+ }
584
+ for (const card of cards) {
585
+ card.naturalPosition = positionFor(card)
586
+ if (!card.positionLocked) card.position = card.naturalPosition
587
+ }
588
+ return placeConversationCards(cards)
589
+ }
590
+
591
+ function conversationCards(threads) {
592
+ const cards = []
593
+ const cardsByThread = new Map()
594
+ for (const thread of threads) {
595
+ const messages = messagesFor(thread)
596
+ const turns = []
597
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
598
+ const question = messages[messageIndex]
599
+ if (question.kind !== 'user') continue
600
+ const replies = []
601
+ for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) {
602
+ const reply = messages[replyIndex]
603
+ if (reply.kind === 'user') break
604
+ if (reply.kind === 'assistant') replies.push(reply)
605
+ }
606
+ const answer = replies.at(-1) ?? null
607
+ const turnIndex = turns.length
608
+ const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
609
+ const previous = turns.at(-1)
610
+ const positionKey = `${thread.id}:turn-index:${turnIndex}`
611
+ const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y }
612
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
613
+ const positionLocked = savedPosition !== undefined
614
+ const position = positionLocked ? savedPosition : naturalPosition
615
+ turns.push({
616
+ id,
617
+ positionKey,
618
+ dshThreadId: thread.id,
619
+ sourceParentId: thread.parentId,
620
+ parentId: null,
621
+ sourceSeq: question.sourceSeq,
622
+ turnIndex,
623
+ naturalPosition,
624
+ position,
625
+ positionLocked,
626
+ question: question.text,
627
+ answer,
628
+ })
629
+ }
630
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
631
+ const latestTurn = turns.at(-1)
632
+ if (liveReply?.running && latestTurn !== undefined && (latestTurn.answer === null || latestTurn.answer.pending === true)) latestTurn.answer = { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() }
633
+ if (turns.length === 0) {
634
+ const id = `${thread.id}:turn:empty`
635
+ const positionKey = `${thread.id}:turn-index:0`
636
+ const naturalPosition = { x: 86, y: 82 }
637
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
638
+ const positionLocked = savedPosition !== undefined
639
+ turns.push({
640
+ id,
641
+ positionKey,
642
+ dshThreadId: thread.id,
643
+ sourceParentId: thread.parentId,
644
+ parentId: null,
645
+ sourceSeq: undefined,
646
+ turnIndex: 0,
647
+ naturalPosition,
648
+ position: positionLocked ? savedPosition : naturalPosition,
649
+ positionLocked,
650
+ question: thread.dshSessionTitle ?? thread.title,
651
+ answer: null,
652
+ })
653
+ }
654
+ turns.at(-1).canContinue = true
655
+ cardsByThread.set(thread.id, turns)
656
+ cards.push(...turns)
657
+ }
658
+ for (const card of cards) {
659
+ const siblings = cardsByThread.get(card.dshThreadId)
660
+ if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id
661
+ else {
662
+ const parentCards = cardsByThread.get(card.sourceParentId)
663
+ const sourceThread = threads.find(thread => thread.id === card.dshThreadId)
664
+ const firstChildQuestion = siblings?.[0]
665
+ const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq
666
+ // A fork inherits every parent event before DSH's durable seed boundary.
667
+ // The latest parent question below that boundary is the exact Turn where
668
+ // this child was born. Canvas coordinates never participate in lineage.
669
+ const inheritedTurn = Number.isSafeInteger(seedLength)
670
+ ? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1)
671
+ : undefined
672
+ card.parentId = state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null
673
+ }
674
+ }
675
+ return layoutConversationGraph(cards, threads)
676
+ }
677
+
678
+ function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) {
679
+ const cardIds = new Set(cards.map(card => card.id))
680
+ const childrenByParent = new Map()
681
+ for (const card of cards) {
682
+ if (card.parentId === null || !cardIds.has(card.parentId)) continue
683
+ const children = childrenByParent.get(card.parentId) ?? []
684
+ children.push(card.id)
685
+ childrenByParent.set(card.parentId, children)
686
+ }
687
+
688
+ const hiddenIds = new Set()
689
+ for (const rootId of collapsedCardIds) {
690
+ if (!cardIds.has(rootId)) continue
691
+ const visited = new Set([rootId])
692
+ const visit = parentId => {
693
+ for (const childId of childrenByParent.get(parentId) ?? []) {
694
+ if (visited.has(childId)) continue
695
+ visited.add(childId)
696
+ hiddenIds.add(childId)
697
+ visit(childId)
698
+ }
699
+ }
700
+ visit(rootId)
701
+ }
702
+
703
+ // Persisted collapse roots must remain visible even if malformed metadata
704
+ // contains a cycle where two collapsed nodes otherwise hide each other.
705
+ for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
706
+
707
+ const descendantCounts = new Map()
708
+ for (const card of cards) {
709
+ const visited = new Set([card.id])
710
+ const pending = [...(childrenByParent.get(card.id) ?? [])]
711
+ while (pending.length > 0) {
712
+ const descendantId = pending.pop()
713
+ if (visited.has(descendantId)) continue
714
+ visited.add(descendantId)
715
+ pending.push(...(childrenByParent.get(descendantId) ?? []))
716
+ }
717
+ descendantCounts.set(card.id, visited.size - 1)
718
+ }
719
+
720
+ return {
721
+ cards: cards.filter(card => !hiddenIds.has(card.id)),
722
+ childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])),
723
+ descendantCounts,
724
+ }
725
+ }
726
+
727
+ function revealConversationThread(cards, threadId) {
728
+ const byId = new Map(cards.map(card => [card.id, card]))
729
+ let changed = false
730
+ for (const target of cards.filter(card => card.dshThreadId === threadId)) {
731
+ const visited = new Set([target.id])
732
+ let parentId = target.parentId
733
+ while (parentId !== null && !visited.has(parentId)) {
734
+ visited.add(parentId)
735
+ if (state.collapsedCardIds.delete(parentId)) changed = true
736
+ parentId = byId.get(parentId)?.parentId ?? null
737
+ }
738
+ }
739
+ if (changed) persistCollapsedCards()
740
+ }
741
+
742
+ function canvasConnectors(cards) {
743
+ const index = new Map(cards.map(card => [card.id, card]))
744
+ const links = cards.map(card => {
745
+ const parent = card.parentId === null ? null : index.get(card.parentId)
746
+ if (parent === undefined || parent === null) return ''
747
+ return `<path data-from="${escapeHtml(parent.id)}" data-to="${escapeHtml(card.id)}" d="${connectorPath(parent.position, card.position)}"></path>`
748
+ })
749
+ const placement = draftPlacement(cards)
750
+ if (placement !== null) {
751
+ links.push(`<path class="draft-connector" data-from="${escapeHtml(placement.parent.id)}" data-to="draft" d="${connectorPath(placement.parent.position, placement.position)}"></path>`)
752
+ }
753
+ return links.join('')
754
+ }
755
+
756
+ function conversationCard(card, graph) {
757
+ const active = card.dshThreadId === state.activeId ? 'active' : ''
758
+ const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
759
+ const continueButton = card.canContinue === true
760
+ ? `<button class="graph-continue-button" data-action="open-continue" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" aria-label="添加追问" title="添加追问"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M8 3.5v9M3.5 8h9"/></svg></button>`
761
+ : ''
762
+ const childCount = graph.childCounts.get(card.id) ?? 0
763
+ const collapsed = state.collapsedCardIds.has(card.id)
764
+ const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
765
+ const foldButton = childCount === 0 || card.canContinue === true ? '' : `<button class="graph-fold-button${collapsed ? ' collapsed' : ''}" data-action="toggle-card-children" data-card="${escapeHtml(card.id)}" aria-expanded="${collapsed ? 'false' : 'true'}" aria-label="${foldLabel}" title="${foldLabel}"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M3.5 8h9"/>${collapsed ? '<path d="M8 3.5v9"/>' : ''}</svg></button>`
766
+ const branchButton = childCount === 0 || card.canContinue === true || !Number.isInteger(card.answer?.sourceSeq) ? '' : `<button class="graph-branch-button" data-action="open-branch" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" data-seq="${card.answer.sourceSeq}" aria-label="在新对话中分支" title="在新对话中分支"><svg aria-hidden="true" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.0762 1.37207C14.0846 1.37228 14.9021 2.19077 14.9023 3.19922C14.9022 4.20772 14.0847 5.02518 13.0762 5.02539C12.2967 5.02539 11.6325 4.53691 11.3701 3.84961H4.35547C4.79397 4.26458 5.15861 4.7644 5.41699 5.33496L7.10645 9.06738C7.88526 10.7875 9.55104 11.9228 11.4189 12.0371C11.7085 11.4109 12.3411 10.9756 13.0762 10.9756C14.0843 10.9759 14.9023 11.7936 14.9023 12.8018C14.9023 13.81 14.0843 14.6277 13.0762 14.6279C12.2534 14.6279 11.5574 14.0832 11.3291 13.335C8.9868 13.1879 6.89981 11.7612 5.92285 9.60352L4.23242 5.87109C3.67503 4.64033 2.44878 3.84961 1.09766 3.84961V2.54883C1.10665 2.54883 1.11601 2.54975 1.125 2.5498L11.3701 2.54883C11.6326 1.86151 12.2969 1.37207 13.0762 1.37207ZM13.0762 12.2764C12.7858 12.2764 12.5508 12.5114 12.5508 12.8018C12.5508 13.0921 12.7858 13.3281 13.0762 13.3281C13.3664 13.3279 13.6025 13.092 13.6025 12.8018C13.6025 12.5115 13.3664 12.2766 13.0762 12.2764ZM13.0762 2.67285C12.7855 2.67285 12.55 2.90861 12.5498 3.19922C12.5499 3.48987 12.7855 3.72559 13.0762 3.72559C13.3667 3.72538 13.6024 3.48975 13.6025 3.19922C13.6023 2.90874 13.3666 2.67306 13.0762 2.67285Z" fill="currentColor"/></svg></button>`
767
+ return `<article class="thread-card ${active}" data-card-id="${escapeHtml(card.id)}" data-position-key="${escapeHtml(card.positionKey)}" data-thread="${card.dshThreadId}" style="left:${card.position.x}px;top:${card.position.y}px;--thread-color:#3478f6">
768
+ <button class="node-handle" data-drag-card="${card.id}" aria-label="拖动 ${escapeHtml(card.question)}" title="拖动卡片"></button>
769
+ ${continueButton}${foldButton}${branchButton}
770
+ <div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
771
+ <div class="thread-meta"><span>${source}</span><span>第 ${card.turnIndex + 1} 轮</span></div>
772
+ <div class="thread-answer">${card.answer === null ? '<p class="thread-answer-empty">等待助手回复</p>' : card.answer.pending && card.answer.text === '' ? '<p class="thread-answer-pending">正在回复</p>' : `${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="thread-answer-pending">正在回复</p>' : ''}`}</div>
773
+ <footer><button data-action="show-thread" data-thread="${card.dshThreadId}">详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}">打开 DSH</button><button data-action="archive-thread" data-thread="${card.dshThreadId}">归档</button></footer>
774
+ </article>`
775
+ }
776
+
777
+ function draftActions(draft) {
778
+ const disabled = draft.sending ? 'disabled' : ''
779
+ return `<div class="draft-actions"><button type="button" data-action="cancel-draft" ${disabled} aria-label="取消" title="取消"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button><button class="primary" type="submit" ${disabled} aria-label="发送" title="发送"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12.5v-9M4.5 7 8 3.5 11.5 7"/></svg></button></div>`
780
+ }
781
+
782
+ function draftPlacement(cards) {
783
+ const draft = state.draft
784
+ if (draft === null || draft.kind === 'new') return null
785
+ const parent = draft.anchorId === undefined
786
+ ? cards.filter(card => card.dshThreadId === draft.parentId).at(-1)
787
+ : cards.find(card => card.id === draft.anchorId)
788
+ if (parent === undefined) return null
789
+ return { parent, position: firstAvailableCardPosition({ x: parent.position.x + 365, y: parent.position.y }, cards.map(card => card.position)) }
790
+ }
791
+
792
+ function draftCard(cards) {
793
+ const draft = state.draft
794
+ if (draft?.kind === 'new') return `<article class="thread-card draft-card first-session-card" data-card-id="draft" style="left:86px;top:82px;--thread-color:#3478f6">
795
+ <div class="thread-card-head"><span class="topic-dot"></span><strong>新会话</strong></div>
796
+ <form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="输入第一条消息" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
797
+ </article>`
798
+ const placement = draftPlacement(cards)
799
+ if (draft === null || placement === null) return ''
800
+ const continuing = draft.kind === 'continue'
801
+ return `<article class="thread-card draft-card" data-card-id="draft" style="left:${placement.position.x}px;top:${placement.position.y}px;--thread-color:#3478f6">
802
+ <div class="thread-card-head"><span class="topic-dot"></span><strong>${continuing ? '新的追问' : '新的分支'}</strong></div>
803
+ <form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="${continuing ? '输入追问' : '输入这个分支的新问题'}" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
804
+ </article>`
805
+ }
806
+
807
+ function renderCanvas() {
808
+ const threads = state.workspace?.threads ?? []
809
+ if (threads.length === 0 && state.draft?.kind !== 'new') return `<section class="empty-canvas"><strong>当前工作目录还没有 DSH 对话。</strong><p>点击新会话,在画布中输入第一条消息。</p><div><button class="primary" type="button" data-action="create-session">新建会话</button></div></section>`
810
+ const allCards = conversationCards(threads)
811
+ const graph = conversationGraphView(allCards)
812
+ const cards = graph.cards
813
+ if (!state.canvasViewInitialized) {
814
+ state.canvasCamera = initialCanvasCamera(cards)
815
+ state.canvasViewInitialized = true
816
+ }
817
+ return `<section class="canvas-view"><div class="canvas-viewport"><div class="canvas-content" style="transform:translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})"><svg class="connectors">${canvasConnectors(cards)}</svg><div class="cards-layer">${cards.map(card => conversationCard(card, graph)).join('')}${draftCard(cards)}</div></div></div></section>`
818
+ }
819
+
820
+ function isProcessMessage(message) {
821
+ if (message.kind === 'tool' || message.kind === 'tool-result') return true
822
+ return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text)
823
+ }
824
+
825
+ function processSummary(text) {
826
+ return text.replace(/\s+/g, ' ').trim().slice(0, 140) || '工具调用记录'
827
+ }
828
+
829
+ function threadMessage(thread, message) {
830
+ const isUser = message.kind === 'user'
831
+ const label = isUser ? '你' : message.kind === 'assistant' ? 'DSH' : message.kind === 'error' ? '错误' : '记录'
832
+ const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq)
833
+ ? `<button class="message-branch" data-action="open-branch" data-thread="${thread.id}" data-seq="${message.sourceSeq}" title="从此回答创建分支"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M4.5 3v6a2.5 2.5 0 0 0 2.5 2.5H12"/><circle cx="4.5" cy="3" r="1.5"/><circle cx="11.5" cy="12" r="1.5"/></svg>分支</button>`
834
+ : ''
835
+ const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}`
836
+ const collapsible = isProcessMessage(message)
837
+ const expanded = state.expandedMessageIds.has(messageId)
838
+ const fold = collapsible ? `<button class="message-fold" data-action="toggle-message" data-message="${escapeHtml(messageId)}" aria-label="${expanded ? '收起过程记录' : '展开过程记录'}" title="${expanded ? '收起' : '展开'}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5"/></svg></button>` : ''
839
+ const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null
840
+ const body = message.pending && message.text === '' ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>'
841
+ : `${collapsible && !expanded ? `<p class="message-summary">${escapeHtml(processSummary(message.text))}</p>` : renderMarkdown(message.text)}${message.pending ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>' : ''}${process === null ? '' : processRecords(process, messageId)}`
842
+ const avatar = isUser ? '' : '<span class="message-avatar" aria-hidden="true"></span>'
843
+ return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
844
+ }
845
+
846
+ function processRecords(process, messageId) {
847
+ const key = `${messageId}:process`
848
+ const expanded = state.expandedMessageIds.has(key)
849
+ const entries = process.map((entry, index) => {
850
+ const entryKey = `${key}:${index}`
851
+ const entryExpanded = state.expandedMessageIds.has(entryKey)
852
+ const status = entry.error !== null ? '失败' : entry.result === null ? '等待结果' : '完成'
853
+ const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `<pre class="process-args">${escapeHtml(entry.arguments)}</pre>`
854
+ const outcomeHtml = entry.error !== null ? `<pre class="process-error">${escapeHtml(entry.error)}</pre>` : entry.result === null ? '' : `<pre class="process-result">${escapeHtml(entry.result)}</pre>`
855
+ return `<div class="process-entry${entryExpanded ? ' expanded' : ''}"><button class="process-entry-fold" data-action="toggle-message" data-message="${escapeHtml(entryKey)}"><span class="process-entry-name">${escapeHtml(entry.name)}</span><span class="process-status${entry.error !== null ? ' process-status-error' : entry.result === null ? ' process-status-pending' : ' process-status-done'}">${status}</span></button>${entryExpanded ? `<div class="process-entry-body">${argumentsHtml}${outcomeHtml}</div>` : ''}</div>`
856
+ }).join('')
857
+ return `<section class="process-records${expanded ? ' expanded' : ''}"><button class="process-records-fold" data-action="toggle-message" data-message="${escapeHtml(key)}"><span>${expanded ? '收起过程记录' : '过程记录'}</span><span class="process-count">${process.length}</span></button>${expanded ? entries : ''}</section>`
858
+ }
859
+
860
+ function renderThread() {
861
+ const thread = currentThread()
862
+ if (thread === null) return renderCanvas()
863
+ const messages = messagesFor(thread)
864
+ const waiting = state.pendingReplies.has(thread.dshSessionId)
865
+ return `<section class="detail-view"><header class="detail-head"><div class="detail-head-title"><div class="detail-head-meta"><span class="detail-badge">${thread.parentId === null ? '会话' : '分支'}</span>${thread.dshSessionTitle ?? thread.title ? `<span class="detail-subtitle">${escapeHtml(thread.dshSessionTitle ?? thread.title)}</span>` : ''}</div><h1>${escapeHtml(questionFor(thread))}</h1></div><div class="detail-head-actions"><button data-action="open-dsh" data-thread="${thread.id}" title="在原生对话中打开此会话">在 DSH 中打开</button><button data-action="open-branch" data-thread="${thread.id}" title="基于最新回答创建分支">创建分支</button><button class="primary" data-action="show-canvas">返回画布</button></div></header><div class="detail-scroll">${messages.map(message => threadMessage(thread, message)).join('') || '<div class="note-empty">等待这条会话的第一条消息。</div>'}</div><form class="message-composer" data-compose="${thread.id}"><textarea maxlength="4000" placeholder="继续当前会话…" ${waiting ? 'disabled' : ''}></textarea><button class="primary" type="submit" ${waiting ? 'disabled' : ''}>${waiting ? '等待回复' : '发送'}</button></form></section>`
866
+ }
867
+
868
+ function render() {
869
+ const detail = state.mode === 'thread' ? document.querySelector('.detail-scroll') : null
870
+ const detailScrollTop = detail instanceof HTMLElement ? detail.scrollTop : null
871
+ const cardScrollTops = new Map()
872
+ if (state.mode === 'canvas') {
873
+ // Key by the unique card id: every card of a session shares data-thread,
874
+ // so keying on it would clobber sibling cards' scroll positions.
875
+ for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
876
+ const card = answer.closest('.thread-card')
877
+ if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
878
+ }
879
+ }
880
+ const workspace = state.workspace
881
+ const threads = workspace?.threads ?? []
882
+ const view = state.mode === 'thread' ? renderThread() : renderCanvas()
883
+ const choices = workspaceChoices()
884
+ const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
885
+ const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout">整理节点</button><button data-action="focus-active" title="定位到当前会话">定位</button><button data-action="zoom-out" aria-label="缩小">-</button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大">+</button></div>` : ''
886
+ const detailAvailable = currentThread() !== null
887
+ const canvasTabs = `<nav class="canvas-tabs" aria-label="会话地图视图"><button class="${state.mode === 'canvas' ? 'active' : ''}" data-action="show-canvas">地图</button><button class="${state.mode === 'thread' ? 'active' : ''}" data-action="show-thread" data-thread="${state.activeId ?? ''}" ${detailAvailable ? '' : 'disabled'}>详情</button></nav>`
888
+ app.innerHTML = `<main class="synapse-shell ${state.sidebarCollapsed ? 'sidebar-collapsed' : ''}"><aside class="sidebar"><div class="sidebar-brand-row"><div class="brand" aria-label="Synapse"><svg class="brand-mark" aria-hidden="true" viewBox="0 0 32 32" fill="none"><path d="M9 10.5 16 7l7 3.5M9 10.5v8L16 22m0-15v15m7-11.5v8L16 22"/><circle cx="9" cy="10" r="2.5"/><circle cx="23" cy="10" r="2.5"/><circle cx="16" cy="23" r="2.5"/></svg><strong>Synapse</strong></div><button class="sidebar-toggle" type="button" data-action="toggle-sidebar" aria-label="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}" title="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25"/><path d="M6 2v12"/></svg></button></div><button class="new-workspace" type="button" data-action="create-session" ${state.draft !== null ? 'disabled' : ''}><svg class="new-session-icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 4.75v6.5M4.75 8h6.5"/></svg><span>新会话</span></button><label class="workspace-label"><span>工作区</span><span class="workspace-select"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 4.75h3l1.2 1.5h6.8v5.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z"/></svg><select data-action="select-workspace" aria-label="选择工作区" ${state.draft !== null ? 'disabled' : ''}>${choices.map(item => `<option value="${item.id}" title="${escapeHtml(item.path ?? item.title)}" ${item.id === selectedWorkspaceId ? 'selected' : ''}>${escapeHtml(item.title)}</option>`).join('')}</select></span></label><div class="sidebar-heading"><span>会话</span></div><nav class="thread-tree">${threads.map(thread => `<button class="tree-row ${thread.id === state.activeId ? 'active' : ''}" data-action="select-thread" data-thread="${thread.id}" style="--thread-color:#374151"><span class="tree-dot"></span><span>${escapeHtml(threadListTitle(thread))}</span>${thread.parentId === null ? '' : '<i>分支</i>'}</button>`).join('') || '<p class="tree-empty">暂未同步会话</p>'}</nav></aside><header class="topbar"><div class="view-switch" role="group" aria-label="视图切换"><button data-action="close" type="button" aria-pressed="false">对话</button><button class="active" type="button" aria-pressed="true">会话地图</button></div>${canvasControls}</header><section class="main-stage">${state.error ? `<div class="status-message" role="alert"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error" aria-label="关闭" title="关闭">×</button></div>` : ''}${canvasTabs}${view}</section></main>`
889
+ installDragging()
890
+ for (const [cardId, scrollTop] of cardScrollTops) {
891
+ const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
892
+ if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
893
+ }
894
+ if (detailScrollTop !== null) window.requestAnimationFrame(() => {
895
+ const nextDetail = document.querySelector('.detail-scroll')
896
+ if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
897
+ })
898
+ }
899
+
900
+ function renderPreservingDetailScroll() {
901
+ render()
902
+ }
903
+
904
+ function applyCanvasTransform() {
905
+ const content = document.querySelector('.canvas-content')
906
+ if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
907
+ }
908
+
909
+ function installDragging() {
910
+ for (const handle of document.querySelectorAll('[data-drag-card]')) handle.addEventListener('pointerdown', event => {
911
+ const cardId = event.currentTarget.dataset.dragCard
912
+ const card = event.currentTarget.closest('.thread-card')
913
+ if (cardId === undefined || !(card instanceof HTMLElement)) return
914
+ event.preventDefault()
915
+ const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } }
916
+ const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
917
+ let position = origin.position
918
+ let stopped = false
919
+ state.dragging = true
920
+ const move = moveEvent => {
921
+ position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
922
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
923
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
924
+ card.style.left = `${position.x}px`
925
+ card.style.top = `${position.y}px`
926
+ refreshCardConnectors(cardId)
927
+ }
928
+ const stop = () => {
929
+ if (stopped) return
930
+ stopped = true
931
+ document.removeEventListener('pointermove', move)
932
+ document.removeEventListener('pointerup', stop)
933
+ document.removeEventListener('pointercancel', stop)
934
+ rememberCardPosition(cardId, position, aliases)
935
+ state.dragging = false
936
+ deferCanvasRefresh(120)
937
+ render()
938
+ }
939
+ document.addEventListener('pointermove', move)
940
+ document.addEventListener('pointerup', stop)
941
+ document.addEventListener('pointercancel', stop)
942
+ })
943
+ }
944
+
945
+ function canvasViewport(target) {
946
+ return target instanceof Element ? target.closest('.canvas-viewport') : null
947
+ }
948
+
949
+ function zoomCanvas(viewport, nextZoom, clientX, clientY) {
950
+ const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
951
+ if (zoom === state.zoom) return
952
+ const bounds = viewport.getBoundingClientRect()
953
+ const localX = clientX - bounds.left
954
+ const localY = clientY - bounds.top
955
+ const worldX = (localX - state.canvasCamera.x) / state.zoom
956
+ const worldY = (localY - state.canvasCamera.y) / state.zoom
957
+ state.zoom = zoom
958
+ state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
959
+ applyCanvasTransform()
960
+ const label = document.querySelector('.canvas-controls span')
961
+ if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
962
+ }
963
+
964
+ function zoomCanvasAtCenter(delta) {
965
+ const viewport = document.querySelector('.canvas-viewport')
966
+ if (!(viewport instanceof HTMLElement)) return
967
+ const bounds = viewport.getBoundingClientRect()
968
+ zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
969
+ }
970
+
971
+ function focusActiveCard() {
972
+ const card = document.querySelector('.thread-card.active') ?? document.querySelector('.thread-card[data-thread]:not(.draft-card)')
973
+ const viewport = document.querySelector('.canvas-viewport')
974
+ if (!(card instanceof HTMLElement) || !(viewport instanceof HTMLElement)) return
975
+ const left = Number.parseFloat(card.style.left)
976
+ const top = Number.parseFloat(card.style.top)
977
+ if (!Number.isFinite(left) || !Number.isFinite(top)) return
978
+ const bounds = viewport.getBoundingClientRect()
979
+ state.canvasCamera = {
980
+ x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
981
+ y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
982
+ }
983
+ applyCanvasTransform()
984
+ }
985
+
986
+ app.addEventListener('pointerdown', event => {
987
+ const viewport = canvasViewport(event.target)
988
+ if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
989
+ event.preventDefault()
990
+ const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
991
+ state.canvasGesture = true
992
+ viewport.classList.add('is-panning')
993
+ viewport.setPointerCapture(event.pointerId)
994
+ const move = moveEvent => {
995
+ state.canvasCamera = {
996
+ x: origin.camera.x + moveEvent.clientX - origin.x,
997
+ y: origin.camera.y + moveEvent.clientY - origin.y,
998
+ }
999
+ applyCanvasTransform()
1000
+ }
1001
+ const stop = () => {
1002
+ viewport.classList.remove('is-panning')
1003
+ document.removeEventListener('pointermove', move)
1004
+ document.removeEventListener('pointerup', stop)
1005
+ document.removeEventListener('pointercancel', stop)
1006
+ state.canvasGesture = false
1007
+ deferCanvasRefresh(120)
1008
+ }
1009
+ document.addEventListener('pointermove', move)
1010
+ document.addEventListener('pointerup', stop)
1011
+ document.addEventListener('pointercancel', stop)
1012
+ })
1013
+
1014
+ app.addEventListener('wheel', event => {
1015
+ const viewport = canvasViewport(event.target)
1016
+ if (!(viewport instanceof HTMLElement)) return
1017
+ const card = event.target instanceof Element ? event.target.closest('.thread-card') : null
1018
+ if (card instanceof HTMLElement) {
1019
+ // Over a card the wheel scrolls that card's own answer with the browser's
1020
+ // native wheel (OS-smooth, never a page jump per notch); the answer's
1021
+ // overscroll-behavior: contain stops the scroll chaining into the canvas.
1022
+ const answer = card.querySelector('.thread-answer')
1023
+ if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) {
1024
+ deferCanvasRefresh()
1025
+ return
1026
+ }
1027
+ // A card with no scrollable answer swallows the wheel instead of zooming.
1028
+ event.preventDefault()
1029
+ deferCanvasRefresh()
1030
+ return
1031
+ }
1032
+ event.preventDefault()
1033
+ zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY)
1034
+ }, { passive: false })
1035
+
1036
+ // Track pointer-down so the card click handler can tell a plain click from a
1037
+ // text-selection or drag gesture; acting on the latter would re-render and
1038
+ // wipe the user's selection.
1039
+ let pointerDownPosition = null
1040
+ app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
1041
+
1042
+ app.addEventListener('click', async event => {
1043
+ const button = event.target.closest('[data-action]')
1044
+ if (!(button instanceof HTMLElement)) {
1045
+ const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]:not(.draft-card)') : null
1046
+ if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return
1047
+ // A double-click selects a word and a drag selects a range; neither is a
1048
+ // select-click, so leave the selection intact instead of re-rendering.
1049
+ if (event.detail > 1) return
1050
+ if (pointerDownPosition !== null
1051
+ && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
1052
+ const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
1053
+ if (thread === undefined) return
1054
+ state.activeId = thread.id
1055
+ state.error = ''
1056
+ render()
1057
+ void loadThreadHistory(thread)
1058
+ // Bidirectional current-session sync: switch DSH's current session
1059
+ // without closing the map; the client confirms via synapse:current-session.
1060
+ if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
1061
+ return
1062
+ }
1063
+ const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
1064
+ try {
1065
+ if (button.dataset.action === 'close') post('synapse:close')
1066
+ if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
1067
+ if (button.dataset.action === 'create-session') openNewSession()
1068
+ if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
1069
+ if (button.dataset.action === 'select-thread' && thread !== undefined) {
1070
+ state.activeId = thread.id
1071
+ state.error = ''
1072
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1073
+ render()
1074
+ void loadThreadHistory(thread)
1075
+ // Bidirectional current-session sync: switch DSH's current session
1076
+ // without closing the map; the client confirms via synapse:current-session.
1077
+ if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
1078
+ }
1079
+ if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; render(); void loadThreadHistory(thread) }
1080
+ if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
1081
+ if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
1082
+ const cardId = button.dataset.card
1083
+ const collapsing = !state.collapsedCardIds.has(cardId)
1084
+ if (collapsing && state.workspace !== null) {
1085
+ const allCards = conversationCards(state.workspace.threads)
1086
+ const nextCollapsed = new Set(state.collapsedCardIds).add(cardId)
1087
+ const visibleCards = conversationGraphView(allCards, nextCollapsed).cards
1088
+ const visibleIds = new Set(visibleCards.map(card => card.id))
1089
+ const draftParentId = draftPlacement(allCards)?.parent.id
1090
+ if (draftParentId !== undefined && !visibleIds.has(draftParentId)) return setError('请先完成或取消正在编辑的追问或分支')
1091
+ if (state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError('当前会话位于这个后续分支中,请先切换会话')
1092
+ }
1093
+ collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId)
1094
+ persistCollapsedCards()
1095
+ render()
1096
+ window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0)
1097
+ }
1098
+ if (button.dataset.action === 'open-continue' && thread !== undefined) openContinue(thread, button.dataset.card)
1099
+ if (button.dataset.action === 'open-branch' && thread !== undefined) {
1100
+ const requestedSeq = Number(button.dataset.seq)
1101
+ if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError('请等待这张卡片的最终回答后再创建分支')
1102
+ const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
1103
+ openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
1104
+ }
1105
+ if (button.dataset.action === 'cancel-draft') { state.draft = null; render() }
1106
+ if (button.dataset.action === 'toggle-message' && button.dataset.message !== undefined) { state.expandedMessageIds.has(button.dataset.message) ? state.expandedMessageIds.delete(button.dataset.message) : state.expandedMessageIds.add(button.dataset.message); renderPreservingDetailScroll() }
1107
+ if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId })
1108
+ if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
1109
+ if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
1110
+ if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
1111
+ if (button.dataset.action === 'focus-active') focusActiveCard()
1112
+ if (button.dataset.action === 'dismiss-error') { state.error = ''; render() }
1113
+ if (button.dataset.action === 'layout' && state.workspace !== null) {
1114
+ resetCardPositions()
1115
+ resetCanvasCamera()
1116
+ render()
1117
+ }
1118
+ } catch (error) { setError(error) }
1119
+ })
1120
+
1121
+ app.addEventListener('change', event => {
1122
+ const select = event.target.closest('[data-action="select-workspace"]')
1123
+ if (!(select instanceof HTMLSelectElement)) return
1124
+ const choice = workspaceChoices().find(item => item.id === select.value)
1125
+ if (choice?.source === 'dsh') {
1126
+ // Map → native sync: switching workspaces moves DSH's current session to
1127
+ // the workspace's most recently updated session, keeping both sides in step.
1128
+ void openDshWorkspace(choice.id).then(opened => {
1129
+ if (!opened) return
1130
+ const threads = state.workspace?.threads ?? []
1131
+ const latest = threads
1132
+ .filter(thread => thread.dshSessionId !== null)
1133
+ .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))[0]
1134
+ const sessionId = latest?.dshSessionId ?? choice.sessionIds[0]
1135
+ if (sessionId !== undefined) post('synapse:activate-session', { sessionId })
1136
+ }).catch(setError)
1137
+ } else if (choice !== undefined) { state.selectedDshWorkspaceId = null; void openWorkspace(choice.id).catch(setError) }
1138
+ })
1139
+ app.addEventListener('input', event => { const input = event.target; if (input instanceof HTMLTextAreaElement && input.closest('[data-draft]') && state.draft !== null) state.draft.text = input.value })
1140
+ app.addEventListener('submit', event => {
1141
+ const form = event.target
1142
+ if (!(form instanceof HTMLFormElement)) return
1143
+ if (form.matches('[data-draft]')) { event.preventDefault(); void submitDraft(); return }
1144
+ const thread = state.workspace?.threads.find(item => item.id === form.dataset.compose)
1145
+ const input = form.querySelector('textarea')
1146
+ if (thread === undefined || !(input instanceof HTMLTextAreaElement) || input.value.trim() === '') return
1147
+ event.preventDefault()
1148
+ const text = input.value.trim()
1149
+ input.value = ''
1150
+ void sendMessage(thread, text).catch(setError)
1151
+ })
1152
+
1153
+ window.addEventListener('message', event => {
1154
+ if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
1155
+ const data = event.data
1156
+ if (data.type === 'synapse:map-opened') {
1157
+ resetCanvasCamera()
1158
+ state.mode = 'canvas'
1159
+ render()
1160
+ window.requestAnimationFrame(() => post('synapse:map-ready'))
1161
+ }
1162
+ if (data.type === 'synapse:theme') {
1163
+ document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light'
1164
+ }
1165
+ if (data.type === 'synapse:workspaces') {
1166
+ state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
1167
+ const current = currentDshWorkspace()
1168
+ if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
1169
+ else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
1170
+ else if (canReplaceView()) render()
1171
+ }
1172
+ if (data.type === 'synapse:current-session') {
1173
+ const previousId = state.currentDsh?.id
1174
+ state.currentDsh = data.session
1175
+ const thread = currentDshThread()
1176
+ if (thread !== undefined) {
1177
+ state.activeId = thread.id
1178
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1179
+ }
1180
+ if (previousId !== data.session?.id) void openCurrentWorkspace().then(opened => { if (!opened && canReplaceView()) render() }).catch(setError)
1181
+ else if (canReplaceView()) render()
1182
+ }
1183
+ if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
1184
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId)
1185
+ if (thread !== undefined) {
1186
+ if (data.running === true) {
1187
+ state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '' })
1188
+ // Streaming: patch the live card's answer in place instead of
1189
+ // rebuilding the whole canvas on every chunk; a full render reconciles
1190
+ // at stream end. The detail view is single-thread, so keep its cheap
1191
+ // throttled full render.
1192
+ if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId)
1193
+ else if (canReplaceView()) scheduleLiveRender()
1194
+ } else {
1195
+ state.liveReplies.delete(data.sessionId)
1196
+ if (canReplaceView() || state.pendingReplies.has(data.sessionId)) renderPreservingDetailScroll()
1197
+ }
1198
+ }
1199
+ }
1200
+ if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
1201
+ if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
1202
+ })
1203
+
1204
+ post('synapse:request-current')
1205
+ refreshSummaries().catch(setError)
1206
+ let polling = false
1207
+ let liveRenderTimer = 0
1208
+ let liveCardFrame = 0
1209
+ let liveCardSessionId = null
1210
+ function scheduleLiveCardUpdate(sessionId) {
1211
+ // Coalesce streaming chunks to one DOM patch per animation frame.
1212
+ liveCardSessionId = sessionId
1213
+ if (liveCardFrame !== 0) return
1214
+ liveCardFrame = window.requestAnimationFrame(() => {
1215
+ liveCardFrame = 0
1216
+ if (liveCardSessionId === null) return
1217
+ const id = liveCardSessionId
1218
+ liveCardSessionId = null
1219
+ applyLiveReplyToCard(id)
1220
+ })
1221
+ }
1222
+ function applyLiveReplyToCard(sessionId) {
1223
+ if (state.mode !== 'canvas') return
1224
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
1225
+ if (thread === undefined) return
1226
+ const live = state.liveReplies.get(sessionId)
1227
+ if (live?.running !== true) return
1228
+ const cards = app.querySelectorAll(`.thread-card[data-thread="${CSS.escape(thread.id)}"]`)
1229
+ const card = cards[cards.length - 1]
1230
+ if (!(card instanceof HTMLElement)) return
1231
+ const answer = card.querySelector('.thread-answer')
1232
+ if (!(answer instanceof HTMLElement)) return
1233
+ const text = live.text
1234
+ answer.innerHTML = text.trim() === ''
1235
+ ? '<p class="thread-answer-pending">正在回复</p>'
1236
+ : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
1237
+ }
1238
+ function scheduleLiveRender() {
1239
+ if (liveRenderTimer !== 0 || !canReplaceView()) return
1240
+ liveRenderTimer = window.setTimeout(() => {
1241
+ liveRenderTimer = 0
1242
+ if (canReplaceView()) renderPreservingDetailScroll()
1243
+ }, 120)
1244
+ }
1245
+ async function pollProjection() {
1246
+ if (polling || document.hidden || !canReplaceView()) return
1247
+ polling = true
1248
+ try {
1249
+ await refreshProjection()
1250
+ } finally { polling = false }
1251
+ }
1252
+ window.setInterval(() => { void pollProjection() }, 1_000)