dsh-retrace 0.4.2 → 0.4.3

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/lib/client.js CHANGED
@@ -129,6 +129,16 @@ const zh = {
129
129
  'timeline.gitInitConfirm': '确定要初始化 git 吗?',
130
130
  'error.generic': '操作失败,请重试',
131
131
  'error.busy': '请先停止当前回复再操作',
132
+ 'view.fork': '分叉',
133
+ 'fork.title': '分叉图',
134
+ 'fork.empty': '还没有分叉。撤回 / 编辑 / 重新生成 / 恢复会在此产生分叉。',
135
+ 'fork.refresh': '刷新',
136
+ 'fork.spine': '当前路径',
137
+ 'fork.shadowed': '被遮蔽 {count} 个节点',
138
+ 'fork.node.user': '用户消息',
139
+ 'fork.node.assistant': '助手回复',
140
+ 'fork.node.tool': '工具结果',
141
+ 'fork.histTitle': '历史分叉点',
132
142
  }
133
143
  /** English dictionary, checked complete against the zh key set. */
134
144
  const en = {
@@ -205,6 +215,16 @@ const en = {
205
215
  'timeline.gitInitConfirm': 'Initialize git in this workspace?',
206
216
  'error.generic': 'Operation failed; please try again',
207
217
  'error.busy': 'Stop the current reply before recalling or editing',
218
+ 'view.fork': 'Fork',
219
+ 'fork.title': 'Fork map',
220
+ 'fork.empty': 'No forks yet. Recall / edit / regenerate / restore fork the path here.',
221
+ 'fork.refresh': 'Refresh',
222
+ 'fork.spine': 'Current path',
223
+ 'fork.shadowed': '{count} shadowed nodes',
224
+ 'fork.node.user': 'User message',
225
+ 'fork.node.assistant': 'Assistant reply',
226
+ 'fork.node.tool': 'Tool result',
227
+ 'fork.histTitle': 'Historical fork points',
208
228
  }
209
229
 
210
230
  // ---------------------------------------------------------------------------
@@ -500,10 +520,10 @@ function useShadowed(useSession, seq) {
500
520
  }
501
521
 
502
522
  /**
503
- * If one marker would hide more than this share of the conversation's rows,
504
- * the hiding is refused (the marker still renders as a notice). A single
505
- * recall/edit must never blank out most of the history — the rows stay in the
506
- * durable log and remain visible.
523
+ * If ALL markers together would hide more than this share of the conversation's
524
+ * rows, hiding is refused for EVERY marker (they render as notices only). A
525
+ * recall/edit — even several stacked edits — must never blank out most of the
526
+ * history: the rows stay in the durable log and remain visible.
507
527
  */
508
528
  const SHADOW_SAFETY_RATIO = 0.4
509
529
 
@@ -512,37 +532,64 @@ const SHADOW_SAFETY_RATIO = 0.4
512
532
  * the shadowed message rows themselves, plus the per-turn action row (copy /
513
533
  * feedback / branch) when its finalized assistant reply is among them.
514
534
  */
515
- function useHiddenKeys(useSession, shadowedSeqs) {
535
+ function hiddenKeysFor(shadowedSeqs, nodes) {
536
+ if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null
537
+ const hidden = new Set(shadowedSeqs)
538
+ const keys = []
539
+ for (const node of nodes.values()) {
540
+ if (node.kind === 'recall-marker') continue
541
+ if (node.kind === 'turn-tail') {
542
+ // `closing` is the finalized assistant-step *data*; the message seq
543
+ // lives on its `finalNode` (matching how the app reads `closing.finalNode.seq`).
544
+ const closingSeq = node.data?.closing?.finalNode?.seq
545
+ if (typeof closingSeq === 'number' && hidden.has(closingSeq)) keys.push(node.key)
546
+ continue
547
+ }
548
+ if (node.kind === 'tool-call') {
549
+ // Tool rows anchor at the tool/call event seq, which is a log-only
550
+ // event and never a surface node, so it cannot appear in shadowedSeqs.
551
+ // Match the settled result's surface seq (root.seq) instead.
552
+ const resultSeq = node.data?.root?.seq
553
+ if (typeof resultSeq === 'number' && hidden.has(resultSeq)) keys.push(node.key)
554
+ continue
555
+ }
556
+ if (typeof node.anchorSeq === 'number' && hidden.has(node.anchorSeq)) keys.push(node.key)
557
+ }
558
+ return keys.length === 0 ? null : keys
559
+ }
560
+
561
+ /**
562
+ * Global hide plan (0.4.3): one snapshot pass computes EVERY marker's hidden
563
+ * keys and applies the safety guard to their UNION. A single normal edit still
564
+ * hides its replaced round (a few rows ≪ the threshold); a session where
565
+ * stacked edits would collectively hide most of the history degrades EVERY
566
+ * marker to notice-only, so history can never silently vanish from the view.
567
+ */
568
+ const EMPTY_HIDE_PLAN = { degraded: false, hiddenFor: () => null }
569
+ function useMarkerHidePlan(useSession) {
516
570
  return useSession((snapshot) => {
517
- if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null
518
- const hidden = new Set(shadowedSeqs)
519
- const keys = []
571
+ const nodes = snapshot.chat.nodes
520
572
  let rowCount = 0
521
- for (const node of snapshot.chat.nodes.values()) {
573
+ const markers = []
574
+ for (const node of nodes.values()) {
522
575
  if (typeof node.anchorSeq === 'number') rowCount += 1
523
- if (node.kind === 'recall-marker') continue
524
- if (node.kind === 'turn-tail') {
525
- // `closing` is the finalized assistant-step *data*; the message seq
526
- // lives on its `finalNode` (matching how the app reads `closing.finalNode.seq`).
527
- const closingSeq = node.data?.closing?.finalNode?.seq
528
- if (typeof closingSeq === 'number' && hidden.has(closingSeq)) keys.push(node.key)
529
- continue
530
- }
531
- if (node.kind === 'tool-call') {
532
- // Tool rows anchor at the tool/call event seq, which is a log-only
533
- // event and never a surface node, so it cannot appear in shadowedSeqs.
534
- // Match the settled result's surface seq (root.seq) instead.
535
- const resultSeq = node.data?.root?.seq
536
- if (typeof resultSeq === 'number' && hidden.has(resultSeq)) keys.push(node.key)
537
- continue
576
+ if (node.kind === 'recall-marker') {
577
+ markers.push({ key: node.key, shadowedSeqs: node.data?.shadowedSeqs })
538
578
  }
539
- if (typeof node.anchorSeq === 'number' && hidden.has(node.anchorSeq)) keys.push(node.key)
540
579
  }
541
- // Safety guard (2026-08-26 incident): a marker covering a huge share of the
542
- // conversation (e.g. an edit with "fresh conversation" semantics) degrades
543
- // to notice-only — history must never silently vanish from the view.
544
- if (keys.length > 0 && rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO) return null
545
- return keys.length === 0 ? null : keys
580
+ if (markers.length === 0) return EMPTY_HIDE_PLAN
581
+ const union = new Set()
582
+ const perMarker = new Map()
583
+ for (const marker of markers) {
584
+ const keys = hiddenKeysFor(marker.shadowedSeqs, nodes)
585
+ perMarker.set(marker.key, keys)
586
+ if (keys !== null) for (const key of keys) union.add(key)
587
+ }
588
+ const degraded = rowCount > 0 && union.size / rowCount > SHADOW_SAFETY_RATIO
589
+ return {
590
+ degraded,
591
+ hiddenFor: (key) => (degraded ? null : (perMarker.get(key) ?? null)),
592
+ }
546
593
  })
547
594
  }
548
595
 
@@ -773,7 +820,8 @@ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
773
820
  function RecallMarkerRow({ node, useSession, t }) {
774
821
  const { seq, op, shadowedSeqs, legacy } = node.data
775
822
  const dismissed = useMarkerDismissed(useSession, seq, op)
776
- const hiddenKeys = legacy || !getConfig().hideShadowed ? null : useHiddenKeys(useSession, shadowedSeqs)
823
+ const hidePlan = useMarkerHidePlan(useSession)
824
+ const hiddenKeys = legacy || !getConfig().hideShadowed ? null : hidePlan.hiddenFor(node.key)
777
825
 
778
826
  // The hide rules must stay mounted even after the notice is dismissed,
779
827
  // otherwise the recalled message would reappear. Legacy markers and the
@@ -889,7 +937,8 @@ const CSS = `
889
937
  .dsh-rt-retention-input:focus{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
890
938
  .dsh-rt-option input{accent-color:var(--dsw-alias-state-business-primary)}
891
939
  /* ---- P1 timeline (conversation view tab) ---- */
892
- .dsh-rt-view{box-sizing:border-box;display:flex;flex-direction:column;gap:6px;height:100%;min-height:0;padding:12px 16px 0}
940
+
941
+ .dsh-rt-view{box-sizing:border-box;display:flex;flex-direction:column;gap:6px;flex:1 1 0%;min-height:0;overflow:hidden;padding:12px 16px 0}
893
942
  .dsh-rt-timeline-head{display:flex;align-items:center;gap:8px;flex:none}
894
943
  .dsh-rt-timeline-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;flex:1}
895
944
  .dsh-rt-timeline-git{display:flex;align-items:center;gap:6px;flex:none;border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;padding:4px 8px}
@@ -926,8 +975,170 @@ const CSS = `
926
975
  .dsh-rt-modal-buttons{display:flex;justify-content:flex-end;gap:8px;flex:none}
927
976
  .dsh-rt-confirm{background:var(--dsw-alias-state-error-primary)}
928
977
  .dsh-rt-modal-json{margin:0;overflow:auto;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;font-family:var(--dsw-font-mono);font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all}
978
+ .dsh-rt-fork-list{overflow-y:auto;flex:1;min-height:0;position:relative}
979
+ .dsh-rt-fork-spine-label{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;flex:none}
980
+ .dsh-rt-fork-row{position:absolute;left:0;right:0;box-sizing:border-box;display:flex;align-items:flex-start;gap:8px;padding:6px 8px;border-radius:8px}
981
+ .dsh-rt-fork-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
982
+ .dsh-rt-fork-icon{flex:none;width:22px;height:22px;display:inline-flex;justify-content:center;align-items:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
983
+ .dsh-rt-fork-boundary .dsh-rt-fork-icon{color:var(--dsw-alias-state-warning-primary)}
984
+ .dsh-rt-fork-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
985
+ .dsh-rt-fork-line{display:flex;align-items:baseline;gap:8px;min-width:0}
986
+ .dsh-rt-fork-label{font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-primary);white-space:nowrap}
987
+ .dsh-rt-fork-seq{font-family:var(--dsw-font-mono);font-size:11px;line-height:18px;color:var(--dsw-alias-label-caption);flex:none}
988
+ .dsh-rt-fork-shadowed{font-size:11px;line-height:18px;color:var(--dsw-alias-state-warning-primary);flex:none}
989
+ .dsh-rt-fork-text{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
990
+ .dsh-rt-fork-boundary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated)}
991
+ .dsh-rt-fork-hist{display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px;overflow-y:auto;flex:none}
992
+ .dsh-rt-fork-hist-row{display:flex;align-items:flex-start;gap:8px;padding:4px 8px;border-radius:8px}
993
+ .dsh-rt-fork-hist-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
929
994
  `
930
995
 
996
+ const JUMP_PAGE_BUDGET = 24
997
+
998
+ /**
999
+ * Switch to a conversation view tab programmatically.
1000
+ *
1001
+ * The official API offers no setView to third-party views: `actions` is only
1002
+ * injected to entries that declare a `store`, and the chat store is private to
1003
+ * ui-conversation — so `actions?.setView?.(...)` silently no-ops for plugins
1004
+ * (0.4.2 relied on it; verified against the renderer source 2026-08-26). The
1005
+ * only programmatic path is clicking the tab-bar button — the same path a user
1006
+ * click takes. Tab buttons render in registration order (priority, then
1007
+ * order): chat=0, trajectory=10, retrace=20, retrace-fork=30. A view with an
1008
+ * earlier order would shift the indices; verified on the real harness before
1009
+ * relying on the positions.
1010
+ */
1011
+ function switchToViewTab(viewId) {
1012
+ const ORDER = { chat: 0, trajectory: 1, retrace: 2, 'retrace-fork': 3 }
1013
+ const index = ORDER[viewId]
1014
+ if (index === undefined) {
1015
+ console.warn(`[dsh-retrace] no tab order registered for "${viewId}"`)
1016
+ return
1017
+ }
1018
+ const buttons = [...document.querySelectorAll('[role="tablist"] [role="tab"]')]
1019
+ const button = buttons[index]
1020
+ if (!button) {
1021
+ console.warn(`[dsh-retrace] tab "${viewId}" (index ${index}) not found in the conversation tab bar`)
1022
+ return
1023
+ }
1024
+ button.click()
1025
+ }
1026
+
1027
+ /** rAF poll for an element that appears after a view switch + render pass. */
1028
+ function waitForElement(selector, frames) {
1029
+ return new Promise((resolve) => {
1030
+ let remaining = frames
1031
+ const probe = () => {
1032
+ const el = document.querySelector(selector)
1033
+ if (el !== null) return resolve(el)
1034
+ if (remaining-- <= 0) return resolve(null)
1035
+ requestAnimationFrame(probe)
1036
+ }
1037
+ requestAnimationFrame(probe)
1038
+ })
1039
+ }
1040
+
1041
+ /** One-shot highlight for the jumped-to row (removes itself). */
1042
+ function flashKey(key) {
1043
+ const id = `dsh-rt-jump-${key.replace(/[^a-z0-9]/gi, '-')}`
1044
+ if (document.querySelector(`style[data-plugin-css="${id}"]`)) return
1045
+ const tag = document.createElement('style')
1046
+ tag.dataset.pluginCss = id
1047
+ tag.textContent = `[data-chat-anchor-key=${JSON.stringify(key)}]{animation:dsh-rt-flash 1.6s ease-out 2}@keyframes dsh-rt-flash{0%,100%{background:transparent}30%,70%{background:var(--dsw-alias-state-business-primary)}55%{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent)}}`
1048
+ document.head.appendChild(tag)
1049
+ setTimeout(() => tag.remove(), 3400)
1050
+ }
1051
+
1052
+ /**
1053
+ * Shared jump (versions + fork views): switch to the chat tab, page up via the
1054
+ * session store until the anchor seq is loaded, then scroll the chat row into
1055
+ * view once it renders. The view switch unmounts the current view, so the
1056
+ * whole flow runs here. The store has no seek-to-seq API — only `loadOlder`
1057
+ * (50/page) — so far-away targets are unreachable without thousands of pages;
1058
+ * cap the budget.
1059
+ */
1060
+ async function jumpToAnchor(store, anchorSeq) {
1061
+ switchToViewTab('chat')
1062
+ if (!store || typeof store.loadOlder !== 'function' || typeof store.getSnapshot !== 'function') return
1063
+ const keyOfSeq = (seq) => {
1064
+ const nodes = store.getSnapshot()?.chat?.nodes
1065
+ if (!nodes) return null
1066
+ for (const node of nodes.values()) {
1067
+ if (typeof node.anchorSeq === 'number' && node.anchorSeq === seq) return node.key
1068
+ }
1069
+ return null
1070
+ }
1071
+ let key = keyOfSeq(anchorSeq)
1072
+ let pages = 0
1073
+ while (key === null && pages < JUMP_PAGE_BUDGET && store.hasMore) {
1074
+ const before = store.getSnapshot()?.chat?.nodes?.size ?? 0
1075
+ await store.loadOlder()
1076
+ const after = store.getSnapshot()?.chat?.nodes?.size ?? 0
1077
+ pages += 1
1078
+ if (after === before) break // page returned nothing
1079
+ key = keyOfSeq(anchorSeq)
1080
+ }
1081
+ if (key === null) {
1082
+ // Give the last prepend a tick to propagate into the snapshot first.
1083
+ await new Promise((resolve) => setTimeout(resolve, 60))
1084
+ key = keyOfSeq(anchorSeq)
1085
+ }
1086
+ if (key === null) {
1087
+ console.warn(`[dsh-retrace] jump target seq ${anchorSeq} not reached within ${JUMP_PAGE_BUDGET} pages`)
1088
+ return
1089
+ }
1090
+ // The chat view is now mounted (tab click above); poll for its rendered row.
1091
+ const el = await waitForElement(`[data-chat-anchor-key=${JSON.stringify(key)}]`, 90)
1092
+ if (el === null) return
1093
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1094
+ flashKey(key)
1095
+ }
1096
+
1097
+ /** Surface node type → icon (fork spine rows). */
1098
+ const NODE_ICONS = { 'user/message': '👤', 'assistant/message': '🤖', 'tool/result': '🔧' }
1099
+
1100
+ /** Surface node type → localized label. */
1101
+ function nodeTypeLabel(type, t) {
1102
+ if (type === 'user/message') return t('fork.node.user')
1103
+ if (type === 'assistant/message') return t('fork.node.assistant')
1104
+ if (type === 'tool/result') return t('fork.node.tool')
1105
+ return type
1106
+ }
1107
+
1108
+ /**
1109
+ * Bound a windowed list to the available height.
1110
+ *
1111
+ * The conversation view area is a flex chain whose middle (`viewArea`,
1112
+ * `flex:1 0 auto; min-height:auto`) grows to CONTENT — a virtualized list's
1113
+ * tall spacer (N × rowHeight) inflates the whole view to the full list height,
1114
+ * so the list never scrolls and the window slice never moves (verified on the
1115
+ * real harness 2026-08-26; the official trajectory view avoids this because
1116
+ * its virtualized content contributes no tall spacer). Measuring the list
1117
+ * height against the viewport and setting it explicitly caps the spacer's
1118
+ * contribution — the view then collapses back to the bounded height.
1119
+ */
1120
+ function bindListHeight(listEl) {
1121
+ if (!listEl) return () => {}
1122
+ const measure = () => {
1123
+ const top = listEl.getBoundingClientRect().top
1124
+ const height = Math.max(120, window.innerHeight - top - 16)
1125
+ if (Math.abs(listEl.clientHeight - height) > 4) {
1126
+ // The list is a flex item (flex:1 → flex-basis:0%); a plain height is
1127
+ // ignored by the flex layout, so pin it with flex:none.
1128
+ listEl.style.flex = 'none'
1129
+ listEl.style.height = `${height}px`
1130
+ }
1131
+ }
1132
+ measure()
1133
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null
1134
+ ro?.observe(document.body)
1135
+ window.addEventListener('resize', measure)
1136
+ return () => {
1137
+ ro?.disconnect()
1138
+ window.removeEventListener('resize', measure)
1139
+ }
1140
+ }
1141
+
931
1142
  function ensureStyle() {
932
1143
  if (typeof document === 'undefined') return () => {}
933
1144
  if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`)) return () => {}
@@ -1037,74 +1248,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1037
1248
  // eslint-disable-next-line react-hooks/exhaustive-deps
1038
1249
  }, [])
1039
1250
 
1040
- // Jump: switch to the chat view, then page up via the session store until
1041
- // the anchor seq is loaded, then scroll the chat row into view once it
1042
- // renders (the view switch unmounts this view, so the whole flow runs here).
1043
- // The store has no seek-to-seq API — only `loadOlder` (50/page) — so
1044
- // far-away targets are unreachable without thousands of pages; cap the budget.
1045
- const JUMP_PAGE_BUDGET = 24
1046
- const jump = async (boundarySeq) => {
1047
- actions?.setView?.('chat')
1048
- const target = store
1049
- if (!target || typeof target.loadOlder !== 'function' || typeof target.getSnapshot !== 'function') return
1050
- const keyOfSeq = (seq) => {
1051
- const nodes = target.getSnapshot()?.chat?.nodes
1052
- if (!nodes) return null
1053
- for (const node of nodes.values()) {
1054
- if (typeof node.anchorSeq === 'number' && node.anchorSeq === seq) return node.key
1055
- }
1056
- return null
1057
- }
1058
- let key = keyOfSeq(boundarySeq)
1059
- let pages = 0
1060
- while (key === null && pages < JUMP_PAGE_BUDGET && target.hasMore) {
1061
- const before = target.getSnapshot()?.chat?.nodes?.size ?? 0
1062
- await target.loadOlder()
1063
- const after = target.getSnapshot()?.chat?.nodes?.size ?? 0
1064
- pages += 1
1065
- if (after === before) break // page returned nothing
1066
- key = keyOfSeq(boundarySeq)
1067
- }
1068
- if (key === null) {
1069
- // Give the last prepend a tick to propagate into the snapshot first.
1070
- await new Promise((resolve) => setTimeout(resolve, 60))
1071
- key = keyOfSeq(boundarySeq)
1072
- }
1073
- if (key === null) {
1074
- console.warn(`[dsh-retrace] jump target seq ${boundarySeq} not reached within ${JUMP_PAGE_BUDGET} pages`)
1075
- return
1076
- }
1077
- // The chat view is now mounted (setView above); poll for its rendered row.
1078
- const el = await waitForElement(`[data-chat-anchor-key=${JSON.stringify(key)}]`, 90)
1079
- if (el === null) return
1080
- el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1081
- flashKey(key)
1082
- }
1083
-
1084
- /** rAF poll for an element that appears after a view switch + render pass. */
1085
- function waitForElement(selector, frames) {
1086
- return new Promise((resolve) => {
1087
- let remaining = frames
1088
- const probe = () => {
1089
- const el = document.querySelector(selector)
1090
- if (el !== null) return resolve(el)
1091
- if (remaining-- <= 0) return resolve(null)
1092
- requestAnimationFrame(probe)
1093
- }
1094
- requestAnimationFrame(probe)
1095
- })
1096
- }
1097
-
1098
- /** One-shot highlight for the jumped-to row (removes itself). */
1099
- function flashKey(key) {
1100
- const id = `dsh-rt-jump-${key.replace(/[^a-z0-9]/gi, '-')}`
1101
- if (document.querySelector(`style[data-plugin-css="${id}"]`)) return
1102
- const tag = document.createElement('style')
1103
- tag.dataset.pluginCss = id
1104
- tag.textContent = `[data-chat-anchor-key=${JSON.stringify(key)}]{animation:dsh-rt-flash 1.6s ease-out 2}@keyframes dsh-rt-flash{0%,100%{background:transparent}30%,70%{background:var(--dsw-alias-state-business-primary)}55%{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent)}}`
1105
- document.head.appendChild(tag)
1106
- setTimeout(() => tag.remove(), 3400)
1107
- }
1251
+ // Jump: switch to the chat tab (tab-bar click — `actions?.setView?.()` is a
1252
+ // silent no-op for store-less third-party views), page up via the session
1253
+ // store until the anchor seq is loaded, then scroll the chat row into view.
1254
+ // The whole flow runs here because the view switch unmounts this view.
1255
+ const jump = (boundarySeq) => jumpToAnchor(store, boundarySeq)
1108
1256
 
1109
1257
  const requestPreview = (record) => {
1110
1258
  setPreviewScope('both')
@@ -1145,6 +1293,13 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1145
1293
  const list = versions ?? []
1146
1294
  const visible = list.slice(Math.max(0, Math.floor(scrollTop / ROW_H) - 2), Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2))
1147
1295
 
1296
+ // Same view-area height trap as the fork list (see bindListHeight).
1297
+ useEffect(() => {
1298
+ if (list.length === 0) return undefined
1299
+ return bindListHeight(document.querySelector('.dsh-rt-view .dsh-rt-timeline-list'))
1300
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1301
+ }, [list.length])
1302
+
1148
1303
  return createElement('div', { className: 'dsh-rt-view' }, [
1149
1304
  createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
1150
1305
  createElement('span', { key: 'title', className: 'dsh-rt-timeline-title' }, t('timeline.title')),
@@ -1187,7 +1342,7 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1187
1342
  t,
1188
1343
  top: list.indexOf(record) * ROW_H,
1189
1344
  onPreview: () => requestPreview(record),
1190
- onTrajectory: () => actions?.setView?.('trajectory'),
1345
+ onTrajectory: () => switchToViewTab('trajectory'),
1191
1346
  onJump: () => jump(record.boundarySeq),
1192
1347
  })),
1193
1348
  ]),
@@ -1273,6 +1428,171 @@ function PreviewBox({ preview, scope, setScope, busy, t, onConfirm, onCancel })
1273
1428
  ])
1274
1429
  }
1275
1430
 
1431
+ /**
1432
+ * P2.1 — Fork map: the branch-topology view (PLAN §5.2).
1433
+ *
1434
+ * Data: `retrace/forkmap` projection — live push frames via
1435
+ * `useProjection('retrace/forkmap')` (zero polling), HTTP `/forkmap` fallback.
1436
+ * Rendering: a spine of current-surface nodes (the CURRENT path — markers sit
1437
+ * on it); at each boundary (recall / edit / regenerate / restore — the fork
1438
+ * points) a card shows the shadowed (OLD-path) cluster count plus the marker
1439
+ * summary, joined from `retrace/versions` by boundarySeq (the fork-map wire
1440
+ * stays lean). Node clicks jump to the conversation via the shared
1441
+ * `jumpToAnchor` (tab-bar click + loadOlder + anchor scroll).
1442
+ *
1443
+ * The old-path node details (messages/thinking/tools inside a cluster) belong
1444
+ * to P2.2 (branch-intent cards); this skeleton establishes the topology.
1445
+ */
1446
+ function ForkView({ sessionId, useProjection, t, actions, store }) {
1447
+ const [fork, setFork] = useState(null)
1448
+ const [loading, setLoading] = useState(false)
1449
+ const [error, setError] = useState(null)
1450
+ const [scrollTop, setScrollTop] = useState(0)
1451
+
1452
+ // Live push-frame path (projection standard kit) — falls back to HTTP.
1453
+ const projected = typeof useProjection === 'function' ? useProjection('retrace/forkmap') : undefined
1454
+ // markerText lives in the versions wire; join it by boundarySeq.
1455
+ const versions = typeof useProjection === 'function' ? useProjection('retrace/versions') : undefined
1456
+ const markerBySeq = new Map((versions?.versions ?? []).map((v) => [v.boundarySeq, v.markerText]))
1457
+
1458
+ useEffect(() => {
1459
+ if (projected && Array.isArray(projected.nodes) && Array.isArray(projected.boundaries)) setFork(projected)
1460
+ }, [projected])
1461
+
1462
+ const refresh = () => {
1463
+ setLoading(true)
1464
+ setError(null)
1465
+ timelineGet(`/forkmap?sessionId=${encodeURIComponent(sessionId)}`)
1466
+ .then((result) => {
1467
+ if (!result || result.ok !== true) throw new Error(result?.error?.message ?? 'forkmap failed')
1468
+ setFork(result.value?.nodes ? result.value : null)
1469
+ })
1470
+ .catch((cause) => setError(cause?.message ?? 'forkmap error'))
1471
+ .finally(() => setLoading(false))
1472
+ }
1473
+
1474
+ // Mount-time load: push frames arrive live when the host mounts the
1475
+ // registry; HTTP is the on-demand fallback for minimal compositions.
1476
+ useEffect(() => {
1477
+ if (projected === undefined) refresh()
1478
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1479
+ }, [])
1480
+
1481
+ const nodes = fork?.nodes ?? []
1482
+ const boundaries = fork?.boundaries ?? []
1483
+ const boundaryBySeq = new Map(boundaries.map((b) => [b.seq, b]))
1484
+ // Boundaries whose marker is no longer on the current surface (a later
1485
+ // replace shadowed it — e.g. chained edits of one message) have no spine
1486
+ // node; render them in a historical section so every fork point stays
1487
+ // visible.
1488
+ const spineSeqSet = new Set(nodes.map((n) => n.seq))
1489
+ const offSpine = boundaries.filter((b) => !spineSeqSet.has(b.seq))
1490
+
1491
+ // The view-area flex chain grows to content; bound the list height so the
1492
+ // virtual spacer doesn't inflate the whole view (see bindListHeight).
1493
+ useEffect(() => {
1494
+ if (nodes.length === 0) return undefined
1495
+ return bindListHeight(document.querySelector('.dsh-rt-view .dsh-rt-fork-list'))
1496
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1497
+ }, [nodes.length])
1498
+
1499
+ // ---- windowed list (uniform rows, zero-dep; same as the versions view) ----
1500
+ const ROW_H = 56
1501
+ const visible = nodes.slice(
1502
+ Math.max(0, Math.floor(scrollTop / ROW_H) - 2),
1503
+ Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2),
1504
+ )
1505
+
1506
+ return createElement('div', { className: 'dsh-rt-view' }, [
1507
+ createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
1508
+ createElement('span', { key: 'title', className: 'dsh-rt-timeline-title' }, t('fork.title')),
1509
+ createElement('button', {
1510
+ key: 'refresh',
1511
+ type: 'button',
1512
+ className: 'dsh-rt-chip',
1513
+ onClick: refresh,
1514
+ }, t('fork.refresh')),
1515
+ ]),
1516
+
1517
+ error !== null && createElement('div', { key: 'error', className: 'dsh-rt-error' }, error),
1518
+ loading && createElement('div', { key: 'loading', className: 'dsh-rt-timeline-empty' }, t('timeline.loading')),
1519
+ !loading && nodes.length === 0 && createElement('div', { key: 'empty', className: 'dsh-rt-timeline-empty' }, t('fork.empty')),
1520
+
1521
+ nodes.length > 0 && createElement('div', { key: 'spine', className: 'dsh-rt-fork-spine-label' }, t('fork.spine')),
1522
+
1523
+ nodes.length > 0 && createElement('div', {
1524
+ key: 'list',
1525
+ className: 'dsh-rt-fork-list',
1526
+ onScroll: (event) => setScrollTop(event.target.scrollTop),
1527
+ }, [
1528
+ createElement('div', { key: 'spacer', style: { height: `${nodes.length * ROW_H}px`, position: 'relative' } }, [
1529
+ visible.map((node) => createElement(ForkRow, {
1530
+ key: node.seq,
1531
+ node,
1532
+ boundary: boundaryBySeq.get(node.seq),
1533
+ markerText: markerBySeq.get(node.seq),
1534
+ t,
1535
+ top: nodes.indexOf(node) * ROW_H,
1536
+ onJump: () => jumpToAnchor(store, node.seq),
1537
+ })),
1538
+ ]),
1539
+ ]),
1540
+
1541
+ offSpine.length > 0 && createElement('div', { key: 'hist', className: 'dsh-rt-fork-hist' }, [
1542
+ createElement('div', { key: 'title', className: 'dsh-rt-fork-spine-label' }, t('fork.histTitle')),
1543
+ offSpine.map((boundary) => createElement(ForkHistRow, {
1544
+ key: boundary.seq,
1545
+ boundary,
1546
+ markerText: markerBySeq.get(boundary.seq),
1547
+ t,
1548
+ onJump: () => jumpToAnchor(store, boundary.seq),
1549
+ })),
1550
+ ]),
1551
+ ])
1552
+ }
1553
+
1554
+ /** One spine row; boundary rows additionally show the old-path cluster. */
1555
+ function ForkRow({ node, boundary, markerText, t, top, onJump }) {
1556
+ const isBoundary = boundary !== undefined
1557
+ const icon = isBoundary ? (KIND_ICONS[boundary.kind] ?? '•') : (NODE_ICONS[node.type] ?? '•')
1558
+ const label = isBoundary ? kindLabel(boundary.kind, t) : nodeTypeLabel(node.type, t)
1559
+ return createElement('div', {
1560
+ className: `dsh-rt-fork-row${isBoundary ? ' dsh-rt-fork-boundary' : ''}`,
1561
+ style: { top: `${top}px` },
1562
+ }, [
1563
+ createElement('span', { key: 'icon', className: 'dsh-rt-fork-icon' }, icon),
1564
+ createElement('div', { key: 'body', className: 'dsh-rt-fork-body' }, [
1565
+ createElement('div', { key: 'line', className: 'dsh-rt-fork-line' }, [
1566
+ createElement('span', { key: 'label', className: 'dsh-rt-fork-label' }, label),
1567
+ createElement('span', { key: 'seq', className: 'dsh-rt-fork-seq' }, `#${node.seq}`),
1568
+ isBoundary && boundary.replacedSeqs.length > 0 && createElement('span', { key: 'shadowed', className: 'dsh-rt-fork-shadowed' },
1569
+ t('fork.shadowed', { count: boundary.replacedSeqs.length })),
1570
+ ]),
1571
+ isBoundary && markerText && createElement('div', { key: 'text', className: 'dsh-rt-fork-text' },
1572
+ markerText.length > 140 ? `${markerText.slice(0, 140)}…` : markerText),
1573
+ ]),
1574
+ createElement('button', { key: 'jump', type: 'button', className: 'dsh-rt-chip', onClick: onJump }, t('timeline.jump')),
1575
+ ])
1576
+ }
1577
+
1578
+ /** A historical fork point whose marker was itself shadowed (not on the spine). */
1579
+ function ForkHistRow({ boundary, markerText, t, onJump }) {
1580
+ return createElement('div', { className: 'dsh-rt-fork-hist-row' }, [
1581
+ createElement('span', { key: 'icon', className: 'dsh-rt-fork-icon' }, KIND_ICONS[boundary.kind] ?? '•'),
1582
+ createElement('div', { key: 'body', className: 'dsh-rt-fork-body' }, [
1583
+ createElement('div', { key: 'line', className: 'dsh-rt-fork-line' }, [
1584
+ createElement('span', { key: 'label', className: 'dsh-rt-fork-label' }, kindLabel(boundary.kind, t)),
1585
+ createElement('span', { key: 'seq', className: 'dsh-rt-fork-seq' }, `#${boundary.seq}`),
1586
+ boundary.replacedSeqs.length > 0 && createElement('span', { key: 'shadowed', className: 'dsh-rt-fork-shadowed' },
1587
+ t('fork.shadowed', { count: boundary.replacedSeqs.length })),
1588
+ ]),
1589
+ markerText && createElement('div', { key: 'text', className: 'dsh-rt-fork-text' },
1590
+ markerText.length > 140 ? `${markerText.slice(0, 140)}…` : markerText),
1591
+ ]),
1592
+ createElement('button', { key: 'jump', type: 'button', className: 'dsh-rt-chip', onClick: onJump }, t('timeline.jump')),
1593
+ ])
1594
+ }
1595
+
1276
1596
  // ---------------------------------------------------------------------------
1277
1597
  // Plugin
1278
1598
  // ---------------------------------------------------------------------------
@@ -1330,6 +1650,19 @@ export function apply(ctx) {
1330
1650
  }),
1331
1651
  }, RetraceView))
1332
1652
 
1653
+ // P2.1 — the fork-map view as a second official tab (next to 对话/轨迹/版本).
1654
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
1655
+ name: 'conversation.view',
1656
+ id: 'retrace-fork',
1657
+ order: 30,
1658
+ locale: NS,
1659
+ label: () => t('view.fork'),
1660
+ inject: (sessionId, actions) => ({
1661
+ actions,
1662
+ store: ctx.get?.('sessions')?.binding?.(sessionId)?.session,
1663
+ }),
1664
+ }, ForkView))
1665
+
1333
1666
  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
1334
1667
  name: 'settings.general.item',
1335
1668
  id: 'retrace',