dsh-synapse 0.5.3 → 0.5.4

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.
Files changed (2) hide show
  1. package/app.js +98 -3
  2. package/package.json +1 -1
package/app.js CHANGED
@@ -1268,7 +1268,7 @@ function render() {
1268
1268
  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>` : ''
1269
1269
  const detailAvailable = currentThread() !== null
1270
1270
  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>`
1271
- 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">${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>`
1271
+ morphInto(app, `<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">${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>`)
1272
1272
  installDragging()
1273
1273
  cacheCardConnectors()
1274
1274
  // The initial camera from renderCanvas is inset (viewport not laid out yet);
@@ -1416,7 +1416,100 @@ function applyCanvasTransform() {
1416
1416
  if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
1417
1417
  }
1418
1418
 
1419
+ // --- DOM morphing ---------------------------------------------------------
1420
+ // The canvas re-renders on every projection tick and on every streamed chunk.
1421
+ // Assigning `app.innerHTML` destroys the exact node the user is pressing, and a
1422
+ // click is only dispatched when mousedown and mouseup share a target, so a
1423
+ // rebuild in between silently swallows the click (W3C uievents #141). Morph the
1424
+ // new markup into the live tree instead: matching nodes are patched in place and
1425
+ // whatever sits under the pointer stays alive.
1426
+
1427
+ const MORPH_KEY_ATTRIBUTES = ['data-morph-key', 'data-card-id', 'data-thread', 'id']
1428
+
1429
+ function morphKeyOf(node) {
1430
+ if (!(node instanceof Element)) return null
1431
+ for (const attribute of MORPH_KEY_ATTRIBUTES) {
1432
+ const value = node.getAttribute(attribute)
1433
+ if (value !== null && value !== '') return `${node.tagName}|${attribute}|${value}`
1434
+ }
1435
+ return null
1436
+ }
1437
+
1438
+ /** An element that owns the caret keeps its children untouched. */
1439
+ function morphHoldsCaret(node) {
1440
+ if (!(node instanceof Element)) return false
1441
+ if (node === document.activeElement) return true
1442
+ const selection = typeof window.getSelection === 'function' ? window.getSelection() : null
1443
+ if (selection === null || selection.rangeCount === 0) return false
1444
+ const anchor = selection.anchorNode
1445
+ return anchor !== null && node.contains(anchor)
1446
+ }
1447
+
1448
+ function morphNode(from, to) {
1449
+ if (from.nodeType !== to.nodeType) return replaceMorphed(from, to)
1450
+ if (from.nodeType === Node.TEXT_NODE || from.nodeType === Node.COMMENT_NODE) {
1451
+ if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue
1452
+ return from
1453
+ }
1454
+ if (!(from instanceof Element) || !(to instanceof Element) || from.tagName !== to.tagName) {
1455
+ return replaceMorphed(from, to)
1456
+ }
1457
+ if (morphHoldsCaret(from)) return from
1458
+ for (const attribute of [...from.attributes]) {
1459
+ if (!to.hasAttribute(attribute.name)) from.removeAttribute(attribute.name)
1460
+ }
1461
+ for (const attribute of [...to.attributes]) {
1462
+ if (from.getAttribute(attribute.name) !== attribute.value) from.setAttribute(attribute.name, attribute.value)
1463
+ }
1464
+ morphChildren(from, to)
1465
+ return from
1466
+ }
1467
+
1468
+ /** Swap one node for a copy of another and hand back the node now in the tree. */
1469
+ function replaceMorphed(from, to) {
1470
+ const next = to.cloneNode(true)
1471
+ from.replaceWith(next)
1472
+ return next
1473
+ }
1474
+
1475
+ function morphChildren(from, to) {
1476
+ const keyed = new Map()
1477
+ for (const child of [...from.childNodes]) {
1478
+ const key = morphKeyOf(child)
1479
+ if (key !== null && !keyed.has(key)) keyed.set(key, child)
1480
+ }
1481
+ const matched = new Set()
1482
+ let cursor = from.firstChild
1483
+ for (const next of [...to.childNodes]) {
1484
+ const key = morphKeyOf(next)
1485
+ let match = key === null ? null : keyed.get(key) ?? null
1486
+ if (match === null && cursor !== null && morphKeyOf(cursor) === null) match = cursor
1487
+ if (match === null) {
1488
+ const inserted = next.cloneNode(true)
1489
+ from.insertBefore(inserted, cursor)
1490
+ matched.add(inserted)
1491
+ continue
1492
+ }
1493
+ if (match !== cursor) from.insertBefore(match, cursor)
1494
+ matched.add(morphNode(match, next))
1495
+ cursor = match.nextSibling
1496
+ }
1497
+ for (const child of [...from.childNodes]) {
1498
+ if (!matched.has(child)) from.removeChild(child)
1499
+ }
1500
+ }
1501
+
1502
+ /** Patch `container`'s children to match `html` without discarding live nodes. */
1503
+ function morphInto(container, html) {
1504
+ const template = document.createElement('template')
1505
+ template.innerHTML = html
1506
+ morphChildren(container, template.content)
1507
+ }
1508
+
1419
1509
  function bindDragHandle(handle) {
1510
+ // Cards now survive a render, so bind each drag handle once.
1511
+ if (handle.dataset.dragBound === '1') return
1512
+ handle.dataset.dragBound = '1'
1420
1513
  handle.addEventListener('pointerdown', event => {
1421
1514
  const cardId = event.currentTarget.dataset.dragCard
1422
1515
  const card = event.currentTarget.closest('.thread-card')
@@ -1925,9 +2018,11 @@ function applyLiveReplyToCard(sessionId) {
1925
2018
  const answer = card.querySelector('.thread-answer')
1926
2019
  if (!(answer instanceof HTMLElement)) return
1927
2020
  const text = live.text
1928
- answer.innerHTML = text.trim() === ''
2021
+ // Morph instead of replacing the subtree: the answer keeps its nodes, so a
2022
+ // pointer that went down inside it still resolves to a click on release.
2023
+ morphInto(answer, text.trim() === ''
1929
2024
  ? '<p class="thread-answer-pending">正在回复</p>'
1930
- : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
2025
+ : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`)
1931
2026
  }
1932
2027
  function scheduleLiveRender() {
1933
2028
  if (liveRenderTimer !== 0 || !canReplaceView()) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-synapse",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "A visual, non-linear conversation workspace plugin for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",