dsh-retrace 0.4.2 → 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/lib/client.js CHANGED
@@ -71,6 +71,8 @@ const zh = {
71
71
  'marker.regenerate': '已重新生成回复',
72
72
  'marker.originalLabel': '原输入',
73
73
  'marker.referenceHint': '点击展开查看原提问(仅作对照,不会进入模型上下文)',
74
+ 'marker.degradedHint': '此操作涉及大范围对话,为保护历史未隐藏内容(日志完好)。',
75
+ 'marker.unionHint': '已累积隐藏约 {count}% 的历史消息;可在 设置→通用 关闭「按标记隐藏」查看完整历史。',
74
76
  'options.title': '消息编辑插件',
75
77
  'options.showOriginalInput': '编辑后显示原提问对照',
76
78
  'options.editFromScratch': '编辑后从新对话开始(隐藏此前的消息,默认关)',
@@ -129,6 +131,16 @@ const zh = {
129
131
  'timeline.gitInitConfirm': '确定要初始化 git 吗?',
130
132
  'error.generic': '操作失败,请重试',
131
133
  'error.busy': '请先停止当前回复再操作',
134
+ 'view.fork': '分叉',
135
+ 'fork.title': '分叉图',
136
+ 'fork.empty': '还没有分叉。撤回 / 编辑 / 重新生成 / 恢复会在此产生分叉。',
137
+ 'fork.refresh': '刷新',
138
+ 'fork.spine': '当前路径',
139
+ 'fork.shadowed': '被遮蔽 {count} 个节点',
140
+ 'fork.node.user': '用户消息',
141
+ 'fork.node.assistant': '助手回复',
142
+ 'fork.node.tool': '工具结果',
143
+ 'fork.histTitle': '历史分叉点',
132
144
  }
133
145
  /** English dictionary, checked complete against the zh key set. */
134
146
  const en = {
@@ -147,6 +159,8 @@ const en = {
147
159
  'marker.regenerate': 'Reply regenerated',
148
160
  'marker.originalLabel': 'Original input',
149
161
  'marker.referenceHint': 'Click to expand the original input (reference only, never sent to the model)',
162
+ 'marker.degradedHint': 'This operation spans a large part of the conversation; content stays visible to protect your history (the log is intact).',
163
+ 'marker.unionHint': 'About {count}% of the history is hidden in total; disable "Hide shadowed messages" in Settings → General to review the full history.',
150
164
  'options.title': 'Message editor plugin',
151
165
  'options.showOriginalInput': 'Show the original input after editing',
152
166
  'options.editFromScratch': 'Start a fresh conversation after editing (hide earlier messages, default off)',
@@ -205,6 +219,16 @@ const en = {
205
219
  'timeline.gitInitConfirm': 'Initialize git in this workspace?',
206
220
  'error.generic': 'Operation failed; please try again',
207
221
  'error.busy': 'Stop the current reply before recalling or editing',
222
+ 'view.fork': 'Fork',
223
+ 'fork.title': 'Fork map',
224
+ 'fork.empty': 'No forks yet. Recall / edit / regenerate / restore fork the path here.',
225
+ 'fork.refresh': 'Refresh',
226
+ 'fork.spine': 'Current path',
227
+ 'fork.shadowed': '{count} shadowed nodes',
228
+ 'fork.node.user': 'User message',
229
+ 'fork.node.assistant': 'Assistant reply',
230
+ 'fork.node.tool': 'Tool result',
231
+ 'fork.histTitle': 'Historical fork points',
208
232
  }
209
233
 
210
234
  // ---------------------------------------------------------------------------
@@ -433,23 +457,38 @@ const recallMarkerDefinition = {
433
457
  kind: 'recall-marker',
434
458
  target: 'chat',
435
459
  match: (event) => {
436
- if (event.type !== 'assistant/message' || !isReplacementSurfaceEvent(event)) return null
437
- const id = event.data?.message?.id
438
- if (!isMarkerId(id)) return null
439
- return { id: `marker:${id}`, role: 'start' }
460
+ if (!isReplacementSurfaceEvent(event)) return null
461
+ if (event.type === 'assistant/message') {
462
+ const id = event.data?.message?.id
463
+ if (!isMarkerId(id)) return null
464
+ return { id: `marker:${id}`, role: 'start' }
465
+ }
466
+ // Compaction checkpoints are user/message replaces with the official
467
+ // `plugin: compact` source. They are NOT our markers, but their shadow
468
+ // range tells us which messages were compacted away — used ONLY to hide
469
+ // the edit/recall entries for those messages (compacted rows are handled
470
+ // by the engine itself, so the checkpoint marker renders nothing and
471
+ // never injects hide rules).
472
+ if (event.type === 'user/message' && isCompactCheckpoint(event.data?.source)) {
473
+ return { id: `marker:compact:${event.seq}`, role: 'start' }
474
+ }
475
+ return null
440
476
  },
441
477
  start: (_context, match) => {
442
478
  const event = match.event
443
- const id = String(event.data.message.id)
444
- const legacy = isLegacyMarkerId(id)
479
+ const compact = event.type === 'user/message' && isCompactCheckpoint(event.data?.source)
480
+ const id = compact ? '' : String(event.data.message.id)
481
+ const legacy = !compact && isLegacyMarkerId(id)
445
482
  return {
446
483
  seq: event.seq,
447
484
  time: event.time,
448
- op: markerOpFromId(id),
485
+ op: compact ? 'compaction' : markerOpFromId(id),
449
486
  legacy,
450
- // Legacy markers never hide: treat their shadowed range as empty so the
451
- // notice/reference render but no row is hidden and no action row is
452
- // suppressed via useShadowed.
487
+ compact,
488
+ // Legacy/compact markers never hide: their shadowed range is kept for
489
+ // the action-row suppression check (useShadowed) but empty for legacy
490
+ // so no row is hidden and no action row is suppressed for legacy
491
+ // markers (rename must never make visible content disappear).
453
492
  shadowedSeqs: legacy ? [] : (Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : []),
454
493
  targetSeq: event.data?.editor?.targetSeq,
455
494
  text: event.data?.editor?.text,
@@ -462,6 +501,11 @@ const recallMarkerDefinition = {
462
501
  },
463
502
  }
464
503
 
504
+ /** Official compaction checkpoint source: `{kind:'plugin', plugin:'compact'}`. */
505
+ function isCompactCheckpoint(source) {
506
+ return Boolean(source) && source.kind === 'plugin' && source.plugin === 'compact'
507
+ }
508
+
465
509
  // ---------------------------------------------------------------------------
466
510
  // Shared selector helpers
467
511
  // ---------------------------------------------------------------------------
@@ -485,14 +529,158 @@ function useMessageSeq(useSession, messageId) {
485
529
  })
486
530
  }
487
531
 
488
- /** True when `seq` was shadowed by any recall/edit/regenerate marker. */
489
- function useShadowed(useSession, seq) {
532
+ /**
533
+ * Safety guard: a SINGLE marker that would hide more than this share of the
534
+ * conversation's rows is refused (the marker renders as a notice only, and an
535
+ * explicit hint explains why). Ordinary recalls/edits shadow a few rows and
536
+ * always hide — the guard only ever trips on whole-surface operations such as
537
+ * "edit, start a fresh conversation". History must never silently vanish.
538
+ *
539
+ * Note (0.4.3 regression, fixed): the previous UNION-wide guard degraded EVERY
540
+ * marker (including fresh recalls) once the session's markers collectively
541
+ * covered >40% of the rows — recall/edit silently stopped hiding. The guard is
542
+ * per-marker again; a stacked-edit session still hides each replaced round,
543
+ * and a visible hint reports how much history is hidden in total.
544
+ */
545
+ const SHADOW_SAFETY_RATIO = 0.4
546
+
547
+ /**
548
+ * Every chat-node key that should disappear when `shadowedSeqs` are recalled:
549
+ * the shadowed message rows themselves, plus the per-turn action row (copy /
550
+ * feedback / branch) when its finalized assistant reply is among them.
551
+ */
552
+ function hiddenKeysFor(shadowedSeqs, nodes) {
553
+ if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null
554
+ const hidden = new Set(shadowedSeqs)
555
+ const keys = []
556
+ for (const node of nodes.values()) {
557
+ if (node.kind === 'recall-marker') continue
558
+ if (node.kind === 'turn-tail') {
559
+ // `closing` is the finalized assistant-step *data*; the message seq
560
+ // lives on its `finalNode` (matching how the app reads `closing.finalNode.seq`).
561
+ const closingSeq = node.data?.closing?.finalNode?.seq
562
+ if (typeof closingSeq === 'number' && hidden.has(closingSeq)) keys.push(node.key)
563
+ continue
564
+ }
565
+ if (node.kind === 'tool-call') {
566
+ // Tool rows anchor at the tool/call event seq, which is a log-only
567
+ // event and never a surface node, so it cannot appear in shadowedSeqs.
568
+ // Match the settled result's surface seq (root.seq) instead.
569
+ const resultSeq = node.data?.root?.seq
570
+ if (typeof resultSeq === 'number' && hidden.has(resultSeq)) keys.push(node.key)
571
+ continue
572
+ }
573
+ if (typeof node.anchorSeq === 'number') {
574
+ // Pseudo rows anchor at HALF seqs to order before their message
575
+ // (retrace-reference uses `seq - 0.5`); the shadow set holds whole
576
+ // seqs, so map the anchor back to its integer seq before matching —
577
+ // otherwise the original-input reference survives the message it
578
+ // belongs to (visible residue after an edit).
579
+ const anchored = node.anchorSeq % 1 === 0 ? node.anchorSeq : Math.ceil(node.anchorSeq)
580
+ if (hidden.has(anchored)) keys.push(node.key)
581
+ }
582
+ }
583
+ return keys.length === 0 ? null : keys
584
+ }
585
+
586
+ /**
587
+ * Hide plan (0.4.3 → per-marker): one snapshot pass computes every marker's
588
+ * hidden keys and applies the safety guard to each marker INDEPENDENTLY. A
589
+ * single normal recall/edit hides its replaced round (a few rows ≪ the
590
+ * threshold); only a marker that would hide most of the history by itself
591
+ * (e.g. "start a fresh conversation") degrades to notice-only. The plan also
592
+ * reports the collective ratio so the UI can hint when stacked edits hide a
593
+ * large share of the conversation.
594
+ */
595
+ const EMPTY_HIDE_PLAN = Object.freeze({
596
+ hiddenFor: () => null,
597
+ planFor: () => null,
598
+ unionRatio: 0,
599
+ firstMarkerKey: null,
600
+ })
601
+
602
+ /**
603
+ * Plugin pseudo-node kinds never represent a real conversation row; they must
604
+ * not count toward the safety-ratio denominator (0.4.x review: counting them
605
+ * diluted the 40% guard to ~55-68% of real rows).
606
+ */
607
+ const PLUGIN_PSEUDO_KINDS = new Set(['user-actions', 'retrace-reference', 'recall-marker'])
608
+
609
+ /** Count only REAL conversation rows (excludes the plugin's pseudo nodes). */
610
+ function realRowCount(nodes) {
611
+ let count = 0
612
+ for (const node of nodes.values()) {
613
+ if (typeof node.anchorSeq === 'number' && !PLUGIN_PSEUDO_KINDS.has(node.kind)) count += 1
614
+ }
615
+ return count
616
+ }
617
+
618
+ // Module-level memo: the conversation snapshot reference is stable between
619
+ // events, so the hide plan (O(nodes) per pass) is computed once per snapshot
620
+ // and shared by every marker row — same object reference → no re-render storm
621
+ // (0.4.x review: each marker row recomputed the whole table every snapshot).
622
+ let hidePlanCacheSnapshot = null
623
+ let hidePlanCacheValue = null
624
+ function useMarkerHidePlan(useSession) {
625
+ return useSession((snapshot) => {
626
+ if (hidePlanCacheSnapshot === snapshot) return hidePlanCacheValue
627
+ const nodes = snapshot.chat.nodes
628
+ const rowCount = realRowCount(nodes)
629
+ const markers = []
630
+ for (const node of nodes.values()) {
631
+ if (node.kind === 'recall-marker' && !node.data?.compact) markers.push(node)
632
+ }
633
+ if (markers.length === 0) {
634
+ hidePlanCacheSnapshot = snapshot
635
+ hidePlanCacheValue = EMPTY_HIDE_PLAN
636
+ return hidePlanCacheValue
637
+ }
638
+ const plans = new Map()
639
+ const union = new Set()
640
+ for (const marker of markers) {
641
+ const keys = hiddenKeysFor(marker.data?.shadowedSeqs, nodes)
642
+ const degraded = keys !== null && rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO
643
+ plans.set(marker.key, { keys: degraded ? null : keys, degraded })
644
+ if (keys !== null) for (const key of keys) union.add(key)
645
+ }
646
+ hidePlanCacheSnapshot = snapshot
647
+ hidePlanCacheValue = {
648
+ planFor: (key) => plans.get(key) ?? null,
649
+ hiddenFor: (key) => plans.get(key)?.keys ?? null,
650
+ unionRatio: rowCount > 0 ? union.size / rowCount : 0,
651
+ firstMarkerKey: markers[0].key,
652
+ }
653
+ return hidePlanCacheValue
654
+ })
655
+ }
656
+
657
+ /**
658
+ * True when the chat row `key` is actually hidden right now — i.e. some
659
+ * marker that is NOT degraded includes it in its hide rules. This mirrors the
660
+ * CSS reality: a degraded marker hides nothing, so rows it shadowed stay
661
+ * visible AND stay operable (their action rows must not vanish).
662
+ */
663
+ function rowHiddenByKey(snapshot, rowKey) {
664
+ if (rowKey === undefined || rowKey === null) return false
665
+ const nodes = snapshot.chat.nodes
666
+ const rowCount = realRowCount(nodes)
667
+ for (const node of nodes.values()) {
668
+ if (node.kind !== 'recall-marker' || node.data?.compact) continue // compact markers never hide rows
669
+ const keys = hiddenKeysFor(node.data?.shadowedSeqs, nodes)
670
+ if (keys === null) continue
671
+ if (rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO) continue // degraded: hides nothing
672
+ if (keys.includes(rowKey)) return true
673
+ }
674
+ return false
675
+ }
676
+
677
+ /** Same as rowHiddenByKey, but resolves the row key from a surface seq. */
678
+ function useSeqHidden(useSession, seq) {
490
679
  return useSession((snapshot) => {
491
680
  if (seq === undefined || seq === null) return false
492
681
  for (const node of snapshot.chat.nodes.values()) {
493
- if (node.kind === 'recall-marker' && Array.isArray(node.data?.shadowedSeqs)
494
- && node.data.shadowedSeqs.includes(seq)) {
495
- return true
682
+ if (node.kind !== 'recall-marker' && typeof node.anchorSeq === 'number' && node.anchorSeq === seq) {
683
+ return rowHiddenByKey(snapshot, node.key)
496
684
  }
497
685
  }
498
686
  return false
@@ -500,49 +688,23 @@ function useShadowed(useSession, seq) {
500
688
  }
501
689
 
502
690
  /**
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.
691
+ * True when `seq` was shadowed by ANY recall/edit/regenerate/compaction
692
+ * marker — the OPERATION-FEASIBILITY dimension, distinct from visual hiding:
693
+ * a shadowed message can never be edited/recalled again (the host rejects it
694
+ * with target-shadowed), so its action entries must be hidden even when the
695
+ * row itself stays visible (guard-degraded or compacted).
507
696
  */
508
- const SHADOW_SAFETY_RATIO = 0.4
509
-
510
- /**
511
- * Every chat-node key that should disappear when `shadowedSeqs` are recalled:
512
- * the shadowed message rows themselves, plus the per-turn action row (copy /
513
- * feedback / branch) when its finalized assistant reply is among them.
514
- */
515
- function useHiddenKeys(useSession, shadowedSeqs) {
697
+ function useShadowed(useSession, seq) {
516
698
  return useSession((snapshot) => {
517
- if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null
518
- const hidden = new Set(shadowedSeqs)
519
- const keys = []
520
- let rowCount = 0
699
+ if (seq === undefined || seq === null) return false
521
700
  for (const node of snapshot.chat.nodes.values()) {
522
- 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
701
+ // Include compact markers: compacted messages are also un-editable.
702
+ if (node.kind === 'recall-marker' && Array.isArray(node.data?.shadowedSeqs)
703
+ && node.data.shadowedSeqs.includes(seq)) {
704
+ return true
538
705
  }
539
- if (typeof node.anchorSeq === 'number' && hidden.has(node.anchorSeq)) keys.push(node.key)
540
706
  }
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
707
+ return false
546
708
  })
547
709
  }
548
710
 
@@ -597,10 +759,14 @@ function useEditReference(useSession, mySeq) {
597
759
  /** 撤回 / 重新生成 strip inside a finalized assistant reply's IconActions row. */
598
760
  function AssistantActions({ messageId, sessionId, useSession, t }) {
599
761
  const seq = useMessageSeq(useSession, messageId)
762
+ const hidden = useSeqHidden(useSession, seq)
600
763
  const shadowed = useShadowed(useSession, seq)
601
764
  const [busy, setBusy] = useState(false)
602
765
  const [failure, setFailure] = useState(null)
603
- if (shadowed || seq === undefined) return null
766
+ // Hidden (visually) or shadowed (un-editable: recalled/edited/compacted
767
+ // away) messages must not offer recall/regenerate — the host would reject
768
+ // with target-shadowed.
769
+ if (hidden || shadowed || seq === undefined) return null
604
770
 
605
771
  const run = (op) => {
606
772
  setBusy(true)
@@ -647,11 +813,15 @@ function AssistantActions({ messageId, sessionId, useSession, t }) {
647
813
  /** 原输入 reference block, rendered just above the re-sent message. */
648
814
  function ReferenceRow({ node, useSession, t }) {
649
815
  const { seq, messageId } = node.data
650
- const shadowed = useShadowed(useSession, seq)
816
+ // The reference node anchors at seq-0.5 (above the message) and never
817
+ // appears in any hide rule — judge by the REAL message seq so a shadowed
818
+ // re-send's reference disappears with it (0.4.4 regression: judging by the
819
+ // node key left stale "original input" blocks after a second edit).
820
+ const hidden = useSeqHidden(useSession, seq)
651
821
  const markerRef = useEditReference(useSession, seq)
652
822
  const referenceText = editReferences.get(messageId) ?? markerRef
653
823
  const config = useConfig()
654
- if (shadowed) return null
824
+ if (hidden) return null
655
825
  if (referenceText === null || !config.showOriginalInput) return null
656
826
  return createElement('div', { className: 'dsh-rt-user-row' }, [
657
827
  createElement('details', { className: 'dsh-rt-reference' }, [
@@ -665,12 +835,18 @@ function ReferenceRow({ node, useSession, t }) {
665
835
  /** 编辑 / 撤回 action row under one user message; recall echoes into the composer. */
666
836
  function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
667
837
  const { seq, messageId, content } = node.data
838
+ // Two independent dimensions: visual hiding (guard-protected, row stays
839
+ // visible when degraded) vs operation feasibility (a shadowed message can
840
+ // never be edited again — the host rejects with target-shadowed). Hide the
841
+ // edit/recall entries when EITHER applies, so compacted or recalled rows
842
+ // that remain visible don't offer operations that would just fail.
843
+ const hidden = useSeqHidden(useSession, seq)
668
844
  const shadowed = useShadowed(useSession, seq)
669
845
  const [editing, setEditing] = useState(false)
670
846
  const [draft, setDraft] = useState('')
671
847
  const [busy, setBusy] = useState(false)
672
848
  const [failure, setFailure] = useState(null)
673
- if (shadowed) return null
849
+ if (hidden || shadowed) return null
674
850
 
675
851
  const openEditor = () => {
676
852
  setDraft(textOf(content))
@@ -771,9 +947,14 @@ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
771
947
 
772
948
  /** The transient notice row: hides shadowed content, dismissed after the user keeps typing. */
773
949
  function RecallMarkerRow({ node, useSession, t }) {
774
- const { seq, op, shadowedSeqs, legacy } = node.data
950
+ const { seq, op, shadowedSeqs, legacy, compact } = node.data
951
+ // Compaction checkpoints render nothing here: their only role is feeding
952
+ // useShadowed so compacted messages lose their edit/recall entries.
953
+ if (compact) return null
775
954
  const dismissed = useMarkerDismissed(useSession, seq, op)
776
- const hiddenKeys = legacy || !getConfig().hideShadowed ? null : useHiddenKeys(useSession, shadowedSeqs)
955
+ const hidePlan = useMarkerHidePlan(useSession)
956
+ const plan = hidePlan.planFor(node.key)
957
+ const hiddenKeys = legacy || !getConfig().hideShadowed ? null : (plan?.keys ?? null)
777
958
 
778
959
  // The hide rules must stay mounted even after the notice is dismissed,
779
960
  // otherwise the recalled message would reappear. Legacy markers and the
@@ -785,10 +966,21 @@ function RecallMarkerRow({ node, useSession, t }) {
785
966
  const label = op === 'recall'
786
967
  ? (count > 1 ? t('marker.recallMany', { count }) : t('marker.recallOne'))
787
968
  : op === 'regenerate' ? t('marker.regenerate') : t('marker.edit')
969
+ // A per-marker safety-guard trip (e.g. "start a fresh conversation") and a
970
+ // collective-hide hint appear once, on the first marker row.
971
+ const degradedHint = !legacy && plan?.degraded === true
972
+ ? createElement('div', { key: 'degraded', className: 'dsh-rt-marker-hint' }, t('marker.degradedHint'))
973
+ : null
974
+ const unionHint = !legacy && hidePlan.firstMarkerKey === node.key && hidePlan.unionRatio > SHADOW_SAFETY_RATIO
975
+ ? createElement('div', { key: 'union', className: 'dsh-rt-marker-hint' },
976
+ t('marker.unionHint', { count: Math.round(hidePlan.unionRatio * 100) }))
977
+ : null
788
978
 
789
979
  return createElement('div', { className: 'dsh-rt-marker-block', 'data-dismissed': dismissed || undefined }, [
790
980
  css !== null && createElement('style', { key: 'hide', dangerouslySetInnerHTML: { __html: css } }),
791
981
  !dismissed && createElement('div', { key: 'label', className: 'dsh-rt-marker', role: 'status' }, label),
982
+ !dismissed && degradedHint,
983
+ !dismissed && unionHint,
792
984
  ])
793
985
  }
794
986
 
@@ -871,6 +1063,7 @@ const CSS = `
871
1063
  .dsh-rt-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;max-width:min(525px,82%)}
872
1064
  .dsh-rt-marker-block{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;max-width:var(--dsh-chat-content-width);box-sizing:border-box;margin:0 auto;padding:2px 0}
873
1065
  .dsh-rt-marker{text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px}
1066
+ .dsh-rt-marker-hint{text-align:center;color:var(--dsw-alias-state-warning-primary);font-size:11px;line-height:16px;margin-top:2px}
874
1067
  .dsh-rt-reference{width:min(525px,82%);box-sizing:border-box;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:10px;padding:2px 12px}
875
1068
  .dsh-rt-reference summary{color:var(--dsw-alias-label-caption);cursor:pointer;user-select:none;font-size:12px;line-height:22px;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
876
1069
  .dsh-rt-reference summary::-webkit-details-marker{display:none}
@@ -889,7 +1082,8 @@ const CSS = `
889
1082
  .dsh-rt-retention-input:focus{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
890
1083
  .dsh-rt-option input{accent-color:var(--dsw-alias-state-business-primary)}
891
1084
  /* ---- 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}
1085
+
1086
+ .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
1087
  .dsh-rt-timeline-head{display:flex;align-items:center;gap:8px;flex:none}
894
1088
  .dsh-rt-timeline-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;flex:1}
895
1089
  .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 +1120,186 @@ const CSS = `
926
1120
  .dsh-rt-modal-buttons{display:flex;justify-content:flex-end;gap:8px;flex:none}
927
1121
  .dsh-rt-confirm{background:var(--dsw-alias-state-error-primary)}
928
1122
  .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}
1123
+ .dsh-rt-fork-list{overflow-y:auto;flex:1;min-height:0;position:relative}
1124
+ .dsh-rt-fork-spine-label{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;flex:none}
1125
+ .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}
1126
+ .dsh-rt-fork-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
1127
+ .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)}
1128
+ .dsh-rt-fork-boundary .dsh-rt-fork-icon{color:var(--dsw-alias-state-warning-primary)}
1129
+ .dsh-rt-fork-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
1130
+ .dsh-rt-fork-line{display:flex;align-items:baseline;gap:8px;min-width:0}
1131
+ .dsh-rt-fork-label{font-size:12px;font-weight:500;line-height:18px;color:var(--dsw-alias-label-primary);white-space:nowrap}
1132
+ .dsh-rt-fork-seq{font-family:var(--dsw-font-mono);font-size:11px;line-height:18px;color:var(--dsw-alias-label-caption);flex:none}
1133
+ .dsh-rt-fork-shadowed{font-size:11px;line-height:18px;color:var(--dsw-alias-state-warning-primary);flex:none}
1134
+ .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}
1135
+ .dsh-rt-fork-boundary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated)}
1136
+ .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}
1137
+ .dsh-rt-fork-hist-row{display:flex;align-items:flex-start;gap:8px;padding:4px 8px;border-radius:8px}
1138
+ .dsh-rt-fork-hist-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
929
1139
  `
930
1140
 
1141
+ const JUMP_PAGE_BUDGET = 24
1142
+
1143
+ /**
1144
+ * Switch to a conversation view tab programmatically.
1145
+ *
1146
+ * The official API offers no setView to third-party views: `actions` is only
1147
+ * injected to entries that declare a `store`, and the chat store is private to
1148
+ * ui-conversation — so `actions?.setView?.(...)` silently no-ops for plugins
1149
+ * (0.4.2 relied on it; verified against the renderer source 2026-08-26). The
1150
+ * only programmatic path is clicking the tab-bar button — the same path a user
1151
+ * click takes. Tab buttons render in registration order (priority, then
1152
+ * order): chat=0, trajectory=10, retrace=20, retrace-fork=30. A view with an
1153
+ * earlier order would shift the indices; verified on the real harness before
1154
+ * relying on the positions.
1155
+ */
1156
+ function switchToViewTab(viewId) {
1157
+ const ORDER = { chat: 0, trajectory: 1, retrace: 2, 'retrace-fork': 3 }
1158
+ const index = ORDER[viewId]
1159
+ if (index === undefined) {
1160
+ console.warn(`[dsh-retrace] no tab order registered for "${viewId}"`)
1161
+ return
1162
+ }
1163
+ // Only VISIBLE tablists count — hidden tabbars (settings etc.) must not
1164
+ // shift the index (0.4.x review: a document-wide query could click the
1165
+ // wrong control). The conversation view's tab bar is the visible one.
1166
+ const buttons = [...document.querySelectorAll('[role="tablist"]')]
1167
+ .filter((tablist) => tablist.getBoundingClientRect().width > 0)
1168
+ .flatMap((tablist) => [...tablist.querySelectorAll('[role="tab"]')])
1169
+ const button = buttons[index]
1170
+ if (!button) {
1171
+ console.warn(`[dsh-retrace] tab "${viewId}" (index ${index}) not found in the conversation tab bar`)
1172
+ return
1173
+ }
1174
+ button.click()
1175
+ }
1176
+
1177
+ /** Count chat nodes regardless of store shape (Map or iterator-only). */
1178
+ function nodeCountOf(nodes) {
1179
+ if (!nodes) return 0
1180
+ if (typeof nodes.size === 'number') return nodes.size
1181
+ return [...nodes.values()].length
1182
+ }
1183
+
1184
+ /** rAF poll for an element that appears after a view switch + render pass. */
1185
+ function waitForElement(selector, frames) {
1186
+ return new Promise((resolve) => {
1187
+ let remaining = frames
1188
+ const probe = () => {
1189
+ const el = document.querySelector(selector)
1190
+ if (el !== null) return resolve(el)
1191
+ if (remaining-- <= 0) return resolve(null)
1192
+ requestAnimationFrame(probe)
1193
+ }
1194
+ requestAnimationFrame(probe)
1195
+ })
1196
+ }
1197
+
1198
+ /** One-shot highlight for the jumped-to row (removes itself). */
1199
+ function flashKey(key) {
1200
+ const id = `dsh-rt-jump-${key.replace(/[^a-z0-9]/gi, '-')}`
1201
+ if (document.querySelector(`style[data-plugin-css="${id}"]`)) return
1202
+ const tag = document.createElement('style')
1203
+ tag.dataset.pluginCss = id
1204
+ 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)}}`
1205
+ document.head.appendChild(tag)
1206
+ setTimeout(() => tag.remove(), 3400)
1207
+ }
1208
+
1209
+ /**
1210
+ * Shared jump (versions + fork views): switch to the chat tab, page up via the
1211
+ * session store until the anchor seq is loaded, then scroll the chat row into
1212
+ * view once it renders. The view switch unmounts the current view, so the
1213
+ * whole flow runs here. The store has no seek-to-seq API — only `loadOlder`
1214
+ * (50/page) — so far-away targets are unreachable without thousands of pages;
1215
+ * cap the budget.
1216
+ */
1217
+ async function jumpToAnchor(store, anchorSeq) {
1218
+ switchToViewTab('chat')
1219
+ if (!store || typeof store.loadOlder !== 'function' || typeof store.getSnapshot !== 'function') return
1220
+ const keyOfSeq = (seq) => {
1221
+ const nodes = store.getSnapshot()?.chat?.nodes
1222
+ if (!nodes) return null
1223
+ for (const node of nodes.values()) {
1224
+ if (typeof node.anchorSeq === 'number' && node.anchorSeq === seq) return node.key
1225
+ }
1226
+ return null
1227
+ }
1228
+ let key = keyOfSeq(anchorSeq)
1229
+ let pages = 0
1230
+ while (key === null && pages < JUMP_PAGE_BUDGET && store.hasMore) {
1231
+ // Map/iterator-agnostic node count: `.size` may be absent on the
1232
+ // snapshot's node store and `values()` yields an iterator with no
1233
+ // `.length` — spread it (0.4.x review: the loop used to break after one
1234
+ // page because the count was always undefined).
1235
+ const before = nodeCountOf(store.getSnapshot()?.chat?.nodes)
1236
+ await store.loadOlder()
1237
+ const after = nodeCountOf(store.getSnapshot()?.chat?.nodes)
1238
+ pages += 1
1239
+ if (after === before) break // page returned nothing
1240
+ key = keyOfSeq(anchorSeq)
1241
+ }
1242
+ if (key === null) {
1243
+ // Give the last prepend a tick to propagate into the snapshot first.
1244
+ await new Promise((resolve) => setTimeout(resolve, 60))
1245
+ key = keyOfSeq(anchorSeq)
1246
+ }
1247
+ if (key === null) {
1248
+ console.warn(`[dsh-retrace] jump target seq ${anchorSeq} not reached within ${JUMP_PAGE_BUDGET} pages`)
1249
+ return
1250
+ }
1251
+ // The chat view is now mounted (tab click above); poll for its rendered row.
1252
+ const el = await waitForElement(`[data-chat-anchor-key=${JSON.stringify(key)}]`, 90)
1253
+ if (el === null) return
1254
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1255
+ flashKey(key)
1256
+ }
1257
+
1258
+ /** Surface node type → icon (fork spine rows). */
1259
+ const NODE_ICONS = { 'user/message': '👤', 'assistant/message': '🤖', 'tool/result': '🔧' }
1260
+
1261
+ /** Surface node type → localized label. */
1262
+ function nodeTypeLabel(type, t) {
1263
+ if (type === 'user/message') return t('fork.node.user')
1264
+ if (type === 'assistant/message') return t('fork.node.assistant')
1265
+ if (type === 'tool/result') return t('fork.node.tool')
1266
+ return type
1267
+ }
1268
+
1269
+ /**
1270
+ * Bound a windowed list to the available height.
1271
+ *
1272
+ * The conversation view area is a flex chain whose middle (`viewArea`,
1273
+ * `flex:1 0 auto; min-height:auto`) grows to CONTENT — a virtualized list's
1274
+ * tall spacer (N × rowHeight) inflates the whole view to the full list height,
1275
+ * so the list never scrolls and the window slice never moves (verified on the
1276
+ * real harness 2026-08-26; the official trajectory view avoids this because
1277
+ * its virtualized content contributes no tall spacer). Measuring the list
1278
+ * height against the viewport and setting it explicitly caps the spacer's
1279
+ * contribution — the view then collapses back to the bounded height.
1280
+ */
1281
+ function bindListHeight(listEl) {
1282
+ if (!listEl) return () => {}
1283
+ const measure = () => {
1284
+ const top = listEl.getBoundingClientRect().top
1285
+ const height = Math.max(120, window.innerHeight - top - 16)
1286
+ if (Math.abs(listEl.clientHeight - height) > 4) {
1287
+ // The list is a flex item (flex:1 → flex-basis:0%); a plain height is
1288
+ // ignored by the flex layout, so pin it with flex:none.
1289
+ listEl.style.flex = 'none'
1290
+ listEl.style.height = `${height}px`
1291
+ }
1292
+ }
1293
+ measure()
1294
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null
1295
+ ro?.observe(document.body)
1296
+ window.addEventListener('resize', measure)
1297
+ return () => {
1298
+ ro?.disconnect()
1299
+ window.removeEventListener('resize', measure)
1300
+ }
1301
+ }
1302
+
931
1303
  function ensureStyle() {
932
1304
  if (typeof document === 'undefined') return () => {}
933
1305
  if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`)) return () => {}
@@ -1037,74 +1409,11 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1037
1409
  // eslint-disable-next-line react-hooks/exhaustive-deps
1038
1410
  }, [])
1039
1411
 
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
- }
1412
+ // Jump: switch to the chat tab (tab-bar click — `actions?.setView?.()` is a
1413
+ // silent no-op for store-less third-party views), page up via the session
1414
+ // store until the anchor seq is loaded, then scroll the chat row into view.
1415
+ // The whole flow runs here because the view switch unmounts this view.
1416
+ const jump = (boundarySeq) => jumpToAnchor(store, boundarySeq)
1108
1417
 
1109
1418
  const requestPreview = (record) => {
1110
1419
  setPreviewScope('both')
@@ -1145,6 +1454,13 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1145
1454
  const list = versions ?? []
1146
1455
  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
1456
 
1457
+ // Same view-area height trap as the fork list (see bindListHeight).
1458
+ useEffect(() => {
1459
+ if (list.length === 0) return undefined
1460
+ return bindListHeight(document.querySelector('.dsh-rt-view .dsh-rt-timeline-list'))
1461
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1462
+ }, [list.length])
1463
+
1148
1464
  return createElement('div', { className: 'dsh-rt-view' }, [
1149
1465
  createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
1150
1466
  createElement('span', { key: 'title', className: 'dsh-rt-timeline-title' }, t('timeline.title')),
@@ -1187,7 +1503,7 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
1187
1503
  t,
1188
1504
  top: list.indexOf(record) * ROW_H,
1189
1505
  onPreview: () => requestPreview(record),
1190
- onTrajectory: () => actions?.setView?.('trajectory'),
1506
+ onTrajectory: () => switchToViewTab('trajectory'),
1191
1507
  onJump: () => jump(record.boundarySeq),
1192
1508
  })),
1193
1509
  ]),
@@ -1273,6 +1589,171 @@ function PreviewBox({ preview, scope, setScope, busy, t, onConfirm, onCancel })
1273
1589
  ])
1274
1590
  }
1275
1591
 
1592
+ /**
1593
+ * P2.1 — Fork map: the branch-topology view (PLAN §5.2).
1594
+ *
1595
+ * Data: `retrace/forkmap` projection — live push frames via
1596
+ * `useProjection('retrace/forkmap')` (zero polling), HTTP `/forkmap` fallback.
1597
+ * Rendering: a spine of current-surface nodes (the CURRENT path — markers sit
1598
+ * on it); at each boundary (recall / edit / regenerate / restore — the fork
1599
+ * points) a card shows the shadowed (OLD-path) cluster count plus the marker
1600
+ * summary, joined from `retrace/versions` by boundarySeq (the fork-map wire
1601
+ * stays lean). Node clicks jump to the conversation via the shared
1602
+ * `jumpToAnchor` (tab-bar click + loadOlder + anchor scroll).
1603
+ *
1604
+ * The old-path node details (messages/thinking/tools inside a cluster) belong
1605
+ * to P2.2 (branch-intent cards); this skeleton establishes the topology.
1606
+ */
1607
+ function ForkView({ sessionId, useProjection, t, actions, store }) {
1608
+ const [fork, setFork] = useState(null)
1609
+ const [loading, setLoading] = useState(false)
1610
+ const [error, setError] = useState(null)
1611
+ const [scrollTop, setScrollTop] = useState(0)
1612
+
1613
+ // Live push-frame path (projection standard kit) — falls back to HTTP.
1614
+ const projected = typeof useProjection === 'function' ? useProjection('retrace/forkmap') : undefined
1615
+ // markerText lives in the versions wire; join it by boundarySeq.
1616
+ const versions = typeof useProjection === 'function' ? useProjection('retrace/versions') : undefined
1617
+ const markerBySeq = new Map((versions?.versions ?? []).map((v) => [v.boundarySeq, v.markerText]))
1618
+
1619
+ useEffect(() => {
1620
+ if (projected && Array.isArray(projected.nodes) && Array.isArray(projected.boundaries)) setFork(projected)
1621
+ }, [projected])
1622
+
1623
+ const refresh = () => {
1624
+ setLoading(true)
1625
+ setError(null)
1626
+ timelineGet(`/forkmap?sessionId=${encodeURIComponent(sessionId)}`)
1627
+ .then((result) => {
1628
+ if (!result || result.ok !== true) throw new Error(result?.error?.message ?? 'forkmap failed')
1629
+ setFork(result.value?.nodes ? result.value : null)
1630
+ })
1631
+ .catch((cause) => setError(cause?.message ?? 'forkmap error'))
1632
+ .finally(() => setLoading(false))
1633
+ }
1634
+
1635
+ // Mount-time load: push frames arrive live when the host mounts the
1636
+ // registry; HTTP is the on-demand fallback for minimal compositions.
1637
+ useEffect(() => {
1638
+ if (projected === undefined) refresh()
1639
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1640
+ }, [])
1641
+
1642
+ const nodes = fork?.nodes ?? []
1643
+ const boundaries = fork?.boundaries ?? []
1644
+ const boundaryBySeq = new Map(boundaries.map((b) => [b.seq, b]))
1645
+ // Boundaries whose marker is no longer on the current surface (a later
1646
+ // replace shadowed it — e.g. chained edits of one message) have no spine
1647
+ // node; render them in a historical section so every fork point stays
1648
+ // visible.
1649
+ const spineSeqSet = new Set(nodes.map((n) => n.seq))
1650
+ const offSpine = boundaries.filter((b) => !spineSeqSet.has(b.seq))
1651
+
1652
+ // The view-area flex chain grows to content; bound the list height so the
1653
+ // virtual spacer doesn't inflate the whole view (see bindListHeight).
1654
+ useEffect(() => {
1655
+ if (nodes.length === 0) return undefined
1656
+ return bindListHeight(document.querySelector('.dsh-rt-view .dsh-rt-fork-list'))
1657
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1658
+ }, [nodes.length])
1659
+
1660
+ // ---- windowed list (uniform rows, zero-dep; same as the versions view) ----
1661
+ const ROW_H = 56
1662
+ const visible = nodes.slice(
1663
+ Math.max(0, Math.floor(scrollTop / ROW_H) - 2),
1664
+ Math.min(nodes.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(640 / ROW_H) + 2),
1665
+ )
1666
+
1667
+ return createElement('div', { className: 'dsh-rt-view' }, [
1668
+ createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
1669
+ createElement('span', { key: 'title', className: 'dsh-rt-timeline-title' }, t('fork.title')),
1670
+ createElement('button', {
1671
+ key: 'refresh',
1672
+ type: 'button',
1673
+ className: 'dsh-rt-chip',
1674
+ onClick: refresh,
1675
+ }, t('fork.refresh')),
1676
+ ]),
1677
+
1678
+ error !== null && createElement('div', { key: 'error', className: 'dsh-rt-error' }, error),
1679
+ loading && createElement('div', { key: 'loading', className: 'dsh-rt-timeline-empty' }, t('timeline.loading')),
1680
+ !loading && nodes.length === 0 && createElement('div', { key: 'empty', className: 'dsh-rt-timeline-empty' }, t('fork.empty')),
1681
+
1682
+ nodes.length > 0 && createElement('div', { key: 'spine', className: 'dsh-rt-fork-spine-label' }, t('fork.spine')),
1683
+
1684
+ nodes.length > 0 && createElement('div', {
1685
+ key: 'list',
1686
+ className: 'dsh-rt-fork-list',
1687
+ onScroll: (event) => setScrollTop(event.target.scrollTop),
1688
+ }, [
1689
+ createElement('div', { key: 'spacer', style: { height: `${nodes.length * ROW_H}px`, position: 'relative' } }, [
1690
+ visible.map((node) => createElement(ForkRow, {
1691
+ key: node.seq,
1692
+ node,
1693
+ boundary: boundaryBySeq.get(node.seq),
1694
+ markerText: markerBySeq.get(node.seq),
1695
+ t,
1696
+ top: nodes.indexOf(node) * ROW_H,
1697
+ onJump: () => jumpToAnchor(store, node.seq),
1698
+ })),
1699
+ ]),
1700
+ ]),
1701
+
1702
+ offSpine.length > 0 && createElement('div', { key: 'hist', className: 'dsh-rt-fork-hist' }, [
1703
+ createElement('div', { key: 'title', className: 'dsh-rt-fork-spine-label' }, t('fork.histTitle')),
1704
+ offSpine.map((boundary) => createElement(ForkHistRow, {
1705
+ key: boundary.seq,
1706
+ boundary,
1707
+ markerText: markerBySeq.get(boundary.seq),
1708
+ t,
1709
+ onJump: () => jumpToAnchor(store, boundary.seq),
1710
+ })),
1711
+ ]),
1712
+ ])
1713
+ }
1714
+
1715
+ /** One spine row; boundary rows additionally show the old-path cluster. */
1716
+ function ForkRow({ node, boundary, markerText, t, top, onJump }) {
1717
+ const isBoundary = boundary !== undefined
1718
+ const icon = isBoundary ? (KIND_ICONS[boundary.kind] ?? '•') : (NODE_ICONS[node.type] ?? '•')
1719
+ const label = isBoundary ? kindLabel(boundary.kind, t) : nodeTypeLabel(node.type, t)
1720
+ return createElement('div', {
1721
+ className: `dsh-rt-fork-row${isBoundary ? ' dsh-rt-fork-boundary' : ''}`,
1722
+ style: { top: `${top}px` },
1723
+ }, [
1724
+ createElement('span', { key: 'icon', className: 'dsh-rt-fork-icon' }, icon),
1725
+ createElement('div', { key: 'body', className: 'dsh-rt-fork-body' }, [
1726
+ createElement('div', { key: 'line', className: 'dsh-rt-fork-line' }, [
1727
+ createElement('span', { key: 'label', className: 'dsh-rt-fork-label' }, label),
1728
+ createElement('span', { key: 'seq', className: 'dsh-rt-fork-seq' }, `#${node.seq}`),
1729
+ isBoundary && boundary.replacedSeqs.length > 0 && createElement('span', { key: 'shadowed', className: 'dsh-rt-fork-shadowed' },
1730
+ t('fork.shadowed', { count: boundary.replacedSeqs.length })),
1731
+ ]),
1732
+ isBoundary && markerText && createElement('div', { key: 'text', className: 'dsh-rt-fork-text' },
1733
+ markerText.length > 140 ? `${markerText.slice(0, 140)}…` : markerText),
1734
+ ]),
1735
+ createElement('button', { key: 'jump', type: 'button', className: 'dsh-rt-chip', onClick: onJump }, t('timeline.jump')),
1736
+ ])
1737
+ }
1738
+
1739
+ /** A historical fork point whose marker was itself shadowed (not on the spine). */
1740
+ function ForkHistRow({ boundary, markerText, t, onJump }) {
1741
+ return createElement('div', { className: 'dsh-rt-fork-hist-row' }, [
1742
+ createElement('span', { key: 'icon', className: 'dsh-rt-fork-icon' }, KIND_ICONS[boundary.kind] ?? '•'),
1743
+ createElement('div', { key: 'body', className: 'dsh-rt-fork-body' }, [
1744
+ createElement('div', { key: 'line', className: 'dsh-rt-fork-line' }, [
1745
+ createElement('span', { key: 'label', className: 'dsh-rt-fork-label' }, kindLabel(boundary.kind, t)),
1746
+ createElement('span', { key: 'seq', className: 'dsh-rt-fork-seq' }, `#${boundary.seq}`),
1747
+ boundary.replacedSeqs.length > 0 && createElement('span', { key: 'shadowed', className: 'dsh-rt-fork-shadowed' },
1748
+ t('fork.shadowed', { count: boundary.replacedSeqs.length })),
1749
+ ]),
1750
+ markerText && createElement('div', { key: 'text', className: 'dsh-rt-fork-text' },
1751
+ markerText.length > 140 ? `${markerText.slice(0, 140)}…` : markerText),
1752
+ ]),
1753
+ createElement('button', { key: 'jump', type: 'button', className: 'dsh-rt-chip', onClick: onJump }, t('timeline.jump')),
1754
+ ])
1755
+ }
1756
+
1276
1757
  // ---------------------------------------------------------------------------
1277
1758
  // Plugin
1278
1759
  // ---------------------------------------------------------------------------
@@ -1284,9 +1765,17 @@ export function apply(ctx) {
1284
1765
 
1285
1766
  const conversationEvents = ctx.get('conversationEvents')
1286
1767
  if (conversationEvents) {
1287
- conversationEvents.register(userActionsDefinition)
1288
- conversationEvents.register(userReferenceDefinition)
1289
- conversationEvents.register(recallMarkerDefinition)
1768
+ // register() returns a disposer that removes the definition from the
1769
+ // registry service's context. It MUST be wired into ctx.effect — otherwise
1770
+ // the definition survives plugin unload/hot-reload and the next apply
1771
+ // throws "already registered" (0.4.x review: the plugin would crash after
1772
+ // one toggle in Settings).
1773
+ const disposeDefinitions = [
1774
+ conversationEvents.register(userActionsDefinition),
1775
+ conversationEvents.register(userReferenceDefinition),
1776
+ conversationEvents.register(recallMarkerDefinition),
1777
+ ]
1778
+ ctx.effect(() => () => disposeDefinitions.forEach((dispose) => dispose()), 'dsh-retrace: conversation definitions')
1290
1779
  }
1291
1780
 
1292
1781
  ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
@@ -1330,6 +1819,19 @@ export function apply(ctx) {
1330
1819
  }),
1331
1820
  }, RetraceView))
1332
1821
 
1822
+ // P2.1 — the fork-map view as a second official tab (next to 对话/轨迹/版本).
1823
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
1824
+ name: 'conversation.view',
1825
+ id: 'retrace-fork',
1826
+ order: 30,
1827
+ locale: NS,
1828
+ label: () => t('view.fork'),
1829
+ inject: (sessionId, actions) => ({
1830
+ actions,
1831
+ store: ctx.get?.('sessions')?.binding?.(sessionId)?.session,
1832
+ }),
1833
+ }, ForkView))
1834
+
1333
1835
  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
1334
1836
  name: 'settings.general.item',
1335
1837
  id: 'retrace',