dsh-synapse 0.4.3 → 0.4.5

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
@@ -1154,7 +1154,7 @@ function renderCardInspector(card) {
1154
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
1155
  : ''
1156
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>`
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 title="${escapeHtml(card.question)}">${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
1158
  }
1159
1159
 
1160
1160
  function renderThread() {
@@ -1201,7 +1201,7 @@ function render() {
1201
1201
  const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout" title="整理节点" aria-label="整理节点"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/></svg>整理</button><button data-action="focus-active" title="定位到当前会话" aria-label="定位到当前会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="8" cy="8" r="3.2"/><path d="M8 1.5v2.6M8 11.9v2.6M1.5 8h2.6M11.9 8h2.6"/></svg>定位</button><button data-action="zoom-out" aria-label="缩小" title="缩小"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M3.5 8h9"/></svg></button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大" title="放大"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>` : ''
1202
1202
  const detailAvailable = currentThread() !== null
1203
1203
  const canvasTabs = `<nav class="canvas-tabs" aria-label="会话地图视图"><button class="${state.mode === 'canvas' ? 'active' : ''}" data-action="show-canvas">地图</button><button class="${state.mode === 'thread' ? 'active' : ''}" data-action="show-thread" data-thread="${state.activeId ?? ''}" ${detailAvailable ? '' : 'disabled'}>详情</button></nav>`
1204
- 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>`
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">${canvasControls}</header><section class="main-stage">${state.error ? `<div class="status-message" role="alert"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error" aria-label="关闭" title="关闭">×</button></div>` : ''}${canvasTabs}${view}${selectionFollowupButton()}</section></main>`
1205
1205
  installDragging()
1206
1206
  cacheCardConnectors()
1207
1207
  // The initial camera from renderCanvas is inset (viewport not laid out yet);
@@ -1340,7 +1340,8 @@ function canvasViewport(target) {
1340
1340
  }
1341
1341
 
1342
1342
  function zoomCanvas(viewport, nextZoom, clientX, clientY) {
1343
- const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
1343
+ // 20% keeps a whole map readable as an overview; 400% is the detail ceiling.
1344
+ const zoom = Math.min(4, Math.max(.2, Math.round(nextZoom * 100) / 100))
1344
1345
  if (zoom === state.zoom) return
1345
1346
  const bounds = viewport.getBoundingClientRect()
1346
1347
  const localX = clientX - bounds.left
package/client.js CHANGED
@@ -52,13 +52,50 @@ window.__ModuleLoader__.load({
52
52
  reference.release()
53
53
  }
54
54
  }
55
+ // Mounting is idempotent. A hot-reloaded or re-applied instance used to
56
+ // leave the previous pill (and its overlay) on the page, which showed up
57
+ // as two switches at different offsets. Drop whatever a previous instance
58
+ // left behind, including a pill the reverted header-slot build moved into
59
+ // the Conversation header.
60
+ for (const stale of document.querySelectorAll('.dsh-synapse-host, .dsh-synapse-switch')) stale.remove()
55
61
  const style = document.createElement('style')
56
- style.textContent = '.dsh-synapse-switch{position:fixed;z-index:80;top:calc(var(--dsh-frame-chrome-top,var(--dsh-frame-top-clearance,0px)) + 12px);left:50%;display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-synapse-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-synapse-switch button:hover{background:#f3f4f6;color:#111827}.dsh-synapse-switch button.active{background:#111827;color:#fff}.dsh-synapse-switch button:focus-visible{outline:2px solid #111827;outline-offset:2px}.dsh-synapse-overlay{position:fixed;z-index:100;inset:var(--dsh-frame-chrome-top,var(--dsh-frame-top-clearance,0px)) 0 0 0;background:#f5f7fa}.dsh-synapse-overlay.is-opening{visibility:hidden}.dsh-synapse-overlay[hidden]{display:none}.dsh-synapse-overlay iframe{display:block;width:100%;height:100%;border:0}html[data-fullscreen] .dsh-synapse-overlay{top:0}html[data-fullscreen] .dsh-synapse-switch{top:12px}'
62
+ // One coordinate, in both views. The switch is a page-level control, so it
63
+ // never takes part in the Conversation header layout: seating it in a slot
64
+ // made it jump between the header utilities and this position on every
65
+ // view change. Three details keep the single seat usable:
66
+ // - `z-index` above the map overlay, so the map can always be left;
67
+ // - `-webkit-app-region: no-drag`, because the pill sits below the
68
+ // Windows title-bar drag band and would otherwise swallow no clicks;
69
+ // - the `left` offset clears the header's left-hand content, which grows
70
+ // rightward (title, lineage and agent-preset chips). Half the sidebar
71
+ // width puts the pill over the centre of the content area instead of
72
+ // the window; `min()` pulls it back in on narrow windows.
73
+ style.textContent = '.dsh-synapse-switch{position:fixed;z-index:200;top:calc(var(--dsh-frame-chrome-top,var(--dsh-frame-top-clearance,0px)) + 12px);left:min(calc(50% + 130px),calc(100% - 96px));display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px);-webkit-app-region:no-drag;box-shadow:0 2px 8px rgba(17,24,39,.1)}.dsh-synapse-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap;-webkit-app-region:no-drag}.dsh-synapse-switch button:hover{background:#f3f4f6;color:#111827}.dsh-synapse-switch button.active{background:#111827;color:#fff}.dsh-synapse-switch button:focus-visible{outline:2px solid #111827;outline-offset:2px}.dsh-synapse-overlay{position:fixed;z-index:100;inset:var(--dsh-frame-chrome-top,var(--dsh-frame-top-clearance,0px)) 0 0 0;background:#f5f7fa}.dsh-synapse-overlay.is-opening{visibility:hidden}.dsh-synapse-overlay[hidden]{display:none}.dsh-synapse-overlay iframe{display:block;width:100%;height:100%;border:0}html[data-fullscreen] .dsh-synapse-overlay{top:0}html[data-fullscreen] .dsh-synapse-switch{top:12px}'
57
74
  document.head.append(style)
58
75
  const host = document.createElement('div')
59
76
  host.className = 'dsh-synapse-host'
60
77
  host.innerHTML = '<div class="dsh-synapse-switch" role="group" aria-label="视图切换"><button type="button" data-view="dialog" class="active" aria-pressed="true">对话</button><button type="button" data-view="map" aria-pressed="false">会话地图</button></div><section class="dsh-synapse-overlay" hidden><iframe title="会话地图" src="/synapse/"></iframe></section>'
61
78
  document.body.append(host)
79
+ const switchEl = host.querySelector('.dsh-synapse-switch')
80
+ // Two live instances (a bundle loaded twice, or a sibling that outlived a
81
+ // redeploy) each mount a pill; no instance can see the other's apply. Let
82
+ // them converge instead of racing: every pill carries its mount stamp and
83
+ // every instance keeps the newest one, breaking ties on document order, so
84
+ // both sides agree on the same winner without a reload.
85
+ switchEl.dataset.mountedAt = String(Date.now())
86
+ const convergeSwitch = () => {
87
+ const pills = [...document.querySelectorAll('.dsh-synapse-switch')]
88
+ if (pills.length < 2) return
89
+ let keep = pills[0]
90
+ for (const pill of pills) {
91
+ const stamp = Number(pill.dataset?.mountedAt ?? 0)
92
+ const best = Number(keep.dataset?.mountedAt ?? 0)
93
+ if (stamp > best || (stamp === best && pills.indexOf(pill) > pills.indexOf(keep))) keep = pill
94
+ }
95
+ for (const pill of pills) if (pill !== keep) pill.remove()
96
+ }
97
+ convergeSwitch()
98
+ const convergeSwitchLater = window.setTimeout(convergeSwitch, 500)
62
99
  const dialogButton = host.querySelector('[data-view="dialog"]')
63
100
  const mapButton = host.querySelector('[data-view="map"]')
64
101
  const overlay = host.querySelector('.dsh-synapse-overlay')
@@ -327,6 +364,7 @@ window.__ModuleLoader__.load({
327
364
  liveSessions.clear()
328
365
  for (const reference of ownReferences.values()) reference.release()
329
366
  ownReferences.clear()
367
+ window.clearTimeout(convergeSwitchLater)
330
368
  host.remove()
331
369
  style.remove()
332
370
  }, 'synapse: web workspace switch')
@@ -76,6 +76,8 @@ With `autoProjection` enabled, committed DSH session events are grouped by worki
76
76
 
77
77
  Each user question becomes a conversation card. The following assistant messages are folded into that turn, and the final assistant reply is shown as the answer. Forked sessions connect to the parent turn at the durable DSH seed boundary rather than at an arbitrary canvas coordinate.
78
78
 
79
+ Only a turn a person typed becomes a card. The harness also writes its own `user/message` rows — the runtime-context snapshot, the skill catalogue `<system-reminder>` — and those carry a structured `data.source.kind`; injected kinds (`skill-catalog`, plus the runtime snapshot's text marker) are not projected, so they neither occupy a card nor steal the answer from the real question. Existing projections are pruned the same way when the store loads.
80
+
79
81
  Projected card text is capped at 8000 characters. Longer messages receive a truncation marker in the card, while their complete content remains available from the conversation detail view.
80
82
 
81
83
  Projection writes are coalesced during event bursts, and live updates reuse cached Markdown and patch the active card instead of rebuilding the complete canvas. Card coordinates remain visual metadata only and never determine conversation lineage.
package/index.js CHANGED
@@ -13,6 +13,16 @@ const MAX_NOTE_LENGTH = 4_000
13
13
  const MAX_PROJECTION_LENGTH = 8_000
14
14
  const PROJECTION_TRUNCATED_SUFFIX = '\n——…(详情查看全文)'
15
15
  const TOPIC_COLORS = ['#0f766e', '#2563eb', '#be123c', '#7c3aed', '#b45309']
16
+ // The harness writes `user/message` rows of its own (runtime snapshots, skill
17
+ // catalogues). A canvas card must stand for a turn a person actually typed, so
18
+ // an injected row is recognised by its structured `source.kind` instead of by
19
+ // guessing at the prose (issue #31 / #23).
20
+ const INJECTED_USER_SOURCE_KINDS = new Set(['skill-catalog'])
21
+ // Stored projections keep no raw event to re-read, so the one-time cleanup
22
+ // recognises a legacy catalogue card by the markers only that injection emits:
23
+ // the `<system-reminder>` opener plus the `<available_skills>` index it wraps.
24
+ const SKILL_CATALOG_OPENER = '<system-reminder>'
25
+ const SKILL_CATALOG_MARKER = '<available_skills>'
16
26
  const LOCK_STALE_MS = 60_000
17
27
  // Deferred (event-projection) writes coalesce into one save per window, so a
18
28
  // burst of session events costs a single full-state write instead of one per
@@ -536,7 +546,7 @@ function normalizeState(value) {
536
546
  ...workspace,
537
547
  threads: Array.isArray(workspace.threads) ? workspace.threads.map(thread => {
538
548
  if (Array.isArray(thread.messages)) {
539
- const messages = thread.messages.filter(message => !isRuntimeContextMessage(message))
549
+ const messages = thread.messages.filter(message => !isInjectedUserMessage(message))
540
550
  if (messages.length !== thread.messages.length) migrated = true
541
551
  return { ...thread, messages }
542
552
  }
@@ -649,6 +659,7 @@ function requiredText(value, maxLength, field) {
649
659
  function projectableEvent(event) {
650
660
  switch (event.type) {
651
661
  case 'user/message': {
662
+ if (isInjectedUserSource(event.data?.source)) return null
652
663
  const text = contentText(event.data.content)
653
664
  return isRuntimeContextText(text) ? null : noteProjection('user', text)
654
665
  }
@@ -694,6 +705,29 @@ function isRuntimeContextMessage(message) {
694
705
  return message?.kind === 'user' && isRuntimeContextText(message.text)
695
706
  }
696
707
 
708
+ /** A harness-authored turn, identified by its structured provenance. */
709
+ function isInjectedUserSource(source) {
710
+ return typeof source?.kind === 'string' && INJECTED_USER_SOURCE_KINDS.has(source.kind)
711
+ }
712
+
713
+ /**
714
+ * Recognises a stored skill catalogue. Only the harness emits both markers
715
+ * together, so a person quoting one of them is still kept as a real turn.
716
+ */
717
+ function isSkillCatalogText(text) {
718
+ if (typeof text !== 'string') return false
719
+ const head = text.trimStart()
720
+ return head.startsWith(SKILL_CATALOG_OPENER) && head.includes(SKILL_CATALOG_MARKER)
721
+ }
722
+
723
+ /**
724
+ * Whether a stored message must stay out of the canvas graph: runtime
725
+ * snapshots and skill catalogues were never a turn the user took.
726
+ */
727
+ function isInjectedUserMessage(message) {
728
+ return message?.kind === 'user' && (isRuntimeContextMessage(message) || isSkillCatalogText(message.text))
729
+ }
730
+
697
731
  function contentText(content) {
698
732
  if (!Array.isArray(content)) return ''
699
733
  return content.flatMap(block => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-synapse",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "A visual, non-linear conversation workspace plugin for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/styles.css CHANGED
@@ -10,7 +10,7 @@ button.primary:hover { border-color: #000; background: #000; color: #fff; }
10
10
  .synapse-shell { --sidebar-width: 280px; --sidebar-half: 140px; height: 100vh; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-rows: 42px minmax(0, 1fr); overflow: hidden; }
11
11
  .synapse-shell.sidebar-collapsed { --sidebar-width: 56px; --sidebar-half: 28px; }
12
12
  .topbar { position: relative; grid-column: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 16px; border: 0; background: #fff; }
13
- .brand, .top-actions, .view-switch, .thread-card-head, .thread-meta, .detail-actions, .compare-title { display: flex; align-items: center; }
13
+ .brand, .top-actions, .thread-card-head, .thread-meta, .detail-actions, .compare-title { display: flex; align-items: center; }
14
14
  .sidebar-brand-row { box-sizing: border-box; display: flex; align-items: center; gap: 8px; height: 60px; flex: none; margin-bottom: 8px; padding: 8px 0 8px 4px; overflow: hidden; }
15
15
  .brand { gap: 8px; min-width: 0; flex: 1; }
16
16
  .brand::before { width: 31px; height: 26px; flex: none; content: ''; background: center / contain no-repeat url('/synapse/deepseek-mark.svg'); }
@@ -21,11 +21,6 @@ button.primary:hover { border-color: #000; background: #000; color: #fff; }
21
21
  .sidebar-toggle:hover { border: 0; background: #eef0f2; color: #374151; }
22
22
  .sidebar-toggle svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.4; }
23
23
  .top-actions { gap: 10px; }
24
- .view-switch { position: fixed; z-index: 80; top: 12px; left: 50%; gap: 2px; padding: 3px; transform: translateX(-50%); border: 1px solid #d1d5db; border-radius: 999px; background: rgba(255,255,255,.96); }
25
- .view-switch button { min-height: 28px; border: 0; border-radius: 999px; background: transparent; padding: 0 11px; color: #64748b; font-size: 12px; font-weight: 650; }
26
- .view-switch button:hover { background: #e5e7eb; color: #111827; }
27
- .view-switch button.active { background: #111827; color: #fff; }
28
- .view-switch button:focus-visible { outline: 2px solid #111827; outline-offset: 2px; }
29
24
  .canvas-controls { position: fixed; z-index: 80; top: 14px; right: 16px; display: flex; align-items: center; gap: 5px; }
30
25
  .canvas-controls button { min-width: 32px; padding: 0 8px; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
31
26
  .canvas-controls button svg { width: 14px; height: 14px; flex: none; }
@@ -135,14 +130,19 @@ button.primary:hover { border-color: #000; background: #000; color: #fff; }
135
130
  .thread-card footer button:hover { background: #f3f4f6; color: #111827; border-radius: 6px; }
136
131
  .thread-card footer .compare-button { margin-right: auto; color: #374151; }
137
132
  .thread-card footer .compare-button.selected { background: #e5e7eb; color: #111827; }
138
- .card-inspector { position: absolute; z-index: 30; top: 0; right: 0; bottom: 0; display: flex; width: min(460px, calc(100% - 48px)); min-width: 360px; flex-direction: column; border-left: 1px solid #dbe3ec; background: #fff; opacity: 1; transform: translateX(0); transition: transform 180ms cubic-bezier(.23,1,.32,1), opacity 150ms ease-out; }
133
+ .card-inspector { position: absolute; z-index: 30; top: 0; right: 0; bottom: 0; display: flex; width: min(460px, calc(100% - 48px)); min-width: 360px; flex-direction: column; overflow: hidden; border-left: 1px solid #dbe3ec; background: #fff; opacity: 1; transform: translateX(0); transition: transform 180ms cubic-bezier(.23,1,.32,1), opacity 150ms ease-out; }
139
134
  .card-inspector.is-opening, .card-inspector.is-closing { opacity: 0; transform: translateX(28px); }
140
- .card-inspector-head { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 12px; align-items: start; padding: 18px 20px 12px; background: #fff; }
135
+ .card-inspector-head { display: grid; flex: none; min-height: 0; grid-template-columns: minmax(0, 1fr) 32px; gap: 12px; align-items: start; padding: 18px 20px 12px; background: #fff; }
141
136
  .card-inspector-meta { display: flex; align-items: center; gap: 7px; color: #8390a0; font-size: 11px; }
142
137
  .card-inspector-meta span + span::before { content: '·'; margin-right: 7px; color: #bdc7d2; }
143
138
  .card-inspector-meta span:last-child { color: inherit; font-weight: inherit; }
144
139
  .card-inspector-meta .card-inspector-error-status { color: #b42318; font-weight: 650; }
145
- .card-inspector-head h2 { margin: 8px 0 0; overflow-wrap: anywhere; color: #172033; font-size: 15px; font-weight: 680; line-height: 1.5; }
140
+ /* The card question is a user prompt and can be arbitrarily long (pasted
141
+ logs, injected skill catalogues). Clamp it so the fixed-height drawer keeps
142
+ its answer scroll area and action bar on screen instead of letting the
143
+ header grow past the panel; the full text stays reachable via `title` and
144
+ in the thread detail. */
145
+ .card-inspector-head h2 { display: -webkit-box; margin: 8px 0 0; max-height: calc(1.5em * 3); overflow: hidden; color: #172033; font-size: 15px; font-weight: 680; line-height: 1.5; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
146
146
  .card-inspector-close { display: grid; width: 32px; height: 32px; min-height: 32px; place-items: center; border: 0; border-radius: 50%; background: transparent; padding: 0; color: #718096; }
147
147
  .card-inspector-close:hover { background: #eef2f6; color: #1f2937; }
148
148
  .card-inspector-close:active, .card-inspector-actions button:active { transform: scale(.97); }
@@ -207,7 +207,9 @@ button.primary:hover { border-color: #000; background: #000; color: #fff; }
207
207
  .detail-badge { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; background: #eaf0fa; padding: 3px 9px; color: #3b6fd4; font-size: 11px; font-weight: 750; letter-spacing: .05em; }
208
208
  .detail-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
209
209
  .detail-subtitle { overflow: hidden; color: #8a97a8; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
210
- .detail-head h1 { margin: 10px 0 0; overflow-wrap: anywhere; color: #152033; font-size: 21px; font-weight: 650; line-height: 1.35; letter-spacing: 0; }
210
+ /* Same overlong-question guard for the detail view: the header is `flex: none`,
211
+ so an unclamped title pushes the message list and composer out of the view. */
212
+ .detail-head h1 { display: -webkit-box; margin: 10px 0 0; max-height: calc(1.35em * 3); overflow: hidden; color: #152033; font-size: 21px; font-weight: 650; line-height: 1.35; letter-spacing: 0; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
211
213
  .detail-head-actions { flex: none; display: flex; gap: 8px; }
212
214
  .detail-head-actions button { min-height: 32px; border: 1px solid #d9e1ea; border-radius: 7px; background: #fff; padding: 0 12px; color: #3d4a5c; font-size: 12px; font-weight: 600; }
213
215
  .detail-head-actions button:hover { border-color: #b9c7d8; background: #f4f7fb; }
@@ -285,7 +287,7 @@ button.primary:hover { border-color: #000; background: #000; color: #fff; }
285
287
  .compare-notes { display: grid; gap: 1px; padding: 0 16px 16px; }
286
288
  .compare-notes p { margin: 0; padding: 11px 0; border-top: 1px solid #eef2f5; color: #475569; font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
287
289
  @media (max-width: 760px) { .synapse-shell { --sidebar-width: 200px; --sidebar-half: 100px; } .synapse-shell.sidebar-collapsed { --sidebar-width: 56px; --sidebar-half: 28px; } .sidebar { padding: 6px 10px; } .detail-head { display: grid; gap: 12px; padding: 22px 18px 14px; } .detail-scroll { padding: 16px 18px 20px; } .message-composer { padding: 12px 18px 16px; } .message-user { max-width: 92%; } .compare-view { padding: 26px 18px; } .compare-columns { grid-template-columns: 1fr; } .topbar { padding: 0 10px; } .brand strong { display: none; } .card-inspector { top: auto; width: 100%; min-width: 0; max-height: 78%; border-top: 1px solid #dbe3ec; border-left: 0; } .card-inspector.is-opening, .card-inspector.is-closing { transform: translateY(24px); } }
288
- @media (max-width: 560px) { .synapse-shell { min-width: 0; grid-template-columns: 1fr; } .sidebar { display: none; } .topbar, .main-stage { grid-column: 1; } .view-switch { left: 50%; } .view-switch button { padding: 0 7px; } .top-actions > button { font-size: 11px; padding: 0 7px; } .thread-card { width: 280px; } }
290
+ @media (max-width: 560px) { .synapse-shell { min-width: 0; grid-template-columns: 1fr; } .sidebar { display: none; } .topbar, .main-stage { grid-column: 1; } .top-actions > button { font-size: 11px; padding: 0 7px; } .thread-card { width: 280px; } }
289
291
  @media (prefers-reduced-motion: reduce) { .card-inspector { transition: opacity 120ms ease; } .card-inspector.is-opening, .card-inspector.is-closing { transform: none; } }
290
292
 
291
293
  /* ── Dark theme: follows DSH's body[data-ds-dark-theme] via synapse:theme ── */
@@ -368,10 +370,6 @@ button.primary:hover { border-color: #000; background: #000; color: #fff; }
368
370
  [data-theme="dark"] .draft-branch-form textarea { border-color: #2a2d33; background: #15171b; color: #e6e8eb; }
369
371
  [data-theme="dark"] .draft-actions button { background: #1b1c1f; color: #a7adb5; }
370
372
  [data-theme="dark"] .draft-actions button.primary { background: #3478f6; color: #fff; }
371
- [data-theme="dark"] .view-switch { border-color: #2a2d33; background: rgba(21,23,27,.96); }
372
- [data-theme="dark"] .view-switch button { color: #8f959e; }
373
- [data-theme="dark"] .view-switch button:hover { background: #26282d; color: #e6e8eb; }
374
- [data-theme="dark"] .view-switch button.active { background: #e6e8eb; color: #111317; }
375
373
  [data-theme="dark"] .canvas-controls span { color: #8f959e; }
376
374
  [data-theme="dark"] .sidebar-toggle { color: #a7adb5; }
377
375
  [data-theme="dark"] .sidebar-toggle:hover { background: #26282d; color: #e6e8eb; }