dsh-retrace 0.3.0 → 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
@@ -23,7 +23,36 @@ export const inject = ['slots', 'locale', 'conversationEvents']
23
23
  const NS = 'retrace'
24
24
  const ROUTE_BASE = '/api/plugins/retrace'
25
25
  const MARKER_PREFIX = 'retrace'
26
+ /**
27
+ * Every marker-id prefix this build can recognise. `MARKER_PREFIX` is the
28
+ * prefix NEW markers are written with; the rest are LEGACY prefixes produced
29
+ * by earlier plugin names (dsh-message-editor). RENAME RULE: when the plugin
30
+ * changes its identity again, keep the old prefix in this list (and in
31
+ * LEGACY_MARKER_PREFIXES) so markers written under the previous name keep
32
+ * rendering — recognition must never be broken by a rename.
33
+ */
34
+ const MARKER_PREFIXES = [MARKER_PREFIX, 'message-editor']
35
+ /**
36
+ * Legacy prefixes whose markers are treated as annotations only: the notice
37
+ * and the "original input" reference render, but their shadowed ranges are
38
+ * NEVER hidden. Rationale: a marker written by an older plugin name may not
39
+ * match this build's edit semantics; hiding content based on it would
40
+ * silently re-hide conversations the user already sees. Renames must ADD the
41
+ * retired prefix here (soft-compat: renamed-era markers become annotations,
42
+ * so a rename can never hide previously visible content).
43
+ */
44
+ const LEGACY_MARKER_PREFIXES = ['message-editor']
45
+ /** Recognise any marker id this build can interpret, current or legacy. */
46
+ function isMarkerId(id) {
47
+ return typeof id === 'string' && MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))
48
+ }
49
+ /** Whether a marker id was written under a legacy (renamed-away) prefix. */
50
+ function isLegacyMarkerId(id) {
51
+ return typeof id === 'string' && LEGACY_MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))
52
+ }
26
53
  const CONFIG_KEY = 'dsh-retrace:config'
54
+ /** Config keys of renamed-away plugin names, read as a fallback on first load. */
55
+ const LEGACY_CONFIG_KEYS = ['dsh-message-editor:config']
27
56
 
28
57
  /** Simplified Chinese dictionary (key-set source of truth). */
29
58
  const zh = {
@@ -44,13 +73,60 @@ const zh = {
44
73
  'marker.referenceHint': '点击展开查看原提问(仅作对照,不会进入模型上下文)',
45
74
  'options.title': '消息编辑插件',
46
75
  'options.showOriginalInput': '编辑后显示原提问对照',
47
- 'options.editFromScratch': '编辑后从新对话开始(隐藏此前的消息)',
76
+ 'options.editFromScratch': '编辑后从新对话开始(隐藏此前的消息,默认关)',
77
+ 'options.hideShadowed': '按标记隐藏被编辑/撤回的消息',
78
+ 'options.hideShadowedDesc': '开(默认):撤回/编辑/重新生成按标记隐藏被替换的那一轮消息。关:所有消息保持可见,标记仅显示提示与对照(查看完整历史用)。一次操作要隐藏超过 40% 的对话时自动降级为不隐藏。',
48
79
  'options.versioning': '版本与产物快照',
49
80
  'options.versioningDesc': '开:每次撤回/编辑记录一个版本(消息与触碰文件),提供时间线与产物回退;关:仅回退上下文,不记录版本、不追踪产物(最省资源)。',
50
81
  'options.git': '启用 git 集成',
51
82
  'options.gitDesc': '开:工作区是 git 仓库时用 git 记录与回退(不自动提交、不动你的分支),非仓库可在时间线里一键启用;关:一律用内置快照(存于 ~/.dsh),不触碰工作区 git 状态,功能等价。',
52
83
  'options.retention': '版本保留上限',
53
84
  'options.retentionDesc': '文件快照只保留最近 N 个版本,超出自动清理最旧的;时间线记录与审计痕迹始终保留。',
85
+ 'timeline.open': '时间线',
86
+ 'timeline.openAria': '打开会话时间线',
87
+ 'timeline.title': '版本时间线',
88
+ 'view.retrace': '版本',
89
+ 'timeline.refresh': '刷新',
90
+ 'timeline.close': '关闭',
91
+ 'timeline.empty': '还没有版本。撤回 / 编辑 / 重新生成会在此记录版本。',
92
+ 'timeline.loading': '加载中…',
93
+ 'timeline.error': '时间线加载失败',
94
+ 'timeline.kind.recall': '撤回',
95
+ 'timeline.kind.edit': '编辑重发',
96
+ 'timeline.kind.regenerate': '重新生成',
97
+ 'timeline.kind.restore': '恢复',
98
+ 'timeline.kind.compaction': '压缩',
99
+ 'timeline.kind.replace': '替换',
100
+ 'timeline.messages': '{count} 条消息',
101
+ 'timeline.files': '{created} 增 / {modified} 改 / {deleted} 删',
102
+ 'timeline.filesNone': '无文件变更',
103
+ 'timeline.preview': '回退预览',
104
+ 'timeline.previewDesc': '将回退到版本 {version}({kind})。',
105
+ 'timeline.contextOnly': '仅对话',
106
+ 'timeline.contextOnlyDesc': '移除该版本之后的消息(保留日志审计痕迹)',
107
+ 'timeline.artifactsOnly': '仅产物',
108
+ 'timeline.artifactsOnlyDesc': '把该版本触碰的文件恢复到当时的內容',
109
+ 'timeline.both': '两者',
110
+ 'timeline.bothDesc': '先回退对话,再回退产物',
111
+ 'timeline.messagesRemoved': '将移除 {count} 条消息',
112
+ 'timeline.noChanges': '当前已在该版本状态,无变化',
113
+ 'timeline.artifactsList': '产物动作({count})',
114
+ 'timeline.artifact.restore': '恢复',
115
+ 'timeline.artifact.delete': '删除',
116
+ 'timeline.artifact.skip': '跳过',
117
+ 'timeline.confirm': '确认回退',
118
+ 'timeline.cancel': '取消',
119
+ 'timeline.busy': '回退中…',
120
+ 'timeline.detail': '详情',
121
+ 'timeline.trajectory': '轨迹台账',
122
+ 'timeline.jump': '跳转',
123
+ 'timeline.jumpFailed': '该版本在较远的过去(超出自动加载预算),无法直接定位。请向上滚动加载更早消息后重试;或用「详情」查看该版本当时的事件原文。',
124
+ 'timeline.gitRepo': 'git 仓库',
125
+ 'timeline.gitHead': 'HEAD {hash}',
126
+ 'timeline.gitDirty': '工作区有未提交改动',
127
+ 'timeline.gitInit': '启用 git 版本管理',
128
+ 'timeline.gitInitDesc': '在工作区执行 git init(仅添加 .gitignore 与一个基线提交),版本回退将优先使用 git。',
129
+ 'timeline.gitInitConfirm': '确定要初始化 git 吗?',
54
130
  'error.generic': '操作失败,请重试',
55
131
  'error.busy': '请先停止当前回复再操作',
56
132
  }
@@ -73,13 +149,60 @@ const en = {
73
149
  'marker.referenceHint': 'Click to expand the original input (reference only, never sent to the model)',
74
150
  'options.title': 'Message editor plugin',
75
151
  'options.showOriginalInput': 'Show the original input after editing',
76
- 'options.editFromScratch': 'Start a fresh conversation after editing (hide earlier messages)',
152
+ 'options.editFromScratch': 'Start a fresh conversation after editing (hide earlier messages, default off)',
153
+ 'options.hideShadowed': 'Hide shadowed messages per marker',
154
+ 'options.hideShadowedDesc': 'On (default): recall/edit/regenerate hide the replaced round per their markers. Off: every message stays visible; markers only show the notice and reference (use to review full history). A single op that would hide more than 40% of the conversation degrades to notice-only automatically.',
77
155
  'options.versioning': 'Version & artifact snapshots',
78
156
  'options.versioningDesc': 'On: every recall/edit records a version (messages and touched files) powering the timeline and artifact rollback. Off: only rewinds context — no version records, no artifact tracking (lightest).',
79
157
  'options.git': 'Git integration',
80
158
  'options.gitDesc': 'On: uses git to record and roll back when the workspace is a repository (never auto-commits, never touches your branches); non-repo workspaces can enable git from the timeline. Off: built-in snapshots under ~/.dsh only — the plugin never touches the workspace git state; equivalent features.',
81
159
  'options.retention': 'Version retention limit',
82
160
  'options.retentionDesc': 'File snapshots are kept for the most recent N versions; older ones are pruned automatically (timeline records and the audit trail are always kept).',
161
+ 'timeline.open': 'Timeline',
162
+ 'timeline.openAria': 'Open the session timeline',
163
+ 'timeline.title': 'Version timeline',
164
+ 'view.retrace': 'Versions',
165
+ 'timeline.refresh': 'Refresh',
166
+ 'timeline.close': 'Close',
167
+ 'timeline.empty': 'No versions yet. Recall / edit / regenerate record a version here.',
168
+ 'timeline.loading': 'Loading…',
169
+ 'timeline.error': 'Failed to load the timeline',
170
+ 'timeline.kind.recall': 'Recall',
171
+ 'timeline.kind.edit': 'Edit & resend',
172
+ 'timeline.kind.regenerate': 'Regenerate',
173
+ 'timeline.kind.restore': 'Restore',
174
+ 'timeline.kind.compaction': 'Compaction',
175
+ 'timeline.kind.replace': 'Replace',
176
+ 'timeline.messages': '{count} messages',
177
+ 'timeline.files': '{created} created / {modified} modified / {deleted} deleted',
178
+ 'timeline.filesNone': 'No file changes',
179
+ 'timeline.preview': 'Rollback preview',
180
+ 'timeline.previewDesc': 'Will roll back to version {version} ({kind}).',
181
+ 'timeline.contextOnly': 'Context only',
182
+ 'timeline.contextOnlyDesc': 'Remove messages after this version (the log audit trail stays)',
183
+ 'timeline.artifactsOnly': 'Artifacts only',
184
+ 'timeline.artifactsOnlyDesc': 'Restore the files this version touched to their state at that version',
185
+ 'timeline.both': 'Both',
186
+ 'timeline.bothDesc': 'Roll back the context first, then the artifacts',
187
+ 'timeline.messagesRemoved': '{count} messages will be removed',
188
+ 'timeline.noChanges': 'Already at this version; nothing to change',
189
+ 'timeline.artifactsList': 'Artifact actions ({count})',
190
+ 'timeline.artifact.restore': 'restore',
191
+ 'timeline.artifact.delete': 'delete',
192
+ 'timeline.artifact.skip': 'skip',
193
+ 'timeline.confirm': 'Confirm rollback',
194
+ 'timeline.cancel': 'Cancel',
195
+ 'timeline.busy': 'Rolling back…',
196
+ 'timeline.detail': 'Details',
197
+ 'timeline.trajectory': 'Trajectory',
198
+ 'timeline.jump': 'Jump',
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.',
200
+ 'timeline.gitRepo': 'git repository',
201
+ 'timeline.gitHead': 'HEAD {hash}',
202
+ 'timeline.gitDirty': 'working tree has uncommitted changes',
203
+ 'timeline.gitInit': 'Enable git versioning',
204
+ 'timeline.gitInitDesc': 'Runs git init in the workspace (adds a minimal .gitignore and a baseline commit); rollback will prefer git.',
205
+ 'timeline.gitInitConfirm': 'Initialize git in this workspace?',
83
206
  'error.generic': 'Operation failed; please try again',
84
207
  'error.busy': 'Stop the current reply before recalling or editing',
85
208
  }
@@ -128,17 +251,67 @@ function retraceConfigHeaders() {
128
251
  // ---------------------------------------------------------------------------
129
252
  // Plugin preferences (localStorage-backed, reactive)
130
253
  // ---------------------------------------------------------------------------
131
- const CONFIG_DEFAULTS = { showOriginalInput: true, editFromScratch: true, versioning: true, git: true, retentionLimit: 50 }
254
+ /**
255
+ * v3 defaults (2026-08-26 incident fix, finalized): v1's default
256
+ * `editFromScratch: true` made a single edit hide the WHOLE conversation before
257
+ * the edit point, so clicking "load earlier" appeared to load nothing. v3 keeps
258
+ * normal edit UX while making the wipe impossible:
259
+ * - `editFromScratch: false` — editing one message no longer rewinds the whole
260
+ * conversation; only the edited round is replaced on the model surface.
261
+ * - `hideShadowed: true` — the replaced round of an edit/recall/regenerate is
262
+ * hidden from the view (the natural "old message disappears" UX), while the
263
+ * safety guard in `useHiddenKeys` (40% of the conversation) refuses any
264
+ * single marker from blanking out most of the history.
265
+ */
266
+ const CONFIG_VERSION = 3
267
+ const CONFIG_DEFAULTS = { version: CONFIG_VERSION, showOriginalInput: true, editFromScratch: false, hideShadowed: true, versioning: true, git: true, retentionLimit: 50, prewrite: true }
132
268
  const configListeners = new Set()
133
269
  let configCache = readConfig()
134
270
 
135
271
  /** resendMessageId -> the exact text that edit replaced (most recent, host-authoritative). */
136
272
  const editReferences = new Map()
137
273
 
274
+ /**
275
+ * Pre-v3 migration: the destructive default `editFromScratch: true` is always
276
+ * reset to false; `hideShadowed` is reset to true (the natural edit UX) for
277
+ * configs written under the interim v2 defaults (where it was force-false).
278
+ * Every other customization is preserved.
279
+ */
280
+ function migrateConfig(parsed) {
281
+ const version = typeof parsed.version === 'number' ? parsed.version : 1
282
+ if (version >= CONFIG_VERSION) return { ...CONFIG_DEFAULTS, ...parsed }
283
+ return {
284
+ ...CONFIG_DEFAULTS,
285
+ ...parsed,
286
+ version: CONFIG_VERSION,
287
+ editFromScratch: CONFIG_DEFAULTS.editFromScratch,
288
+ hideShadowed: CONFIG_DEFAULTS.hideShadowed,
289
+ }
290
+ }
291
+
138
292
  function readConfig() {
139
293
  try {
140
294
  const raw = localStorage.getItem(CONFIG_KEY)
141
- return { ...CONFIG_DEFAULTS, ...(raw ? JSON.parse(raw) : {}) }
295
+ if (raw !== null) {
296
+ const parsed = raw ? JSON.parse(raw) : {}
297
+ const migrated = migrateConfig(parsed)
298
+ const storedVersion = typeof parsed.version === 'number' ? parsed.version : 1
299
+ if (storedVersion < CONFIG_VERSION) {
300
+ // Persist the migration so the next load starts from the v3 shape.
301
+ try { localStorage.setItem(CONFIG_KEY, JSON.stringify(migrated)) } catch { /* storage unavailable */ }
302
+ }
303
+ return migrated
304
+ }
305
+ // Rename-safe fallback: pick up settings saved under a previous plugin name.
306
+ for (const legacyKey of LEGACY_CONFIG_KEYS) {
307
+ const legacyRaw = localStorage.getItem(legacyKey)
308
+ if (legacyRaw !== null) {
309
+ const migrated = migrateConfig(legacyRaw ? JSON.parse(legacyRaw) : {})
310
+ try { localStorage.setItem(CONFIG_KEY, JSON.stringify(migrated)) } catch { /* storage unavailable */ }
311
+ return migrated
312
+ }
313
+ }
314
+ return { ...CONFIG_DEFAULTS }
142
315
  } catch {
143
316
  return { ...CONFIG_DEFAULTS }
144
317
  }
@@ -240,9 +413,11 @@ const userReferenceDefinition = {
240
413
  }
241
414
 
242
415
  function markerOpFromId(id) {
243
- if (id.startsWith(`${MARKER_PREFIX}-recall-`)) return 'recall'
244
- if (id.startsWith(`${MARKER_PREFIX}-edit-`)) return 'edit'
245
- if (id.startsWith(`${MARKER_PREFIX}-regenerate-`)) return 'regenerate'
416
+ for (const p of MARKER_PREFIXES) {
417
+ if (id.startsWith(`${p}-recall-`)) return 'recall'
418
+ if (id.startsWith(`${p}-edit-`)) return 'edit'
419
+ if (id.startsWith(`${p}-regenerate-`)) return 'regenerate'
420
+ }
246
421
  return 'edit'
247
422
  }
248
423
 
@@ -250,6 +425,9 @@ function markerOpFromId(id) {
250
425
  * The recall/edit/regenerate marker node. Renders a notice row and injects CSS
251
426
  * that hides every shadowed message row (they stay in the durable log as an
252
427
  * audit trail but disappear from the flow, so view and model context agree).
428
+ * Legacy-prefix markers (written under a renamed-away plugin name) render as
429
+ * annotations only: their shadowed ranges are never hidden, so a rename can
430
+ * never make previously visible content disappear.
253
431
  */
254
432
  const recallMarkerDefinition = {
255
433
  kind: 'recall-marker',
@@ -257,16 +435,22 @@ const recallMarkerDefinition = {
257
435
  match: (event) => {
258
436
  if (event.type !== 'assistant/message' || !isReplacementSurfaceEvent(event)) return null
259
437
  const id = event.data?.message?.id
260
- if (typeof id !== 'string' || !id.startsWith(`${MARKER_PREFIX}-`)) return null
438
+ if (!isMarkerId(id)) return null
261
439
  return { id: `marker:${id}`, role: 'start' }
262
440
  },
263
441
  start: (_context, match) => {
264
442
  const event = match.event
443
+ const id = String(event.data.message.id)
444
+ const legacy = isLegacyMarkerId(id)
265
445
  return {
266
446
  seq: event.seq,
267
447
  time: event.time,
268
- op: markerOpFromId(String(event.data.message.id)),
269
- shadowedSeqs: Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
448
+ op: markerOpFromId(id),
449
+ legacy,
450
+ // Legacy markers never hide: treat their shadowed range as empty so the
451
+ // notice/reference render but no row is hidden and no action row is
452
+ // suppressed via useShadowed.
453
+ shadowedSeqs: legacy ? [] : (Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : []),
270
454
  targetSeq: event.data?.editor?.targetSeq,
271
455
  text: event.data?.editor?.text,
272
456
  }
@@ -315,6 +499,14 @@ function useShadowed(useSession, seq) {
315
499
  })
316
500
  }
317
501
 
502
+ /**
503
+ * If one marker would hide more than this share of the conversation's rows,
504
+ * the hiding is refused (the marker still renders as a notice). A single
505
+ * recall/edit must never blank out most of the history — the rows stay in the
506
+ * durable log and remain visible.
507
+ */
508
+ const SHADOW_SAFETY_RATIO = 0.4
509
+
318
510
  /**
319
511
  * Every chat-node key that should disappear when `shadowedSeqs` are recalled:
320
512
  * the shadowed message rows themselves, plus the per-turn action row (copy /
@@ -325,7 +517,9 @@ function useHiddenKeys(useSession, shadowedSeqs) {
325
517
  if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null
326
518
  const hidden = new Set(shadowedSeqs)
327
519
  const keys = []
520
+ let rowCount = 0
328
521
  for (const node of snapshot.chat.nodes.values()) {
522
+ if (typeof node.anchorSeq === 'number') rowCount += 1
329
523
  if (node.kind === 'recall-marker') continue
330
524
  if (node.kind === 'turn-tail') {
331
525
  // `closing` is the finalized assistant-step *data*; the message seq
@@ -344,6 +538,10 @@ function useHiddenKeys(useSession, shadowedSeqs) {
344
538
  }
345
539
  if (typeof node.anchorSeq === 'number' && hidden.has(node.anchorSeq)) keys.push(node.key)
346
540
  }
541
+ // Safety guard (2026-08-26 incident): a marker covering a huge share of the
542
+ // conversation (e.g. an edit with "fresh conversation" semantics) degrades
543
+ // to notice-only — history must never silently vanish from the view.
544
+ if (keys.length > 0 && rowCount > 0 && keys.length / rowCount > SHADOW_SAFETY_RATIO) return null
347
545
  return keys.length === 0 ? null : keys
348
546
  })
349
547
  }
@@ -573,12 +771,13 @@ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
573
771
 
574
772
  /** The transient notice row: hides shadowed content, dismissed after the user keeps typing. */
575
773
  function RecallMarkerRow({ node, useSession, t }) {
576
- const { seq, op, shadowedSeqs } = node.data
774
+ const { seq, op, shadowedSeqs, legacy } = node.data
577
775
  const dismissed = useMarkerDismissed(useSession, seq, op)
578
- const hiddenKeys = useHiddenKeys(useSession, shadowedSeqs)
776
+ const hiddenKeys = legacy || !getConfig().hideShadowed ? null : useHiddenKeys(useSession, shadowedSeqs)
579
777
 
580
778
  // The hide rules must stay mounted even after the notice is dismissed,
581
- // otherwise the recalled message would reappear.
779
+ // otherwise the recalled message would reappear. Legacy markers and the
780
+ // `hideShadowed: off` preference never inject hide rules.
582
781
  const css = hiddenKeys === null
583
782
  ? null
584
783
  : hiddenKeys.map((key) => `[data-chat-anchor-key=${JSON.stringify(key)}]{display:none!important}`).join('')
@@ -622,6 +821,7 @@ function OptionsRow({ t }) {
622
821
  }),
623
822
  createElement('span', null, t('options.editFromScratch')),
624
823
  ]),
824
+ optionRow('hideShadowed', 'options.hideShadowed', 'options.hideShadowedDesc'),
625
825
  optionRow('versioning', 'options.versioning', 'options.versioningDesc'),
626
826
  optionRow('git', 'options.git', 'options.gitDesc'),
627
827
  createElement('div', { key: 'retention', className: 'dsh-rt-option dsh-rt-option-number' }, [
@@ -688,6 +888,44 @@ const CSS = `
688
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}
689
889
  .dsh-rt-retention-input:focus{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
690
890
  .dsh-rt-option input{accent-color:var(--dsw-alias-state-business-primary)}
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
+ .dsh-rt-timeline-head{display:flex;align-items:center;gap:8px;flex:none}
894
+ .dsh-rt-timeline-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;flex:1}
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
+ .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:1;min-height:0;position:relative;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
898
+ .dsh-rt-timeline-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:12px 4px;text-align:center}
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
+ .dsh-rt-version:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}
901
+ .dsh-rt-version-kind{flex:none;width:22px;height:22px;display:inline-flex;justify-content:center;align-items:center;border-radius:6px;background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:1px}
902
+ .dsh-rt-version-kind-restore{background:var(--dsw-alias-state-success-bg);color:var(--dsw-alias-state-success-primary)}
903
+ .dsh-rt-version-kind-compaction{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-caption)}
904
+ .dsh-rt-version-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
905
+ .dsh-rt-version-line{display:flex;align-items:center;gap:8px;min-width:0;white-space:nowrap}
906
+ .dsh-rt-version-kind-label{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;line-height:16px}
907
+ .dsh-rt-version-time,.dsh-rt-version-msgs{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
908
+ .dsh-rt-version-files{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis}
909
+ .dsh-rt-version-text{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
910
+ .dsh-rt-version-actions{flex:none;display:inline-flex;gap:4px;opacity:0;transition:opacity .1s}
911
+ .dsh-rt-version:hover .dsh-rt-version-actions{opacity:1}
912
+ .dsh-rt-chip-danger{color:var(--dsw-alias-state-error-primary)}
913
+ .dsh-rt-chip-danger:hover{color:var(--dsw-alias-state-error-primary)}
914
+ .dsh-rt-modal{position:absolute;inset:0;z-index:130;display:flex;flex-direction:column;gap:8px;box-sizing:border-box;border-radius:12px;background:var(--dsw-specific-menu);padding:10px;box-shadow:var(--dsw-shadow-lv3)}
915
+ .dsh-rt-modal-title{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}
916
+ .dsh-rt-modal-sub{color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}
917
+ .dsh-rt-modal-body{display:flex;flex-direction:column;gap:6px;overflow-y:auto;min-height:0}
918
+ .dsh-rt-modal-line{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
919
+ .dsh-rt-modal-files{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;max-height:160px;overflow-y:auto}
920
+ .dsh-rt-modal-files li{display:flex;gap:8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
921
+ .dsh-rt-art-restore{color:var(--dsw-alias-state-success-primary);flex:none}
922
+ .dsh-rt-art-delete{color:var(--dsw-alias-state-error-primary);flex:none}
923
+ .dsh-rt-art-skip{color:var(--dsw-alias-label-caption);flex:none}
924
+ .dsh-rt-art-path{font-family:var(--dsw-font-mono);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
925
+ .dsh-rt-modal-scope{display:flex;flex-direction:column;gap:4px}
926
+ .dsh-rt-modal-buttons{display:flex;justify-content:flex-end;gap:8px;flex:none}
927
+ .dsh-rt-confirm{background:var(--dsw-alias-state-error-primary)}
928
+ .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}
691
929
  `
692
930
 
693
931
  function ensureStyle() {
@@ -703,6 +941,338 @@ function ensureStyle() {
703
941
  }
704
942
  }
705
943
 
944
+ // ---------------------------------------------------------------------------
945
+ // P1 — Version timeline (header action + floating panel)
946
+ // ---------------------------------------------------------------------------
947
+ // Data sources, per PLAN.md §5.1: the live `session/projection` push frames
948
+ // arrive through the `useProjection` standard kit when present (zero polling);
949
+ // the HTTP `/versions` route is the fallback (opened on demand + manual
950
+ // refresh). Detail reads are lazy `GET /event`; rollback runs
951
+ // `POST /rollback/preview` → confirm → `POST /rollback`; the git banner uses
952
+ // `GET /git/status` and `POST /git/init`. The list window is a zero-dependency
953
+ // fixed-row virtualizer (uniform rows) — @tanstack/react-virtual was evaluated
954
+ // but bundling it (~15 KiB) for a list this size is not worth it.
955
+
956
+ const KIND_ICONS = { recall: '↩', edit: '✎', regenerate: '↻', restore: '⟲', compaction: '▤', replace: '⇄' }
957
+
958
+ function kindLabel(kind, t) {
959
+ return t(`timeline.kind.${kind}`) || kind
960
+ }
961
+
962
+ function timeLabel(ms) {
963
+ const date = new Date(ms)
964
+ return `${date.getMonth() + 1}-${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
965
+ }
966
+
967
+ function timelineGet(path) {
968
+ return fetch(`${ROUTE_BASE}${path}`, { headers: retraceConfigHeaders() }).then((res) => {
969
+ if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`)
970
+ return res.json()
971
+ })
972
+ }
973
+
974
+ /** Format a file-count badge. */
975
+ function fileCountLabel(counts, t) {
976
+ const total = (counts?.created ?? 0) + (counts?.modified ?? 0) + (counts?.deleted ?? 0)
977
+ if (total === 0) return t('timeline.filesNone')
978
+ return t('timeline.files', {
979
+ created: counts.created ?? 0,
980
+ modified: counts.modified ?? 0,
981
+ deleted: counts.deleted ?? 0,
982
+ })
983
+ }
984
+
985
+ /**
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.
994
+ */
995
+ function RetraceView({ sessionId, useProjection, t, actions, store }) {
996
+ const [versions, setVersions] = useState(null)
997
+ const [loading, setLoading] = useState(false)
998
+ const [error, setError] = useState(null)
999
+ const [git, setGit] = useState(null)
1000
+ const [gitBusy, setGitBusy] = useState(false)
1001
+ const [preview, setPreview] = useState(null)
1002
+ const [previewScope, setPreviewScope] = useState('both')
1003
+ const [rollbackBusy, setRollbackBusy] = useState(false)
1004
+ const [scrollTop, setScrollTop] = useState(0)
1005
+
1006
+ // Live push-frame path (projection standard kit) — falls back to HTTP.
1007
+ const projected = typeof useProjection === 'function' ? useProjection('retrace/versions') : undefined
1008
+
1009
+ useEffect(() => {
1010
+ if (projected && Array.isArray(projected.versions)) setVersions(projected.versions)
1011
+ }, [projected])
1012
+
1013
+ const refresh = () => {
1014
+ setLoading(true)
1015
+ setError(null)
1016
+ timelineGet(`/versions?sessionId=${encodeURIComponent(sessionId)}`)
1017
+ .then((result) => {
1018
+ if (!result || result.ok !== true) throw new Error(result?.error?.message ?? 'versions failed')
1019
+ setVersions(result.value?.versions ?? [])
1020
+ })
1021
+ .catch((cause) => setError(cause?.message ?? 'timeline error'))
1022
+ .finally(() => setLoading(false))
1023
+ }
1024
+
1025
+ const refreshGit = () => {
1026
+ if (getConfig().git !== true) return
1027
+ timelineGet(`/git/status?sessionId=${encodeURIComponent(sessionId)}`)
1028
+ .then((result) => setGit(result?.ok === true ? result.value : null))
1029
+ .catch(() => setGit(null))
1030
+ }
1031
+
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.
1034
+ useEffect(() => {
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.
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
+ }
1108
+
1109
+ const requestPreview = (record) => {
1110
+ setPreviewScope('both')
1111
+ setPreview({ versionId: record.versionId, kind: record.kind, boundarySeq: record.boundarySeq, data: null, error: null })
1112
+ callOp('rollback/preview', { sessionId, versionId: record.versionId, scope: 'both' }).then((result) => {
1113
+ setPreview((prev) => prev && prev.versionId === record.versionId
1114
+ ? { ...prev, data: result?.ok === true ? result.value : null, error: result?.ok === true ? null : result?.error?.message ?? null }
1115
+ : prev)
1116
+ })
1117
+ }
1118
+
1119
+ const confirmRollback = () => {
1120
+ if (!preview) return
1121
+ setRollbackBusy(true)
1122
+ callOp('rollback', { sessionId, versionId: preview.versionId, scope: previewScope }).then((result) => {
1123
+ setRollbackBusy(false)
1124
+ if (!result || result.ok !== true) {
1125
+ setPreview((prev) => prev && { ...prev, error: result?.error?.message ?? 'rollback failed' })
1126
+ return
1127
+ }
1128
+ setPreview(null)
1129
+ if (projected === undefined) refresh()
1130
+ refreshGit()
1131
+ })
1132
+ }
1133
+
1134
+ const initGit = () => {
1135
+ if (!window.confirm(t('timeline.gitInitConfirm'))) return
1136
+ setGitBusy(true)
1137
+ callOp('git/init', { sessionId }).then((result) => {
1138
+ setGitBusy(false)
1139
+ refreshGit()
1140
+ })
1141
+ }
1142
+
1143
+ // ---- windowed list (uniform rows, zero-dep) ----
1144
+ const ROW_H = 64
1145
+ const list = versions ?? []
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
+ ]),
1158
+
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')),
1172
+ ]),
1173
+
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')),
1177
+
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
+ })),
1193
+ ]),
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
+ }),
1206
+ ])
1207
+ }
1208
+
1209
+ /** One version row (uniform height for the windowed list). */
1210
+ function VersionRow({ record, top, t, onPreview, onTrajectory, onJump }) {
1211
+ return createElement('div', { className: 'dsh-rt-version', style: { top: `${top}px` } }, [
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] ?? '•'),
1213
+ createElement('div', { key: 'body', className: 'dsh-rt-version-body' }, [
1214
+ createElement('div', { key: 'line1', className: 'dsh-rt-version-line' }, [
1215
+ createElement('span', { key: 'kind', className: 'dsh-rt-version-kind-label' }, kindLabel(record.kind, t)),
1216
+ createElement('span', { key: 'time', className: 'dsh-rt-version-time' }, timeLabel(record.createdAt)),
1217
+ createElement('span', { key: 'msgs', className: 'dsh-rt-version-msgs' }, t('timeline.messages', { count: record.messageCount })),
1218
+ createElement('span', { key: 'files', className: 'dsh-rt-version-files' }, fileCountLabel(record.fileCounts, t)),
1219
+ ]),
1220
+ record.markerText
1221
+ ? createElement('div', { key: 'text', className: 'dsh-rt-version-text' }, record.markerText.length > 120 ? `${record.markerText.slice(0, 120)}…` : record.markerText)
1222
+ : null,
1223
+ ]),
1224
+ createElement('span', { key: 'actions', className: 'dsh-rt-version-actions' }, [
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')),
1227
+ createElement('button', { key: 'preview', type: 'button', className: 'dsh-rt-chip dsh-rt-chip-danger', onClick: onPreview }, t('timeline.preview')),
1228
+ ]),
1229
+ ])
1230
+ }
1231
+
1232
+ /** Rollback preview + confirm box (scope selector + artifact actions). */
1233
+ function PreviewBox({ preview, scope, setScope, busy, t, onConfirm, onCancel }) {
1234
+ const data = preview.data
1235
+ const scopes = [
1236
+ ['context', 'timeline.contextOnly', 'timeline.contextOnlyDesc'],
1237
+ ['artifacts', 'timeline.artifactsOnly', 'timeline.artifactsOnlyDesc'],
1238
+ ['both', 'timeline.both', 'timeline.bothDesc'],
1239
+ ]
1240
+ return createElement('div', { className: 'dsh-rt-modal' }, [
1241
+ createElement('div', { key: 'title', className: 'dsh-rt-modal-title' }, [
1242
+ t('timeline.preview'),
1243
+ createElement('span', { key: 'ver', className: 'dsh-rt-modal-sub' }, t('timeline.previewDesc', { version: preview.versionId, kind: kindLabel(preview.kind, t) })),
1244
+ ]),
1245
+ preview.error && createElement('div', { key: 'err', className: 'dsh-rt-error' }, preview.error),
1246
+ data === null && !preview.error && createElement('div', { key: 'wait', className: 'dsh-rt-timeline-empty' }, t('timeline.loading')),
1247
+ data && createElement('div', { key: 'body', className: 'dsh-rt-modal-body' }, [
1248
+ createElement('div', { key: 'ctx', className: 'dsh-rt-modal-line' },
1249
+ data.context?.messages > 0
1250
+ ? t('timeline.messagesRemoved', { count: data.context.messages })
1251
+ : t('timeline.noChanges')),
1252
+ createElement('div', { key: 'art', className: 'dsh-rt-modal-line' }, t('timeline.artifactsList', { count: data.artifacts?.rows?.length ?? 0 })),
1253
+ createElement('ul', { key: 'files', className: 'dsh-rt-modal-files' }, (data.artifacts?.rows ?? []).slice(0, 12).map((row) =>
1254
+ createElement('li', { key: row.path }, [
1255
+ createElement('span', { key: 'a', className: `dsh-rt-art-${row.action}` },
1256
+ row.action === 'skip' ? `${t('timeline.artifact.skip')} (${row.reason ?? ''})` : (row.action === 'delete' ? t('timeline.artifact.delete') : t('timeline.artifact.restore'))),
1257
+ createElement('span', { key: 'p', className: 'dsh-rt-art-path' }, row.path),
1258
+ ]))),
1259
+ createElement('div', { key: 'scope', className: 'dsh-rt-modal-scope' }, scopes.map(([value, labelKey, descKey]) =>
1260
+ createElement('label', { key: value, className: 'dsh-rt-option' }, [
1261
+ createElement('input', { type: 'radio', name: 'rt-scope', checked: scope === value, onChange: () => setScope(value) }),
1262
+ createElement('span', { className: 'dsh-rt-option-text' }, [
1263
+ createElement('span', { className: 'dsh-rt-option-label' }, t(labelKey)),
1264
+ createElement('span', { className: 'dsh-rt-option-desc' }, t(descKey)),
1265
+ ]),
1266
+ ]))),
1267
+ ]),
1268
+ createElement('div', { key: 'buttons', className: 'dsh-rt-modal-buttons' }, [
1269
+ createElement('button', { key: 'cancel', type: 'button', className: 'dsh-rt-editor-cancel', disabled: busy, onClick: onCancel }, t('timeline.cancel')),
1270
+ createElement('button', { key: 'confirm', type: 'button', className: 'dsh-rt-editor-send dsh-rt-confirm', disabled: busy || preview.error !== null, onClick: onConfirm },
1271
+ busy ? t('timeline.busy') : t('timeline.confirm')),
1272
+ ]),
1273
+ ])
1274
+ }
1275
+
706
1276
  // ---------------------------------------------------------------------------
707
1277
  // Plugin
708
1278
  // ---------------------------------------------------------------------------
@@ -710,6 +1280,7 @@ export function apply(ctx) {
710
1280
  const disposeStyle = ensureStyle()
711
1281
  ctx.effect(() => () => disposeStyle(), 'dsh-retrace: styles')
712
1282
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-retrace: dictionaries')
1283
+ const t = ctx.locale.bind(NS)
713
1284
 
714
1285
  const conversationEvents = ctx.get('conversationEvents')
715
1286
  if (conversationEvents) {
@@ -743,6 +1314,22 @@ export function apply(ctx) {
743
1314
  locale: NS,
744
1315
  }, RecallMarkerRow))
745
1316
 
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,
1325
+ locale: NS,
1326
+ label: () => t('view.retrace'),
1327
+ inject: (sessionId, actions) => ({
1328
+ actions,
1329
+ store: ctx.get?.('sessions')?.binding?.(sessionId)?.session,
1330
+ }),
1331
+ }, RetraceView))
1332
+
746
1333
  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
747
1334
  name: 'settings.general.item',
748
1335
  id: 'retrace',