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/README.md +40 -27
- package/README.zh.md +37 -28
- package/lib/artifact-store.js +9 -0
- package/lib/client.bundle.js +475 -12
- package/lib/client.js +600 -13
- package/lib/dynamic-client.js +475 -12
- package/lib/dynamic-host.js +107 -80
- package/lib/git-adapter.js +177 -0
- package/lib/host-core.js +107 -80
- package/lib/http.js +104 -3
- package/lib/index.js +15 -2
- package/lib/prewrite-guard.js +71 -0
- package/lib/projection/versions.js +32 -10
- package/lib/rollback.js +283 -0
- package/lib/version-index.js +15 -4
- package/lib/versioning.js +188 -1
- package/package.json +13 -10
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 (
|
|
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(
|
|
269
|
-
|
|
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',
|