dsh-synapse 0.5.2 → 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 (3) hide show
  1. package/app.js +117 -5
  2. package/client.js +13 -4
  3. package/package.json +1 -1
package/app.js CHANGED
@@ -268,6 +268,24 @@ function openNewSession() {
268
268
  }
269
269
 
270
270
  async function archiveThread(thread) {
271
+ // A DSH-backed thread is archived in DSH itself, so the sidebar and this map
272
+ // share one archive set (issue #21 / #19). Keeping a second local delete is
273
+ // what made the map claim "archived" while DSH still listed the session.
274
+ if (thread.dshSessionId !== null) {
275
+ if (!window.confirm(`在 DSH 中归档「${thread.title}」及其分支?DSH 侧会话也会被归档。`)) return
276
+ try {
277
+ await dshRpc('synapse:archive-session', { sessionId: thread.dshSessionId })
278
+ } catch (error) {
279
+ setError(error instanceof Error ? error.message : '归档失败')
280
+ return
281
+ }
282
+ state.archivedSessionIds.add(thread.dshSessionId)
283
+ state.activeId = state.activeId === thread.id ? (state.workspace?.threads.find(item => item.id !== thread.id)?.id ?? null) : state.activeId
284
+ if (state.inspectorCardId !== null && state.canvasCardsById?.get(state.inspectorCardId)?.dshThreadId === thread.id) state.inspectorCardId = null
285
+ render()
286
+ await refreshSummaries()
287
+ return
288
+ }
271
289
  if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
272
290
  await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
273
291
  state.historyBySession.delete(thread.dshSessionId)
@@ -303,8 +321,6 @@ async function archiveThread(thread) {
303
321
  } else {
304
322
  state.activeId = null
305
323
  }
306
- // Archive in DSH as well, so both sides agree on one archive set (#21 / #19).
307
- if (thread.dshSessionId !== null) post('synapse:archive-session', { sessionId: thread.dshSessionId })
308
324
  await refreshSummaries()
309
325
  }
310
326
 
@@ -1252,7 +1268,7 @@ function render() {
1252
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>` : ''
1253
1269
  const detailAvailable = currentThread() !== null
1254
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>`
1255
- 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>`)
1256
1272
  installDragging()
1257
1273
  cacheCardConnectors()
1258
1274
  // The initial camera from renderCanvas is inset (viewport not laid out yet);
@@ -1400,7 +1416,100 @@ function applyCanvasTransform() {
1400
1416
  if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
1401
1417
  }
1402
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
+
1403
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'
1404
1513
  handle.addEventListener('pointerdown', event => {
1405
1514
  const cardId = event.currentTarget.dataset.dragCard
1406
1515
  const card = event.currentTarget.closest('.thread-card')
@@ -1872,6 +1981,7 @@ window.addEventListener('message', event => {
1872
1981
  }
1873
1982
  }
1874
1983
  if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
1984
+ if (data.type === 'synapse:archived-session') settleRpc(data.requestId, data)
1875
1985
  if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
1876
1986
  })
1877
1987
 
@@ -1908,9 +2018,11 @@ function applyLiveReplyToCard(sessionId) {
1908
2018
  const answer = card.querySelector('.thread-answer')
1909
2019
  if (!(answer instanceof HTMLElement)) return
1910
2020
  const text = live.text
1911
- 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() === ''
1912
2024
  ? '<p class="thread-answer-pending">正在回复</p>'
1913
- : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
2025
+ : `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`)
1914
2026
  }
1915
2027
  function scheduleLiveRender() {
1916
2028
  if (liveRenderTimer !== 0 || !canReplaceView()) return
package/client.js CHANGED
@@ -316,14 +316,23 @@ window.__ModuleLoader__.load({
316
316
  }
317
317
  if (event.data.type === 'synapse:archive-session') {
318
318
  // One archive set only: archive through the Workspace action so DSH's
319
- // sidebar and this map agree, then report the refreshed set.
319
+ // sidebar and this map agree, then acknowledge the request so the map
320
+ // can hide the card instead of silently diverging (#21 / #19).
320
321
  const sessionId = event.data.sessionId
322
+ const requestId = event.data.requestId
321
323
  if (typeof sessionId !== 'string' || sessionId === '') return
322
- if (typeof ctx.workspaces.archiveSession !== 'function') return send('synapse:bridge-error', { message: '当前 DSH 版本不支持归档接口' })
324
+ if (typeof ctx.workspaces.archiveSession !== 'function') {
325
+ return send('synapse:bridge-error', { requestId, message: '当前 DSH 版本不支持归档接口' })
326
+ }
323
327
  Promise.resolve()
324
328
  .then(() => ctx.workspaces.archiveSession(sessionId))
325
- .then(() => send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) }))
326
- .catch(() => send('synapse:bridge-error', { message: 'DSH 归档失败,请稍后重试' }))
329
+ .then(() => {
330
+ send('synapse:archived-session', { requestId, sessionId })
331
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
332
+ })
333
+ .catch(error => {
334
+ send('synapse:bridge-error', { requestId, message: `DSH 归档失败:${error instanceof Error ? error.message : String(error)}` })
335
+ })
327
336
  return
328
337
  }
329
338
  if (event.data.type === 'synapse:fork-session') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-synapse",
3
- "version": "0.5.2",
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",