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