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.bundle.js +435 -104
- package/lib/client.js +634 -132
- package/lib/dynamic-client.js +435 -104
- package/lib/forkmap.js +106 -0
- package/lib/http.js +14 -0
- package/lib/projection/forkmap.js +69 -0
- package/lib/versioning.js +21 -6
- package/package.json +1 -1
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 (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
444
|
-
const
|
|
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
|
-
|
|
451
|
-
//
|
|
452
|
-
//
|
|
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
|
-
/**
|
|
489
|
-
|
|
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
|
|
494
|
-
|
|
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
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
523
|
-
if (node.kind === 'recall-marker')
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
// The
|
|
1044
|
-
|
|
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: () =>
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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',
|