dsh-synapse 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -1,5 +1,5 @@
1
- const app = document.querySelector('#app')
2
- if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
1
+ const app = document.querySelector('#app')
2
+ if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
3
3
  const LEGACY_CARD_POSITIONS_KEY = 'dsh-synapse:card-positions'
4
4
  const CARD_POSITIONS_KEY = 'dsh-synapse:card-positions:v3'
5
5
  const COLLAPSED_CARDS_KEY = 'dsh-synapse:collapsed-cards:v1'
@@ -23,62 +23,62 @@ const savedQuickPhrases = (() => {
23
23
  return stored === null ? DEFAULT_QUICK_PHRASES : normalizeQuickPhrases(JSON.parse(stored))
24
24
  } catch { return DEFAULT_QUICK_PHRASES }
25
25
  })()
26
- const savedBranchAnchors = (() => {
27
- try {
28
- const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
29
- return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && typeof item[1] === 'string') : []
30
- } catch { return [] }
31
- })()
32
- const savedCardPositions = (() => {
33
- try {
34
- // Drop formats that were never persisted; the current key stores drags.
35
- localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
36
- localStorage.removeItem('dsh-synapse:card-positions:v2')
37
- const value = JSON.parse(localStorage.getItem(CARD_POSITIONS_KEY) ?? '[]')
38
- 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)) : []
39
- } catch { return [] }
40
- })()
41
- const savedCollapsedCards = (() => {
42
- try {
43
- const value = JSON.parse(localStorage.getItem(COLLAPSED_CARDS_KEY) ?? '[]')
44
- return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []
45
- } catch { return [] }
46
- })()
47
- const CARD_WIDTH = 310
48
- const CARD_HEIGHT = 276
49
- const CARD_GAP_Y = 42
50
- const CAMERA_INSET_X = 56
51
- const CAMERA_INSET_Y = 56
52
- // Cards outside the viewport (plus this world-space margin) are not mounted
53
- // into the DOM; the margin pre-mounts cards just before they scroll into view
54
- // so panning never flashes empty space.
55
- const VIEWPORT_MARGIN = 1400
56
- const state = {
26
+ const savedBranchAnchors = (() => {
27
+ try {
28
+ const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
29
+ return Array.isArray(value) ? value.filter(item => Array.isArray(item) && typeof item[0] === 'string' && typeof item[1] === 'string') : []
30
+ } catch { return [] }
31
+ })()
32
+ const savedCardPositions = (() => {
33
+ try {
34
+ // Drop formats that were never persisted; the current key stores drags.
35
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
36
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
37
+ const value = JSON.parse(localStorage.getItem(CARD_POSITIONS_KEY) ?? '[]')
38
+ 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)) : []
39
+ } catch { return [] }
40
+ })()
41
+ const savedCollapsedCards = (() => {
42
+ try {
43
+ const value = JSON.parse(localStorage.getItem(COLLAPSED_CARDS_KEY) ?? '[]')
44
+ return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []
45
+ } catch { return [] }
46
+ })()
47
+ const CARD_WIDTH = 310
48
+ const CARD_HEIGHT = 276
49
+ const CARD_GAP_Y = 42
50
+ const CAMERA_INSET_X = 56
51
+ const CAMERA_INSET_Y = 56
52
+ // Cards outside the viewport (plus this world-space margin) are not mounted
53
+ // into the DOM; the margin pre-mounts cards just before they scroll into view
54
+ // so panning never flashes empty space.
55
+ const VIEWPORT_MARGIN = 1400
56
+ const state = {
57
57
  summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
58
- dshWorkspaces: [], selectedDshWorkspaceId: null,
59
- historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
58
+ dshWorkspaces: [], selectedDshWorkspaceId: null,
59
+ historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
60
60
  draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
61
61
  dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, mapCardSessionSwitches: new Set(),
62
- expandedMessageIds: new Set(),
63
- canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
62
+ expandedMessageIds: new Set(),
63
+ canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
64
64
  detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null,
65
65
  inspectorCardId: null, inspectorOpening: false, inspectorScrollByCard: new Map(),
66
- }
67
-
68
- const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character]))
69
- const formatTime = value => new Date(value).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
70
- const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null
71
- const threadListTitle = thread => thread.dshSessionTitle ?? thread.title ?? questionFor(thread)
72
-
73
- function rememberBranchAnchor(sessionId, cardId) {
74
- state.branchAnchors.set(sessionId, cardId)
75
- try { localStorage.setItem('dsh-synapse:branch-anchors', JSON.stringify([...state.branchAnchors])) } catch { /* Private browsing may disable local storage. */ }
76
- }
77
-
78
- function persistCardPositions() {
79
- try { localStorage.setItem(CARD_POSITIONS_KEY, JSON.stringify([...state.cardPositions])) } catch { /* Private browsing may disable local storage. */ }
80
- }
81
-
66
+ }
67
+
68
+ const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character]))
69
+ const formatTime = value => new Date(value).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
70
+ const currentThread = () => state.workspace?.threads.find(thread => thread.id === state.activeId) ?? state.workspace?.threads[0] ?? null
71
+ const threadListTitle = thread => thread.dshSessionTitle ?? thread.title ?? questionFor(thread)
72
+
73
+ function rememberBranchAnchor(sessionId, cardId) {
74
+ state.branchAnchors.set(sessionId, cardId)
75
+ try { localStorage.setItem('dsh-synapse:branch-anchors', JSON.stringify([...state.branchAnchors])) } catch { /* Private browsing may disable local storage. */ }
76
+ }
77
+
78
+ function persistCardPositions() {
79
+ try { localStorage.setItem(CARD_POSITIONS_KEY, JSON.stringify([...state.cardPositions])) } catch { /* Private browsing may disable local storage. */ }
80
+ }
81
+
82
82
  function persistCollapsedCards() {
83
83
  try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
84
84
  }
@@ -86,168 +86,168 @@ function persistCollapsedCards() {
86
86
  function persistQuickPhrases() {
87
87
  try { localStorage.setItem(QUICK_PHRASES_KEY, JSON.stringify(state.quickPhrases)) } catch { /* Private browsing may disable local storage. */ }
88
88
  }
89
-
90
- function rememberCardPosition(cardId, position, aliases = []) {
91
- state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
92
- for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
93
- persistCardPositions()
94
- }
95
-
96
- function resetCardPositions() {
97
- state.cardPositions.clear()
98
- persistCardPositions()
99
- try {
100
- localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
101
- localStorage.removeItem('dsh-synapse:card-positions:v2')
102
- } catch { /* Private browsing may disable local storage. */ }
103
- }
104
-
105
- function resetCanvasCamera() {
106
- state.canvasViewInitialized = false
107
- state.canvasCamera = { x: 0, y: 0 }
108
- }
109
-
110
- async function api(path, options = {}) {
111
- const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) } })
112
- const body = await response.json().catch(() => ({}))
113
- if (!response.ok) throw new Error(body.error ?? '请求失败')
114
- return body
115
- }
116
-
117
- function post(type, payload = {}) {
118
- if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin)
119
- }
120
-
121
- function dshRpc(type, payload = {}) {
122
- if (window.parent === window) return Promise.reject(new Error('请从 DSH 页面打开 Synapse 后再操作会话'))
123
- const requestId = crypto.randomUUID()
124
- post(type, { requestId, ...payload })
125
- return new Promise((resolve, reject) => {
126
- const timer = window.setTimeout(() => {
127
- state.pendingRpc.delete(requestId)
128
- reject(new Error('DSH 未在规定时间内响应'))
129
- }, 20_000)
130
- state.pendingRpc.set(requestId, { resolve, reject, timer })
131
- })
132
- }
133
-
134
- function settleRpc(requestId, value, error) {
135
- const pending = state.pendingRpc.get(requestId)
136
- if (pending === undefined) return
137
- state.pendingRpc.delete(requestId)
138
- window.clearTimeout(pending.timer)
139
- if (error === undefined) pending.resolve(value)
140
- else pending.reject(error instanceof Error ? error : new Error(String(error)))
141
- }
142
-
143
- function setError(error = '') { state.error = error instanceof Error ? error.message : error; render() }
144
-
145
- function messagesFromEvents(events) {
146
- if (!Array.isArray(events)) return []
147
- return events.flatMap(event => {
148
- const content = event?.data?.message?.content ?? event?.data?.content
149
- const text = Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).filter(Boolean).join('\n') : ''
150
- 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 }]
151
- if (event?.type === 'assistant/message' && text) return [{ kind: 'assistant', text, at: event.time, sourceSeq: event.seq }]
152
- return []
153
- })
154
- }
155
-
156
- async function loadThreadHistory() {}
157
-
158
- function canReplaceView() {
159
- return state.draft === null && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea')
160
- }
161
-
162
- function deferCanvasRefresh(delay = 700) {
163
- state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay)
164
- }
165
-
166
- function currentDshWorkspace() {
167
- const id = state.currentDsh?.id
168
- return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined
169
- }
170
-
171
- function selectedDshWorkspace() {
172
- return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId)
173
- }
174
-
175
- function currentDshThread(threads = state.workspace?.threads ?? []) {
176
- const id = state.currentDsh?.id
177
- return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined
178
- }
179
-
180
- function workspaceChoices() {
181
- if (state.dshWorkspaces.length > 0) return state.dshWorkspaces.map(workspace => ({ ...workspace, source: 'dsh' }))
182
- return state.summaries.map(workspace => ({ id: workspace.id, title: workspace.title, path: workspace.cwd, sessionIds: [], source: 'projection' }))
183
- }
184
-
185
- async function threadsForDshWorkspace(workspace) {
186
- if (workspace.sessionIds.length === 0) return []
187
- const requested = new Set(workspace.sessionIds)
188
- const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`)))
189
- return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
190
- }
191
-
89
+
90
+ function rememberCardPosition(cardId, position, aliases = []) {
91
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
92
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
93
+ persistCardPositions()
94
+ }
95
+
96
+ function resetCardPositions() {
97
+ state.cardPositions.clear()
98
+ persistCardPositions()
99
+ try {
100
+ localStorage.removeItem(LEGACY_CARD_POSITIONS_KEY)
101
+ localStorage.removeItem('dsh-synapse:card-positions:v2')
102
+ } catch { /* Private browsing may disable local storage. */ }
103
+ }
104
+
105
+ function resetCanvasCamera() {
106
+ state.canvasViewInitialized = false
107
+ state.canvasCamera = { x: 0, y: 0 }
108
+ }
109
+
110
+ async function api(path, options = {}) {
111
+ const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) } })
112
+ const body = await response.json().catch(() => ({}))
113
+ if (!response.ok) throw new Error(body.error ?? '请求失败')
114
+ return body
115
+ }
116
+
117
+ function post(type, payload = {}) {
118
+ if (window.parent !== window) window.parent.postMessage({ source: 'dsh-synapse', type, ...payload }, window.location.origin)
119
+ }
120
+
121
+ function dshRpc(type, payload = {}) {
122
+ if (window.parent === window) return Promise.reject(new Error('请从 DSH 页面打开 Synapse 后再操作会话'))
123
+ const requestId = crypto.randomUUID()
124
+ post(type, { requestId, ...payload })
125
+ return new Promise((resolve, reject) => {
126
+ const timer = window.setTimeout(() => {
127
+ state.pendingRpc.delete(requestId)
128
+ reject(new Error('DSH 未在规定时间内响应'))
129
+ }, 20_000)
130
+ state.pendingRpc.set(requestId, { resolve, reject, timer })
131
+ })
132
+ }
133
+
134
+ function settleRpc(requestId, value, error) {
135
+ const pending = state.pendingRpc.get(requestId)
136
+ if (pending === undefined) return
137
+ state.pendingRpc.delete(requestId)
138
+ window.clearTimeout(pending.timer)
139
+ if (error === undefined) pending.resolve(value)
140
+ else pending.reject(error instanceof Error ? error : new Error(String(error)))
141
+ }
142
+
143
+ function setError(error = '') { state.error = error instanceof Error ? error.message : error; render() }
144
+
145
+ function messagesFromEvents(events) {
146
+ if (!Array.isArray(events)) return []
147
+ return events.flatMap(event => {
148
+ const content = event?.data?.message?.content ?? event?.data?.content
149
+ const text = Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).filter(Boolean).join('\n') : ''
150
+ 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 }]
151
+ if (event?.type === 'assistant/message' && text) return [{ kind: 'assistant', text, at: event.time, sourceSeq: event.seq }]
152
+ return []
153
+ })
154
+ }
155
+
156
+ async function loadThreadHistory() {}
157
+
158
+ function canReplaceView() {
159
+ return state.draft === null && !state.dragging && !state.canvasGesture && Date.now() >= state.canvasRefreshAfter && !document.activeElement?.matches('textarea')
160
+ }
161
+
162
+ function deferCanvasRefresh(delay = 700) {
163
+ state.canvasRefreshAfter = Math.max(state.canvasRefreshAfter, Date.now() + delay)
164
+ }
165
+
166
+ function currentDshWorkspace() {
167
+ const id = state.currentDsh?.id
168
+ return typeof id === 'string' ? state.dshWorkspaces.find(workspace => workspace.sessionIds.includes(id)) : undefined
169
+ }
170
+
171
+ function selectedDshWorkspace() {
172
+ return state.dshWorkspaces.find(workspace => workspace.id === state.selectedDshWorkspaceId)
173
+ }
174
+
175
+ function currentDshThread(threads = state.workspace?.threads ?? []) {
176
+ const id = state.currentDsh?.id
177
+ return typeof id === 'string' ? threads.find(thread => thread.dshSessionId === id) : undefined
178
+ }
179
+
180
+ function workspaceChoices() {
181
+ if (state.dshWorkspaces.length > 0) return state.dshWorkspaces.map(workspace => ({ ...workspace, source: 'dsh' }))
182
+ return state.summaries.map(workspace => ({ id: workspace.id, title: workspace.title, path: workspace.cwd, sessionIds: [], source: 'projection' }))
183
+ }
184
+
185
+ async function threadsForDshWorkspace(workspace) {
186
+ if (workspace.sessionIds.length === 0) return []
187
+ const requested = new Set(workspace.sessionIds)
188
+ const projections = await Promise.all(state.summaries.map(summary => api(`/synapse/api/workspaces/${summary.id}`)))
189
+ return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
190
+ }
191
+
192
192
  async function openDshWorkspace(id, { renderAfter = true, preserveCanvasCamera = false } = {}) {
193
- const workspace = state.dshWorkspaces.find(item => item.id === id)
194
- if (workspace === undefined) return false
195
- const load = ++state.workspaceLoad
196
- state.selectedDshWorkspaceId = id
197
- const threads = await threadsForDshWorkspace(workspace)
198
- if (load !== state.workspaceLoad) return true
199
- const nextWorkspaceId = `dsh:${workspace.id}`
193
+ const workspace = state.dshWorkspaces.find(item => item.id === id)
194
+ if (workspace === undefined) return false
195
+ const load = ++state.workspaceLoad
196
+ state.selectedDshWorkspaceId = id
197
+ const threads = await threadsForDshWorkspace(workspace)
198
+ if (load !== state.workspaceLoad) return true
199
+ const nextWorkspaceId = `dsh:${workspace.id}`
200
200
  if (state.workspace?.id !== nextWorkspaceId && !preserveCanvasCamera) resetCanvasCamera()
201
- state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
202
- const currentThread = currentDshThread(state.workspace.threads)
203
- state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
204
- if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id)
205
- if (renderAfter && canReplaceView()) render()
206
- await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
207
- if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
208
- return true
209
- }
210
-
201
+ state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
202
+ const currentThread = currentDshThread(state.workspace.threads)
203
+ state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
204
+ if (currentThread !== undefined) revealConversationThread(conversationCards(state.workspace.threads), currentThread.id)
205
+ if (renderAfter && canReplaceView()) render()
206
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
207
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
208
+ return true
209
+ }
210
+
211
211
  async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) {
212
212
  const workspace = currentDshWorkspace()
213
213
  if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
214
214
  return openDshWorkspace(workspace.id, { preserveCanvasCamera })
215
- }
216
-
217
- async function refreshSummaries({ renderAfter = true } = {}) {
218
- const before = JSON.stringify(state.summaries)
219
- const body = await api('/synapse/api/workspaces')
220
- state.summaries = body.workspaces
221
- const changed = before !== JSON.stringify(state.summaries)
222
- const current = state.workspace?.id
223
- if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null
224
- const selected = selectedDshWorkspace()
225
- if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter })
226
- else if (state.workspace === null && state.summaries.length > 0) await openWorkspace(state.summaries[0].id)
227
- else if (renderAfter && changed && canReplaceView()) render()
228
- return changed
229
- }
230
-
231
- async function openWorkspace(id, { renderAfter = true } = {}) {
232
- const load = ++state.workspaceLoad
233
- const body = await api(`/synapse/api/workspaces/${id}`)
234
- if (load !== state.workspaceLoad) return
235
- if (state.workspace?.id !== body.workspace.id) resetCanvasCamera()
236
- state.workspace = body.workspace
237
- state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null
238
- if (renderAfter && canReplaceView()) render()
239
- await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
240
- if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
241
- }
242
-
243
- async function refreshProjection() {
244
- const summariesChanged = await refreshSummaries({ renderAfter: false })
245
- if (!summariesChanged || state.workspace === null || !canReplaceView()) return summariesChanged
246
- if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId)
247
- else await openWorkspace(state.workspace.id)
248
- return true
249
- }
250
-
215
+ }
216
+
217
+ async function refreshSummaries({ renderAfter = true } = {}) {
218
+ const before = JSON.stringify(state.summaries)
219
+ const body = await api('/synapse/api/workspaces')
220
+ state.summaries = body.workspaces
221
+ const changed = before !== JSON.stringify(state.summaries)
222
+ const current = state.workspace?.id
223
+ if (state.selectedDshWorkspaceId === null && current !== null && !state.summaries.some(item => item.id === current)) state.workspace = null
224
+ const selected = selectedDshWorkspace()
225
+ if (selected !== undefined && (changed || state.workspace === null)) await openDshWorkspace(selected.id, { renderAfter })
226
+ else if (state.workspace === null && state.summaries.length > 0) await openWorkspace(state.summaries[0].id)
227
+ else if (renderAfter && changed && canReplaceView()) render()
228
+ return changed
229
+ }
230
+
231
+ async function openWorkspace(id, { renderAfter = true } = {}) {
232
+ const load = ++state.workspaceLoad
233
+ const body = await api(`/synapse/api/workspaces/${id}`)
234
+ if (load !== state.workspaceLoad) return
235
+ if (state.workspace?.id !== body.workspace.id) resetCanvasCamera()
236
+ state.workspace = body.workspace
237
+ state.activeId = state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null
238
+ if (renderAfter && canReplaceView()) render()
239
+ await Promise.all(state.workspace.threads.map(thread => loadThreadHistory(thread, false)))
240
+ if (renderAfter && load === state.workspaceLoad && canReplaceView()) render()
241
+ }
242
+
243
+ async function refreshProjection() {
244
+ const summariesChanged = await refreshSummaries({ renderAfter: false })
245
+ if (!summariesChanged || state.workspace === null || !canReplaceView()) return summariesChanged
246
+ if (state.selectedDshWorkspaceId !== null) await openDshWorkspace(state.selectedDshWorkspaceId)
247
+ else await openWorkspace(state.workspace.id)
248
+ return true
249
+ }
250
+
251
251
  function openNewSession() {
252
252
  if (state.draft !== null) return
253
253
  state.mode = 'canvas'
@@ -257,51 +257,51 @@ function openNewSession() {
257
257
  state.inspectorOpening = false
258
258
  state.quickPhraseEditorOpen = false
259
259
  state.draft = { kind: 'new', text: '', sending: false }
260
- state.error = ''
261
- resetCanvasCamera()
262
- render()
263
- window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
264
- }
265
-
266
- async function archiveThread(thread) {
267
- if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
268
- await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
269
- state.historyBySession.delete(thread.dshSessionId)
270
- state.detailScrollByThread.delete(thread.id)
271
- state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId
272
- if (state.workspace !== null) {
273
- const removed = new Set([thread.id])
274
- for (let changed = true; changed;) {
275
- changed = false
276
- for (const item of state.workspace.threads) {
277
- if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) {
278
- removed.add(item.id)
279
- changed = true
280
- }
281
- }
282
- }
283
- state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id))
284
- for (const key of [...state.cardPositions.keys()]) {
285
- if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key)
286
- }
287
- let collapsedChanged = false
288
- for (const key of [...state.collapsedCardIds]) {
289
- if ([...removed].some(id => key.startsWith(`${id}:`))) {
290
- state.collapsedCardIds.delete(key)
291
- collapsedChanged = true
292
- }
293
- }
294
- if (collapsedChanged) persistCollapsedCards()
295
- state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId)
296
- ? state.activeId
297
- : state.workspace.threads[0]?.id ?? null
298
- render()
299
- } else {
300
- state.activeId = null
301
- }
302
- await refreshSummaries()
303
- }
304
-
260
+ state.error = ''
261
+ resetCanvasCamera()
262
+ render()
263
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
264
+ }
265
+
266
+ async function archiveThread(thread) {
267
+ if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
268
+ await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
269
+ state.historyBySession.delete(thread.dshSessionId)
270
+ state.detailScrollByThread.delete(thread.id)
271
+ state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId
272
+ if (state.workspace !== null) {
273
+ const removed = new Set([thread.id])
274
+ for (let changed = true; changed;) {
275
+ changed = false
276
+ for (const item of state.workspace.threads) {
277
+ if (item.parentId !== null && removed.has(item.parentId) && !removed.has(item.id)) {
278
+ removed.add(item.id)
279
+ changed = true
280
+ }
281
+ }
282
+ }
283
+ state.workspace.threads = state.workspace.threads.filter(item => !removed.has(item.id))
284
+ for (const key of [...state.cardPositions.keys()]) {
285
+ if ([...removed].some(id => key.startsWith(`${id}:`))) state.cardPositions.delete(key)
286
+ }
287
+ let collapsedChanged = false
288
+ for (const key of [...state.collapsedCardIds]) {
289
+ if ([...removed].some(id => key.startsWith(`${id}:`))) {
290
+ state.collapsedCardIds.delete(key)
291
+ collapsedChanged = true
292
+ }
293
+ }
294
+ if (collapsedChanged) persistCollapsedCards()
295
+ state.activeId = state.activeId !== null && state.workspace.threads.some(item => item.id === state.activeId)
296
+ ? state.activeId
297
+ : state.workspace.threads[0]?.id ?? null
298
+ render()
299
+ } else {
300
+ state.activeId = null
301
+ }
302
+ await refreshSummaries()
303
+ }
304
+
305
305
  function focusDraftInput() {
306
306
  const input = document.querySelector('[data-draft] textarea')
307
307
  if (!(input instanceof HTMLTextAreaElement)) return
@@ -317,354 +317,354 @@ function openContinue(parent, anchorId = undefined, text = '') {
317
317
  render()
318
318
  window.setTimeout(focusDraftInput, 0)
319
319
  }
320
-
320
+
321
321
  function openBranch(parent, atSeq = undefined, anchorId = undefined) {
322
322
  if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
323
323
  state.activeId = parent.id
324
324
  state.quickPhraseEditorOpen = false
325
325
  state.draft = { kind: 'branch', parentId: parent.id, atSeq, anchorId, text: '', sending: false }
326
- render()
327
- window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
328
- }
329
-
330
- async function sendMessage(thread, text) {
331
- if (thread.dshSessionId === null) throw new Error('该节点没有关联的 DSH 会话')
332
- if (state.pendingReplies.has(thread.dshSessionId)) throw new Error('该会话正在回复,请稍后再发送')
333
- state.pendingReplies.set(thread.dshSessionId, { text, at: Date.now() })
334
- state.error = ''
335
- render()
336
- try {
337
- await dshRpc('synapse:send-message', { sessionId: thread.dshSessionId, text })
338
- void loadThreadHistory(thread)
339
- } catch (error) {
340
- state.pendingReplies.delete(thread.dshSessionId)
341
- render()
342
- throw error
343
- }
344
- }
345
-
346
- async function submitDraft() {
347
- const draft = state.draft
348
- const text = draft?.text.trim()
349
- if (draft === null || !text) return
350
- const branchPosition = draft.kind === 'branch' && state.workspace !== null ? draftPlacement(conversationCards(state.workspace.threads))?.position : undefined
351
- draft.sending = true
352
- state.error = ''
353
- render()
354
- try {
355
- if (draft.kind === 'new') {
356
- const session = await dshRpc('synapse:create-session', { workspaceId: state.selectedDshWorkspaceId, cwd: state.currentDsh?.cwd })
357
- await dshRpc('synapse:send-message', { sessionId: session.id, text })
358
- state.draft = null
359
- render()
360
- window.setTimeout(() => {
361
- void refreshProjection().catch(() => {})
362
- }, 150)
363
- return
364
- }
365
- const parent = state.workspace?.threads.find(thread => thread.id === draft.parentId)
366
- if (parent === undefined) throw new Error('来源会话不存在')
367
- if (draft.kind === 'continue') {
368
- state.draft = null
369
- await sendMessage(parent, text)
370
- return
371
- }
372
- const session = await dshRpc('synapse:fork-session', { sessionId: parent.dshSessionId, atSeq: draft.atSeq })
373
- if (draft.anchorId !== undefined) rememberBranchAnchor(session.id, draft.anchorId)
374
- 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 }) })
375
- 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)
376
- state.activeId = result.thread.id
377
- state.draft = null
378
- state.pendingReplies.set(result.thread.dshSessionId, { text, at: Date.now() })
379
- render()
380
- await dshRpc('synapse:send-message', { sessionId: result.thread.dshSessionId, text })
381
- void loadThreadHistory(result.thread)
382
- await refreshProjection()
383
- } catch (error) {
384
- if (draft.kind === 'branch') {
385
- state.pendingReplies.delete(state.workspace?.threads.find(thread => thread.id === state.activeId)?.dshSessionId)
386
- if (state.draft !== null) state.draft = { ...draft, sending: false }
387
- } else {
388
- state.draft = { ...draft, sending: false }
389
- }
390
- setError(error)
391
- }
392
- }
393
-
394
- function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
395
- function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
396
-
397
- function pendingUserIndex(messages, pending) {
398
- return messages.findLastIndex(message => message.kind === 'user' && message.text === pending.text && new Date(message.at).getTime() >= pending.at - 2_000)
399
- }
400
-
401
- function settlePendingReply(thread, messages) {
402
- const pending = state.pendingReplies.get(thread.dshSessionId)
403
- if (pending === undefined) return false
404
- const userIndex = pendingUserIndex(messages, pending)
405
- if (userIndex === -1 || !messages.slice(userIndex + 1).some(message => message.kind === 'assistant')) return false
406
- state.pendingReplies.delete(thread.dshSessionId)
407
- return true
408
- }
409
-
410
- function messagesFor(thread) {
411
- // A runtime-context snapshot is internal DSH state, never a user turn.
412
- // Filter here as well as during persistence so existing saved workspaces
413
- // immediately render one question and its answer as one card.
414
- 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.')))
415
- const pending = state.pendingReplies.get(thread.dshSessionId)
416
- if (pending === undefined) return messages
417
- if (settlePendingReply(thread, messages)) {
418
- state.liveReplies.delete(thread.dshSessionId)
419
- return messages
420
- }
421
- const liveReply = state.liveReplies.get(thread.dshSessionId)
422
- const liveAssistant = liveReply?.running ? { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() } : { kind: 'assistant', text: '', pending: true, at: new Date().toISOString() }
423
- const userIndex = pendingUserIndex(messages, pending)
424
- if (userIndex !== -1) return [...messages, liveAssistant]
425
- return [...messages, { kind: 'user', text: pending.text, pending: true, at: new Date(pending.at).toISOString() }, liveAssistant]
426
- }
427
-
428
- function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) }
429
- function questionFor(thread) { return latestMessage(thread, 'user')?.text ?? thread.dshSessionTitle ?? '等待用户提问' }
430
- function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null }
431
-
432
- function inlineMarkdown(text) {
433
- return escapeHtml(text)
434
- .replace(/`([^`\n]+)`/g, '<code>$1</code>')
435
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
436
- .replace(/~~([^~]+)~~/g, '<s>$1</s>')
437
- .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
438
- }
439
-
440
- const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
441
-
442
- const isTableDelimiter = line => {
443
- const cells = tableCells(line)
444
- return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell))
445
- }
446
-
447
- function markdownBlock(text) {
448
- const lines = text.split('\n')
449
- const output = []
450
- for (let index = 0; index < lines.length;) {
451
- const line = lines[index]
452
- if (line.trim() === '') { index++; continue }
453
- const heading = /^(#{1,3})\s+(.+)$/.exec(line)
454
- if (heading !== null) {
455
- const level = heading[1].length
456
- output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`)
457
- index++
458
- continue
459
- }
460
- const unordered = /^[-*+]\s+(.+)$/.exec(line)
461
- const ordered = /^\d+[.)]\s+(.+)$/.exec(line)
462
- if (unordered !== null || ordered !== null) {
463
- const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/
464
- const items = []
465
- while (index < lines.length) {
466
- const item = matcher.exec(lines[index])
467
- if (item === null) break
468
- items.push(`<li>${inlineMarkdown(item[1])}</li>`)
469
- index++
470
- }
471
- output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}</${unordered === null ? 'ol' : 'ul'}>`)
472
- continue
473
- }
474
- // GFM table: a leading-pipe header row followed by a |-delimiter row,
475
- // then any number of leading-pipe body rows.
476
- if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) {
477
- const header = line
478
- const body = []
479
- index += 2
480
- while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
481
- body.push(lines[index])
482
- index++
483
- }
484
- 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>`)
485
- continue
486
- }
487
- const paragraph = []
488
- 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++])
489
- // A marker-only line such as PowerShell's "+ " diagnostic is neither a
490
- // list item nor paragraph content under the rules above. Consume it so
491
- // the parser always makes progress.
492
- if (paragraph.length === 0) paragraph.push(lines[index++])
493
- output.push(`<p>${paragraph.map(inlineMarkdown).join('<br>')}</p>`)
494
- }
495
- return output.join('')
496
- }
497
-
498
- // Markdown parsing is pure CPU and repeats for every card on every canvas
499
- // rebuild; cache the rendered HTML by input text so stable answers are never
500
- // re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
501
- const markdownCache = new Map()
502
- const MARKDOWN_CACHE_LIMIT = 5000
503
- function renderMarkdown(text) {
504
- const key = String(text)
505
- const cached = markdownCache.get(key)
506
- if (cached !== undefined) return cached
507
- const parts = key.split(/```/)
508
- const rendered = parts.map((part, index) => index % 2 === 1
509
- ? `<pre><code>${escapeHtml(part.replace(/^\w*\n/, ''))}</code></pre>`
510
- : markdownBlock(part)).join('')
511
- if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value)
512
- markdownCache.set(key, rendered)
513
- return rendered
514
- }
515
-
516
- function overlapsCard(position, other) {
517
- return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x
518
- && position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y
519
- }
520
-
521
- function firstAvailableCardPosition(position, occupied) {
522
- const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) }
523
- while (true) {
524
- const collisions = occupied.filter(other => overlapsCard(candidate, other))
525
- if (collisions.length === 0) return candidate
526
- candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y))
527
- }
528
- }
529
-
530
- function connectorPath(fromPosition, toPosition) {
531
- const fromX = fromPosition.x + CARD_WIDTH
532
- const fromY = fromPosition.y + CARD_HEIGHT / 2
533
- const toX = toPosition.x
534
- const toY = toPosition.y + CARD_HEIGHT / 2
535
- const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
536
- return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
537
- }
538
-
539
- function connectorPathFromElements(fromCard, toCard) {
540
- const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH
541
- const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2
542
- const toX = Number.parseFloat(toCard.style.left)
543
- const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2
544
- if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null
545
- const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
546
- return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
547
- }
548
-
549
- function selectorValue(value) {
550
- return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
551
- }
552
-
553
- // Connector paths are rebuilt together with the canvas DOM; cache the mapping
554
- // from card id to its incident paths so dragging never scans the whole SVG.
555
- let connectorPathsByCard = new Map()
556
- function cacheCardConnectors() {
557
- connectorPathsByCard = new Map()
558
- const viewport = document.querySelector('.canvas-viewport')
559
- if (!(viewport instanceof HTMLElement)) return
560
- for (const path of viewport.querySelectorAll('.connectors path[data-from]')) {
561
- const fromId = path.getAttribute('data-from')
562
- const toId = path.getAttribute('data-to')
563
- if (fromId === null || toId === null) continue
564
- for (const id of [fromId, toId]) {
565
- const paths = connectorPathsByCard.get(id)
566
- if (paths === undefined) connectorPathsByCard.set(id, new Set([path]))
567
- else paths.add(path)
568
- }
569
- }
570
- }
571
-
572
- function refreshCardConnectors(cardId) {
573
- const paths = connectorPathsByCard.get(cardId)
574
- if (paths === undefined || paths.size === 0) return
575
- const byId = state.canvasCardsById
576
- if (byId === undefined) return
577
- for (const path of paths) {
578
- const fromId = path.getAttribute('data-from')
579
- const toId = path.getAttribute('data-to')
580
- if (fromId === null || toId === null) continue
581
- const fromCard = byId.get(fromId)
582
- const toCard = byId.get(toId)
583
- if (fromCard === undefined || toCard === undefined) continue
584
- // Data-driven endpoints: the counterpart card may be unmounted (outside
585
- // the viewport) but its position is still authoritative.
586
- path.setAttribute('d', connectorPath(fromCard.position, toCard.position))
587
- }
588
- }
589
-
590
- function initialCanvasCamera(cards) {
591
- const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
592
- // Focus the active conversation's latest turn, not its first: after many
593
- // rounds the canvas should open where work is happening, at the newest card.
594
- const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
595
- const active = activeCards.at(-1)
596
- const focus = draft ?? active ?? cards[0]
597
- const position = focus?.position
598
- if (position === undefined) return { x: 0, y: 0 }
599
- return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom }
600
- }
601
-
602
- function placeConversationCards(cards) {
603
- const saved = new Map(cards.flatMap(card => {
604
- if (card.positionLocked !== true) return []
605
- const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey)
606
- return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]]
607
- }))
608
- const occupied = []
609
- for (const card of cards) {
610
- const position = saved.get(card.id)
611
- if (position !== undefined) {
612
- card.position = position
613
- continue
614
- }
615
- card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied)
616
- occupied.push(card.position)
617
- }
618
- return cards
619
- }
620
-
621
- function layoutConversationGraph(cards, threads) {
622
- const childrenByThread = new Map()
623
- for (const thread of threads) {
624
- if (thread.parentId === null) continue
625
- const children = childrenByThread.get(thread.parentId) ?? []
626
- children.push(thread.id)
627
- childrenByThread.set(thread.parentId, children)
628
- }
629
- const laneByThread = new Map()
630
- const visitThread = threadId => {
631
- if (laneByThread.has(threadId)) return
632
- laneByThread.set(threadId, laneByThread.size)
633
- for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId)
634
- }
635
- for (const thread of threads) if (thread.parentId === null) visitThread(thread.id)
636
- for (const thread of threads) visitThread(thread.id)
637
-
638
- const byId = new Map(cards.map(card => [card.id, card]))
639
- const positioned = new Map()
640
- const positionFor = (card, visiting = new Set()) => {
641
- if (positioned.has(card.id)) return positioned.get(card.id)
642
- if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) }
643
- visiting.add(card.id)
644
- const parent = card.parentId === null ? undefined : byId.get(card.parentId)
645
- const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting)
646
- const position = {
647
- x: parentPosition === undefined ? 86 : parentPosition.x + 365,
648
- y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y),
649
- }
650
- visiting.delete(card.id)
651
- positioned.set(card.id, position)
652
- return position
653
- }
654
- for (const card of cards) {
655
- card.naturalPosition = positionFor(card)
656
- if (!card.positionLocked) card.position = card.naturalPosition
657
- }
658
- return placeConversationCards(cards)
659
- }
660
-
661
- function conversationCards(threads) {
662
- const cards = []
663
- const cardsByThread = new Map()
664
- for (const thread of threads) {
665
- const messages = messagesFor(thread)
666
- const turns = []
667
- for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
326
+ render()
327
+ window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
328
+ }
329
+
330
+ async function sendMessage(thread, text) {
331
+ if (thread.dshSessionId === null) throw new Error('该节点没有关联的 DSH 会话')
332
+ if (state.pendingReplies.has(thread.dshSessionId)) throw new Error('该会话正在回复,请稍后再发送')
333
+ state.pendingReplies.set(thread.dshSessionId, { text, at: Date.now() })
334
+ state.error = ''
335
+ render()
336
+ try {
337
+ await dshRpc('synapse:send-message', { sessionId: thread.dshSessionId, text })
338
+ void loadThreadHistory(thread)
339
+ } catch (error) {
340
+ state.pendingReplies.delete(thread.dshSessionId)
341
+ render()
342
+ throw error
343
+ }
344
+ }
345
+
346
+ async function submitDraft() {
347
+ const draft = state.draft
348
+ const text = draft?.text.trim()
349
+ if (draft === null || !text) return
350
+ const branchPosition = draft.kind === 'branch' && state.workspace !== null ? draftPlacement(conversationCards(state.workspace.threads))?.position : undefined
351
+ draft.sending = true
352
+ state.error = ''
353
+ render()
354
+ try {
355
+ if (draft.kind === 'new') {
356
+ const session = await dshRpc('synapse:create-session', { workspaceId: state.selectedDshWorkspaceId, cwd: state.currentDsh?.cwd })
357
+ await dshRpc('synapse:send-message', { sessionId: session.id, text })
358
+ state.draft = null
359
+ render()
360
+ window.setTimeout(() => {
361
+ void refreshProjection().catch(() => {})
362
+ }, 150)
363
+ return
364
+ }
365
+ const parent = state.workspace?.threads.find(thread => thread.id === draft.parentId)
366
+ if (parent === undefined) throw new Error('来源会话不存在')
367
+ if (draft.kind === 'continue') {
368
+ state.draft = null
369
+ await sendMessage(parent, text)
370
+ return
371
+ }
372
+ const session = await dshRpc('synapse:fork-session', { sessionId: parent.dshSessionId, atSeq: draft.atSeq })
373
+ if (draft.anchorId !== undefined) rememberBranchAnchor(session.id, draft.anchorId)
374
+ 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 }) })
375
+ 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)
376
+ state.activeId = result.thread.id
377
+ state.draft = null
378
+ state.pendingReplies.set(result.thread.dshSessionId, { text, at: Date.now() })
379
+ render()
380
+ await dshRpc('synapse:send-message', { sessionId: result.thread.dshSessionId, text })
381
+ void loadThreadHistory(result.thread)
382
+ await refreshProjection()
383
+ } catch (error) {
384
+ if (draft.kind === 'branch') {
385
+ state.pendingReplies.delete(state.workspace?.threads.find(thread => thread.id === state.activeId)?.dshSessionId)
386
+ if (state.draft !== null) state.draft = { ...draft, sending: false }
387
+ } else {
388
+ state.draft = { ...draft, sending: false }
389
+ }
390
+ setError(error)
391
+ }
392
+ }
393
+
394
+ function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
395
+ function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
396
+
397
+ function pendingUserIndex(messages, pending) {
398
+ return messages.findLastIndex(message => message.kind === 'user' && message.text === pending.text && new Date(message.at).getTime() >= pending.at - 2_000)
399
+ }
400
+
401
+ function settlePendingReply(thread, messages) {
402
+ const pending = state.pendingReplies.get(thread.dshSessionId)
403
+ if (pending === undefined) return false
404
+ const userIndex = pendingUserIndex(messages, pending)
405
+ if (userIndex === -1 || !messages.slice(userIndex + 1).some(message => message.kind === 'assistant')) return false
406
+ state.pendingReplies.delete(thread.dshSessionId)
407
+ return true
408
+ }
409
+
410
+ function messagesFor(thread) {
411
+ // A runtime-context snapshot is internal DSH state, never a user turn.
412
+ // Filter here as well as during persistence so existing saved workspaces
413
+ // immediately render one question and its answer as one card.
414
+ 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.')))
415
+ const pending = state.pendingReplies.get(thread.dshSessionId)
416
+ if (pending === undefined) return messages
417
+ if (settlePendingReply(thread, messages)) {
418
+ state.liveReplies.delete(thread.dshSessionId)
419
+ return messages
420
+ }
421
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
422
+ const liveAssistant = liveReply?.running ? { kind: 'assistant', text: liveReply.text, pending: true, at: new Date().toISOString() } : { kind: 'assistant', text: '', pending: true, at: new Date().toISOString() }
423
+ const userIndex = pendingUserIndex(messages, pending)
424
+ if (userIndex !== -1) return [...messages, liveAssistant]
425
+ return [...messages, { kind: 'user', text: pending.text, pending: true, at: new Date(pending.at).toISOString() }, liveAssistant]
426
+ }
427
+
428
+ function latestMessage(thread, kind) { return [...messagesFor(thread)].reverse().find(message => message.kind === kind) }
429
+ function questionFor(thread) { return latestMessage(thread, 'user')?.text ?? thread.dshSessionTitle ?? '等待用户提问' }
430
+ function answerFor(thread) { return latestMessage(thread, 'assistant') ?? null }
431
+
432
+ function inlineMarkdown(text) {
433
+ return escapeHtml(text)
434
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
435
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
436
+ .replace(/~~([^~]+)~~/g, '<s>$1</s>')
437
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
438
+ }
439
+
440
+ const tableCells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
441
+
442
+ const isTableDelimiter = line => {
443
+ const cells = tableCells(line)
444
+ return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell))
445
+ }
446
+
447
+ function markdownBlock(text) {
448
+ const lines = text.split('\n')
449
+ const output = []
450
+ for (let index = 0; index < lines.length;) {
451
+ const line = lines[index]
452
+ if (line.trim() === '') { index++; continue }
453
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line)
454
+ if (heading !== null) {
455
+ const level = heading[1].length
456
+ output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`)
457
+ index++
458
+ continue
459
+ }
460
+ const unordered = /^[-*+]\s+(.+)$/.exec(line)
461
+ const ordered = /^\d+[.)]\s+(.+)$/.exec(line)
462
+ if (unordered !== null || ordered !== null) {
463
+ const matcher = unordered === null ? /^\d+[.)]\s+(.+)$/ : /^[-*+]\s+(.+)$/
464
+ const items = []
465
+ while (index < lines.length) {
466
+ const item = matcher.exec(lines[index])
467
+ if (item === null) break
468
+ items.push(`<li>${inlineMarkdown(item[1])}</li>`)
469
+ index++
470
+ }
471
+ output.push(`<${unordered === null ? 'ol' : 'ul'}>${items.join('')}</${unordered === null ? 'ol' : 'ul'}>`)
472
+ continue
473
+ }
474
+ // GFM table: a leading-pipe header row followed by a |-delimiter row,
475
+ // then any number of leading-pipe body rows.
476
+ if (/^\s*\|/.test(line) && index + 1 < lines.length && isTableDelimiter(lines[index + 1])) {
477
+ const header = line
478
+ const body = []
479
+ index += 2
480
+ while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
481
+ body.push(lines[index])
482
+ index++
483
+ }
484
+ 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>`)
485
+ continue
486
+ }
487
+ const paragraph = []
488
+ 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++])
489
+ // A marker-only line such as PowerShell's "+ " diagnostic is neither a
490
+ // list item nor paragraph content under the rules above. Consume it so
491
+ // the parser always makes progress.
492
+ if (paragraph.length === 0) paragraph.push(lines[index++])
493
+ output.push(`<p>${paragraph.map(inlineMarkdown).join('<br>')}</p>`)
494
+ }
495
+ return output.join('')
496
+ }
497
+
498
+ // Markdown parsing is pure CPU and repeats for every card on every canvas
499
+ // rebuild; cache the rendered HTML by input text so stable answers are never
500
+ // re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
501
+ const markdownCache = new Map()
502
+ const MARKDOWN_CACHE_LIMIT = 5000
503
+ function renderMarkdown(text) {
504
+ const key = String(text)
505
+ const cached = markdownCache.get(key)
506
+ if (cached !== undefined) return cached
507
+ const parts = key.split(/```/)
508
+ const rendered = parts.map((part, index) => index % 2 === 1
509
+ ? `<pre><code>${escapeHtml(part.replace(/^\w*\n/, ''))}</code></pre>`
510
+ : markdownBlock(part)).join('')
511
+ if (markdownCache.size >= MARKDOWN_CACHE_LIMIT) markdownCache.delete(markdownCache.keys().next().value)
512
+ markdownCache.set(key, rendered)
513
+ return rendered
514
+ }
515
+
516
+ function overlapsCard(position, other) {
517
+ return position.x < other.x + CARD_WIDTH && position.x + CARD_WIDTH > other.x
518
+ && position.y < other.y + CARD_HEIGHT && position.y + CARD_HEIGHT > other.y
519
+ }
520
+
521
+ function firstAvailableCardPosition(position, occupied) {
522
+ const candidate = { x: Math.round(position.x), y: Math.max(82, Math.round(position.y)) }
523
+ while (true) {
524
+ const collisions = occupied.filter(other => overlapsCard(candidate, other))
525
+ if (collisions.length === 0) return candidate
526
+ candidate.y = Math.max(...collisions.map(other => other.y + CARD_HEIGHT + CARD_GAP_Y))
527
+ }
528
+ }
529
+
530
+ function connectorPath(fromPosition, toPosition) {
531
+ const fromX = fromPosition.x + CARD_WIDTH
532
+ const fromY = fromPosition.y + CARD_HEIGHT / 2
533
+ const toX = toPosition.x
534
+ const toY = toPosition.y + CARD_HEIGHT / 2
535
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
536
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
537
+ }
538
+
539
+ function connectorPathFromElements(fromCard, toCard) {
540
+ const fromX = Number.parseFloat(fromCard.style.left) + CARD_WIDTH
541
+ const fromY = Number.parseFloat(fromCard.style.top) + CARD_HEIGHT / 2
542
+ const toX = Number.parseFloat(toCard.style.left)
543
+ const toY = Number.parseFloat(toCard.style.top) + CARD_HEIGHT / 2
544
+ if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null
545
+ const bend = Math.min(110, Math.max(36, Math.abs(toX - fromX) * .2))
546
+ return `M ${fromX} ${fromY} C ${fromX + bend} ${fromY}, ${toX - bend} ${toY}, ${toX} ${toY}`
547
+ }
548
+
549
+ function selectorValue(value) {
550
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
551
+ }
552
+
553
+ // Connector paths are rebuilt together with the canvas DOM; cache the mapping
554
+ // from card id to its incident paths so dragging never scans the whole SVG.
555
+ let connectorPathsByCard = new Map()
556
+ function cacheCardConnectors() {
557
+ connectorPathsByCard = new Map()
558
+ const viewport = document.querySelector('.canvas-viewport')
559
+ if (!(viewport instanceof HTMLElement)) return
560
+ for (const path of viewport.querySelectorAll('.connectors path[data-from]')) {
561
+ const fromId = path.getAttribute('data-from')
562
+ const toId = path.getAttribute('data-to')
563
+ if (fromId === null || toId === null) continue
564
+ for (const id of [fromId, toId]) {
565
+ const paths = connectorPathsByCard.get(id)
566
+ if (paths === undefined) connectorPathsByCard.set(id, new Set([path]))
567
+ else paths.add(path)
568
+ }
569
+ }
570
+ }
571
+
572
+ function refreshCardConnectors(cardId) {
573
+ const paths = connectorPathsByCard.get(cardId)
574
+ if (paths === undefined || paths.size === 0) return
575
+ const byId = state.canvasCardsById
576
+ if (byId === undefined) return
577
+ for (const path of paths) {
578
+ const fromId = path.getAttribute('data-from')
579
+ const toId = path.getAttribute('data-to')
580
+ if (fromId === null || toId === null) continue
581
+ const fromCard = byId.get(fromId)
582
+ const toCard = byId.get(toId)
583
+ if (fromCard === undefined || toCard === undefined) continue
584
+ // Data-driven endpoints: the counterpart card may be unmounted (outside
585
+ // the viewport) but its position is still authoritative.
586
+ path.setAttribute('d', connectorPath(fromCard.position, toCard.position))
587
+ }
588
+ }
589
+
590
+ function initialCanvasCamera(cards) {
591
+ const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
592
+ // Focus the active conversation's latest turn, not its first: after many
593
+ // rounds the canvas should open where work is happening, at the newest card.
594
+ const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
595
+ const active = activeCards.at(-1)
596
+ const focus = draft ?? active ?? cards[0]
597
+ const position = focus?.position
598
+ if (position === undefined) return { x: 0, y: 0 }
599
+ return { x: CAMERA_INSET_X - position.x * state.zoom, y: CAMERA_INSET_Y - position.y * state.zoom }
600
+ }
601
+
602
+ function placeConversationCards(cards) {
603
+ const saved = new Map(cards.flatMap(card => {
604
+ if (card.positionLocked !== true) return []
605
+ const position = state.cardPositions.get(card.id) ?? state.cardPositions.get(card.positionKey)
606
+ return position === undefined ? [] : [[card.id, { x: position.x, y: position.y }]]
607
+ }))
608
+ const occupied = []
609
+ for (const card of cards) {
610
+ const position = saved.get(card.id)
611
+ if (position !== undefined) {
612
+ card.position = position
613
+ continue
614
+ }
615
+ card.position = firstAvailableCardPosition(card.naturalPosition ?? card.position, occupied)
616
+ occupied.push(card.position)
617
+ }
618
+ return cards
619
+ }
620
+
621
+ function layoutConversationGraph(cards, threads) {
622
+ const childrenByThread = new Map()
623
+ for (const thread of threads) {
624
+ if (thread.parentId === null) continue
625
+ const children = childrenByThread.get(thread.parentId) ?? []
626
+ children.push(thread.id)
627
+ childrenByThread.set(thread.parentId, children)
628
+ }
629
+ const laneByThread = new Map()
630
+ const visitThread = threadId => {
631
+ if (laneByThread.has(threadId)) return
632
+ laneByThread.set(threadId, laneByThread.size)
633
+ for (const childId of childrenByThread.get(threadId) ?? []) visitThread(childId)
634
+ }
635
+ for (const thread of threads) if (thread.parentId === null) visitThread(thread.id)
636
+ for (const thread of threads) visitThread(thread.id)
637
+
638
+ const byId = new Map(cards.map(card => [card.id, card]))
639
+ const positioned = new Map()
640
+ const positionFor = (card, visiting = new Set()) => {
641
+ if (positioned.has(card.id)) return positioned.get(card.id)
642
+ if (visiting.has(card.id)) return { x: 86, y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y) }
643
+ visiting.add(card.id)
644
+ const parent = card.parentId === null ? undefined : byId.get(card.parentId)
645
+ const parentPosition = parent === undefined ? undefined : positionFor(parent, visiting)
646
+ const position = {
647
+ x: parentPosition === undefined ? 86 : parentPosition.x + 365,
648
+ y: 82 + (laneByThread.get(card.dshThreadId) ?? 0) * (CARD_HEIGHT + CARD_GAP_Y),
649
+ }
650
+ visiting.delete(card.id)
651
+ positioned.set(card.id, position)
652
+ return position
653
+ }
654
+ for (const card of cards) {
655
+ card.naturalPosition = positionFor(card)
656
+ if (!card.positionLocked) card.position = card.naturalPosition
657
+ }
658
+ return placeConversationCards(cards)
659
+ }
660
+
661
+ function conversationCards(threads) {
662
+ const cards = []
663
+ const cardsByThread = new Map()
664
+ for (const thread of threads) {
665
+ const messages = messagesFor(thread)
666
+ const turns = []
667
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
668
668
  const question = messages[messageIndex]
669
669
  if (question.kind !== 'user') continue
670
670
  const replies = []
@@ -680,188 +680,188 @@ function conversationCards(threads) {
680
680
  }
681
681
  const answer = replies.at(-1) ?? null
682
682
  const error = errors.at(-1) ?? null
683
- const turnIndex = turns.length
684
- const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
685
- const previous = turns.at(-1)
686
- const positionKey = `${thread.id}:turn-index:${turnIndex}`
687
- const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y }
688
- const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
689
- const positionLocked = savedPosition !== undefined
690
- const position = positionLocked ? savedPosition : naturalPosition
691
- turns.push({
692
- id,
693
- positionKey,
694
- dshThreadId: thread.id,
695
- sourceParentId: thread.parentId,
696
- parentId: null,
697
- sourceSeq: question.sourceSeq,
698
- turnIndex,
699
- naturalPosition,
700
- position,
701
- positionLocked,
683
+ const turnIndex = turns.length
684
+ const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
685
+ const previous = turns.at(-1)
686
+ const positionKey = `${thread.id}:turn-index:${turnIndex}`
687
+ const naturalPosition = previous === undefined ? { x: 86, y: 82 } : { x: previous.naturalPosition.x + 365, y: previous.naturalPosition.y }
688
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
689
+ const positionLocked = savedPosition !== undefined
690
+ const position = positionLocked ? savedPosition : naturalPosition
691
+ turns.push({
692
+ id,
693
+ positionKey,
694
+ dshThreadId: thread.id,
695
+ sourceParentId: thread.parentId,
696
+ parentId: null,
697
+ sourceSeq: question.sourceSeq,
698
+ turnIndex,
699
+ naturalPosition,
700
+ position,
701
+ positionLocked,
702
702
  question: question.text,
703
703
  answer,
704
704
  error,
705
705
  processCount,
706
- })
707
- }
708
- const liveReply = state.liveReplies.get(thread.dshSessionId)
709
- const latestTurn = turns.at(-1)
710
- 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() }
711
- if (turns.length === 0) {
712
- const id = `${thread.id}:turn:empty`
713
- const positionKey = `${thread.id}:turn-index:0`
714
- const naturalPosition = { x: 86, y: 82 }
715
- const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
716
- const positionLocked = savedPosition !== undefined
717
- turns.push({
718
- id,
719
- positionKey,
720
- dshThreadId: thread.id,
721
- sourceParentId: thread.parentId,
722
- parentId: null,
723
- sourceSeq: undefined,
724
- turnIndex: 0,
725
- naturalPosition,
726
- position: positionLocked ? savedPosition : naturalPosition,
727
- positionLocked,
706
+ })
707
+ }
708
+ const liveReply = state.liveReplies.get(thread.dshSessionId)
709
+ const latestTurn = turns.at(-1)
710
+ 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() }
711
+ if (turns.length === 0) {
712
+ const id = `${thread.id}:turn:empty`
713
+ const positionKey = `${thread.id}:turn-index:0`
714
+ const naturalPosition = { x: 86, y: 82 }
715
+ const savedPosition = state.cardPositions?.get(id) ?? state.cardPositions?.get(positionKey)
716
+ const positionLocked = savedPosition !== undefined
717
+ turns.push({
718
+ id,
719
+ positionKey,
720
+ dshThreadId: thread.id,
721
+ sourceParentId: thread.parentId,
722
+ parentId: null,
723
+ sourceSeq: undefined,
724
+ turnIndex: 0,
725
+ naturalPosition,
726
+ position: positionLocked ? savedPosition : naturalPosition,
727
+ positionLocked,
728
728
  question: thread.dshSessionTitle ?? thread.title,
729
729
  answer: null,
730
730
  error: null,
731
731
  processCount: 0,
732
- })
733
- }
734
- turns.at(-1).canContinue = true
735
- cardsByThread.set(thread.id, turns)
736
- cards.push(...turns)
737
- }
738
- for (const card of cards) {
739
- const siblings = cardsByThread.get(card.dshThreadId)
740
- if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id
741
- else {
742
- const parentCards = cardsByThread.get(card.sourceParentId)
743
- const sourceThread = threads.find(thread => thread.id === card.dshThreadId)
744
- const firstChildQuestion = siblings?.[0]
745
- const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq
746
- // A fork inherits every parent event before DSH's durable seed boundary.
747
- // The latest parent question below that boundary is the exact Turn where
748
- // this child was born. Canvas coordinates never participate in lineage.
749
- const inheritedTurn = Number.isSafeInteger(seedLength)
750
- ? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1)
751
- : undefined
752
- card.parentId = state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null
753
- }
754
- }
755
- return layoutConversationGraph(cards, threads)
756
- }
757
-
758
- function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) {
759
- const cardIds = new Set(cards.map(card => card.id))
760
- const childrenByParent = new Map()
761
- for (const card of cards) {
762
- if (card.parentId === null || !cardIds.has(card.parentId)) continue
763
- const children = childrenByParent.get(card.parentId) ?? []
764
- children.push(card.id)
765
- childrenByParent.set(card.parentId, children)
766
- }
767
-
768
- const hiddenIds = new Set()
769
- for (const rootId of collapsedCardIds) {
770
- if (!cardIds.has(rootId)) continue
771
- const visited = new Set([rootId])
772
- const visit = parentId => {
773
- for (const childId of childrenByParent.get(parentId) ?? []) {
774
- if (visited.has(childId)) continue
775
- visited.add(childId)
776
- hiddenIds.add(childId)
777
- visit(childId)
778
- }
779
- }
780
- visit(rootId)
781
- }
782
-
783
- // Persisted collapse roots must remain visible even if malformed metadata
784
- // contains a cycle where two collapsed nodes otherwise hide each other.
785
- for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
786
-
787
- // Post-order accumulation: each card's descendant count is 1 + the sum of
788
- // its children's subtree sizes, so the whole graph is O(n) instead of a BFS
789
- // from every card (O(n²) on deep chains). Malformed parent cycles are
790
- // detected through the DFS path: every member of a cycle reaches every other
791
- // member plus the union of their off-cycle subtrees, so when the cycle entry
792
- // pops last, all members are settled to (cycleSize - 1) + off-cycle total,
793
- // which matches the per-card BFS' unique-descendant count.
794
- const descendantCounts = new Map()
795
- const inStack = new Set()
796
- for (const card of cards) {
797
- if (descendantCounts.has(card.id)) continue
798
- const stack = [{ id: card.id, children: childrenByParent.get(card.id) ?? [], index: 0 }]
799
- const path = [card.id]
800
- let cycleEntry = null
801
- let cycleMembers = null
802
- let cycleOffCycleTotal = 0
803
- inStack.add(card.id)
804
- while (stack.length > 0) {
805
- const top = stack[stack.length - 1]
806
- if (top.index < top.children.length) {
807
- const childId = top.children[top.index++]
808
- if (descendantCounts.has(childId)) continue
809
- if (inStack.has(childId)) {
810
- // Back edge: the nodes from childId up to top.id form a cycle.
811
- cycleEntry = childId
812
- cycleMembers = new Set(path.slice(path.indexOf(childId)))
813
- cycleOffCycleTotal = 0
814
- continue
815
- }
816
- inStack.add(childId)
817
- path.push(childId)
818
- stack.push({ id: childId, children: childrenByParent.get(childId) ?? [], index: 0 })
819
- } else {
820
- stack.pop()
821
- path.pop()
822
- inStack.delete(top.id)
823
- let count = 0
824
- for (const childId of top.children) {
825
- if (cycleMembers !== null && cycleMembers.has(childId)) continue // ring edge; base count added below
826
- count += 1 + (descendantCounts.get(childId) ?? 0)
827
- }
828
- if (cycleMembers !== null && cycleMembers.has(top.id)) cycleOffCycleTotal += count
829
- if (cycleMembers !== null && top.id === cycleEntry) {
830
- // All cycle members have popped (the entry pops last in post-order);
831
- // settle them so ancestors popping next read the final counts.
832
- const base = cycleMembers.size - 1
833
- for (const id of cycleMembers) descendantCounts.set(id, base + cycleOffCycleTotal)
834
- cycleEntry = null
835
- cycleMembers = null
836
- } else {
837
- descendantCounts.set(top.id, count)
838
- }
839
- }
840
- }
841
- }
842
-
843
- return {
844
- cards: cards.filter(card => !hiddenIds.has(card.id)),
845
- childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])),
846
- descendantCounts,
847
- }
848
- }
849
-
850
- function revealConversationThread(cards, threadId) {
851
- const byId = new Map(cards.map(card => [card.id, card]))
852
- let changed = false
853
- for (const target of cards.filter(card => card.dshThreadId === threadId)) {
854
- const visited = new Set([target.id])
855
- let parentId = target.parentId
856
- while (parentId !== null && !visited.has(parentId)) {
857
- visited.add(parentId)
858
- if (state.collapsedCardIds.delete(parentId)) changed = true
859
- parentId = byId.get(parentId)?.parentId ?? null
860
- }
861
- }
862
- if (changed) persistCollapsedCards()
863
- }
864
-
732
+ })
733
+ }
734
+ turns.at(-1).canContinue = true
735
+ cardsByThread.set(thread.id, turns)
736
+ cards.push(...turns)
737
+ }
738
+ for (const card of cards) {
739
+ const siblings = cardsByThread.get(card.dshThreadId)
740
+ if (card.turnIndex > 0) card.parentId = siblings[card.turnIndex - 1].id
741
+ else {
742
+ const parentCards = cardsByThread.get(card.sourceParentId)
743
+ const sourceThread = threads.find(thread => thread.id === card.dshThreadId)
744
+ const firstChildQuestion = siblings?.[0]
745
+ const seedLength = sourceThread?.sourceSeedLength ?? firstChildQuestion?.sourceSeq
746
+ // A fork inherits every parent event before DSH's durable seed boundary.
747
+ // The latest parent question below that boundary is the exact Turn where
748
+ // this child was born. Canvas coordinates never participate in lineage.
749
+ const inheritedTurn = Number.isSafeInteger(seedLength)
750
+ ? parentCards?.filter(candidate => Number.isInteger(candidate.sourceSeq) && candidate.sourceSeq < seedLength).at(-1)
751
+ : undefined
752
+ card.parentId = state.branchAnchors.get(card.dshThreadId) ?? inheritedTurn?.id ?? null
753
+ }
754
+ }
755
+ return layoutConversationGraph(cards, threads)
756
+ }
757
+
758
+ function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds) {
759
+ const cardIds = new Set(cards.map(card => card.id))
760
+ const childrenByParent = new Map()
761
+ for (const card of cards) {
762
+ if (card.parentId === null || !cardIds.has(card.parentId)) continue
763
+ const children = childrenByParent.get(card.parentId) ?? []
764
+ children.push(card.id)
765
+ childrenByParent.set(card.parentId, children)
766
+ }
767
+
768
+ const hiddenIds = new Set()
769
+ for (const rootId of collapsedCardIds) {
770
+ if (!cardIds.has(rootId)) continue
771
+ const visited = new Set([rootId])
772
+ const visit = parentId => {
773
+ for (const childId of childrenByParent.get(parentId) ?? []) {
774
+ if (visited.has(childId)) continue
775
+ visited.add(childId)
776
+ hiddenIds.add(childId)
777
+ visit(childId)
778
+ }
779
+ }
780
+ visit(rootId)
781
+ }
782
+
783
+ // Persisted collapse roots must remain visible even if malformed metadata
784
+ // contains a cycle where two collapsed nodes otherwise hide each other.
785
+ for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
786
+
787
+ // Post-order accumulation: each card's descendant count is 1 + the sum of
788
+ // its children's subtree sizes, so the whole graph is O(n) instead of a BFS
789
+ // from every card (O(n²) on deep chains). Malformed parent cycles are
790
+ // detected through the DFS path: every member of a cycle reaches every other
791
+ // member plus the union of their off-cycle subtrees, so when the cycle entry
792
+ // pops last, all members are settled to (cycleSize - 1) + off-cycle total,
793
+ // which matches the per-card BFS' unique-descendant count.
794
+ const descendantCounts = new Map()
795
+ const inStack = new Set()
796
+ for (const card of cards) {
797
+ if (descendantCounts.has(card.id)) continue
798
+ const stack = [{ id: card.id, children: childrenByParent.get(card.id) ?? [], index: 0 }]
799
+ const path = [card.id]
800
+ let cycleEntry = null
801
+ let cycleMembers = null
802
+ let cycleOffCycleTotal = 0
803
+ inStack.add(card.id)
804
+ while (stack.length > 0) {
805
+ const top = stack[stack.length - 1]
806
+ if (top.index < top.children.length) {
807
+ const childId = top.children[top.index++]
808
+ if (descendantCounts.has(childId)) continue
809
+ if (inStack.has(childId)) {
810
+ // Back edge: the nodes from childId up to top.id form a cycle.
811
+ cycleEntry = childId
812
+ cycleMembers = new Set(path.slice(path.indexOf(childId)))
813
+ cycleOffCycleTotal = 0
814
+ continue
815
+ }
816
+ inStack.add(childId)
817
+ path.push(childId)
818
+ stack.push({ id: childId, children: childrenByParent.get(childId) ?? [], index: 0 })
819
+ } else {
820
+ stack.pop()
821
+ path.pop()
822
+ inStack.delete(top.id)
823
+ let count = 0
824
+ for (const childId of top.children) {
825
+ if (cycleMembers !== null && cycleMembers.has(childId)) continue // ring edge; base count added below
826
+ count += 1 + (descendantCounts.get(childId) ?? 0)
827
+ }
828
+ if (cycleMembers !== null && cycleMembers.has(top.id)) cycleOffCycleTotal += count
829
+ if (cycleMembers !== null && top.id === cycleEntry) {
830
+ // All cycle members have popped (the entry pops last in post-order);
831
+ // settle them so ancestors popping next read the final counts.
832
+ const base = cycleMembers.size - 1
833
+ for (const id of cycleMembers) descendantCounts.set(id, base + cycleOffCycleTotal)
834
+ cycleEntry = null
835
+ cycleMembers = null
836
+ } else {
837
+ descendantCounts.set(top.id, count)
838
+ }
839
+ }
840
+ }
841
+ }
842
+
843
+ return {
844
+ cards: cards.filter(card => !hiddenIds.has(card.id)),
845
+ childCounts: new Map(cards.map(card => [card.id, childrenByParent.get(card.id)?.length ?? 0])),
846
+ descendantCounts,
847
+ }
848
+ }
849
+
850
+ function revealConversationThread(cards, threadId) {
851
+ const byId = new Map(cards.map(card => [card.id, card]))
852
+ let changed = false
853
+ for (const target of cards.filter(card => card.dshThreadId === threadId)) {
854
+ const visited = new Set([target.id])
855
+ let parentId = target.parentId
856
+ while (parentId !== null && !visited.has(parentId)) {
857
+ visited.add(parentId)
858
+ if (state.collapsedCardIds.delete(parentId)) changed = true
859
+ parentId = byId.get(parentId)?.parentId ?? null
860
+ }
861
+ }
862
+ if (changed) persistCollapsedCards()
863
+ }
864
+
865
865
  function canvasConnectors(cards) {
866
866
  const index = new Map(cards.map(card => [card.id, card]))
867
867
  const links = cards.map(card => {
@@ -869,35 +869,35 @@ function canvasConnectors(cards) {
869
869
  if (parent === undefined || parent === null) return ''
870
870
  const active = card.dshThreadId === state.activeId && parent.dshThreadId === state.activeId ? ' active-connector' : ''
871
871
  return `<path class="${active.trim()}" data-from="${escapeHtml(parent.id)}" data-to="${escapeHtml(card.id)}" d="${connectorPath(parent.position, card.position)}"></path>`
872
- })
873
- const placement = draftPlacement(cards)
874
- if (placement !== null) {
875
- links.push(`<path class="draft-connector" data-from="${escapeHtml(placement.parent.id)}" data-to="draft" d="${connectorPath(placement.parent.position, placement.position)}"></path>`)
876
- }
877
- return links.join('')
878
- }
879
-
872
+ })
873
+ const placement = draftPlacement(cards)
874
+ if (placement !== null) {
875
+ links.push(`<path class="draft-connector" data-from="${escapeHtml(placement.parent.id)}" data-to="draft" d="${connectorPath(placement.parent.position, placement.position)}"></path>`)
876
+ }
877
+ return links.join('')
878
+ }
879
+
880
880
  function conversationCard(card, graph) {
881
881
  const selected = card.id === state.selectedCardId ? 'selected' : ''
882
- const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
883
- const continueButton = card.canContinue === true
884
- ? `<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>`
885
- : ''
886
- const childCount = graph.childCounts.get(card.id) ?? 0
887
- const collapsed = state.collapsedCardIds.has(card.id)
888
- const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
889
- 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>`
890
- 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>`
882
+ const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
883
+ const continueButton = card.canContinue === true
884
+ ? `<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>`
885
+ : ''
886
+ const childCount = graph.childCounts.get(card.id) ?? 0
887
+ const collapsed = state.collapsedCardIds.has(card.id)
888
+ const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
889
+ 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>`
890
+ 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>`
891
891
  return `<article class="thread-card ${selected}" 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">
892
- <button class="node-handle" data-drag-card="${card.id}" aria-label="拖动 ${escapeHtml(card.question)}" title="拖动卡片"></button>
893
- ${continueButton}${foldButton}${branchButton}
894
- <div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
892
+ <button class="node-handle" data-drag-card="${card.id}" aria-label="拖动 ${escapeHtml(card.question)}" title="拖动卡片"></button>
893
+ ${continueButton}${foldButton}${branchButton}
894
+ <div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
895
895
  <div class="thread-meta"><span>${source}</span><span>第 ${card.turnIndex + 1} 轮</span>${card.error === null ? '' : '<span class="card-error-status">失败</span>'}${card.processCount > 0 ? `<span class="card-process-count">工具 ${card.processCount}</span>` : ''}</div>
896
896
  <div class="thread-answer">${card.answer === null ? (card.error === 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>' : ''}`}${card.error === null ? '' : `<p class="thread-answer-error" title="${escapeHtml(card.error.text)}">本轮失败:${escapeHtml(card.error.text)}</p>`}</div>
897
- <footer><button data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话" aria-label="查看完整会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8.5 8 2.5l6 6V13.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5Z"/><path d="M6.2 14v-3.6a1.8 1.8 0 0 1 3.6 0V14" /></svg>详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}" data-seq="${Number.isInteger(card.sourceSeq) ? card.sourceSeq : ''}" title="在 DSH 中打开" aria-label="在 DSH 中打开"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5H4.5A1.5 1.5 0 0 0 3 5v6.5A1.5 1.5 0 0 0 4.5 13H11a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M9.5 3.5h3v3M12.4 3.6 7.5 8.5"/></svg>DSH</button><button data-action="archive-thread" data-thread="${card.dshThreadId}" title="归档此会话" aria-label="归档此会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 5h11M5.5 7v5.5a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V7"/><path d="M4 5 5 2.8a.7.7 0 0 1 .6-.4h4.8a.7.7 0 0 1 .6.4L12 5M6 9.5h4"/></svg>归档</button></footer>
898
- </article>`
899
- }
900
-
897
+ <footer><button data-action="show-thread" data-thread="${card.dshThreadId}" data-card="${escapeHtml(card.id)}" title="查看完整会话" aria-label="查看完整会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8.5 8 2.5l6 6V13.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5Z"/><path d="M6.2 14v-3.6a1.8 1.8 0 0 1 3.6 0V14" /></svg>详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}" data-seq="${Number.isInteger(card.sourceSeq) ? card.sourceSeq : ''}" title="在 DSH 中打开" aria-label="在 DSH 中打开"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5H4.5A1.5 1.5 0 0 0 3 5v6.5A1.5 1.5 0 0 0 4.5 13H11a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M9.5 3.5h3v3M12.4 3.6 7.5 8.5"/></svg>DSH</button><button data-action="archive-thread" data-thread="${card.dshThreadId}" title="归档此会话" aria-label="归档此会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 5h11M5.5 7v5.5a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V7"/><path d="M4 5 5 2.8a.7.7 0 0 1 .6-.4h4.8a.7.7 0 0 1 .6.4L12 5M6 9.5h4"/></svg>归档</button></footer>
898
+ </article>`
899
+ }
900
+
901
901
  function draftActions(draft) {
902
902
  const disabled = draft.sending ? 'disabled' : ''
903
903
  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>`
@@ -956,89 +956,89 @@ function updateQuickPhrase(index, value) {
956
956
  persistQuickPhrases()
957
957
  render()
958
958
  }
959
-
960
- function draftPlacement(cards) {
961
- const draft = state.draft
962
- if (draft === null || draft.kind === 'new') return null
963
- const parent = draft.anchorId === undefined
964
- ? cards.filter(card => card.dshThreadId === draft.parentId).at(-1)
965
- : cards.find(card => card.id === draft.anchorId)
966
- if (parent === undefined) return null
967
- return { parent, position: firstAvailableCardPosition({ x: parent.position.x + 365, y: parent.position.y }, cards.map(card => card.position)) }
968
- }
969
-
959
+
960
+ function draftPlacement(cards) {
961
+ const draft = state.draft
962
+ if (draft === null || draft.kind === 'new') return null
963
+ const parent = draft.anchorId === undefined
964
+ ? cards.filter(card => card.dshThreadId === draft.parentId).at(-1)
965
+ : cards.find(card => card.id === draft.anchorId)
966
+ if (parent === undefined) return null
967
+ return { parent, position: firstAvailableCardPosition({ x: parent.position.x + 365, y: parent.position.y }, cards.map(card => card.position)) }
968
+ }
969
+
970
970
  function draftCard(cards) {
971
- const draft = state.draft
972
- 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">
973
- <div class="thread-card-head"><span class="topic-dot"></span><strong>新会话</strong></div>
974
- <form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="输入第一条消息" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
975
- </article>`
976
- const placement = draftPlacement(cards)
977
- if (draft === null || placement === null) return ''
978
- const continuing = draft.kind === 'continue'
979
- 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">
980
- <div class="thread-card-head"><span class="topic-dot"></span><strong>${continuing ? '新的追问' : '新的分支'}</strong></div>
971
+ const draft = state.draft
972
+ 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">
973
+ <div class="thread-card-head"><span class="topic-dot"></span><strong>新会话</strong></div>
974
+ <form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="输入第一条消息" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
975
+ </article>`
976
+ const placement = draftPlacement(cards)
977
+ if (draft === null || placement === null) return ''
978
+ const continuing = draft.kind === 'continue'
979
+ 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">
980
+ <div class="thread-card-head"><span class="topic-dot"></span><strong>${continuing ? '新的追问' : '新的分支'}</strong></div>
981
981
  <form class="draft-branch-form" data-draft>${draftQuickPhrases(draft)}<textarea maxlength="4000" placeholder="${continuing ? '输入追问' : '输入这个分支的新问题'}" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
982
- </article>`
982
+ </article>`
983
983
  }
984
984
 
985
985
  function selectionFollowupButton() {
986
986
  return `<button class="selection-followup" type="button" data-action="follow-selection" hidden aria-label="基于所选内容创建追问" title="基于所选内容追问"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M3 3.5h10v6.25H7.2L4 12.5V9.75H3Z"/><path d="M8 4.9v3.4M6.3 6.6h3.4"/></svg><span>追问</span></button>`
987
987
  }
988
-
989
- // Cards are mounted into the DOM only when they intersect the viewport
990
- // (inflated by VIEWPORT_MARGIN) in world coordinates. The camera transform is
991
- // translate(camera) scale(zoom), so screen = world * zoom + camera.
992
- function visibleCardIds(cards) {
993
- const viewport = document.querySelector('.canvas-viewport')
994
- if (!(viewport instanceof HTMLElement)) return new Set(cards.map(card => card.id))
995
- const bounds = viewport.getBoundingClientRect()
996
- const left = (-state.canvasCamera.x - VIEWPORT_MARGIN) / state.zoom
997
- const right = (bounds.width - state.canvasCamera.x + VIEWPORT_MARGIN) / state.zoom
998
- const top = (-state.canvasCamera.y - VIEWPORT_MARGIN) / state.zoom
999
- const bottom = (bounds.height - state.canvasCamera.y + VIEWPORT_MARGIN) / state.zoom
1000
- const visible = new Set()
1001
- for (const card of cards) {
1002
- const { x, y } = card.position
1003
- if (x + CARD_WIDTH < left || x > right || y + CARD_HEIGHT < top || y > bottom) continue
1004
- visible.add(card.id)
1005
- }
1006
- return visible
1007
- }
1008
-
1009
- // Incrementally mount cards entering the viewport and unmount cards leaving
1010
- // it, without rebuilding the canvas. Called after pan/zoom/focus camera moves.
1011
- function syncCanvasViewport() {
1012
- if (state.mode !== 'canvas' || state.canvasCards === undefined) return
1013
- const layer = document.querySelector('.cards-layer')
1014
- if (!(layer instanceof HTMLElement)) return
1015
- const visible = visibleCardIds(state.canvasCards)
1016
- for (const cardId of [...state.mountedCardIds]) {
1017
- if (visible.has(cardId)) continue
1018
- const element = layer.querySelector(`[data-card-id="${selectorValue(cardId)}"]`)
1019
- if (element instanceof HTMLElement) element.remove()
1020
- state.mountedCardIds.delete(cardId)
1021
- }
1022
- for (const card of state.canvasCards) {
1023
- if (!visible.has(card.id) || state.mountedCardIds.has(card.id)) continue
1024
- const wrapper = document.createElement('div')
1025
- wrapper.innerHTML = conversationCard(card, state.canvasGraph)
1026
- const element = wrapper.firstElementChild
1027
- if (element instanceof HTMLElement) {
1028
- layer.appendChild(element)
1029
- const handle = element.querySelector('[data-drag-card]')
1030
- if (handle instanceof HTMLElement) bindDragHandle(handle)
1031
- }
1032
- state.mountedCardIds.add(card.id)
1033
- }
1034
- }
1035
-
1036
- function renderCanvas() {
1037
- const threads = state.workspace?.threads ?? []
1038
- 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>`
1039
- const allCards = conversationCards(threads)
1040
- const graph = conversationGraphView(allCards)
1041
- const cards = graph.cards
988
+
989
+ // Cards are mounted into the DOM only when they intersect the viewport
990
+ // (inflated by VIEWPORT_MARGIN) in world coordinates. The camera transform is
991
+ // translate(camera) scale(zoom), so screen = world * zoom + camera.
992
+ function visibleCardIds(cards) {
993
+ const viewport = document.querySelector('.canvas-viewport')
994
+ if (!(viewport instanceof HTMLElement)) return new Set(cards.map(card => card.id))
995
+ const bounds = viewport.getBoundingClientRect()
996
+ const left = (-state.canvasCamera.x - VIEWPORT_MARGIN) / state.zoom
997
+ const right = (bounds.width - state.canvasCamera.x + VIEWPORT_MARGIN) / state.zoom
998
+ const top = (-state.canvasCamera.y - VIEWPORT_MARGIN) / state.zoom
999
+ const bottom = (bounds.height - state.canvasCamera.y + VIEWPORT_MARGIN) / state.zoom
1000
+ const visible = new Set()
1001
+ for (const card of cards) {
1002
+ const { x, y } = card.position
1003
+ if (x + CARD_WIDTH < left || x > right || y + CARD_HEIGHT < top || y > bottom) continue
1004
+ visible.add(card.id)
1005
+ }
1006
+ return visible
1007
+ }
1008
+
1009
+ // Incrementally mount cards entering the viewport and unmount cards leaving
1010
+ // it, without rebuilding the canvas. Called after pan/zoom/focus camera moves.
1011
+ function syncCanvasViewport() {
1012
+ if (state.mode !== 'canvas' || state.canvasCards === undefined) return
1013
+ const layer = document.querySelector('.cards-layer')
1014
+ if (!(layer instanceof HTMLElement)) return
1015
+ const visible = visibleCardIds(state.canvasCards)
1016
+ for (const cardId of [...state.mountedCardIds]) {
1017
+ if (visible.has(cardId)) continue
1018
+ const element = layer.querySelector(`[data-card-id="${selectorValue(cardId)}"]`)
1019
+ if (element instanceof HTMLElement) element.remove()
1020
+ state.mountedCardIds.delete(cardId)
1021
+ }
1022
+ for (const card of state.canvasCards) {
1023
+ if (!visible.has(card.id) || state.mountedCardIds.has(card.id)) continue
1024
+ const wrapper = document.createElement('div')
1025
+ wrapper.innerHTML = conversationCard(card, state.canvasGraph)
1026
+ const element = wrapper.firstElementChild
1027
+ if (element instanceof HTMLElement) {
1028
+ layer.appendChild(element)
1029
+ const handle = element.querySelector('[data-drag-card]')
1030
+ if (handle instanceof HTMLElement) bindDragHandle(handle)
1031
+ }
1032
+ state.mountedCardIds.add(card.id)
1033
+ }
1034
+ }
1035
+
1036
+ function renderCanvas() {
1037
+ const threads = state.workspace?.threads ?? []
1038
+ 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>`
1039
+ const allCards = conversationCards(threads)
1040
+ const graph = conversationGraphView(allCards)
1041
+ const cards = graph.cards
1042
1042
  state.canvasCards = cards
1043
1043
  state.canvasCardsById = new Map(cards.map(card => [card.id, card]))
1044
1044
  state.canvasGraph = graph
@@ -1046,57 +1046,57 @@ function renderCanvas() {
1046
1046
  state.inspectorCardId = null
1047
1047
  state.inspectorOpening = false
1048
1048
  }
1049
- if (!state.canvasViewInitialized) {
1050
- state.canvasCamera = initialCanvasCamera(cards)
1051
- state.canvasViewInitialized = true
1052
- // The viewport is not laid out yet while renderCanvas builds its HTML;
1053
- // center the focused card once the DOM is mounted (render tail).
1054
- state.canvasNeedsCenter = true
1055
- }
1049
+ if (!state.canvasViewInitialized) {
1050
+ state.canvasCamera = initialCanvasCamera(cards)
1051
+ state.canvasViewInitialized = true
1052
+ // The viewport is not laid out yet while renderCanvas builds its HTML;
1053
+ // center the focused card once the DOM is mounted (render tail).
1054
+ state.canvasNeedsCenter = true
1055
+ }
1056
1056
  const visible = visibleCardIds(cards)
1057
1057
  state.mountedCardIds = new Set(visible)
1058
1058
  const mounted = cards.filter(card => visible.has(card.id))
1059
1059
  const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId))
1060
1060
  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">${mounted.map(card => conversationCard(card, graph)).join('')}${draftCard(cards)}</div></div></div>${inspector}</section>`
1061
1061
  }
1062
-
1063
- function isProcessMessage(message) {
1064
- if (message.kind === 'tool' || message.kind === 'tool-result') return true
1065
- return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text)
1066
- }
1067
-
1068
- function processSummary(text) {
1069
- return text.replace(/\s+/g, ' ').trim().slice(0, 140) || '工具调用记录'
1070
- }
1071
-
1072
- function threadMessage(thread, message) {
1073
- const isUser = message.kind === 'user'
1074
- const label = isUser ? '你' : message.kind === 'assistant' ? 'DSH' : message.kind === 'error' ? '错误' : '记录'
1075
- const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq)
1076
- ? `<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>`
1077
- : ''
1078
- const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}`
1079
- const collapsible = isProcessMessage(message)
1080
- const expanded = state.expandedMessageIds.has(messageId)
1081
- 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>` : ''
1082
- const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null
1083
- const body = message.pending && message.text === '' ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>'
1084
- : `${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)}`
1085
- const avatar = isUser ? '' : '<span class="message-avatar" aria-hidden="true"></span>'
1086
- return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}" data-message-seq="${Number.isInteger(message.sourceSeq) ? message.sourceSeq : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
1087
- }
1088
-
1062
+
1063
+ function isProcessMessage(message) {
1064
+ if (message.kind === 'tool' || message.kind === 'tool-result') return true
1065
+ return message.kind === 'assistant' && /(?:^|\n)\s*(?:bash|pwsh|powershell|web_search|web_fetch|browser|read_file|write_file)\s*\n\s*\{/.test(message.text)
1066
+ }
1067
+
1068
+ function processSummary(text) {
1069
+ return text.replace(/\s+/g, ' ').trim().slice(0, 140) || '工具调用记录'
1070
+ }
1071
+
1072
+ function threadMessage(thread, message) {
1073
+ const isUser = message.kind === 'user'
1074
+ const label = isUser ? '你' : message.kind === 'assistant' ? 'DSH' : message.kind === 'error' ? '错误' : '记录'
1075
+ const branch = message.kind === 'assistant' && Number.isInteger(message.sourceSeq)
1076
+ ? `<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>`
1077
+ : ''
1078
+ const messageId = `${thread.id}:${message.sourceSeq ?? `${message.kind}:${message.at}`}`
1079
+ const collapsible = isProcessMessage(message)
1080
+ const expanded = state.expandedMessageIds.has(messageId)
1081
+ 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>` : ''
1082
+ const process = Array.isArray(message.process) && message.process.length > 0 ? message.process : null
1083
+ const body = message.pending && message.text === '' ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>'
1084
+ : `${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)}`
1085
+ const avatar = isUser ? '' : '<span class="message-avatar" aria-hidden="true"></span>'
1086
+ return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}" data-message-seq="${Number.isInteger(message.sourceSeq) ? message.sourceSeq : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
1087
+ }
1088
+
1089
1089
  function processRecords(process, messageId) {
1090
1090
  const key = `${messageId}:process`
1091
1091
  const expanded = state.expandedMessageIds.has(key)
1092
- const entries = process.map((entry, index) => {
1093
- const entryKey = `${key}:${index}`
1094
- const entryExpanded = state.expandedMessageIds.has(entryKey)
1095
- const status = entry.error !== null ? '失败' : entry.result === null ? '等待结果' : '完成'
1096
- const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `<pre class="process-args">${escapeHtml(entry.arguments)}</pre>`
1097
- const outcomeHtml = entry.error !== null ? `<pre class="process-error">${escapeHtml(entry.error)}</pre>` : entry.result === null ? '' : `<pre class="process-result">${escapeHtml(entry.result)}</pre>`
1098
- 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>`
1099
- }).join('')
1092
+ const entries = process.map((entry, index) => {
1093
+ const entryKey = `${key}:${index}`
1094
+ const entryExpanded = state.expandedMessageIds.has(entryKey)
1095
+ const status = entry.error !== null ? '失败' : entry.result === null ? '等待结果' : '完成'
1096
+ const argumentsHtml = entry.arguments === null || entry.arguments === '' ? '' : `<pre class="process-args">${escapeHtml(entry.arguments)}</pre>`
1097
+ const outcomeHtml = entry.error !== null ? `<pre class="process-error">${escapeHtml(entry.error)}</pre>` : entry.result === null ? '' : `<pre class="process-result">${escapeHtml(entry.result)}</pre>`
1098
+ 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>`
1099
+ }).join('')
1100
1100
  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>`
1101
1101
  }
1102
1102
 
@@ -1158,18 +1158,18 @@ function renderCardInspector(card) {
1158
1158
  }
1159
1159
 
1160
1160
  function renderThread() {
1161
- const thread = currentThread()
1162
- if (thread === null) return renderCanvas()
1163
- const messages = messagesFor(thread)
1164
- const waiting = state.pendingReplies.has(thread.dshSessionId)
1165
- const latestAssistantSeq = [...messages].reverse().find(message => Number.isInteger(message.sourceSeq))?.sourceSeq
1166
- 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}" data-seq="${Number.isInteger(latestAssistantSeq) ? latestAssistantSeq : ''}" 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>`
1167
- }
1168
-
1169
- function render() {
1170
- // Remember the departing thread's scroll position per thread id, so
1171
- // switching sessions restores each conversation's own place instead of
1172
- // smearing one session's position onto another.
1161
+ const thread = currentThread()
1162
+ if (thread === null) return renderCanvas()
1163
+ const messages = messagesFor(thread)
1164
+ const waiting = state.pendingReplies.has(thread.dshSessionId)
1165
+ const latestAssistantSeq = [...messages].reverse().find(message => Number.isInteger(message.sourceSeq))?.sourceSeq
1166
+ 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}" data-seq="${Number.isInteger(latestAssistantSeq) ? latestAssistantSeq : ''}" 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>`
1167
+ }
1168
+
1169
+ function render() {
1170
+ // Remember the departing thread's scroll position per thread id, so
1171
+ // switching sessions restores each conversation's own place instead of
1172
+ // smearing one session's position onto another.
1173
1173
  if (state.mode === 'thread' && state.detailThreadId !== null) {
1174
1174
  const detail = document.querySelector('.detail-scroll')
1175
1175
  if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop)
@@ -1181,39 +1181,39 @@ function render() {
1181
1181
  state.detailThreadId = state.mode === 'thread' ? state.activeId : null
1182
1182
  const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null
1183
1183
  const inspectorScrollTop = state.mode === 'canvas' && state.inspectorCardId !== null ? state.inspectorScrollByCard.get(state.inspectorCardId) ?? null : null
1184
- const cardScrollTops = new Map()
1185
- if (state.mode === 'canvas') {
1186
- // Key by the unique card id: every card of a session shares data-thread,
1187
- // so keying on it would clobber sibling cards' scroll positions. Only
1188
- // scrollable answers have a position worth preserving; reading the two
1189
- // height properties shares the same forced layout as the scrollTop read.
1190
- for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
1191
- if (answer.scrollHeight <= answer.clientHeight) continue
1192
- const card = answer.closest('.thread-card')
1193
- if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
1194
- }
1195
- }
1196
- const workspace = state.workspace
1197
- const threads = workspace?.threads ?? []
1198
- const view = state.mode === 'thread' ? renderThread() : renderCanvas()
1199
- const choices = workspaceChoices()
1200
- const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
1201
- const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout" title="整理节点" aria-label="整理节点"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/></svg>整理</button><button data-action="focus-active" title="定位到当前会话" aria-label="定位到当前会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="8" cy="8" r="3.2"/><path d="M8 1.5v2.6M8 11.9v2.6M1.5 8h2.6M11.9 8h2.6"/></svg>定位</button><button data-action="zoom-out" aria-label="缩小" title="缩小"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M3.5 8h9"/></svg></button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大" title="放大"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>` : ''
1202
- const detailAvailable = currentThread() !== null
1203
- 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>`
1184
+ const cardScrollTops = new Map()
1185
+ if (state.mode === 'canvas') {
1186
+ // Key by the unique card id: every card of a session shares data-thread,
1187
+ // so keying on it would clobber sibling cards' scroll positions. Only
1188
+ // scrollable answers have a position worth preserving; reading the two
1189
+ // height properties shares the same forced layout as the scrollTop read.
1190
+ for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
1191
+ if (answer.scrollHeight <= answer.clientHeight) continue
1192
+ const card = answer.closest('.thread-card')
1193
+ if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
1194
+ }
1195
+ }
1196
+ const workspace = state.workspace
1197
+ const threads = workspace?.threads ?? []
1198
+ const view = state.mode === 'thread' ? renderThread() : renderCanvas()
1199
+ const choices = workspaceChoices()
1200
+ const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
1201
+ const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout" title="整理节点" aria-label="整理节点"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/></svg>整理</button><button data-action="focus-active" title="定位到当前会话" aria-label="定位到当前会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="8" cy="8" r="3.2"/><path d="M8 1.5v2.6M8 11.9v2.6M1.5 8h2.6M11.9 8h2.6"/></svg>定位</button><button data-action="zoom-out" aria-label="缩小" title="缩小"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M3.5 8h9"/></svg></button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大" title="放大"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>` : ''
1202
+ const detailAvailable = currentThread() !== null
1203
+ 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>`
1204
1204
  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}${selectionFollowupButton()}</section></main>`
1205
- installDragging()
1206
- cacheCardConnectors()
1207
- // The initial camera from renderCanvas is inset (viewport not laid out yet);
1208
- // center it on the focused card once the canvas DOM is mounted.
1209
- if (state.canvasNeedsCenter) {
1210
- state.canvasNeedsCenter = false
1211
- window.requestAnimationFrame(() => { if (state.mode === 'canvas') focusActiveCard() })
1212
- }
1213
- for (const [cardId, scrollTop] of cardScrollTops) {
1214
- const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
1215
- if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
1216
- }
1205
+ installDragging()
1206
+ cacheCardConnectors()
1207
+ // The initial camera from renderCanvas is inset (viewport not laid out yet);
1208
+ // center it on the focused card once the canvas DOM is mounted.
1209
+ if (state.canvasNeedsCenter) {
1210
+ state.canvasNeedsCenter = false
1211
+ window.requestAnimationFrame(() => { if (state.mode === 'canvas') focusActiveCard() })
1212
+ }
1213
+ for (const [cardId, scrollTop] of cardScrollTops) {
1214
+ const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
1215
+ if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
1216
+ }
1217
1217
  if (detailScrollTop !== null) window.requestAnimationFrame(() => {
1218
1218
  const nextDetail = document.querySelector('.detail-scroll')
1219
1219
  if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
@@ -1226,21 +1226,21 @@ function render() {
1226
1226
  document.querySelector('.card-inspector')?.classList.remove('is-opening')
1227
1227
  state.inspectorOpening = false
1228
1228
  })
1229
- // Jump the detail view to the card the user clicked: card ids carry the
1230
- // source sequence (`<thread>:turn:<seq>`), which matches data-message-seq
1231
- // anchors on the rendered messages.
1232
- const targetCardId = state.detailTargetCardId
1233
- state.detailTargetCardId = null
1234
- if (targetCardId !== null) {
1235
- const match = /:turn:(\d+)$/.exec(targetCardId)
1236
- const seq = match === null ? null : match[1]
1237
- if (seq !== null) window.requestAnimationFrame(() => {
1238
- const target = app.querySelector(`[data-message-seq="${CSS.escape(seq)}"]`)
1239
- if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
1240
- })
1241
- }
1242
- }
1243
-
1229
+ // Jump the detail view to the card the user clicked: card ids carry the
1230
+ // source sequence (`<thread>:turn:<seq>`), which matches data-message-seq
1231
+ // anchors on the rendered messages.
1232
+ const targetCardId = state.detailTargetCardId
1233
+ state.detailTargetCardId = null
1234
+ if (targetCardId !== null) {
1235
+ const match = /:turn:(\d+)$/.exec(targetCardId)
1236
+ const seq = match === null ? null : match[1]
1237
+ if (seq !== null) window.requestAnimationFrame(() => {
1238
+ const target = app.querySelector(`[data-message-seq="${CSS.escape(seq)}"]`)
1239
+ if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
1240
+ })
1241
+ }
1242
+ }
1243
+
1244
1244
  function renderPreservingDetailScroll() {
1245
1245
  render()
1246
1246
  }
@@ -1277,121 +1277,121 @@ function closeCardInspector({ animate = true } = {}) {
1277
1277
  }
1278
1278
 
1279
1279
  function applyCanvasTransform() {
1280
- const content = document.querySelector('.canvas-content')
1281
- if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
1282
- }
1283
-
1284
- function bindDragHandle(handle) {
1285
- handle.addEventListener('pointerdown', event => {
1286
- const cardId = event.currentTarget.dataset.dragCard
1287
- const card = event.currentTarget.closest('.thread-card')
1288
- if (cardId === undefined || !(card instanceof HTMLElement)) return
1289
- event.preventDefault()
1290
- const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } }
1291
- const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
1292
- let position = origin.position
1293
- let stopped = false
1294
- let frame = 0
1295
- state.dragging = true
1296
- // Coalesce pointermove updates to one DOM pass per animation frame so a
1297
- // high report-rate pointer cannot queue a reflow per event.
1298
- const apply = () => {
1299
- frame = 0
1300
- state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
1301
- for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
1302
- // Keep the virtualized data object in sync so viewport visibility and
1303
- // connector paths track the live drag position.
1304
- const dataCard = state.canvasCardsById?.get(cardId)
1305
- if (dataCard !== undefined) dataCard.position = { x: position.x, y: position.y }
1306
- card.style.left = `${position.x}px`
1307
- card.style.top = `${position.y}px`
1308
- refreshCardConnectors(cardId)
1309
- }
1310
- const move = moveEvent => {
1311
- position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
1312
- if (frame === 0) frame = window.requestAnimationFrame(apply)
1313
- }
1314
- const stop = () => {
1315
- if (stopped) return
1316
- stopped = true
1317
- document.removeEventListener('pointermove', move)
1318
- document.removeEventListener('pointerup', stop)
1319
- document.removeEventListener('pointercancel', stop)
1320
- if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1321
- apply()
1322
- rememberCardPosition(cardId, position, aliases)
1323
- state.dragging = false
1324
- deferCanvasRefresh(120)
1325
- // No full render: only the dragged card's inline position and its
1326
- // connectors changed; rebuilding the whole canvas on drop is the jank.
1327
- }
1328
- document.addEventListener('pointermove', move)
1329
- document.addEventListener('pointerup', stop)
1330
- document.addEventListener('pointercancel', stop)
1331
- })
1332
- }
1333
-
1334
- function installDragging() {
1335
- for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle)
1336
- }
1337
-
1338
- function canvasViewport(target) {
1339
- return target instanceof Element ? target.closest('.canvas-viewport') : null
1340
- }
1341
-
1342
- function zoomCanvas(viewport, nextZoom, clientX, clientY) {
1343
- const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
1344
- if (zoom === state.zoom) return
1345
- const bounds = viewport.getBoundingClientRect()
1346
- const localX = clientX - bounds.left
1347
- const localY = clientY - bounds.top
1348
- const worldX = (localX - state.canvasCamera.x) / state.zoom
1349
- const worldY = (localY - state.canvasCamera.y) / state.zoom
1350
- state.zoom = zoom
1351
- state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
1352
- const content = viewport.querySelector('.canvas-content')
1353
- if (content instanceof HTMLElement) {
1354
- // Drop the composited layer before zooming: a cached will-change raster
1355
- // would be upscaled instead of re-rasterized, which was the original
1356
- // zoom-blur bug. will-change re-applies via .is-panning on the next pan.
1357
- content.style.willChange = 'auto'
1358
- applyCanvasTransform()
1359
- syncCanvasViewport()
1360
- window.requestAnimationFrame(() => { content.style.willChange = '' })
1361
- } else {
1362
- applyCanvasTransform()
1363
- syncCanvasViewport()
1364
- }
1365
- const label = document.querySelector('.canvas-controls span')
1366
- if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
1367
- }
1368
-
1369
- function zoomCanvasAtCenter(delta) {
1370
- const viewport = document.querySelector('.canvas-viewport')
1371
- if (!(viewport instanceof HTMLElement)) return
1372
- const bounds = viewport.getBoundingClientRect()
1373
- zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
1374
- }
1375
-
1280
+ const content = document.querySelector('.canvas-content')
1281
+ if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
1282
+ }
1283
+
1284
+ function bindDragHandle(handle) {
1285
+ handle.addEventListener('pointerdown', event => {
1286
+ const cardId = event.currentTarget.dataset.dragCard
1287
+ const card = event.currentTarget.closest('.thread-card')
1288
+ if (cardId === undefined || !(card instanceof HTMLElement)) return
1289
+ event.preventDefault()
1290
+ const origin = { x: event.clientX, y: event.clientY, position: { x: Number.parseFloat(card.style.left), y: Number.parseFloat(card.style.top) } }
1291
+ const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
1292
+ let position = origin.position
1293
+ let stopped = false
1294
+ let frame = 0
1295
+ state.dragging = true
1296
+ // Coalesce pointermove updates to one DOM pass per animation frame so a
1297
+ // high report-rate pointer cannot queue a reflow per event.
1298
+ const apply = () => {
1299
+ frame = 0
1300
+ state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
1301
+ for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
1302
+ // Keep the virtualized data object in sync so viewport visibility and
1303
+ // connector paths track the live drag position.
1304
+ const dataCard = state.canvasCardsById?.get(cardId)
1305
+ if (dataCard !== undefined) dataCard.position = { x: position.x, y: position.y }
1306
+ card.style.left = `${position.x}px`
1307
+ card.style.top = `${position.y}px`
1308
+ refreshCardConnectors(cardId)
1309
+ }
1310
+ const move = moveEvent => {
1311
+ position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
1312
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
1313
+ }
1314
+ const stop = () => {
1315
+ if (stopped) return
1316
+ stopped = true
1317
+ document.removeEventListener('pointermove', move)
1318
+ document.removeEventListener('pointerup', stop)
1319
+ document.removeEventListener('pointercancel', stop)
1320
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1321
+ apply()
1322
+ rememberCardPosition(cardId, position, aliases)
1323
+ state.dragging = false
1324
+ deferCanvasRefresh(120)
1325
+ // No full render: only the dragged card's inline position and its
1326
+ // connectors changed; rebuilding the whole canvas on drop is the jank.
1327
+ }
1328
+ document.addEventListener('pointermove', move)
1329
+ document.addEventListener('pointerup', stop)
1330
+ document.addEventListener('pointercancel', stop)
1331
+ })
1332
+ }
1333
+
1334
+ function installDragging() {
1335
+ for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle)
1336
+ }
1337
+
1338
+ function canvasViewport(target) {
1339
+ return target instanceof Element ? target.closest('.canvas-viewport') : null
1340
+ }
1341
+
1342
+ function zoomCanvas(viewport, nextZoom, clientX, clientY) {
1343
+ const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
1344
+ if (zoom === state.zoom) return
1345
+ const bounds = viewport.getBoundingClientRect()
1346
+ const localX = clientX - bounds.left
1347
+ const localY = clientY - bounds.top
1348
+ const worldX = (localX - state.canvasCamera.x) / state.zoom
1349
+ const worldY = (localY - state.canvasCamera.y) / state.zoom
1350
+ state.zoom = zoom
1351
+ state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
1352
+ const content = viewport.querySelector('.canvas-content')
1353
+ if (content instanceof HTMLElement) {
1354
+ // Drop the composited layer before zooming: a cached will-change raster
1355
+ // would be upscaled instead of re-rasterized, which was the original
1356
+ // zoom-blur bug. will-change re-applies via .is-panning on the next pan.
1357
+ content.style.willChange = 'auto'
1358
+ applyCanvasTransform()
1359
+ syncCanvasViewport()
1360
+ window.requestAnimationFrame(() => { content.style.willChange = '' })
1361
+ } else {
1362
+ applyCanvasTransform()
1363
+ syncCanvasViewport()
1364
+ }
1365
+ const label = document.querySelector('.canvas-controls span')
1366
+ if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
1367
+ }
1368
+
1369
+ function zoomCanvasAtCenter(delta) {
1370
+ const viewport = document.querySelector('.canvas-viewport')
1371
+ if (!(viewport instanceof HTMLElement)) return
1372
+ const bounds = viewport.getBoundingClientRect()
1373
+ zoomCanvas(viewport, state.zoom + delta, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
1374
+ }
1375
+
1376
1376
  function focusActiveCard() {
1377
- const viewport = document.querySelector('.canvas-viewport')
1378
- if (!(viewport instanceof HTMLElement)) return
1379
- const cards = state.canvasCards
1380
- if (cards === undefined || cards.length === 0) return
1381
- // Drafts win over the active conversation's latest turn; fall back to the
1382
- // first card. Cards may be unmounted (outside the viewport), so the focus
1383
- // target comes from the data model, never from DOM queries.
1384
- const draft = state.draft === null ? undefined
1385
- : state.draft.kind === 'new' ? { position: { x: 86, y: 82 } } : draftPlacement(cards)
1386
- const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
1387
- const card = draft ?? activeCards.at(-1) ?? cards[0]
1388
- const { x: left, y: top } = card.position
1389
- const bounds = viewport.getBoundingClientRect()
1390
- state.canvasCamera = {
1391
- x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
1392
- y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
1393
- }
1394
- applyCanvasTransform()
1377
+ const viewport = document.querySelector('.canvas-viewport')
1378
+ if (!(viewport instanceof HTMLElement)) return
1379
+ const cards = state.canvasCards
1380
+ if (cards === undefined || cards.length === 0) return
1381
+ // Drafts win over the active conversation's latest turn; fall back to the
1382
+ // first card. Cards may be unmounted (outside the viewport), so the focus
1383
+ // target comes from the data model, never from DOM queries.
1384
+ const draft = state.draft === null ? undefined
1385
+ : state.draft.kind === 'new' ? { position: { x: 86, y: 82 } } : draftPlacement(cards)
1386
+ const activeCards = state.activeId === null || state.activeId === undefined ? [] : cards.filter(card => card.dshThreadId === state.activeId)
1387
+ const card = draft ?? activeCards.at(-1) ?? cards[0]
1388
+ const { x: left, y: top } = card.position
1389
+ const bounds = viewport.getBoundingClientRect()
1390
+ state.canvasCamera = {
1391
+ x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
1392
+ y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
1393
+ }
1394
+ applyCanvasTransform()
1395
1395
  syncCanvasViewport()
1396
1396
  }
1397
1397
 
@@ -1446,70 +1446,70 @@ function queueSelectionFollowup() {
1446
1446
  }
1447
1447
 
1448
1448
  app.addEventListener('pointerdown', event => {
1449
- const viewport = canvasViewport(event.target)
1450
- if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
1451
- event.preventDefault()
1452
- const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
1453
- let pendingCamera = null
1454
- let frame = 0
1455
- state.canvasGesture = true
1456
- viewport.classList.add('is-panning')
1457
- viewport.setPointerCapture(event.pointerId)
1458
- const apply = () => {
1459
- frame = 0
1460
- if (pendingCamera === null) return
1461
- state.canvasCamera = pendingCamera
1462
- pendingCamera = null
1463
- applyCanvasTransform()
1464
- syncCanvasViewport()
1465
- }
1466
- const move = moveEvent => {
1467
- pendingCamera = {
1468
- x: origin.camera.x + moveEvent.clientX - origin.x,
1469
- y: origin.camera.y + moveEvent.clientY - origin.y,
1470
- }
1471
- if (frame === 0) frame = window.requestAnimationFrame(apply)
1472
- }
1473
- const stop = () => {
1474
- viewport.classList.remove('is-panning')
1475
- document.removeEventListener('pointermove', move)
1476
- document.removeEventListener('pointerup', stop)
1477
- document.removeEventListener('pointercancel', stop)
1478
- if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1479
- apply()
1480
- state.canvasGesture = false
1481
- deferCanvasRefresh(120)
1482
- }
1483
- document.addEventListener('pointermove', move)
1484
- document.addEventListener('pointerup', stop)
1485
- document.addEventListener('pointercancel', stop)
1486
- })
1487
-
1488
- app.addEventListener('wheel', event => {
1489
- const viewport = canvasViewport(event.target)
1490
- if (!(viewport instanceof HTMLElement)) return
1491
- const card = event.target instanceof Element ? event.target.closest('.thread-card') : null
1492
- if (card instanceof HTMLElement) {
1493
- // Over a card the wheel scrolls that card's own answer with the browser's
1494
- // native wheel (OS-smooth, never a page jump per notch); the answer's
1495
- // overscroll-behavior: contain stops the scroll chaining into the canvas.
1496
- const answer = card.querySelector('.thread-answer')
1497
- if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) {
1498
- deferCanvasRefresh()
1499
- return
1500
- }
1501
- // A card with no scrollable answer swallows the wheel instead of zooming.
1502
- event.preventDefault()
1503
- deferCanvasRefresh()
1504
- return
1505
- }
1506
- event.preventDefault()
1507
- zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY)
1508
- }, { passive: false })
1509
-
1510
- // Track pointer-down so the card click handler can tell a plain click from a
1511
- // text-selection or drag gesture; acting on the latter would re-render and
1512
- // wipe the user's selection.
1449
+ const viewport = canvasViewport(event.target)
1450
+ if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
1451
+ event.preventDefault()
1452
+ const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
1453
+ let pendingCamera = null
1454
+ let frame = 0
1455
+ state.canvasGesture = true
1456
+ viewport.classList.add('is-panning')
1457
+ viewport.setPointerCapture(event.pointerId)
1458
+ const apply = () => {
1459
+ frame = 0
1460
+ if (pendingCamera === null) return
1461
+ state.canvasCamera = pendingCamera
1462
+ pendingCamera = null
1463
+ applyCanvasTransform()
1464
+ syncCanvasViewport()
1465
+ }
1466
+ const move = moveEvent => {
1467
+ pendingCamera = {
1468
+ x: origin.camera.x + moveEvent.clientX - origin.x,
1469
+ y: origin.camera.y + moveEvent.clientY - origin.y,
1470
+ }
1471
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
1472
+ }
1473
+ const stop = () => {
1474
+ viewport.classList.remove('is-panning')
1475
+ document.removeEventListener('pointermove', move)
1476
+ document.removeEventListener('pointerup', stop)
1477
+ document.removeEventListener('pointercancel', stop)
1478
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1479
+ apply()
1480
+ state.canvasGesture = false
1481
+ deferCanvasRefresh(120)
1482
+ }
1483
+ document.addEventListener('pointermove', move)
1484
+ document.addEventListener('pointerup', stop)
1485
+ document.addEventListener('pointercancel', stop)
1486
+ })
1487
+
1488
+ app.addEventListener('wheel', event => {
1489
+ const viewport = canvasViewport(event.target)
1490
+ if (!(viewport instanceof HTMLElement)) return
1491
+ const card = event.target instanceof Element ? event.target.closest('.thread-card') : null
1492
+ if (card instanceof HTMLElement) {
1493
+ // Over a card the wheel scrolls that card's own answer with the browser's
1494
+ // native wheel (OS-smooth, never a page jump per notch); the answer's
1495
+ // overscroll-behavior: contain stops the scroll chaining into the canvas.
1496
+ const answer = card.querySelector('.thread-answer')
1497
+ if (answer instanceof HTMLElement && answer.scrollHeight > answer.clientHeight) {
1498
+ deferCanvasRefresh()
1499
+ return
1500
+ }
1501
+ // A card with no scrollable answer swallows the wheel instead of zooming.
1502
+ event.preventDefault()
1503
+ deferCanvasRefresh()
1504
+ return
1505
+ }
1506
+ event.preventDefault()
1507
+ zoomCanvas(viewport, state.zoom + (event.deltaY < 0 ? .05 : -.05), event.clientX, event.clientY)
1508
+ }, { passive: false })
1509
+
1510
+ // Track pointer-down so the card click handler can tell a plain click from a
1511
+ // text-selection or drag gesture; acting on the latter would re-render and
1512
+ // wipe the user's selection.
1513
1513
  let pointerDownPosition = null
1514
1514
  app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
1515
1515
  app.addEventListener('pointerdown', event => {
@@ -1527,15 +1527,15 @@ document.addEventListener('keydown', event => {
1527
1527
  })
1528
1528
 
1529
1529
  app.addEventListener('click', async event => {
1530
- const button = event.target.closest('[data-action]')
1531
- if (!(button instanceof HTMLElement)) {
1532
- const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]:not(.draft-card)') : null
1533
- if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return
1534
- // A double-click selects a word and a drag selects a range; neither is a
1535
- // select-click, so leave the selection intact instead of re-rendering.
1536
- if (event.detail > 1) return
1537
- if (pointerDownPosition !== null
1538
- && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
1530
+ const button = event.target.closest('[data-action]')
1531
+ if (!(button instanceof HTMLElement)) {
1532
+ const card = event.target instanceof Element ? event.target.closest('.thread-card[data-thread]:not(.draft-card)') : null
1533
+ if (!(card instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.node-handle, textarea, select, form')) return
1534
+ // A double-click selects a word and a drag selects a range; neither is a
1535
+ // select-click, so leave the selection intact instead of re-rendering.
1536
+ if (event.detail > 1) return
1537
+ if (pointerDownPosition !== null
1538
+ && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
1539
1539
  const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
1540
1540
  if (thread === undefined) return
1541
1541
  const cardId = card.dataset.cardId
@@ -1546,13 +1546,13 @@ app.addEventListener('click', async event => {
1546
1546
  state.error = ''
1547
1547
  render()
1548
1548
  void loadThreadHistory(thread)
1549
- // Bidirectional current-session sync: switch DSH's current session
1550
- // without closing the map; the client confirms via synapse:current-session.
1549
+ // Bidirectional current-session sync: switch DSH's current session
1550
+ // without closing the map; the client confirms via synapse:current-session.
1551
1551
  if (thread.dshSessionId !== null) {
1552
1552
  if (thread.dshSessionId !== state.currentDsh?.id) state.mapCardSessionSwitches.add(thread.dshSessionId)
1553
1553
  post('synapse:activate-session', { sessionId: thread.dshSessionId })
1554
1554
  }
1555
- return
1555
+ return
1556
1556
  }
1557
1557
  const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
1558
1558
  try {
@@ -1583,9 +1583,9 @@ app.addEventListener('click', async event => {
1583
1583
  }
1584
1584
  if (button.dataset.action === 'close') post('synapse:close')
1585
1585
  if (button.dataset.action === 'close-card-inspector') { closeCardInspector(); return }
1586
- if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
1587
- if (button.dataset.action === 'create-session') openNewSession()
1588
- if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
1586
+ if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
1587
+ if (button.dataset.action === 'create-session') openNewSession()
1588
+ if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
1589
1589
  if (button.dataset.action === 'select-thread' && thread !== undefined) {
1590
1590
  state.mapCardSessionSwitches.clear()
1591
1591
  state.activeId = thread.id
@@ -1593,55 +1593,55 @@ app.addEventListener('click', async event => {
1593
1593
  state.inspectorCardId = null
1594
1594
  state.inspectorOpening = false
1595
1595
  state.error = ''
1596
- if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1597
- render()
1598
- void loadThreadHistory(thread)
1599
- // Bidirectional current-session sync: switch DSH's current session
1600
- // without closing the map; the client confirms via synapse:current-session.
1601
- if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
1602
- }
1603
- if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; state.detailTargetCardId = button.dataset.card ?? null; render(); void loadThreadHistory(thread) }
1604
- if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
1605
- if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
1606
- const cardId = button.dataset.card
1607
- const collapsing = !state.collapsedCardIds.has(cardId)
1608
- if (collapsing && state.workspace !== null) {
1609
- const allCards = conversationCards(state.workspace.threads)
1610
- const nextCollapsed = new Set(state.collapsedCardIds).add(cardId)
1611
- const visibleCards = conversationGraphView(allCards, nextCollapsed).cards
1612
- const visibleIds = new Set(visibleCards.map(card => card.id))
1613
- const draftParentId = draftPlacement(allCards)?.parent.id
1614
- if (draftParentId !== undefined && !visibleIds.has(draftParentId)) return setError('请先完成或取消正在编辑的追问或分支')
1615
- if (state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError('当前会话位于这个后续分支中,请先切换会话')
1616
- }
1617
- collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId)
1618
- persistCollapsedCards()
1619
- render()
1620
- window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0)
1621
- }
1622
- if (button.dataset.action === 'open-continue' && thread !== undefined) openContinue(thread, button.dataset.card)
1623
- if (button.dataset.action === 'open-branch' && thread !== undefined) {
1624
- const requestedSeq = Number(button.dataset.seq)
1625
- if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError('请等待这张卡片的最终回答后再创建分支')
1626
- const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
1627
- openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
1628
- }
1596
+ if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1597
+ render()
1598
+ void loadThreadHistory(thread)
1599
+ // Bidirectional current-session sync: switch DSH's current session
1600
+ // without closing the map; the client confirms via synapse:current-session.
1601
+ if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
1602
+ }
1603
+ if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; state.detailTargetCardId = button.dataset.card ?? null; render(); void loadThreadHistory(thread) }
1604
+ if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
1605
+ if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
1606
+ const cardId = button.dataset.card
1607
+ const collapsing = !state.collapsedCardIds.has(cardId)
1608
+ if (collapsing && state.workspace !== null) {
1609
+ const allCards = conversationCards(state.workspace.threads)
1610
+ const nextCollapsed = new Set(state.collapsedCardIds).add(cardId)
1611
+ const visibleCards = conversationGraphView(allCards, nextCollapsed).cards
1612
+ const visibleIds = new Set(visibleCards.map(card => card.id))
1613
+ const draftParentId = draftPlacement(allCards)?.parent.id
1614
+ if (draftParentId !== undefined && !visibleIds.has(draftParentId)) return setError('请先完成或取消正在编辑的追问或分支')
1615
+ if (state.activeId !== null && !visibleCards.some(card => card.dshThreadId === state.activeId)) return setError('当前会话位于这个后续分支中,请先切换会话')
1616
+ }
1617
+ collapsing ? state.collapsedCardIds.add(cardId) : state.collapsedCardIds.delete(cardId)
1618
+ persistCollapsedCards()
1619
+ render()
1620
+ window.setTimeout(() => document.querySelector(`[data-action="toggle-card-children"][data-card="${selectorValue(cardId)}"]`)?.focus(), 0)
1621
+ }
1622
+ if (button.dataset.action === 'open-continue' && thread !== undefined) openContinue(thread, button.dataset.card)
1623
+ if (button.dataset.action === 'open-branch' && thread !== undefined) {
1624
+ const requestedSeq = Number(button.dataset.seq)
1625
+ if (button.dataset.card !== undefined && !Number.isInteger(requestedSeq)) return setError('请等待这张卡片的最终回答后再创建分支')
1626
+ const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
1627
+ openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
1628
+ }
1629
1629
  if (button.dataset.action === 'cancel-draft') { state.draft = null; state.quickPhraseEditorOpen = false; render() }
1630
- 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() }
1631
- if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId, seq: Number.isInteger(Number(button.dataset.seq)) ? Number(button.dataset.seq) : undefined })
1632
- if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
1633
- if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
1634
- if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
1635
- if (button.dataset.action === 'focus-active') focusActiveCard()
1636
- if (button.dataset.action === 'dismiss-error') { state.error = ''; render() }
1637
- if (button.dataset.action === 'layout' && state.workspace !== null) {
1638
- resetCardPositions()
1639
- resetCanvasCamera()
1640
- render()
1641
- }
1642
- } catch (error) { setError(error) }
1643
- })
1644
-
1630
+ 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() }
1631
+ if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId, seq: Number.isInteger(Number(button.dataset.seq)) ? Number(button.dataset.seq) : undefined })
1632
+ if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
1633
+ if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
1634
+ if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
1635
+ if (button.dataset.action === 'focus-active') focusActiveCard()
1636
+ if (button.dataset.action === 'dismiss-error') { state.error = ''; render() }
1637
+ if (button.dataset.action === 'layout' && state.workspace !== null) {
1638
+ resetCardPositions()
1639
+ resetCanvasCamera()
1640
+ render()
1641
+ }
1642
+ } catch (error) { setError(error) }
1643
+ })
1644
+
1645
1645
  app.addEventListener('change', event => {
1646
1646
  const quickPhrase = event.target instanceof Element ? event.target.closest('[data-quick-phrase-index]') : null
1647
1647
  if (quickPhrase instanceof HTMLInputElement) {
@@ -1654,55 +1654,55 @@ app.addEventListener('change', event => {
1654
1654
  state.inspectorCardId = null
1655
1655
  state.inspectorOpening = false
1656
1656
  if (choice?.source === 'dsh') {
1657
- // Map → native sync: switching workspaces moves DSH's current session to
1658
- // the workspace's most recently updated session, keeping both sides in step.
1659
- void openDshWorkspace(choice.id).then(opened => {
1660
- if (!opened) return
1661
- const threads = state.workspace?.threads ?? []
1662
- const latest = threads
1663
- .filter(thread => thread.dshSessionId !== null)
1664
- .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))[0]
1665
- const sessionId = latest?.dshSessionId ?? choice.sessionIds[0]
1666
- if (sessionId !== undefined) post('synapse:activate-session', { sessionId })
1667
- }).catch(setError)
1668
- } else if (choice !== undefined) { state.selectedDshWorkspaceId = null; void openWorkspace(choice.id).catch(setError) }
1669
- })
1670
- app.addEventListener('input', event => { const input = event.target; if (input instanceof HTMLTextAreaElement && input.closest('[data-draft]') && state.draft !== null) state.draft.text = input.value })
1671
- app.addEventListener('submit', event => {
1672
- const form = event.target
1673
- if (!(form instanceof HTMLFormElement)) return
1674
- if (form.matches('[data-draft]')) { event.preventDefault(); void submitDraft(); return }
1675
- const thread = state.workspace?.threads.find(item => item.id === form.dataset.compose)
1676
- const input = form.querySelector('textarea')
1677
- if (thread === undefined || !(input instanceof HTMLTextAreaElement) || input.value.trim() === '') return
1678
- event.preventDefault()
1679
- const text = input.value.trim()
1680
- input.value = ''
1681
- void sendMessage(thread, text).catch(setError)
1682
- })
1683
-
1684
- window.addEventListener('message', event => {
1685
- if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
1686
- const data = event.data
1687
- if (data.type === 'synapse:map-opened') {
1688
- // Do NOT reset the camera here: toggling dialog<->map for the same
1689
- // session must keep the user's viewport. A fresh canvas (canvasView
1690
- // not initialized) still centers via renderCanvas; a real session switch
1691
- // re-centers in the current-session handler below.
1692
- state.mode = 'canvas'
1693
- render()
1694
- window.requestAnimationFrame(() => post('synapse:map-ready'))
1695
- }
1696
- if (data.type === 'synapse:theme') {
1697
- document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light'
1698
- }
1699
- if (data.type === 'synapse:workspaces') {
1700
- state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
1701
- const current = currentDshWorkspace()
1702
- if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
1703
- else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
1704
- else if (canReplaceView()) render()
1705
- }
1657
+ // Map → native sync: switching workspaces moves DSH's current session to
1658
+ // the workspace's most recently updated session, keeping both sides in step.
1659
+ void openDshWorkspace(choice.id).then(opened => {
1660
+ if (!opened) return
1661
+ const threads = state.workspace?.threads ?? []
1662
+ const latest = threads
1663
+ .filter(thread => thread.dshSessionId !== null)
1664
+ .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))[0]
1665
+ const sessionId = latest?.dshSessionId ?? choice.sessionIds[0]
1666
+ if (sessionId !== undefined) post('synapse:activate-session', { sessionId })
1667
+ }).catch(setError)
1668
+ } else if (choice !== undefined) { state.selectedDshWorkspaceId = null; void openWorkspace(choice.id).catch(setError) }
1669
+ })
1670
+ app.addEventListener('input', event => { const input = event.target; if (input instanceof HTMLTextAreaElement && input.closest('[data-draft]') && state.draft !== null) state.draft.text = input.value })
1671
+ app.addEventListener('submit', event => {
1672
+ const form = event.target
1673
+ if (!(form instanceof HTMLFormElement)) return
1674
+ if (form.matches('[data-draft]')) { event.preventDefault(); void submitDraft(); return }
1675
+ const thread = state.workspace?.threads.find(item => item.id === form.dataset.compose)
1676
+ const input = form.querySelector('textarea')
1677
+ if (thread === undefined || !(input instanceof HTMLTextAreaElement) || input.value.trim() === '') return
1678
+ event.preventDefault()
1679
+ const text = input.value.trim()
1680
+ input.value = ''
1681
+ void sendMessage(thread, text).catch(setError)
1682
+ })
1683
+
1684
+ window.addEventListener('message', event => {
1685
+ if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
1686
+ const data = event.data
1687
+ if (data.type === 'synapse:map-opened') {
1688
+ // Do NOT reset the camera here: toggling dialog<->map for the same
1689
+ // session must keep the user's viewport. A fresh canvas (canvasView
1690
+ // not initialized) still centers via renderCanvas; a real session switch
1691
+ // re-centers in the current-session handler below.
1692
+ state.mode = 'canvas'
1693
+ render()
1694
+ window.requestAnimationFrame(() => post('synapse:map-ready'))
1695
+ }
1696
+ if (data.type === 'synapse:theme') {
1697
+ document.documentElement.dataset.theme = data.dark === true ? 'dark' : 'light'
1698
+ }
1699
+ if (data.type === 'synapse:workspaces') {
1700
+ state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
1701
+ const current = currentDshWorkspace()
1702
+ if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
1703
+ else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
1704
+ else if (canReplaceView()) render()
1705
+ }
1706
1706
  if (data.type === 'synapse:current-session') {
1707
1707
  const previousId = state.currentDsh?.id
1708
1708
  state.currentDsh = data.session
@@ -1717,90 +1717,90 @@ window.addEventListener('message', event => {
1717
1717
  state.inspectorOpening = false
1718
1718
  }
1719
1719
  if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1720
- }
1721
- if (previousId !== data.session?.id) {
1722
- // A real session switch: re-center on the new session's latest turn,
1723
- // whether it lives in the same workspace (openCurrentWorkspace returns
1724
- // false) or a different one (it resets the camera itself).
1720
+ }
1721
+ if (previousId !== data.session?.id) {
1722
+ // A real session switch: re-center on the new session's latest turn,
1723
+ // whether it lives in the same workspace (openCurrentWorkspace returns
1724
+ // false) or a different one (it resets the camera itself).
1725
1725
  void openCurrentWorkspace({ preserveCanvasCamera }).then(opened => {
1726
1726
  if (!opened && canReplaceView()) {
1727
1727
  render()
1728
1728
  if (!preserveCanvasCamera) focusActiveCard()
1729
1729
  }
1730
- }).catch(setError)
1731
- }
1732
- else if (canReplaceView()) render()
1733
- }
1734
- if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
1735
- const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId)
1736
- if (thread !== undefined) {
1737
- if (data.running === true) {
1738
- state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '' })
1739
- // Streaming: patch the live card's answer in place instead of
1740
- // rebuilding the whole canvas on every chunk; a full render reconciles
1741
- // at stream end. The detail view is single-thread, so keep its cheap
1742
- // throttled full render.
1743
- if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId)
1744
- else if (canReplaceView()) scheduleLiveRender()
1745
- } else {
1746
- state.liveReplies.delete(data.sessionId)
1747
- if (canReplaceView() || state.pendingReplies.has(data.sessionId)) renderPreservingDetailScroll()
1748
- }
1749
- }
1750
- }
1751
- if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
1752
- if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
1753
- })
1754
-
1755
- post('synapse:request-current')
1756
- refreshSummaries().catch(setError)
1757
- let polling = false
1758
- let liveRenderTimer = 0
1759
- let liveCardFrame = 0
1760
- let liveCardSessionId = null
1761
- function scheduleLiveCardUpdate(sessionId) {
1762
- // Coalesce streaming chunks to one DOM patch per animation frame.
1763
- liveCardSessionId = sessionId
1764
- if (liveCardFrame !== 0) return
1765
- liveCardFrame = window.requestAnimationFrame(() => {
1766
- liveCardFrame = 0
1767
- if (liveCardSessionId === null) return
1768
- const id = liveCardSessionId
1769
- liveCardSessionId = null
1770
- applyLiveReplyToCard(id)
1771
- })
1772
- }
1773
- function applyLiveReplyToCard(sessionId) {
1774
- if (state.mode !== 'canvas') return
1775
- // Never patch cards mid-gesture: the reflow would compete with the drag or
1776
- // pan frame; the next live-reply chunk re-applies after the gesture ends.
1777
- if (state.dragging || state.canvasGesture) return
1778
- const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
1779
- if (thread === undefined) return
1780
- const live = state.liveReplies.get(sessionId)
1781
- if (live?.running !== true) return
1782
- const cards = app.querySelectorAll(`.thread-card[data-thread="${CSS.escape(thread.id)}"]`)
1783
- const card = cards[cards.length - 1]
1784
- if (!(card instanceof HTMLElement)) return
1785
- const answer = card.querySelector('.thread-answer')
1786
- if (!(answer instanceof HTMLElement)) return
1787
- const text = live.text
1788
- answer.innerHTML = text.trim() === ''
1789
- ? '<p class="thread-answer-pending">正在回复</p>'
1790
- : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
1791
- }
1792
- function scheduleLiveRender() {
1793
- if (liveRenderTimer !== 0 || !canReplaceView()) return
1794
- liveRenderTimer = window.setTimeout(() => {
1795
- liveRenderTimer = 0
1796
- if (canReplaceView()) renderPreservingDetailScroll()
1797
- }, 120)
1798
- }
1799
- async function pollProjection() {
1800
- if (polling || document.hidden || !canReplaceView()) return
1801
- polling = true
1802
- try {
1803
- await refreshProjection()
1804
- } finally { polling = false }
1805
- }
1806
- window.setInterval(() => { void pollProjection() }, 1_000)
1730
+ }).catch(setError)
1731
+ }
1732
+ else if (canReplaceView()) render()
1733
+ }
1734
+ if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
1735
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === data.sessionId)
1736
+ if (thread !== undefined) {
1737
+ if (data.running === true) {
1738
+ state.liveReplies.set(data.sessionId, { running: true, text: typeof data.text === 'string' ? data.text : '' })
1739
+ // Streaming: patch the live card's answer in place instead of
1740
+ // rebuilding the whole canvas on every chunk; a full render reconciles
1741
+ // at stream end. The detail view is single-thread, so keep its cheap
1742
+ // throttled full render.
1743
+ if (state.mode === 'canvas') scheduleLiveCardUpdate(data.sessionId)
1744
+ else if (canReplaceView()) scheduleLiveRender()
1745
+ } else {
1746
+ state.liveReplies.delete(data.sessionId)
1747
+ if (canReplaceView() || state.pendingReplies.has(data.sessionId)) renderPreservingDetailScroll()
1748
+ }
1749
+ }
1750
+ }
1751
+ if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
1752
+ if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
1753
+ })
1754
+
1755
+ post('synapse:request-current')
1756
+ refreshSummaries().catch(setError)
1757
+ let polling = false
1758
+ let liveRenderTimer = 0
1759
+ let liveCardFrame = 0
1760
+ let liveCardSessionId = null
1761
+ function scheduleLiveCardUpdate(sessionId) {
1762
+ // Coalesce streaming chunks to one DOM patch per animation frame.
1763
+ liveCardSessionId = sessionId
1764
+ if (liveCardFrame !== 0) return
1765
+ liveCardFrame = window.requestAnimationFrame(() => {
1766
+ liveCardFrame = 0
1767
+ if (liveCardSessionId === null) return
1768
+ const id = liveCardSessionId
1769
+ liveCardSessionId = null
1770
+ applyLiveReplyToCard(id)
1771
+ })
1772
+ }
1773
+ function applyLiveReplyToCard(sessionId) {
1774
+ if (state.mode !== 'canvas') return
1775
+ // Never patch cards mid-gesture: the reflow would compete with the drag or
1776
+ // pan frame; the next live-reply chunk re-applies after the gesture ends.
1777
+ if (state.dragging || state.canvasGesture) return
1778
+ const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
1779
+ if (thread === undefined) return
1780
+ const live = state.liveReplies.get(sessionId)
1781
+ if (live?.running !== true) return
1782
+ const cards = app.querySelectorAll(`.thread-card[data-thread="${CSS.escape(thread.id)}"]`)
1783
+ const card = cards[cards.length - 1]
1784
+ if (!(card instanceof HTMLElement)) return
1785
+ const answer = card.querySelector('.thread-answer')
1786
+ if (!(answer instanceof HTMLElement)) return
1787
+ const text = live.text
1788
+ answer.innerHTML = text.trim() === ''
1789
+ ? '<p class="thread-answer-pending">正在回复</p>'
1790
+ : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
1791
+ }
1792
+ function scheduleLiveRender() {
1793
+ if (liveRenderTimer !== 0 || !canReplaceView()) return
1794
+ liveRenderTimer = window.setTimeout(() => {
1795
+ liveRenderTimer = 0
1796
+ if (canReplaceView()) renderPreservingDetailScroll()
1797
+ }, 120)
1798
+ }
1799
+ async function pollProjection() {
1800
+ if (polling || document.hidden || !canReplaceView()) return
1801
+ polling = true
1802
+ try {
1803
+ await refreshProjection()
1804
+ } finally { polling = false }
1805
+ }
1806
+ window.setInterval(() => { void pollProjection() }, 1_000)