dsh-retrace 0.4.1 → 0.4.2

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
@@ -85,6 +85,7 @@ const zh = {
85
85
  'timeline.open': '时间线',
86
86
  'timeline.openAria': '打开会话时间线',
87
87
  'timeline.title': '版本时间线',
88
+ 'view.retrace': '版本',
88
89
  'timeline.refresh': '刷新',
89
90
  'timeline.close': '关闭',
90
91
  'timeline.empty': '还没有版本。撤回 / 编辑 / 重新生成会在此记录版本。',
@@ -117,6 +118,7 @@ const zh = {
117
118
  'timeline.cancel': '取消',
118
119
  'timeline.busy': '回退中…',
119
120
  'timeline.detail': '详情',
121
+ 'timeline.trajectory': '轨迹台账',
120
122
  'timeline.jump': '跳转',
121
123
  'timeline.jumpFailed': '该版本在较远的过去(超出自动加载预算),无法直接定位。请向上滚动加载更早消息后重试;或用「详情」查看该版本当时的事件原文。',
122
124
  'timeline.gitRepo': 'git 仓库',
@@ -159,6 +161,7 @@ const en = {
159
161
  'timeline.open': 'Timeline',
160
162
  'timeline.openAria': 'Open the session timeline',
161
163
  'timeline.title': 'Version timeline',
164
+ 'view.retrace': 'Versions',
162
165
  'timeline.refresh': 'Refresh',
163
166
  'timeline.close': 'Close',
164
167
  'timeline.empty': 'No versions yet. Recall / edit / regenerate record a version here.',
@@ -191,6 +194,7 @@ const en = {
191
194
  'timeline.cancel': 'Cancel',
192
195
  'timeline.busy': 'Rolling back…',
193
196
  'timeline.detail': 'Details',
197
+ 'timeline.trajectory': 'Trajectory',
194
198
  'timeline.jump': 'Jump',
195
199
  'timeline.jumpFailed': 'This version lies too far back (beyond the auto-load budget) to locate directly. Scroll up to load earlier messages, or use Details to read the original event text of this version.',
196
200
  'timeline.gitRepo': 'git repository',
@@ -884,17 +888,13 @@ const CSS = `
884
888
  .dsh-rt-retention-input{width:64px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:2px 6px;font:inherit;font-size:13px;outline:none;margin-top:1px}
885
889
  .dsh-rt-retention-input:focus{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
886
890
  .dsh-rt-option input{accent-color:var(--dsw-alias-state-business-primary)}
887
- /* ---- P1 timeline ---- */
888
- .dsh-rt-timeline{position:relative}
889
- .dsh-rt-timeline-trigger{min-height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:transparent;border:none;border-radius:6px;display:inline-flex;align-items:center;gap:4px;padding:3px 6px;font-size:12px;line-height:18px}
890
- .dsh-rt-timeline-trigger:hover,.dsh-rt-timeline-trigger:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
891
- .dsh-rt-timeline-icon{font-size:13px}
892
- .dsh-rt-timeline-panel{position:fixed;top:calc(100% + 6px);right:8px;z-index:2147483000;width:min(520px,calc(100vw - 32px));max-height:min(70vh,640px);box-sizing:border-box;display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-specific-menu);box-shadow:var(--dsw-shadow-lv3);border-radius:12px;padding:8px;overflow:hidden}
891
+ /* ---- 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}
893
893
  .dsh-rt-timeline-head{display:flex;align-items:center;gap:8px;flex:none}
894
894
  .dsh-rt-timeline-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;flex:1}
895
895
  .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}
896
896
  .dsh-rt-timeline-git-text{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px}
897
- .dsh-rt-timeline-list{overflow-y:auto;flex:none;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
897
+ .dsh-rt-timeline-list{overflow-y:auto;flex:1;min-height:0;position:relative;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
898
898
  .dsh-rt-timeline-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:12px 4px;text-align:center}
899
899
  .dsh-rt-version{position:absolute;left:0;right:0;height:60px;box-sizing:border-box;display:flex;align-items:flex-start;gap:8px;border:1px solid transparent;border-radius:10px;padding:6px 8px}
900
900
  .dsh-rt-version:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}
@@ -983,12 +983,16 @@ function fileCountLabel(counts, t) {
983
983
  }
984
984
 
985
985
  /**
986
- * The header action + floating timeline panel (PLAN §5.1). Rendered inside
987
- * `conversation.session.header.actions`; the panel is absolutely positioned
988
- * under the trigger so the chat stays mounted (jump-to-conversation needs it).
986
+ * P1 — Version timeline as an official conversation view tab (PLAN §5.1,
987
+ * 0.4.2 trajectory-leverage): rendered in the `conversation.view` slot (a tab
988
+ * next to 对话/轨迹). The versions data channel stays plugin-owned — the live
989
+ * `session/projection` push frames arrive through
990
+ * `useProjection('retrace/versions')` when present (zero polling), with the
991
+ * HTTP `/versions` route as fallback — because versions are DERIVED data the
992
+ * official event stream does not carry. The view shell, view switching,
993
+ * paging and the trajectory handoff reuse the official mechanisms.
989
994
  */
990
- function TimelinePanel({ sessionId, useSession, useProjection, useSessions, t, ctx }) {
991
- const [open, setOpen] = useState(false)
995
+ function RetraceView({ sessionId, useProjection, t, actions, store }) {
992
996
  const [versions, setVersions] = useState(null)
993
997
  const [loading, setLoading] = useState(false)
994
998
  const [error, setError] = useState(null)
@@ -997,49 +1001,15 @@ function TimelinePanel({ sessionId, useSession, useProjection, useSessions, t, c
997
1001
  const [preview, setPreview] = useState(null)
998
1002
  const [previewScope, setPreviewScope] = useState('both')
999
1003
  const [rollbackBusy, setRollbackBusy] = useState(false)
1000
- const [detail, setDetail] = useState(null)
1001
- const [jumpTarget, setJumpTarget] = useState(null)
1002
- const [jumpNotice, setJumpNotice] = useState(null)
1003
1004
  const [scrollTop, setScrollTop] = useState(0)
1004
- const [panelPos, setPanelPos] = useState(null)
1005
- const rootRef = { current: null }
1006
- const triggerRef = { current: null }
1007
- const snapRef = { current: null }
1008
1005
 
1009
1006
  // Live push-frame path (projection standard kit) — falls back to HTTP.
1010
1007
  const projected = typeof useProjection === 'function' ? useProjection('retrace/versions') : undefined
1011
- useSession((snapshot) => {
1012
- snapRef.current = snapshot
1013
- return snapshot.chat.nodes.size
1014
- })
1015
1008
 
1016
1009
  useEffect(() => {
1017
1010
  if (projected && Array.isArray(projected.versions)) setVersions(projected.versions)
1018
1011
  }, [projected])
1019
1012
 
1020
- /** Anchor the panel to the trigger's live viewport rect (fixed positioning). */
1021
- const anchorPanel = () => {
1022
- const rect = triggerRef.current?.getBoundingClientRect?.()
1023
- if (!rect) return
1024
- setPanelPos({
1025
- top: rect.bottom + 6,
1026
- right: Math.max(8, window.innerWidth - rect.right),
1027
- })
1028
- }
1029
-
1030
- // Re-anchor on open and while open (scroll/resize keep it under the trigger).
1031
- useEffect(() => {
1032
- if (!open) return
1033
- anchorPanel()
1034
- const onViewportChange = () => anchorPanel()
1035
- window.addEventListener('resize', onViewportChange)
1036
- window.addEventListener('scroll', onViewportChange, true)
1037
- return () => {
1038
- window.removeEventListener('resize', onViewportChange)
1039
- window.removeEventListener('scroll', onViewportChange, true)
1040
- }
1041
- }, [open])
1042
-
1043
1013
  const refresh = () => {
1044
1014
  setLoading(true)
1045
1015
  setError(null)
@@ -1059,91 +1029,82 @@ function TimelinePanel({ sessionId, useSession, useProjection, useSessions, t, c
1059
1029
  .catch(() => setGit(null))
1060
1030
  }
1061
1031
 
1062
- const toggle = () => {
1063
- const next = !open
1064
- setOpen(next)
1065
- if (next) {
1066
- if (projected === undefined) refresh()
1067
- refreshGit()
1068
- }
1069
- }
1070
-
1071
- // Dismiss on outside pointer (matching the jobs menu pattern).
1032
+ // Mount-time load: projection push frames arrive live when the host mounts
1033
+ // the registry; HTTP is the on-demand fallback for minimal compositions.
1072
1034
  useEffect(() => {
1073
- if (!open) return
1074
- const onPointerDown = (event) => {
1075
- if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false)
1076
- }
1077
- document.addEventListener('pointerdown', onPointerDown)
1078
- return () => document.removeEventListener('pointerdown', onPointerDown)
1079
- }, [open])
1080
-
1081
- // Jump: page up via the session store until the anchor is loaded, then the
1082
- // reactive effect below scrolls once the node appears. The store has no
1083
- // seek-to-seq API — only `loadOlder` (50/page) — so far-away targets are
1084
- // unreachable without thousands of pages; cap the budget and fall back to a
1085
- // clear notice (details drawer shows the version's event text instead).
1035
+ if (projected === undefined) refresh()
1036
+ refreshGit()
1037
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1038
+ }, [])
1039
+
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.
1086
1045
  const JUMP_PAGE_BUDGET = 24
1087
1046
  const jump = async (boundarySeq) => {
1088
- setJumpNotice(null)
1089
- setJumpTarget(boundarySeq)
1090
- const binding = ctx?.get?.('sessions')?.binding?.(sessionId)
1091
- const store = binding?.session
1092
- if (!store || typeof store.loadOlder !== 'function') return
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)
1093
1059
  let pages = 0
1094
- while (pages < JUMP_PAGE_BUDGET && store.hasMore) {
1095
- const before = snapRef.current?.chat?.nodes?.size ?? 0
1096
- await store.loadOlder()
1097
- const after = snapRef.current?.chat?.nodes?.size ?? 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
1098
1064
  pages += 1
1099
1065
  if (after === before) break // page returned nothing
1066
+ key = keyOfSeq(boundarySeq)
1100
1067
  }
1101
- // Budget exhausted without the anchor appearing → tell the user honestly.
1102
- // Give the last prepend a tick to propagate into the snapshot first.
1103
- await new Promise((resolve) => setTimeout(resolve, 60))
1104
- if (!nodePresent(boundarySeq)) {
1105
- setJumpNotice(t('timeline.jumpFailed'))
1106
- setJumpTarget(null)
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)
1107
1072
  }
1108
- }
1109
-
1110
- /** Whether any loaded chat node anchors at `seq`. */
1111
- function nodePresent(seq) {
1112
- const nodes = snapRef.current?.chat?.nodes
1113
- if (!nodes) return false
1114
- for (const node of nodes.values()) {
1115
- if (typeof node.anchorSeq === 'number' && node.anchorSeq === seq) return true
1073
+ if (key === null) {
1074
+ console.warn(`[dsh-retrace] jump target seq ${boundarySeq} not reached within ${JUMP_PAGE_BUDGET} pages`)
1075
+ return
1116
1076
  }
1117
- return false
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)
1118
1082
  }
1119
1083
 
1120
- const foundKey = useSession((snapshot) => {
1121
- if (jumpTarget === null) return null
1122
- for (const node of snapshot.chat.nodes.values()) {
1123
- if (typeof node.anchorSeq === 'number' && node.anchorSeq === jumpTarget) return node.key
1124
- }
1125
- return null
1126
- })
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
+ }
1127
1097
 
1128
- useEffect(() => {
1129
- if (foundKey === null || jumpTarget === null) return
1130
- const el = document.querySelector(`[data-chat-anchor-key=${JSON.stringify(foundKey)}]`)
1131
- if (!el) {
1132
- setJumpNotice(t('timeline.jumpFailed'))
1133
- setJumpTarget(null)
1134
- return
1135
- }
1136
- el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1137
- const id = `dsh-rt-jump-${foundKey.replace(/[^a-z0-9]/gi, '-')}`
1138
- if (!document.querySelector(`style[data-plugin-css="${id}"]`)) {
1139
- const tag = document.createElement('style')
1140
- tag.dataset.pluginCss = id
1141
- tag.textContent = `[data-chat-anchor-key=${JSON.stringify(foundKey)}]{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)}}`
1142
- document.head.appendChild(tag)
1143
- setTimeout(() => tag.remove(), 3400)
1144
- }
1145
- setJumpTarget(null)
1146
- }, [foundKey, jumpTarget])
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
+ }
1147
1108
 
1148
1109
  const requestPreview = (record) => {
1149
1110
  setPreviewScope('both')
@@ -1170,16 +1131,6 @@ function TimelinePanel({ sessionId, useSession, useProjection, useSessions, t, c
1170
1131
  })
1171
1132
  }
1172
1133
 
1173
- const openDetail = (record) => {
1174
- setDetail({ seq: record.boundarySeq, data: null, error: null })
1175
- timelineGet(`/event?sessionId=${encodeURIComponent(sessionId)}&seq=${record.boundarySeq}&before=2&after=2`)
1176
- .then((result) => {
1177
- setDetail((prev) => prev && prev.seq === record.boundarySeq
1178
- ? { ...prev, data: result?.ok === true ? result.value : null, error: result?.ok === true ? null : result?.error?.message ?? null }
1179
- : prev)
1180
- })
1181
- }
1182
-
1183
1134
  const initGit = () => {
1184
1135
  if (!window.confirm(t('timeline.gitInitConfirm'))) return
1185
1136
  setGitBusy(true)
@@ -1192,100 +1143,71 @@ function TimelinePanel({ sessionId, useSession, useProjection, useSessions, t, c
1192
1143
  // ---- windowed list (uniform rows, zero-dep) ----
1193
1144
  const ROW_H = 64
1194
1145
  const list = versions ?? []
1195
- const visible = list.slice(Math.max(0, Math.floor(scrollTop / ROW_H) - 2), Math.min(list.length, Math.ceil(scrollTop / ROW_H) + Math.ceil(420 / ROW_H) + 2))
1146
+ 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
+
1148
+ return createElement('div', { className: 'dsh-rt-view' }, [
1149
+ createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
1150
+ createElement('span', { key: 'title', className: 'dsh-rt-timeline-title' }, t('timeline.title')),
1151
+ createElement('button', {
1152
+ key: 'refresh',
1153
+ type: 'button',
1154
+ className: 'dsh-rt-chip',
1155
+ onClick: refresh,
1156
+ }, t('timeline.refresh')),
1157
+ ]),
1196
1158
 
1197
- return createElement('div', { className: 'dsh-rt-timeline', ref: rootRef }, [
1198
- createElement('button', {
1199
- key: 'trigger',
1200
- ref: triggerRef,
1201
- type: 'button',
1202
- className: 'dsh-rt-timeline-trigger',
1203
- title: t('timeline.open'),
1204
- 'aria-label': t('timeline.openAria'),
1205
- onClick: toggle,
1206
- }, [
1207
- createElement('span', { key: 'i', className: 'dsh-rt-timeline-icon' }, '⧉'),
1208
- createElement('span', { key: 'label', className: 'dsh-rt-timeline-label' }, t('timeline.open')),
1159
+ git !== null && git !== undefined && createElement('div', { key: 'git', className: 'dsh-rt-timeline-git' }, [
1160
+ git.headHash
1161
+ ? [
1162
+ createElement('span', { key: 'r', className: 'dsh-rt-timeline-git-text' }, `${t('timeline.gitRepo')} · ${t('timeline.gitHead', { hash: git.headHash.slice(0, 8) })}${git.dirty ? ` · ${t('timeline.gitDirty')}` : ''}`),
1163
+ ]
1164
+ : createElement('button', {
1165
+ key: 'init',
1166
+ type: 'button',
1167
+ className: 'dsh-rt-chip',
1168
+ disabled: gitBusy,
1169
+ onClick: initGit,
1170
+ title: t('timeline.gitInitDesc'),
1171
+ }, t('timeline.gitInit')),
1209
1172
  ]),
1210
1173
 
1211
- open && createElement('div', {
1212
- key: 'panel',
1213
- className: 'dsh-rt-timeline-panel',
1214
- style: panelPos ? { top: `${panelPos.top}px`, right: `${panelPos.right}px` } : undefined,
1215
- }, [
1216
- createElement('div', { key: 'head', className: 'dsh-rt-timeline-head' }, [
1217
- createElement('span', { key: 'title', className: 'dsh-rt-timeline-title' }, t('timeline.title')),
1218
- createElement('button', {
1219
- key: 'refresh',
1220
- type: 'button',
1221
- className: 'dsh-rt-chip',
1222
- onClick: refresh,
1223
- }, t('timeline.refresh')),
1224
- createElement('button', {
1225
- key: 'close',
1226
- type: 'button',
1227
- className: 'dsh-rt-chip',
1228
- onClick: () => setOpen(false),
1229
- }, t('timeline.close')),
1230
- ]),
1174
+ error !== null && createElement('div', { key: 'error', className: 'dsh-rt-error' }, error),
1175
+ loading && createElement('div', { key: 'loading', className: 'dsh-rt-timeline-empty' }, t('timeline.loading')),
1176
+ !loading && list.length === 0 && createElement('div', { key: 'empty', className: 'dsh-rt-timeline-empty' }, t('timeline.empty')),
1231
1177
 
1232
- git !== null && git !== undefined && createElement('div', { key: 'git', className: 'dsh-rt-timeline-git' }, [
1233
- git.headHash
1234
- ? [
1235
- createElement('span', { key: 'r', className: 'dsh-rt-timeline-git-text' }, `${t('timeline.gitRepo')} · ${t('timeline.gitHead', { hash: git.headHash.slice(0, 8) })}${git.dirty ? ` · ${t('timeline.gitDirty')}` : ''}`),
1236
- ]
1237
- : createElement('button', {
1238
- key: 'init',
1239
- type: 'button',
1240
- className: 'dsh-rt-chip',
1241
- disabled: gitBusy,
1242
- onClick: initGit,
1243
- title: t('timeline.gitInitDesc'),
1244
- }, t('timeline.gitInit')),
1245
- ]),
1246
-
1247
- error !== null && createElement('div', { key: 'error', className: 'dsh-rt-error' }, error),
1248
- loading && createElement('div', { key: 'loading', className: 'dsh-rt-timeline-empty' }, t('timeline.loading')),
1249
- !loading && list.length === 0 && createElement('div', { key: 'empty', className: 'dsh-rt-timeline-empty' }, t('timeline.empty')),
1250
-
1251
- list.length > 0 && createElement('div', {
1252
- key: 'list',
1253
- className: 'dsh-rt-timeline-list',
1254
- style: { height: `${Math.min(420, list.length * ROW_H)}px` },
1255
- onScroll: (event) => setScrollTop(event.target.scrollTop),
1256
- }, [
1257
- createElement('div', { key: 'spacer', style: { height: `${list.length * ROW_H}px`, position: 'relative' } }, [
1258
- visible.map((record) => createElement(VersionRow, {
1259
- key: record.versionId,
1260
- record,
1261
- t,
1262
- top: list.indexOf(record) * ROW_H,
1263
- onPreview: () => requestPreview(record),
1264
- onDetail: () => openDetail(record),
1265
- onJump: () => jump(record.boundarySeq),
1266
- })),
1267
- ]),
1178
+ list.length > 0 && createElement('div', {
1179
+ key: 'list',
1180
+ className: 'dsh-rt-timeline-list',
1181
+ onScroll: (event) => setScrollTop(event.target.scrollTop),
1182
+ }, [
1183
+ createElement('div', { key: 'spacer', style: { height: `${list.length * ROW_H}px`, position: 'relative' } }, [
1184
+ visible.map((record) => createElement(VersionRow, {
1185
+ key: record.versionId,
1186
+ record,
1187
+ t,
1188
+ top: list.indexOf(record) * ROW_H,
1189
+ onPreview: () => requestPreview(record),
1190
+ onTrajectory: () => actions?.setView?.('trajectory'),
1191
+ onJump: () => jump(record.boundarySeq),
1192
+ })),
1268
1193
  ]),
1269
-
1270
- preview && createElement(PreviewBox, {
1271
- key: 'preview',
1272
- preview,
1273
- scope: previewScope,
1274
- setScope: setPreviewScope,
1275
- busy: rollbackBusy,
1276
- t,
1277
- onConfirm: confirmRollback,
1278
- onCancel: () => setPreview(null),
1279
- }),
1280
-
1281
- detail && createElement(DetailBox, { key: 'detail', detail, t, onClose: () => setDetail(null) }),
1282
- jumpNotice !== null && createElement('div', { key: 'jump', className: 'dsh-rt-error' }, jumpNotice),
1283
1194
  ]),
1195
+
1196
+ preview && createElement(PreviewBox, {
1197
+ key: 'preview',
1198
+ preview,
1199
+ scope: previewScope,
1200
+ setScope: setPreviewScope,
1201
+ busy: rollbackBusy,
1202
+ t,
1203
+ onConfirm: confirmRollback,
1204
+ onCancel: () => setPreview(null),
1205
+ }),
1284
1206
  ])
1285
1207
  }
1286
1208
 
1287
1209
  /** One version row (uniform height for the windowed list). */
1288
- function VersionRow({ record, top, t, onPreview, onDetail, onJump }) {
1210
+ function VersionRow({ record, top, t, onPreview, onTrajectory, onJump }) {
1289
1211
  return createElement('div', { className: 'dsh-rt-version', style: { top: `${top}px` } }, [
1290
1212
  createElement('span', { key: 'kind', className: `dsh-rt-version-kind dsh-rt-version-kind-${record.kind}`, title: kindLabel(record.kind, t) }, KIND_ICONS[record.kind] ?? '•'),
1291
1213
  createElement('div', { key: 'body', className: 'dsh-rt-version-body' }, [
@@ -1300,8 +1222,8 @@ function VersionRow({ record, top, t, onPreview, onDetail, onJump }) {
1300
1222
  : null,
1301
1223
  ]),
1302
1224
  createElement('span', { key: 'actions', className: 'dsh-rt-version-actions' }, [
1303
- createElement('button', { key: 'detail', type: 'button', className: 'dsh-rt-chip', onClick: onDetail }, t('timeline.detail')),
1304
1225
  createElement('button', { key: 'jump', type: 'button', className: 'dsh-rt-chip', onClick: onJump }, t('timeline.jump')),
1226
+ createElement('button', { key: 'trajectory', type: 'button', className: 'dsh-rt-chip', onClick: onTrajectory, title: t('timeline.trajectory') }, t('timeline.trajectory')),
1305
1227
  createElement('button', { key: 'preview', type: 'button', className: 'dsh-rt-chip dsh-rt-chip-danger', onClick: onPreview }, t('timeline.preview')),
1306
1228
  ]),
1307
1229
  ])
@@ -1351,19 +1273,6 @@ function PreviewBox({ preview, scope, setScope, busy, t, onConfirm, onCancel })
1351
1273
  ])
1352
1274
  }
1353
1275
 
1354
- /** Lazy event viewer (GET /event). */
1355
- function DetailBox({ detail, t, onClose }) {
1356
- return createElement('div', { className: 'dsh-rt-modal' }, [
1357
- createElement('div', { key: 'title', className: 'dsh-rt-modal-title' }, [
1358
- `${t('timeline.detail')} · seq ${detail.seq}`,
1359
- createElement('button', { key: 'close', type: 'button', className: 'dsh-rt-chip', onClick: onClose }, t('timeline.close')),
1360
- ]),
1361
- detail.error && createElement('div', { key: 'err', className: 'dsh-rt-error' }, detail.error),
1362
- detail.data === null && !detail.error && createElement('div', { key: 'wait', className: 'dsh-rt-timeline-empty' }, t('timeline.loading')),
1363
- detail.data && createElement('pre', { key: 'json', className: 'dsh-rt-modal-json' }, JSON.stringify(detail.data, null, 2)),
1364
- ])
1365
- }
1366
-
1367
1276
  // ---------------------------------------------------------------------------
1368
1277
  // Plugin
1369
1278
  // ---------------------------------------------------------------------------
@@ -1371,6 +1280,7 @@ export function apply(ctx) {
1371
1280
  const disposeStyle = ensureStyle()
1372
1281
  ctx.effect(() => () => disposeStyle(), 'dsh-retrace: styles')
1373
1282
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-retrace: dictionaries')
1283
+ const t = ctx.locale.bind(NS)
1374
1284
 
1375
1285
  const conversationEvents = ctx.get('conversationEvents')
1376
1286
  if (conversationEvents) {
@@ -1404,13 +1314,21 @@ export function apply(ctx) {
1404
1314
  locale: NS,
1405
1315
  }, RecallMarkerRow))
1406
1316
 
1407
- // P1 — timeline entry in the session header (order ≈30, after the session crumb).
1408
- ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
1409
- name: 'conversation.session.header.actions',
1410
- id: 'retrace-timeline',
1411
- order: 30,
1317
+ // P0 (0.4.2, trajectory-leverage) — the version timeline as an official
1318
+ // conversation view tab (next to 对话/轨迹). The view shell + switching are
1319
+ // the official mechanisms; the versions data channel stays plugin-owned
1320
+ // (projection push frames → HTTP /versions fallback).
1321
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
1322
+ name: 'conversation.view',
1323
+ id: 'retrace',
1324
+ order: 20,
1412
1325
  locale: NS,
1413
- }, (props) => TimelinePanel({ ...props, ctx })))
1326
+ label: () => t('view.retrace'),
1327
+ inject: (sessionId, actions) => ({
1328
+ actions,
1329
+ store: ctx.get?.('sessions')?.binding?.(sessionId)?.session,
1330
+ }),
1331
+ }, RetraceView))
1414
1332
 
1415
1333
  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
1416
1334
  name: 'settings.general.item',