dsh-synapse 0.4.0 → 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
@@ -3,6 +3,26 @@ 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'
6
+ const QUICK_PHRASES_KEY = 'dsh-synapse:quick-phrases:v1'
7
+ const DEFAULT_QUICK_PHRASES = ['展开说明', '举例', '通俗易懂', '对比解释']
8
+ const MAX_QUICK_PHRASES = 12
9
+ const MAX_QUICK_PHRASE_LENGTH = 16
10
+ function normalizeQuickPhrases(value) {
11
+ if (!Array.isArray(value)) return []
12
+ const phrases = []
13
+ for (const item of value) {
14
+ const phrase = typeof item === 'string' ? item.trim().slice(0, MAX_QUICK_PHRASE_LENGTH) : ''
15
+ if (phrase !== '' && !phrases.includes(phrase)) phrases.push(phrase)
16
+ if (phrases.length === MAX_QUICK_PHRASES) break
17
+ }
18
+ return phrases
19
+ }
20
+ const savedQuickPhrases = (() => {
21
+ try {
22
+ const stored = localStorage.getItem(QUICK_PHRASES_KEY)
23
+ return stored === null ? DEFAULT_QUICK_PHRASES : normalizeQuickPhrases(JSON.parse(stored))
24
+ } catch { return DEFAULT_QUICK_PHRASES }
25
+ })()
6
26
  const savedBranchAnchors = (() => {
7
27
  try {
8
28
  const value = JSON.parse(localStorage.getItem('dsh-synapse:branch-anchors') ?? '[]')
@@ -29,13 +49,20 @@ const CARD_HEIGHT = 276
29
49
  const CARD_GAP_Y = 42
30
50
  const CAMERA_INSET_X = 56
31
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
32
56
  const state = {
33
- summaries: [], workspace: null, activeId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
57
+ summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
34
58
  dshWorkspaces: [], selectedDshWorkspaceId: null,
35
59
  historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
36
- draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards),
37
- dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 },
60
+ draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
61
+ dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, mapCardSessionSwitches: new Set(),
38
62
  expandedMessageIds: new Set(),
63
+ canvasCards: undefined, canvasCardsById: undefined, canvasGraph: undefined, mountedCardIds: new Set(), canvasNeedsCenter: false,
64
+ detailScrollByThread: new Map(), detailThreadId: null, detailTargetCardId: null,
65
+ inspectorCardId: null, inspectorOpening: false, inspectorScrollByCard: new Map(),
39
66
  }
40
67
 
41
68
  const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character]))
@@ -56,6 +83,10 @@ function persistCollapsedCards() {
56
83
  try { localStorage.setItem(COLLAPSED_CARDS_KEY, JSON.stringify([...state.collapsedCardIds])) } catch { /* Private browsing may disable local storage. */ }
57
84
  }
58
85
 
86
+ function persistQuickPhrases() {
87
+ try { localStorage.setItem(QUICK_PHRASES_KEY, JSON.stringify(state.quickPhrases)) } catch { /* Private browsing may disable local storage. */ }
88
+ }
89
+
59
90
  function rememberCardPosition(cardId, position, aliases = []) {
60
91
  state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
61
92
  for (const alias of aliases) state.cardPositions.set(alias, { x: Math.round(position.x), y: Math.round(position.y) })
@@ -158,7 +189,7 @@ async function threadsForDshWorkspace(workspace) {
158
189
  return projections.flatMap(projection => projection.workspace.threads.filter(thread => requested.has(thread.dshSessionId)))
159
190
  }
160
191
 
161
- async function openDshWorkspace(id, { renderAfter = true } = {}) {
192
+ async function openDshWorkspace(id, { renderAfter = true, preserveCanvasCamera = false } = {}) {
162
193
  const workspace = state.dshWorkspaces.find(item => item.id === id)
163
194
  if (workspace === undefined) return false
164
195
  const load = ++state.workspaceLoad
@@ -166,7 +197,7 @@ async function openDshWorkspace(id, { renderAfter = true } = {}) {
166
197
  const threads = await threadsForDshWorkspace(workspace)
167
198
  if (load !== state.workspaceLoad) return true
168
199
  const nextWorkspaceId = `dsh:${workspace.id}`
169
- if (state.workspace?.id !== nextWorkspaceId) resetCanvasCamera()
200
+ if (state.workspace?.id !== nextWorkspaceId && !preserveCanvasCamera) resetCanvasCamera()
170
201
  state.workspace = { id: nextWorkspaceId, title: workspace.title, cwd: workspace.path, threads }
171
202
  const currentThread = currentDshThread(state.workspace.threads)
172
203
  state.activeId = currentThread?.id ?? (state.workspace.threads.some(thread => thread.id === state.activeId) ? state.activeId : state.workspace.threads[0]?.id ?? null)
@@ -177,10 +208,10 @@ async function openDshWorkspace(id, { renderAfter = true } = {}) {
177
208
  return true
178
209
  }
179
210
 
180
- async function openCurrentWorkspace() {
211
+ async function openCurrentWorkspace({ preserveCanvasCamera = false } = {}) {
181
212
  const workspace = currentDshWorkspace()
182
213
  if (workspace === undefined || workspace.id === state.selectedDshWorkspaceId) return false
183
- return openDshWorkspace(workspace.id)
214
+ return openDshWorkspace(workspace.id, { preserveCanvasCamera })
184
215
  }
185
216
 
186
217
  async function refreshSummaries({ renderAfter = true } = {}) {
@@ -221,6 +252,10 @@ function openNewSession() {
221
252
  if (state.draft !== null) return
222
253
  state.mode = 'canvas'
223
254
  state.activeId = null
255
+ state.selectedCardId = null
256
+ state.inspectorCardId = null
257
+ state.inspectorOpening = false
258
+ state.quickPhraseEditorOpen = false
224
259
  state.draft = { kind: 'new', text: '', sending: false }
225
260
  state.error = ''
226
261
  resetCanvasCamera()
@@ -232,6 +267,8 @@ async function archiveThread(thread) {
232
267
  if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
233
268
  await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
234
269
  state.historyBySession.delete(thread.dshSessionId)
270
+ state.detailScrollByThread.delete(thread.id)
271
+ state.detailTargetCardId = state.detailThreadId === thread.id ? null : state.detailTargetCardId
235
272
  if (state.workspace !== null) {
236
273
  const removed = new Set([thread.id])
237
274
  for (let changed = true; changed;) {
@@ -265,17 +302,26 @@ async function archiveThread(thread) {
265
302
  await refreshSummaries()
266
303
  }
267
304
 
268
- function openContinue(parent, anchorId = undefined) {
305
+ function focusDraftInput() {
306
+ const input = document.querySelector('[data-draft] textarea')
307
+ if (!(input instanceof HTMLTextAreaElement)) return
308
+ input.focus()
309
+ input.setSelectionRange(input.value.length, input.value.length)
310
+ }
311
+
312
+ function openContinue(parent, anchorId = undefined, text = '') {
269
313
  if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
270
314
  state.activeId = parent.id
271
- state.draft = { kind: 'continue', parentId: parent.id, anchorId, text: '', sending: false }
315
+ state.quickPhraseEditorOpen = false
316
+ state.draft = { kind: 'continue', parentId: parent.id, anchorId, text, sending: false }
272
317
  render()
273
- window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
318
+ window.setTimeout(focusDraftInput, 0)
274
319
  }
275
320
 
276
321
  function openBranch(parent, atSeq = undefined, anchorId = undefined) {
277
322
  if (parent.dshSessionId === null) return setError('该节点没有关联的 DSH 会话')
278
323
  state.activeId = parent.id
324
+ state.quickPhraseEditorOpen = false
279
325
  state.draft = { kind: 'branch', parentId: parent.id, atSeq, anchorId, text: '', sending: false }
280
326
  render()
281
327
  window.setTimeout(() => document.querySelector('[data-draft] textarea')?.focus(), 0)
@@ -453,7 +499,7 @@ function markdownBlock(text) {
453
499
  // rebuild; cache the rendered HTML by input text so stable answers are never
454
500
  // re-parsed. Bounded: streaming partial texts churn keys, so evict oldest.
455
501
  const markdownCache = new Map()
456
- const MARKDOWN_CACHE_LIMIT = 500
502
+ const MARKDOWN_CACHE_LIMIT = 5000
457
503
  function renderMarkdown(text) {
458
504
  const key = String(text)
459
505
  const cached = markdownCache.get(key)
@@ -504,25 +550,49 @@ function selectorValue(value) {
504
550
  return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
505
551
  }
506
552
 
507
- function refreshCardConnectors(cardId) {
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()
508
558
  const viewport = document.querySelector('.canvas-viewport')
509
559
  if (!(viewport instanceof HTMLElement)) return
510
- const id = selectorValue(cardId)
511
- for (const path of viewport.querySelectorAll(`.connectors path[data-from="${id}"], .connectors path[data-to="${id}"]`)) {
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) {
512
578
  const fromId = path.getAttribute('data-from')
513
579
  const toId = path.getAttribute('data-to')
514
580
  if (fromId === null || toId === null) continue
515
- const fromCard = viewport.querySelector(`[data-card-id="${selectorValue(fromId)}"]`)
516
- const toCard = viewport.querySelector(`[data-card-id="${selectorValue(toId)}"]`)
517
- if (!(fromCard instanceof HTMLElement) || !(toCard instanceof HTMLElement)) continue
518
- const nextPath = connectorPathFromElements(fromCard, toCard)
519
- if (nextPath !== null) path.setAttribute('d', nextPath)
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))
520
587
  }
521
588
  }
522
589
 
523
590
  function initialCanvasCamera(cards) {
524
591
  const draft = state.draft?.kind === 'new' ? { id: 'draft:new', position: { x: 86, y: 82 } } : draftPlacement(cards)
525
- const active = state.activeId === null ? undefined : cards.find(card => card.dshThreadId === state.activeId)
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)
526
596
  const focus = draft ?? active ?? cards[0]
527
597
  const position = focus?.position
528
598
  if (position === undefined) return { x: 0, y: 0 }
@@ -598,12 +668,18 @@ function conversationCards(threads) {
598
668
  const question = messages[messageIndex]
599
669
  if (question.kind !== 'user') continue
600
670
  const replies = []
671
+ const errors = []
672
+ let processCount = 0
601
673
  for (let replyIndex = messageIndex + 1; replyIndex < messages.length; replyIndex++) {
602
674
  const reply = messages[replyIndex]
603
675
  if (reply.kind === 'user') break
604
676
  if (reply.kind === 'assistant') replies.push(reply)
677
+ if (reply.kind === 'error') errors.push(reply)
678
+ if (Array.isArray(reply.process)) processCount += reply.process.length
679
+ else if (reply.kind === 'tool') processCount += 1
605
680
  }
606
681
  const answer = replies.at(-1) ?? null
682
+ const error = errors.at(-1) ?? null
607
683
  const turnIndex = turns.length
608
684
  const id = `${thread.id}:turn:${question.sourceSeq ?? messageIndex}`
609
685
  const previous = turns.at(-1)
@@ -625,6 +701,8 @@ function conversationCards(threads) {
625
701
  positionLocked,
626
702
  question: question.text,
627
703
  answer,
704
+ error,
705
+ processCount,
628
706
  })
629
707
  }
630
708
  const liveReply = state.liveReplies.get(thread.dshSessionId)
@@ -649,6 +727,8 @@ function conversationCards(threads) {
649
727
  positionLocked,
650
728
  question: thread.dshSessionTitle ?? thread.title,
651
729
  answer: null,
730
+ error: null,
731
+ processCount: 0,
652
732
  })
653
733
  }
654
734
  turns.at(-1).canContinue = true
@@ -704,17 +784,60 @@ function conversationGraphView(cards, collapsedCardIds = state.collapsedCardIds)
704
784
  // contains a cycle where two collapsed nodes otherwise hide each other.
705
785
  for (const rootId of collapsedCardIds) hiddenIds.delete(rootId)
706
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.
707
794
  const descendantCounts = new Map()
795
+ const inStack = new Set()
708
796
  for (const card of cards) {
709
- const visited = new Set([card.id])
710
- const pending = [...(childrenByParent.get(card.id) ?? [])]
711
- while (pending.length > 0) {
712
- const descendantId = pending.pop()
713
- if (visited.has(descendantId)) continue
714
- visited.add(descendantId)
715
- pending.push(...(childrenByParent.get(descendantId) ?? []))
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
+ }
716
840
  }
717
- descendantCounts.set(card.id, visited.size - 1)
718
841
  }
719
842
 
720
843
  return {
@@ -744,7 +867,8 @@ function canvasConnectors(cards) {
744
867
  const links = cards.map(card => {
745
868
  const parent = card.parentId === null ? null : index.get(card.parentId)
746
869
  if (parent === undefined || parent === null) return ''
747
- return `<path data-from="${escapeHtml(parent.id)}" data-to="${escapeHtml(card.id)}" d="${connectorPath(parent.position, card.position)}"></path>`
870
+ const active = card.dshThreadId === state.activeId && parent.dshThreadId === state.activeId ? ' active-connector' : ''
871
+ return `<path class="${active.trim()}" data-from="${escapeHtml(parent.id)}" data-to="${escapeHtml(card.id)}" d="${connectorPath(parent.position, card.position)}"></path>`
748
872
  })
749
873
  const placement = draftPlacement(cards)
750
874
  if (placement !== null) {
@@ -754,7 +878,7 @@ function canvasConnectors(cards) {
754
878
  }
755
879
 
756
880
  function conversationCard(card, graph) {
757
- const active = card.dshThreadId === state.activeId ? 'active' : ''
881
+ const selected = card.id === state.selectedCardId ? 'selected' : ''
758
882
  const source = card.parentId === null ? 'DSH 会话' : card.turnIndex === 0 ? 'DSH 分支' : '追问'
759
883
  const continueButton = card.canContinue === true
760
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>`
@@ -764,13 +888,13 @@ function conversationCard(card, graph) {
764
888
  const foldLabel = collapsed ? '展开后续对话' : '折叠后续对话'
765
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>`
766
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>`
767
- return `<article class="thread-card ${active}" data-card-id="${escapeHtml(card.id)}" data-position-key="${escapeHtml(card.positionKey)}" data-thread="${card.dshThreadId}" style="left:${card.position.x}px;top:${card.position.y}px;--thread-color:#3478f6">
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">
768
892
  <button class="node-handle" data-drag-card="${card.id}" aria-label="拖动 ${escapeHtml(card.question)}" title="拖动卡片"></button>
769
893
  ${continueButton}${foldButton}${branchButton}
770
- <div class="thread-card-head"><span class="topic-dot"></span><button class="thread-title" data-action="show-thread" data-thread="${card.dshThreadId}" title="查看完整会话:${escapeHtml(card.question)}">${escapeHtml(card.question)}</button></div>
771
- <div class="thread-meta"><span>${source}</span><span>第 ${card.turnIndex + 1} 轮</span></div>
772
- <div class="thread-answer">${card.answer === null ? '<p class="thread-answer-empty">等待助手回复</p>' : card.answer.pending && card.answer.text === '' ? '<p class="thread-answer-pending">正在回复</p>' : `${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="thread-answer-pending">正在回复</p>' : ''}`}</div>
773
- <footer><button data-action="show-thread" data-thread="${card.dshThreadId}">详情</button><button data-action="open-dsh" data-thread="${card.dshThreadId}">打开 DSH</button><button data-action="archive-thread" data-thread="${card.dshThreadId}">归档</button></footer>
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
+ <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
+ <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>
774
898
  </article>`
775
899
  }
776
900
 
@@ -779,6 +903,60 @@ function draftActions(draft) {
779
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>`
780
904
  }
781
905
 
906
+ function quickPhraseEditor(draft) {
907
+ const disabled = draft.sending ? 'disabled' : ''
908
+ const phrases = state.quickPhrases.map((phrase, index) => `<div class="draft-quick-phrase-editor-row"><input data-quick-phrase-index="${index}" maxlength="${MAX_QUICK_PHRASE_LENGTH}" value="${escapeHtml(phrase)}" aria-label="快捷词 ${index + 1}" ${disabled}><button type="button" data-action="remove-quick-phrase" data-quick-phrase-index="${index}" aria-label="删除 ${escapeHtml(phrase)}" title="删除" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></div>`).join('')
909
+ return `<section class="draft-quick-editor" aria-label="编辑快捷词"><div class="draft-quick-editor-list">${phrases}</div><div class="draft-quick-phrase-add"><input maxlength="${MAX_QUICK_PHRASE_LENGTH}" placeholder="添加快捷词" aria-label="添加快捷词" ${disabled}><button class="primary" type="button" data-action="add-quick-phrase" aria-label="添加快捷词" title="添加快捷词" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div><button class="draft-quick-editor-close" type="button" data-action="close-quick-phrase-editor" ${disabled}>完成</button></section>`
910
+ }
911
+
912
+ function draftQuickPhrases(draft) {
913
+ const disabled = draft.sending ? 'disabled' : ''
914
+ if (state.quickPhraseEditorOpen) return quickPhraseEditor(draft)
915
+ const phrases = state.quickPhrases.map(phrase => `<button class="draft-quick-phrase" type="button" data-action="insert-quick-phrase" data-quick-phrase="${escapeHtml(phrase)}" ${disabled}>${escapeHtml(phrase)}</button>`).join('')
916
+ return `<div class="draft-quick-phrases" aria-label="常用补充词">${phrases}<button class="draft-quick-phrase-add-button" type="button" data-action="open-quick-phrase-editor" aria-label="管理快捷词" title="管理快捷词" ${disabled}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>`
917
+ }
918
+
919
+ function insertQuickPhrase(phrase) {
920
+ const input = document.querySelector('[data-draft] textarea')
921
+ if (!(input instanceof HTMLTextAreaElement) || state.draft === null) return
922
+ const start = input.selectionStart
923
+ const end = input.selectionEnd
924
+ const prefix = input.value.slice(0, start)
925
+ const suffix = input.value.slice(end)
926
+ const separator = prefix !== '' && !prefix.endsWith('\n') ? '\n' : ''
927
+ const text = `${prefix}${separator}${phrase}${suffix}`
928
+ if (text.length > input.maxLength) return setError('追问内容不能超过 4000 个字符')
929
+ const caret = prefix.length + separator.length + phrase.length
930
+ input.value = text
931
+ state.draft.text = text
932
+ input.focus()
933
+ input.setSelectionRange(caret, caret)
934
+ }
935
+
936
+ function addQuickPhrase(value) {
937
+ const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
938
+ if (phrase === '') return false
939
+ if (state.quickPhrases.includes(phrase)) return setError('这个快捷词已经存在')
940
+ if (state.quickPhrases.length >= MAX_QUICK_PHRASES) return setError(`最多保留 ${MAX_QUICK_PHRASES} 个快捷词`)
941
+ state.quickPhrases.push(phrase)
942
+ persistQuickPhrases()
943
+ return true
944
+ }
945
+
946
+ function updateQuickPhrase(index, value) {
947
+ if (!Number.isInteger(index) || index < 0 || index >= state.quickPhrases.length) return
948
+ const phrase = value.trim().slice(0, MAX_QUICK_PHRASE_LENGTH)
949
+ if (phrase === '') {
950
+ state.quickPhrases.splice(index, 1)
951
+ } else if (state.quickPhrases.some((item, itemIndex) => itemIndex !== index && item === phrase)) {
952
+ return setError('这个快捷词已经存在')
953
+ } else {
954
+ state.quickPhrases[index] = phrase
955
+ }
956
+ persistQuickPhrases()
957
+ render()
958
+ }
959
+
782
960
  function draftPlacement(cards) {
783
961
  const draft = state.draft
784
962
  if (draft === null || draft.kind === 'new') return null
@@ -800,21 +978,86 @@ function draftCard(cards) {
800
978
  const continuing = draft.kind === 'continue'
801
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">
802
980
  <div class="thread-card-head"><span class="topic-dot"></span><strong>${continuing ? '新的追问' : '新的分支'}</strong></div>
803
- <form class="draft-branch-form" data-draft><textarea maxlength="4000" placeholder="${continuing ? '输入追问' : '输入这个分支的新问题'}" ${draft.sending ? 'disabled' : ''}>${escapeHtml(draft.text)}</textarea>${draftActions(draft)}</form>
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>
804
982
  </article>`
805
983
  }
806
984
 
985
+ function selectionFollowupButton() {
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
+ }
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
+
807
1036
  function renderCanvas() {
808
1037
  const threads = state.workspace?.threads ?? []
809
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>`
810
1039
  const allCards = conversationCards(threads)
811
1040
  const graph = conversationGraphView(allCards)
812
1041
  const cards = graph.cards
1042
+ state.canvasCards = cards
1043
+ state.canvasCardsById = new Map(cards.map(card => [card.id, card]))
1044
+ state.canvasGraph = graph
1045
+ if (state.inspectorCardId !== null && !state.canvasCardsById.has(state.inspectorCardId)) {
1046
+ state.inspectorCardId = null
1047
+ state.inspectorOpening = false
1048
+ }
813
1049
  if (!state.canvasViewInitialized) {
814
1050
  state.canvasCamera = initialCanvasCamera(cards)
815
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
816
1055
  }
817
- return `<section class="canvas-view"><div class="canvas-viewport"><div class="canvas-content" style="transform:translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})"><svg class="connectors">${canvasConnectors(cards)}</svg><div class="cards-layer">${cards.map(card => conversationCard(card, graph)).join('')}${draftCard(cards)}</div></div></div></section>`
1056
+ const visible = visibleCardIds(cards)
1057
+ state.mountedCardIds = new Set(visible)
1058
+ const mounted = cards.filter(card => visible.has(card.id))
1059
+ const inspector = state.inspectorCardId === null ? '' : renderCardInspector(state.canvasCardsById.get(state.inspectorCardId))
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>`
818
1061
  }
819
1062
 
820
1063
  function isProcessMessage(message) {
@@ -840,7 +1083,7 @@ function threadMessage(thread, message) {
840
1083
  const body = message.pending && message.text === '' ? '<p class="message-streaming"><span class="streaming-dot"></span>正在回复</p>'
841
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)}`
842
1085
  const avatar = isUser ? '' : '<span class="message-avatar" aria-hidden="true"></span>'
843
- return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
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>`
844
1087
  }
845
1088
 
846
1089
  function processRecords(process, messageId) {
@@ -857,22 +1100,95 @@ function processRecords(process, messageId) {
857
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>`
858
1101
  }
859
1102
 
1103
+ function messagesForCard(card) {
1104
+ const thread = state.workspace?.threads.find(item => item.id === card.dshThreadId)
1105
+ if (thread === undefined) return { thread: null, messages: [] }
1106
+ const messages = messagesFor(thread)
1107
+ let turnIndex = -1
1108
+ let start = -1
1109
+ for (let index = 0; index < messages.length; index++) {
1110
+ if (messages[index].kind !== 'user') continue
1111
+ turnIndex += 1
1112
+ if (turnIndex === card.turnIndex) {
1113
+ start = index
1114
+ break
1115
+ }
1116
+ }
1117
+ if (start === -1) return { thread, messages: [] }
1118
+ const end = messages.findIndex((message, index) => index > start && message.kind === 'user')
1119
+ return { thread, messages: messages.slice(start, end === -1 ? undefined : end) }
1120
+ }
1121
+
1122
+ function inspectorProcessEntries(messages) {
1123
+ const entries = []
1124
+ for (const message of messages) {
1125
+ if (Array.isArray(message.process)) {
1126
+ entries.push(...message.process.map(entry => ({ ...entry })))
1127
+ continue
1128
+ }
1129
+ if (message.kind === 'tool') {
1130
+ entries.push({ name: processSummary(message.text), arguments: message.text, result: null, error: null })
1131
+ continue
1132
+ }
1133
+ if (message.kind === 'tool-result') {
1134
+ const previous = entries.at(-1)
1135
+ if (previous !== undefined && previous.result === null && previous.error === null) previous.result = message.text
1136
+ else entries.push({ name: '工具结果', arguments: null, result: message.text, error: null })
1137
+ }
1138
+ }
1139
+ return entries
1140
+ }
1141
+
1142
+ function renderCardInspector(card) {
1143
+ if (card === undefined) return ''
1144
+ const { thread, messages } = messagesForCard(card)
1145
+ if (thread === null) return ''
1146
+ const process = inspectorProcessEntries(messages)
1147
+ const answer = card.answer === null
1148
+ ? card.error === null ? '<p class="card-inspector-pending">等待助手回复</p>' : ''
1149
+ : `<article class="card-inspector-answer">${renderMarkdown(card.answer.text)}${card.answer.pending ? '<p class="card-inspector-pending">正在回复</p>' : ''}</article>`
1150
+ const error = card.error === null ? '' : `<section class="card-inspector-error" role="alert"><strong>本轮未完成</strong><p>${escapeHtml(card.error.text)}</p></section>`
1151
+ const processRecordsHtml = process.length === 0 ? '' : processRecords(process, `${thread.id}:${card.id}:inspector`)
1152
+ const continueAction = card.canContinue === true ? `<button type="button" data-action="open-continue" data-thread="${thread.id}" data-card="${escapeHtml(card.id)}"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 3.5h11v7h-6l-3.5 2.5v-2.5h-1.5Z"/><path d="M8 5.5v3M6.5 7h3"/></svg>继续追问</button>` : ''
1153
+ const branch = Number.isInteger(card.answer?.sourceSeq)
1154
+ ? `<button type="button" data-action="open-branch" data-thread="${thread.id}" data-card="${escapeHtml(card.id)}" data-seq="${card.answer.sourceSeq}"><svg aria-hidden="true" viewBox="0 0 16 16"><circle cx="4" cy="3.5" r="1.5"/><circle cx="12" cy="3.5" r="1.5"/><circle cx="12" cy="12.5" r="1.5"/><path d="M5.5 3.5h2A2.5 2.5 0 0 1 10 6v5"/></svg>创建分支</button>`
1155
+ : ''
1156
+ const openDshAction = `<button class="primary" type="button" data-action="open-dsh" data-thread="${thread.id}" data-seq="${Number.isInteger(card.answer?.sourceSeq) ? card.answer.sourceSeq : ''}"><svg aria-hidden="true" viewBox="0 0 16 16"><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>`
1157
+ return `<aside class="card-inspector${state.inspectorOpening ? ' is-opening' : ''}" aria-label="卡片详情" data-inspector-card="${escapeHtml(card.id)}"><header class="card-inspector-head"><div><div class="card-inspector-meta"><span>第 ${card.turnIndex + 1} 轮</span>${card.error === null ? '' : '<span class="card-inspector-error-status">失败</span>'}${process.length > 0 ? `<span>工具 ${process.length}</span>` : ''}</div><h2>${escapeHtml(card.question)}</h2></div><button class="card-inspector-close" type="button" data-action="close-card-inspector" aria-label="关闭卡片详情" title="关闭"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></header><div class="card-inspector-scroll">${error}${answer}${processRecordsHtml}</div><footer class="card-inspector-actions">${continueAction}${branch}${openDshAction}</footer></aside>`
1158
+ }
1159
+
860
1160
  function renderThread() {
861
1161
  const thread = currentThread()
862
1162
  if (thread === null) return renderCanvas()
863
1163
  const messages = messagesFor(thread)
864
1164
  const waiting = state.pendingReplies.has(thread.dshSessionId)
865
- return `<section class="detail-view"><header class="detail-head"><div class="detail-head-title"><div class="detail-head-meta"><span class="detail-badge">${thread.parentId === null ? '会话' : '分支'}</span>${thread.dshSessionTitle ?? thread.title ? `<span class="detail-subtitle">${escapeHtml(thread.dshSessionTitle ?? thread.title)}</span>` : ''}</div><h1>${escapeHtml(questionFor(thread))}</h1></div><div class="detail-head-actions"><button data-action="open-dsh" data-thread="${thread.id}" title="在原生对话中打开此会话">在 DSH 中打开</button><button data-action="open-branch" data-thread="${thread.id}" title="基于最新回答创建分支">创建分支</button><button class="primary" data-action="show-canvas">返回画布</button></div></header><div class="detail-scroll">${messages.map(message => threadMessage(thread, message)).join('') || '<div class="note-empty">等待这条会话的第一条消息。</div>'}</div><form class="message-composer" data-compose="${thread.id}"><textarea maxlength="4000" placeholder="继续当前会话…" ${waiting ? 'disabled' : ''}></textarea><button class="primary" type="submit" ${waiting ? 'disabled' : ''}>${waiting ? '等待回复' : '发送'}</button></form></section>`
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>`
866
1167
  }
867
1168
 
868
1169
  function render() {
869
- const detail = state.mode === 'thread' ? document.querySelector('.detail-scroll') : null
870
- const detailScrollTop = detail instanceof HTMLElement ? detail.scrollTop : null
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
+ if (state.mode === 'thread' && state.detailThreadId !== null) {
1174
+ const detail = document.querySelector('.detail-scroll')
1175
+ if (detail instanceof HTMLElement) state.detailScrollByThread.set(state.detailThreadId, detail.scrollTop)
1176
+ }
1177
+ if (state.mode === 'canvas' && state.inspectorCardId !== null) {
1178
+ const inspector = document.querySelector('.card-inspector-scroll')
1179
+ if (inspector instanceof HTMLElement) state.inspectorScrollByCard.set(state.inspectorCardId, inspector.scrollTop)
1180
+ }
1181
+ state.detailThreadId = state.mode === 'thread' ? state.activeId : null
1182
+ const detailScrollTop = state.detailThreadId === null ? null : state.detailScrollByThread.get(state.detailThreadId) ?? null
1183
+ const inspectorScrollTop = state.mode === 'canvas' && state.inspectorCardId !== null ? state.inspectorScrollByCard.get(state.inspectorCardId) ?? null : null
871
1184
  const cardScrollTops = new Map()
872
1185
  if (state.mode === 'canvas') {
873
1186
  // Key by the unique card id: every card of a session shares data-thread,
874
- // so keying on it would clobber sibling cards' scroll positions.
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.
875
1190
  for (const answer of document.querySelectorAll('.thread-card[data-thread] .thread-answer')) {
1191
+ if (answer.scrollHeight <= answer.clientHeight) continue
876
1192
  const card = answer.closest('.thread-card')
877
1193
  if (card instanceof HTMLElement && typeof card.dataset.cardId === 'string') cardScrollTops.set(card.dataset.cardId, answer.scrollTop)
878
1194
  }
@@ -882,11 +1198,18 @@ function render() {
882
1198
  const view = state.mode === 'thread' ? renderThread() : renderCanvas()
883
1199
  const choices = workspaceChoices()
884
1200
  const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
885
- const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout">整理节点</button><button data-action="focus-active" title="定位到当前会话">定位</button><button data-action="zoom-out" aria-label="缩小">-</button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大">+</button></div>` : ''
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>` : ''
886
1202
  const detailAvailable = currentThread() !== null
887
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>`
888
- app.innerHTML = `<main class="synapse-shell ${state.sidebarCollapsed ? 'sidebar-collapsed' : ''}"><aside class="sidebar"><div class="sidebar-brand-row"><div class="brand" aria-label="Synapse"><svg class="brand-mark" aria-hidden="true" viewBox="0 0 32 32" fill="none"><path d="M9 10.5 16 7l7 3.5M9 10.5v8L16 22m0-15v15m7-11.5v8L16 22"/><circle cx="9" cy="10" r="2.5"/><circle cx="23" cy="10" r="2.5"/><circle cx="16" cy="23" r="2.5"/></svg><strong>Synapse</strong></div><button class="sidebar-toggle" type="button" data-action="toggle-sidebar" aria-label="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}" title="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25"/><path d="M6 2v12"/></svg></button></div><button class="new-workspace" type="button" data-action="create-session" ${state.draft !== null ? 'disabled' : ''}><svg class="new-session-icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 4.75v6.5M4.75 8h6.5"/></svg><span>新会话</span></button><label class="workspace-label"><span>工作区</span><span class="workspace-select"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 4.75h3l1.2 1.5h6.8v5.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z"/></svg><select data-action="select-workspace" aria-label="选择工作区" ${state.draft !== null ? 'disabled' : ''}>${choices.map(item => `<option value="${item.id}" title="${escapeHtml(item.path ?? item.title)}" ${item.id === selectedWorkspaceId ? 'selected' : ''}>${escapeHtml(item.title)}</option>`).join('')}</select></span></label><div class="sidebar-heading"><span>会话</span></div><nav class="thread-tree">${threads.map(thread => `<button class="tree-row ${thread.id === state.activeId ? 'active' : ''}" data-action="select-thread" data-thread="${thread.id}" style="--thread-color:#374151"><span class="tree-dot"></span><span>${escapeHtml(threadListTitle(thread))}</span>${thread.parentId === null ? '' : '<i>分支</i>'}</button>`).join('') || '<p class="tree-empty">暂未同步会话</p>'}</nav></aside><header class="topbar"><div class="view-switch" role="group" aria-label="视图切换"><button data-action="close" type="button" aria-pressed="false">对话</button><button class="active" type="button" aria-pressed="true">会话地图</button></div>${canvasControls}</header><section class="main-stage">${state.error ? `<div class="status-message" role="alert"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error" aria-label="关闭" title="关闭">×</button></div>` : ''}${canvasTabs}${view}</section></main>`
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>`
889
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
+ }
890
1213
  for (const [cardId, scrollTop] of cardScrollTops) {
891
1214
  const answer = app.querySelector(`.thread-card[data-card-id="${CSS.escape(cardId)}"] .thread-answer`)
892
1215
  if (answer instanceof HTMLElement) answer.scrollTop = scrollTop
@@ -895,19 +1218,71 @@ function render() {
895
1218
  const nextDetail = document.querySelector('.detail-scroll')
896
1219
  if (nextDetail instanceof HTMLElement) nextDetail.scrollTop = detailScrollTop
897
1220
  })
1221
+ if (inspectorScrollTop !== null) window.requestAnimationFrame(() => {
1222
+ const inspector = document.querySelector('.card-inspector-scroll')
1223
+ if (inspector instanceof HTMLElement) inspector.scrollTop = inspectorScrollTop
1224
+ })
1225
+ if (state.inspectorOpening) window.requestAnimationFrame(() => {
1226
+ document.querySelector('.card-inspector')?.classList.remove('is-opening')
1227
+ state.inspectorOpening = false
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
+ }
898
1242
  }
899
1243
 
900
1244
  function renderPreservingDetailScroll() {
901
1245
  render()
902
1246
  }
903
1247
 
1248
+ let inspectorCloseTimer = 0
1249
+ function openCardInspector(cardId) {
1250
+ if (inspectorCloseTimer !== 0) {
1251
+ window.clearTimeout(inspectorCloseTimer)
1252
+ inspectorCloseTimer = 0
1253
+ }
1254
+ state.inspectorOpening = state.inspectorCardId === null
1255
+ state.inspectorCardId = cardId
1256
+ }
1257
+
1258
+ function closeCardInspector({ animate = true } = {}) {
1259
+ if (state.inspectorCardId === null) return
1260
+ if (inspectorCloseTimer !== 0) window.clearTimeout(inspectorCloseTimer)
1261
+ const cardId = state.inspectorCardId
1262
+ const inspector = document.querySelector('.card-inspector')
1263
+ if (!animate || !(inspector instanceof HTMLElement)) {
1264
+ state.inspectorCardId = null
1265
+ state.inspectorOpening = false
1266
+ render()
1267
+ return
1268
+ }
1269
+ inspector.classList.add('is-closing')
1270
+ inspectorCloseTimer = window.setTimeout(() => {
1271
+ inspectorCloseTimer = 0
1272
+ if (state.inspectorCardId !== cardId) return
1273
+ state.inspectorCardId = null
1274
+ state.inspectorOpening = false
1275
+ render()
1276
+ }, 180)
1277
+ }
1278
+
904
1279
  function applyCanvasTransform() {
905
1280
  const content = document.querySelector('.canvas-content')
906
1281
  if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
907
1282
  }
908
1283
 
909
- function installDragging() {
910
- for (const handle of document.querySelectorAll('[data-drag-card]')) handle.addEventListener('pointerdown', event => {
1284
+ function bindDragHandle(handle) {
1285
+ handle.addEventListener('pointerdown', event => {
911
1286
  const cardId = event.currentTarget.dataset.dragCard
912
1287
  const card = event.currentTarget.closest('.thread-card')
913
1288
  if (cardId === undefined || !(card instanceof HTMLElement)) return
@@ -916,25 +1291,39 @@ function installDragging() {
916
1291
  const aliases = card.dataset.positionKey === undefined ? [] : [card.dataset.positionKey]
917
1292
  let position = origin.position
918
1293
  let stopped = false
1294
+ let frame = 0
919
1295
  state.dragging = true
920
- const move = moveEvent => {
921
- position = { x: origin.position.x + (moveEvent.clientX - origin.x) / state.zoom, y: origin.position.y + (moveEvent.clientY - origin.y) / state.zoom }
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
922
1300
  state.cardPositions.set(cardId, { x: Math.round(position.x), y: Math.round(position.y) })
923
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 }
924
1306
  card.style.left = `${position.x}px`
925
1307
  card.style.top = `${position.y}px`
926
1308
  refreshCardConnectors(cardId)
927
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
+ }
928
1314
  const stop = () => {
929
1315
  if (stopped) return
930
1316
  stopped = true
931
1317
  document.removeEventListener('pointermove', move)
932
1318
  document.removeEventListener('pointerup', stop)
933
1319
  document.removeEventListener('pointercancel', stop)
1320
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1321
+ apply()
934
1322
  rememberCardPosition(cardId, position, aliases)
935
1323
  state.dragging = false
936
1324
  deferCanvasRefresh(120)
937
- render()
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.
938
1327
  }
939
1328
  document.addEventListener('pointermove', move)
940
1329
  document.addEventListener('pointerup', stop)
@@ -942,6 +1331,10 @@ function installDragging() {
942
1331
  })
943
1332
  }
944
1333
 
1334
+ function installDragging() {
1335
+ for (const handle of document.querySelectorAll('[data-drag-card]')) bindDragHandle(handle)
1336
+ }
1337
+
945
1338
  function canvasViewport(target) {
946
1339
  return target instanceof Element ? target.closest('.canvas-viewport') : null
947
1340
  }
@@ -956,7 +1349,19 @@ function zoomCanvas(viewport, nextZoom, clientX, clientY) {
956
1349
  const worldY = (localY - state.canvasCamera.y) / state.zoom
957
1350
  state.zoom = zoom
958
1351
  state.canvasCamera = { x: localX - worldX * zoom, y: localY - worldY * zoom }
959
- applyCanvasTransform()
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
+ }
960
1365
  const label = document.querySelector('.canvas-controls span')
961
1366
  if (label !== null) label.textContent = `${Math.round(state.zoom * 100)}%`
962
1367
  }
@@ -969,18 +1374,75 @@ function zoomCanvasAtCenter(delta) {
969
1374
  }
970
1375
 
971
1376
  function focusActiveCard() {
972
- const card = document.querySelector('.thread-card.active') ?? document.querySelector('.thread-card[data-thread]:not(.draft-card)')
973
1377
  const viewport = document.querySelector('.canvas-viewport')
974
- if (!(card instanceof HTMLElement) || !(viewport instanceof HTMLElement)) return
975
- const left = Number.parseFloat(card.style.left)
976
- const top = Number.parseFloat(card.style.top)
977
- if (!Number.isFinite(left) || !Number.isFinite(top)) return
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
978
1389
  const bounds = viewport.getBoundingClientRect()
979
1390
  state.canvasCamera = {
980
1391
  x: bounds.width / 2 - (left + CARD_WIDTH / 2) * state.zoom,
981
1392
  y: bounds.height / 2 - (top + CARD_HEIGHT / 2) * state.zoom,
982
1393
  }
983
1394
  applyCanvasTransform()
1395
+ syncCanvasViewport()
1396
+ }
1397
+
1398
+ let selectionFollowup = null
1399
+ let selectionFollowupFrame = 0
1400
+
1401
+ function hideSelectionFollowup() {
1402
+ if (selectionFollowupFrame !== 0) {
1403
+ window.cancelAnimationFrame(selectionFollowupFrame)
1404
+ selectionFollowupFrame = 0
1405
+ }
1406
+ selectionFollowup = null
1407
+ const button = app.querySelector('.selection-followup')
1408
+ if (button instanceof HTMLButtonElement) button.hidden = true
1409
+ }
1410
+
1411
+ function selectionFollowupTarget(range) {
1412
+ const start = range.startContainer instanceof Element ? range.startContainer : range.startContainer.parentElement
1413
+ const end = range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement
1414
+ if (!(start instanceof Element) || !(end instanceof Element)) return null
1415
+ const answer = start.closest('.thread-answer')
1416
+ if (answer instanceof HTMLElement && answer.contains(end)) {
1417
+ const card = answer.closest('.thread-card[data-thread]:not(.draft-card)')
1418
+ if (card instanceof HTMLElement && card.dataset.thread !== undefined) return { threadId: card.dataset.thread }
1419
+ }
1420
+ const messageBody = start.closest('.message-assistant .message-body')
1421
+ const thread = currentThread()
1422
+ if (messageBody instanceof HTMLElement && messageBody.contains(end) && thread !== null) return { threadId: thread.id }
1423
+ return null
1424
+ }
1425
+
1426
+ function updateSelectionFollowup() {
1427
+ selectionFollowupFrame = 0
1428
+ const button = app.querySelector('.selection-followup')
1429
+ const selection = window.getSelection()
1430
+ if (!(button instanceof HTMLButtonElement) || state.draft !== null || selection === null || selection.rangeCount !== 1 || selection.isCollapsed) return hideSelectionFollowup()
1431
+ const text = selection.toString().trim()
1432
+ const range = selection.getRangeAt(0)
1433
+ const target = text === '' || text.length > 4000 ? null : selectionFollowupTarget(range)
1434
+ const rect = range.getBoundingClientRect()
1435
+ if (target === null || rect.width === 0 || rect.height === 0) return hideSelectionFollowup()
1436
+ selectionFollowup = { ...target, text }
1437
+ button.dataset.thread = target.threadId
1438
+ button.style.left = `${Math.min(window.innerWidth - 12, Math.max(76, rect.right))}px`
1439
+ button.style.top = `${Math.min(window.innerHeight - 38, Math.max(8, rect.bottom + 8))}px`
1440
+ button.hidden = false
1441
+ }
1442
+
1443
+ function queueSelectionFollowup() {
1444
+ if (selectionFollowupFrame !== 0) return
1445
+ selectionFollowupFrame = window.requestAnimationFrame(updateSelectionFollowup)
984
1446
  }
985
1447
 
986
1448
  app.addEventListener('pointerdown', event => {
@@ -988,21 +1450,33 @@ app.addEventListener('pointerdown', event => {
988
1450
  if (!(viewport instanceof HTMLElement) || event.target instanceof Element && event.target.closest('.thread-card, button, textarea, select')) return
989
1451
  event.preventDefault()
990
1452
  const origin = { x: event.clientX, y: event.clientY, camera: { ...state.canvasCamera } }
1453
+ let pendingCamera = null
1454
+ let frame = 0
991
1455
  state.canvasGesture = true
992
1456
  viewport.classList.add('is-panning')
993
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
+ }
994
1466
  const move = moveEvent => {
995
- state.canvasCamera = {
1467
+ pendingCamera = {
996
1468
  x: origin.camera.x + moveEvent.clientX - origin.x,
997
1469
  y: origin.camera.y + moveEvent.clientY - origin.y,
998
1470
  }
999
- applyCanvasTransform()
1471
+ if (frame === 0) frame = window.requestAnimationFrame(apply)
1000
1472
  }
1001
1473
  const stop = () => {
1002
1474
  viewport.classList.remove('is-panning')
1003
1475
  document.removeEventListener('pointermove', move)
1004
1476
  document.removeEventListener('pointerup', stop)
1005
1477
  document.removeEventListener('pointercancel', stop)
1478
+ if (frame !== 0) { window.cancelAnimationFrame(frame); frame = 0 }
1479
+ apply()
1006
1480
  state.canvasGesture = false
1007
1481
  deferCanvasRefresh(120)
1008
1482
  }
@@ -1038,6 +1512,19 @@ app.addEventListener('wheel', event => {
1038
1512
  // wipe the user's selection.
1039
1513
  let pointerDownPosition = null
1040
1514
  app.addEventListener('pointerdown', event => { pointerDownPosition = { x: event.clientX, y: event.clientY } })
1515
+ app.addEventListener('pointerdown', event => {
1516
+ const button = event.target instanceof Element ? event.target.closest('.selection-followup') : null
1517
+ if (button instanceof HTMLButtonElement) event.preventDefault()
1518
+ else hideSelectionFollowup()
1519
+ })
1520
+ app.addEventListener('pointerup', queueSelectionFollowup)
1521
+ app.addEventListener('scroll', hideSelectionFollowup, true)
1522
+ document.addEventListener('selectionchange', queueSelectionFollowup)
1523
+ document.addEventListener('keydown', event => {
1524
+ if (event.key !== 'Escape' || state.mode !== 'canvas' || state.inspectorCardId === null) return
1525
+ event.preventDefault()
1526
+ closeCardInspector({ animate: false })
1527
+ })
1041
1528
 
1042
1529
  app.addEventListener('click', async event => {
1043
1530
  const button = event.target.closest('[data-action]')
@@ -1051,23 +1538,60 @@ app.addEventListener('click', async event => {
1051
1538
  && Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y) > 4) return
1052
1539
  const thread = state.workspace?.threads.find(item => item.id === card.dataset.thread)
1053
1540
  if (thread === undefined) return
1541
+ const cardId = card.dataset.cardId
1542
+ if (cardId === undefined) return
1054
1543
  state.activeId = thread.id
1544
+ state.selectedCardId = cardId
1545
+ openCardInspector(cardId)
1055
1546
  state.error = ''
1056
1547
  render()
1057
1548
  void loadThreadHistory(thread)
1058
1549
  // Bidirectional current-session sync: switch DSH's current session
1059
1550
  // without closing the map; the client confirms via synapse:current-session.
1060
- if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
1551
+ if (thread.dshSessionId !== null) {
1552
+ if (thread.dshSessionId !== state.currentDsh?.id) state.mapCardSessionSwitches.add(thread.dshSessionId)
1553
+ post('synapse:activate-session', { sessionId: thread.dshSessionId })
1554
+ }
1061
1555
  return
1062
1556
  }
1063
1557
  const thread = state.workspace?.threads.find(item => item.id === button.dataset.thread)
1064
1558
  try {
1559
+ if (button.dataset.action === 'follow-selection') {
1560
+ const followup = selectionFollowup
1561
+ hideSelectionFollowup()
1562
+ if (followup !== null && thread !== undefined && thread.id === followup.threadId && state.draft === null) openContinue(thread, undefined, followup.text)
1563
+ return
1564
+ }
1565
+ if (button.dataset.action === 'insert-quick-phrase' && button.dataset.quickPhrase !== undefined) insertQuickPhrase(button.dataset.quickPhrase)
1566
+ if (button.dataset.action === 'open-quick-phrase-editor') { state.quickPhraseEditorOpen = true; render() }
1567
+ if (button.dataset.action === 'close-quick-phrase-editor') { state.quickPhraseEditorOpen = false; render() }
1568
+ if (button.dataset.action === 'add-quick-phrase') {
1569
+ const editor = button.closest('.draft-quick-phrase-add')
1570
+ const input = editor?.querySelector('input')
1571
+ if (input instanceof HTMLInputElement && addQuickPhrase(input.value)) {
1572
+ render()
1573
+ window.setTimeout(() => document.querySelector('.draft-quick-phrase-add input')?.focus(), 0)
1574
+ }
1575
+ }
1576
+ if (button.dataset.action === 'remove-quick-phrase') {
1577
+ const index = Number(button.dataset.quickPhraseIndex)
1578
+ if (Number.isInteger(index) && index >= 0 && index < state.quickPhrases.length) {
1579
+ state.quickPhrases.splice(index, 1)
1580
+ persistQuickPhrases()
1581
+ render()
1582
+ }
1583
+ }
1065
1584
  if (button.dataset.action === 'close') post('synapse:close')
1585
+ if (button.dataset.action === 'close-card-inspector') { closeCardInspector(); return }
1066
1586
  if (button.dataset.action === 'toggle-sidebar') { state.sidebarCollapsed = !state.sidebarCollapsed; render() }
1067
1587
  if (button.dataset.action === 'create-session') openNewSession()
1068
1588
  if (button.dataset.action === 'open-current' && state.currentDsh !== null) post('synapse:open-session', { sessionId: state.currentDsh.id })
1069
1589
  if (button.dataset.action === 'select-thread' && thread !== undefined) {
1590
+ state.mapCardSessionSwitches.clear()
1070
1591
  state.activeId = thread.id
1592
+ state.selectedCardId = null
1593
+ state.inspectorCardId = null
1594
+ state.inspectorOpening = false
1071
1595
  state.error = ''
1072
1596
  if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1073
1597
  render()
@@ -1076,7 +1600,7 @@ app.addEventListener('click', async event => {
1076
1600
  // without closing the map; the client confirms via synapse:current-session.
1077
1601
  if (thread.dshSessionId !== null) post('synapse:activate-session', { sessionId: thread.dshSessionId })
1078
1602
  }
1079
- if (button.dataset.action === 'show-thread' && thread !== undefined) { state.activeId = thread.id; state.mode = 'thread'; render(); void loadThreadHistory(thread) }
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) }
1080
1604
  if (button.dataset.action === 'show-canvas') { state.mode = 'canvas'; render() }
1081
1605
  if (button.dataset.action === 'toggle-card-children' && button.dataset.card !== undefined) {
1082
1606
  const cardId = button.dataset.card
@@ -1102,9 +1626,9 @@ app.addEventListener('click', async event => {
1102
1626
  const fallbackSeq = latestMessage(thread, 'assistant')?.sourceSeq
1103
1627
  openBranch(thread, Number.isInteger(requestedSeq) ? requestedSeq : fallbackSeq, button.dataset.card)
1104
1628
  }
1105
- if (button.dataset.action === 'cancel-draft') { state.draft = null; render() }
1629
+ if (button.dataset.action === 'cancel-draft') { state.draft = null; state.quickPhraseEditorOpen = false; render() }
1106
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() }
1107
- if (button.dataset.action === 'open-dsh' && thread?.dshSessionId !== null) post('synapse:open-session', { sessionId: thread.dshSessionId })
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 })
1108
1632
  if (button.dataset.action === 'archive-thread' && thread !== undefined) await archiveThread(thread)
1109
1633
  if (button.dataset.action === 'zoom-in') zoomCanvasAtCenter(.1)
1110
1634
  if (button.dataset.action === 'zoom-out') zoomCanvasAtCenter(-.1)
@@ -1119,9 +1643,16 @@ app.addEventListener('click', async event => {
1119
1643
  })
1120
1644
 
1121
1645
  app.addEventListener('change', event => {
1646
+ const quickPhrase = event.target instanceof Element ? event.target.closest('[data-quick-phrase-index]') : null
1647
+ if (quickPhrase instanceof HTMLInputElement) {
1648
+ updateQuickPhrase(Number(quickPhrase.dataset.quickPhraseIndex), quickPhrase.value)
1649
+ return
1650
+ }
1122
1651
  const select = event.target.closest('[data-action="select-workspace"]')
1123
1652
  if (!(select instanceof HTMLSelectElement)) return
1124
1653
  const choice = workspaceChoices().find(item => item.id === select.value)
1654
+ state.inspectorCardId = null
1655
+ state.inspectorOpening = false
1125
1656
  if (choice?.source === 'dsh') {
1126
1657
  // Map → native sync: switching workspaces moves DSH's current session to
1127
1658
  // the workspace's most recently updated session, keeping both sides in step.
@@ -1154,7 +1685,10 @@ window.addEventListener('message', event => {
1154
1685
  if (event.origin !== window.location.origin || event.data?.source !== 'dsh-synapse') return
1155
1686
  const data = event.data
1156
1687
  if (data.type === 'synapse:map-opened') {
1157
- resetCanvasCamera()
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.
1158
1692
  state.mode = 'canvas'
1159
1693
  render()
1160
1694
  window.requestAnimationFrame(() => post('synapse:map-ready'))
@@ -1172,12 +1706,29 @@ window.addEventListener('message', event => {
1172
1706
  if (data.type === 'synapse:current-session') {
1173
1707
  const previousId = state.currentDsh?.id
1174
1708
  state.currentDsh = data.session
1709
+ const preserveCanvasCamera = previousId !== data.session?.id && state.mapCardSessionSwitches.delete(data.session?.id)
1175
1710
  const thread = currentDshThread()
1176
1711
  if (thread !== undefined) {
1712
+ const preserveSelectedCard = state.activeId === thread.id
1177
1713
  state.activeId = thread.id
1714
+ if (!preserveSelectedCard) {
1715
+ state.selectedCardId = null
1716
+ state.inspectorCardId = null
1717
+ state.inspectorOpening = false
1718
+ }
1178
1719
  if (state.workspace !== null) revealConversationThread(conversationCards(state.workspace.threads), thread.id)
1179
1720
  }
1180
- if (previousId !== data.session?.id) void openCurrentWorkspace().then(opened => { if (!opened && canReplaceView()) render() }).catch(setError)
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
+ void openCurrentWorkspace({ preserveCanvasCamera }).then(opened => {
1726
+ if (!opened && canReplaceView()) {
1727
+ render()
1728
+ if (!preserveCanvasCamera) focusActiveCard()
1729
+ }
1730
+ }).catch(setError)
1731
+ }
1181
1732
  else if (canReplaceView()) render()
1182
1733
  }
1183
1734
  if (data.type === 'synapse:live-reply' && typeof data.sessionId === 'string') {
@@ -1221,6 +1772,9 @@ function scheduleLiveCardUpdate(sessionId) {
1221
1772
  }
1222
1773
  function applyLiveReplyToCard(sessionId) {
1223
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
1224
1778
  const thread = state.workspace?.threads.find(item => item.dshSessionId === sessionId)
1225
1779
  if (thread === undefined) return
1226
1780
  const live = state.liveReplies.get(sessionId)