@kangyishuai/dsh-session-rewind 0.3.0 → 0.5.0

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 CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Unofficial plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). Not affiliated with DeepSeek.
10
10
 
11
- > **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 31 GUI assertions in a throwaway DSH home, 158 unit tests, and a CDP-driven desktop pass (conflict refusal, conversation + file rewind, created-file trash, modified-file restore, rescue restore, panel/audit/diagnostics). Young software — keep backups of anything you cannot lose.
11
+ > **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 185 unit tests, 34 GUI assertions in a throwaway DSH home, and **36 assertions against the real desktop app** (read-only surface plus file mode: created-file trash, modified-file restore, rescue restore, timeline). `pnpm gate:session-rewind` runs the whole release gate in one command. Young software — keep backups of anything you cannot lose.
12
12
 
13
13
  ## What it does
14
14
 
@@ -16,11 +16,13 @@ Long conversations drift. A few turns later you wish you had answered a prompt d
16
16
 
17
17
  - **Two entries.** Press `Esc` `Esc` to open the rewind dialog, or click the ↶ button in any user message's action row (next to Copy).
18
18
  - **Running turns are not a dead end.** Confirming a rewind while a turn is running cancels that turn (keeping the queue), waits for it to go idle, then re-plans against the settled history.
19
+ - **State-change warnings.** If the withdrawn range changed session state that lives outside the transcript — plan mode, goal, todo list, model selection, permission/sandbox presets, agent preset, scheduled tasks, working directory — the dialog lists it and says that rewinding does not roll that state back.
19
20
  - **Two scopes, chosen per rewind.** The confirmation dialog offers **Rewind conversation only** (default) and **Rewind conversation and files**. The dialog first shows a dry-run preview: how many messages and roughly how many tokens will be withdrawn, which files will be restored or moved to the trash, conflicts, and skipped files.
20
21
  - **Conversation.** The target message and everything after it are withdrawn from the model-visible context and from the transcript. The session id, title and earlier history are untouched. Nothing is deleted from the log — one marker event is appended.
21
22
  - **Files (optional).** Files changed by `write` / `edit` / `apply_patch` in the withdrawn turns are restored to their pre-change content. Files created after the target are moved to the plugin's trash instead of deleted. A rescue point of the current files is created before anything is written; failures roll back from it.
22
23
  - **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten. Tick **Send this message again right after rewinding** in the confirmation dialog to retry the turn in one click (conversation-only rewinds).
23
- - **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (audit of each rewind: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
24
+ - **Every view stays in sync.** Rewind in one tab or window and other open views of the same session hide the withdrawn rows immediately, without a reload (the notice travels through `localStorage`; the host's cuts remain the only source of truth).
25
+ - **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (a timeline of each rewind and each rescue-point file restore, newest first: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
24
26
 
25
27
  ## Read-only agent tool
26
28
 
@@ -104,9 +106,9 @@ On startup the plugin also removes data for sessions that no longer exist (they
104
106
 
105
107
  ## Compatibility
106
108
 
107
- | DSH version | Status |
108
- | ----------- | --------------------------------------------------------------------------------------------------------------------------- |
109
- | 0.2.2-rc.1 | Verified: `pnpm smoke:session-rewind` (31/31) + 158 unit tests + CDP-driven desktop pass (conversation-only and file modes) |
109
+ | DSH version | Status |
110
+ | ----------- | ------------------------------------------------------------------------------------------------------ |
111
+ | 0.2.2-rc.1 | Verified: 185 unit tests + `smoke:session-rewind` 34/34 + desktop 36/36 (`--deep`, file mode included) |
110
112
 
111
113
  The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is pre-release and semver ranges over prereleases are unreliable (see [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md)). Treat the table as the compatibility statement. After a DSH upgrade run `pnpm probe:runtime`, `pnpm smoke:session-rewind`, and check the plugin's diagnostics tab; if a private seam changed, the plugin degrades instead of breaking the session.
112
114
 
@@ -116,7 +118,7 @@ The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is
116
118
  pnpm test # unit tests (host engine, host RPC, client helpers)
117
119
  pnpm verify # repo-wide gates
118
120
  pnpm smoke:session-rewind # temp DSH home + real GUI end-to-end (needs dsh & ego-browser)
119
- pnpm smoke:session-rewind-desktop # read-only desktop check (needs --remote-debugging-port=9222)
121
+ pnpm smoke:session-rewind-desktop # desktop (Electron) check: 21 read-only + 12 with --deep --workspace <dir>
120
122
  ```
121
123
 
122
124
  Design notes: [ADR 0006](../../docs/adr/0006-session-rewind-architecture.md) and [ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md) (Chinese; the repository docs are written in Chinese).
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  非官方插件,与 DeepSeek 无关。面向 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)。
10
10
 
11
- > **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:一次性 DSH 目录里 31 项 GUI 断言 + 158 个单测 + CDP 驱动的桌面版实测(冲突拒绝、对话+文件回退、新建文件进回收站、修改文件还原、救援点恢复、面板/审计/诊断)。插件还年轻,重要数据请自行备份。
11
+ > **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:185 个单测、一次性 DSH 目录里 34 项 GUI 断言、以及**真实桌面版 36 项断言**(只读界面 + 文件模式:新建文件进回收站、修改文件还原、救援点恢复、时间线)。`pnpm gate:session-rewind` 一条命令跑完发版门禁。插件还年轻,重要数据请自行备份。
12
12
 
13
13
  ## 它做什么
14
14
 
@@ -18,9 +18,11 @@
18
18
  - **每次回溯二选一**:确认框里提供「**仅回退对话**」(默认)与「**回退对话和文件**」,并先展示 dry-run 预览:将撤回多少条消息、约多少 token、哪些文件会还原/移入回收站、冲突与被跳过的文件。
19
19
  - **对话**:目标消息及其后的内容从模型上下文和渲染对话中撤回;会话 id、标题和目标之前的历史不变;日志只追加一条 marker,不删历史。
20
20
  - **文件(可选)**:被撤回轮次里由 `write` / `edit` / `apply_patch` 改过的文件还原到改动前内容;目标之后新建的文件移入插件回收站而不是删除。写入前先建救援点,失败从救援点回滚。
21
+ - **状态变更提示**:如果要撤回的区间里改过会话状态(plan 模式、goal、待办、模型选择、权限/沙箱预设、智能体预设、定时任务、工作目录),确认框会列出来并明确说明——回溯只撤回对话,**不会**回滚这些状态。
21
22
  - **运行中也能回退**:确认回退时如果这一轮正在跑,会先取消它(保留队列)、等它空闲,再对新历史重新求值。
22
23
  - **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
23
- - **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(审计:范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
24
+ - **多视图自动同步**:在一个标签页/窗口里回溯后,其它已打开的同会话视图会立刻隐藏被撤回的行,无需刷新(通知走 `localStorage`,渲染只认宿主 cuts)。
25
+ - **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(时间线:每次回溯与每次救援点恢复,新在上;范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
24
26
 
25
27
  ## 只读 agent 工具
26
28
 
@@ -104,9 +106,9 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
104
106
 
105
107
  ## 兼容性
106
108
 
107
- | DSH 版本 | 状态 |
108
- | ---------- | ------------------------------------------------------------------------------------------------- |
109
- | 0.2.2-rc.1 | 已验证:`pnpm smoke:session-rewind` 31/31 + 158 个单测 + CDP 驱动的桌面版实测(仅对话与文件模式) |
109
+ | DSH 版本 | 状态 |
110
+ | ---------- | ---------------------------------------------------------------------------------------- |
111
+ | 0.2.2-rc.1 | 已验证:185 个单测 + `smoke:session-rewind` 34/34 + 桌面版 36/36(`--deep`,含文件模式) |
110
112
 
111
113
  插件不声明 `engines.dsh`,也不声明 `@deepseek-ai/*` 的 peer 范围:DSH 还在预发布阶段,预发布版本的 semver 范围不可靠(见 [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md))。以这张表为准。DSH 升级后跑 `pnpm probe:runtime`、`pnpm smoke:session-rewind`,并看插件诊断页签;私有 seam 变了会降级而不是让会话出错。
112
114
 
@@ -116,7 +118,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
116
118
  pnpm test # 单测(宿主文件引擎、宿主 RPC、客户端 helper)
117
119
  pnpm verify # 仓库统一门禁
118
120
  pnpm smoke:session-rewind # 临时 DSH 目录 + 真实 GUI 端到端(需要 dsh 与 ego-browser)
119
- pnpm smoke:session-rewind-desktop # 只读桌面回归(需要以 --remote-debugging-port=9222 重启)
121
+ pnpm smoke:session-rewind-desktop # 桌面(Electron)回归:只读 21 项,加 --deep --workspace <dir> 再 +12 项
120
122
  ```
121
123
 
122
124
  设计记录:[ADR 0006](../../docs/adr/0006-session-rewind-architecture.md)、[ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md)。
package/lib/client.js CHANGED
@@ -72,6 +72,20 @@ window.__ModuleLoader__.load({
72
72
  "confirm.resendHint":
73
73
  "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
74
74
  "confirm.openToolCall": "目标之后有未完成的工具调用,回溯会截断它",
75
+ "confirm.stateChanges": "这段区间里改过会话状态:{list};回溯只撤回对话,不会回滚这些状态",
76
+ "confirm.stateHint": "如需一并回退 plan/goal/待办等状态,请手动调整。",
77
+ "state.planMode": "计划模式",
78
+ "state.goal": "目标(goal)",
79
+ "state.todo": "待办清单",
80
+ "state.model": "模型选择",
81
+ "state.permission": "权限预设",
82
+ "state.sandbox": "沙箱模式",
83
+ "state.agentPreset": "智能体预设",
84
+ "state.schedule": "定时任务",
85
+ "state.workingDirectory": "工作目录",
86
+ "state.separator": "、",
87
+ "timeline.kindRescue": "文件恢复",
88
+ "timeline.rescueMeta": "从救援点恢复 {restored} 个文件 · {id}",
75
89
  "toast.done": "已回退到此处,原消息已放回输入框",
76
90
  "toast.draft": "已回退;输入框已有内容,未覆盖",
77
91
  "toast.noComposer": "已回退,但未找到输入框",
@@ -202,6 +216,21 @@ window.__ModuleLoader__.load({
202
216
  "confirm.resendHint":
203
217
  "Use this to retry a turn; to change the text, edit it in the composer instead.",
204
218
  "confirm.openToolCall": "An unfinished tool call follows the target; rewinding truncates it.",
219
+ "confirm.stateChanges":
220
+ "Session state changed inside this range: {list}. Rewinding only withdraws messages; these stay as they are.",
221
+ "confirm.stateHint": "Adjust plan/goal/todo state by hand if you need it rolled back too.",
222
+ "state.planMode": "plan mode",
223
+ "state.goal": "goal",
224
+ "state.todo": "todo list",
225
+ "state.model": "model selection",
226
+ "state.permission": "permission preset",
227
+ "state.sandbox": "sandbox mode",
228
+ "state.agentPreset": "agent preset",
229
+ "state.schedule": "scheduled task",
230
+ "state.workingDirectory": "working directory",
231
+ "state.separator": ", ",
232
+ "timeline.kindRescue": "File restore",
233
+ "timeline.rescueMeta": "Restored {restored} file(s) from rescue {id}",
205
234
  "toast.stoppedTurn": ", and the running turn was stopped",
206
235
  "toast.resent": ", and the message was sent again",
207
236
  "toast.done": "Rewound. The original message is back in the composer.",
@@ -545,6 +574,33 @@ window.__ModuleLoader__.load({
545
574
  /** localStorage 偏好键;版本化,方便未来迁移。 */
546
575
  let PREF_STORAGE_KEY = "dsh-session-rewind.prefs.v1";
547
576
 
577
+ /** 跨视图广播键:某个视图回溯成功后写入,其它视图据此重新拉宿主 cuts。 */
578
+ let CUT_BROADCAST_KEY = "dsh-session-rewind.cut-broadcast.v1";
579
+
580
+ /**
581
+ * 解析跨视图广播载荷(脏数据一律返回 null)。
582
+ * @param {any} raw localStorage 里的字符串
583
+ * @returns {{sessionId: string, targetSeq: number|null, markerSeq: number|null, at: number} | null}
584
+ */
585
+ function parseCutBroadcast(raw) {
586
+ if (typeof raw !== "string" || raw === "") return null;
587
+ let value;
588
+ try {
589
+ value = JSON.parse(raw);
590
+ } catch (_error) {
591
+ return null;
592
+ }
593
+ if (!value || typeof value !== "object") return null;
594
+ let sessionId = typeof value.sessionId === "string" ? value.sessionId : "";
595
+ if (sessionId === "") return null;
596
+ return {
597
+ sessionId: sessionId,
598
+ targetSeq: typeof value.targetSeq === "number" ? value.targetSeq : null,
599
+ markerSeq: typeof value.markerSeq === "number" ? value.markerSeq : null,
600
+ at: typeof value.at === "number" ? value.at : 0,
601
+ };
602
+ }
603
+
548
604
  /** 客户端偏好默认值:默认仅回退对话、每页 100 条、列表选择先确认。 */
549
605
  let PREF_DEFAULTS = Object.freeze({
550
606
  defaultMode: "chat",
@@ -691,7 +747,25 @@ window.__ModuleLoader__.load({
691
747
  * @param {any} value
692
748
  * @returns {any}
693
749
  */
694
- function normalizePreview(value) {
750
+ /** 会话状态变更提示(plan/goal/todo/模型…)。 @param {any} value */
751
+ function normalizeStateChanges(value) {
752
+ /** @type {any[]} */
753
+ let out = [];
754
+ let source = Array.isArray(value) ? value : [];
755
+ for (let i = 0; i < source.length; i += 1) {
756
+ let item = source[i];
757
+ if (!item || typeof item !== "object") continue;
758
+ if (typeof item.labelKey !== "string") continue;
759
+ out.push({
760
+ type: typeof item.type === "string" ? item.type : "",
761
+ labelKey: item.labelKey,
762
+ count: typeof item.count === "number" && item.count > 0 ? item.count : 1,
763
+ });
764
+ }
765
+ return out;
766
+ }
767
+
768
+ function normalizePreview(/** @type {any} */ value) {
695
769
  let source = value && typeof value === "object" ? value : {};
696
770
  let target = source.target && typeof source.target === "object" ? source.target : {};
697
771
  let chat = source.chat && typeof source.chat === "object" ? source.chat : {};
@@ -731,6 +805,7 @@ window.__ModuleLoader__.load({
731
805
  crossesOpenToolCall: chat.crossesOpenToolCall === true,
732
806
  reachable: chat.reachable !== false,
733
807
  },
808
+ stateChanges: normalizeStateChanges(source.stateChanges ?? chat.stateChanges),
734
809
  files: normalizeFiles(source.files),
735
810
  warnings: warnings,
736
811
  };
@@ -965,8 +1040,13 @@ window.__ModuleLoader__.load({
965
1040
  : typeof entry.id === "number"
966
1041
  ? String(entry.id)
967
1042
  : "";
1043
+ let action = typeof entry.action === "string" ? entry.action : "";
1044
+ let kind =
1045
+ action === "rescue-restore" ? "rescue-restore" : entry.mode === "both" ? "both" : "chat";
968
1046
  return {
969
1047
  id: id,
1048
+ action: action,
1049
+ kind: kind,
970
1050
  time: typeof entry.time === "number" ? entry.time : null,
971
1051
  targetSeq: typeof entry.targetSeq === "number" ? entry.targetSeq : null,
972
1052
  markerSeq: typeof entry.markerSeq === "number" ? entry.markerSeq : null,
@@ -989,6 +1069,12 @@ window.__ModuleLoader__.load({
989
1069
  if (entry.id === "") continue;
990
1070
  out.push(entry);
991
1071
  }
1072
+ // 时间线按时间倒序(新在上);缺时间戳的排最后。
1073
+ out.sort((/** @type {any} */ left, /** @type {any} */ right) => {
1074
+ let l = typeof left.time === "number" ? left.time : -1;
1075
+ let r = typeof right.time === "number" ? right.time : -1;
1076
+ return r - l;
1077
+ });
992
1078
  return out;
993
1079
  }
994
1080
 
@@ -1928,6 +2014,27 @@ window.__ModuleLoader__.load({
1928
2014
  h("div", { key: "tool", className: "dsh-srw-warn" }, t("confirm.openToolCall")),
1929
2015
  );
1930
2016
  }
2017
+ // 区间里改过的会话状态:回溯只切模型上下文,不会回滚这些设置。
2018
+ let stateChanges = Array.isArray(preview.stateChanges) ? preview.stateChanges : [];
2019
+ if (stateChanges.length > 0) {
2020
+ let labels = stateChanges
2021
+ .map((/** @type {any} */ item) =>
2022
+ item.count > 1 ? `${t(item.labelKey)} ×${item.count}` : t(item.labelKey),
2023
+ )
2024
+ .join(t("state.separator"));
2025
+ blocks.push(
2026
+ h(
2027
+ "div",
2028
+ { key: "state", className: "dsh-srw-warn", "data-rewind-state": "true" },
2029
+ t("confirm.stateChanges", { list: labels }),
2030
+ ),
2031
+ h(
2032
+ "div",
2033
+ { key: "state-hint", className: "dsh-srw-option-hint" },
2034
+ t("confirm.stateHint"),
2035
+ ),
2036
+ );
2037
+ }
1931
2038
  blocks.push(
1932
2039
  h(ModePicker, {
1933
2040
  key: "modes",
@@ -2307,12 +2414,14 @@ window.__ModuleLoader__.load({
2307
2414
  if (data.entries.length === 0) {
2308
2415
  return h("div", { className: "dsh-srw-dim" }, t("audit.empty"));
2309
2416
  }
2417
+ // 时间线:竖线 + 节点。回溯事件与「从救援点恢复文件」放在同一条轴上,按时间倒序。
2310
2418
  return h(
2311
2419
  "div",
2312
- { className: "dsh-srw-cuts" },
2420
+ { className: "dsh-srw-timeline" },
2313
2421
  data.entries.map((/** @type {any} */ entry) => {
2314
2422
  let expanded = data.expanded && data.expanded[entry.id] === true;
2315
2423
  let files = entry.files;
2424
+ let isRescueRestore = entry.kind === "rescue-restore";
2316
2425
  /** @type {any[]} */
2317
2426
  let pathGroups = [];
2318
2427
  if (files) {
@@ -2334,70 +2443,86 @@ window.__ModuleLoader__.load({
2334
2443
  (/** @type {any} */ sum, /** @type {any} */ group) => sum + group.paths.length,
2335
2444
  0,
2336
2445
  );
2446
+ let badge = isRescueRestore ? t("timeline.kindRescue") : t(modeLabelKey(entry.mode));
2447
+ let meta = isRescueRestore
2448
+ ? t("timeline.rescueMeta", {
2449
+ restored: files ? files.restored.length : 0,
2450
+ id: entry.rescueId || "",
2451
+ })
2452
+ : t("audit.meta", {
2453
+ mode: t(modeLabelKey(entry.mode)),
2454
+ n: entry.removedCount,
2455
+ tokens: formatCount(entry.tokens),
2456
+ });
2337
2457
  return h(
2338
2458
  "div",
2339
- { key: entry.id, className: "dsh-srw-cut" },
2459
+ {
2460
+ key: entry.id,
2461
+ className: "dsh-srw-tl-node",
2462
+ "data-kind": isRescueRestore ? "rescue-restore" : entry.mode,
2463
+ },
2464
+ h("span", { className: "dsh-srw-tl-dot", "aria-hidden": "true" }),
2340
2465
  h(
2341
2466
  "div",
2342
- { className: "dsh-srw-cut-head" },
2343
- h("span", { className: "dsh-srw-strong" }, formatDateTime(entry.time) || entry.id),
2344
- h("span", { className: "dsh-srw-badge" }, t(modeLabelKey(entry.mode))),
2467
+ { className: "dsh-srw-tl-card" },
2345
2468
  h(
2346
- "span",
2347
- { className: "dsh-srw-time" },
2348
- t("audit.meta", {
2349
- mode: t(modeLabelKey(entry.mode)),
2350
- n: entry.removedCount,
2351
- tokens: formatCount(entry.tokens),
2352
- }),
2469
+ "div",
2470
+ { className: "dsh-srw-tl-head" },
2471
+ h("span", { className: "dsh-srw-strong" }, formatDateTime(entry.time) || entry.id),
2472
+ h("span", { className: "dsh-srw-badge" }, badge),
2353
2473
  ),
2354
- ),
2355
- files
2356
- ? h(
2357
- "div",
2358
- { className: "dsh-srw-audit-files" },
2359
- t("audit.files", {
2360
- restored: files.restored.length,
2361
- trashed: files.trashed.length,
2362
- skipped: files.skipped.length,
2363
- failed: files.failed.length,
2364
- }),
2365
- )
2366
- : null,
2367
- files?.rescueId
2368
- ? h("div", { className: "dsh-srw-dim" }, t("audit.rescueId", { id: files.rescueId }))
2369
- : null,
2370
- h(
2371
- "div",
2372
- { className: "dsh-srw-item-actions dsh-srw-audit-actions" },
2373
- pathCount > 0
2474
+ h("div", { className: "dsh-srw-time" }, meta),
2475
+ files
2374
2476
  ? h(
2375
- LinkBtn,
2376
- { onClick: () => api.toggleAuditPaths(entry.id) },
2377
- expanded ? t("audit.pathsLess") : t("audit.paths", { n: pathCount }),
2477
+ "div",
2478
+ { className: "dsh-srw-audit-files" },
2479
+ t("audit.files", {
2480
+ restored: files.restored.length,
2481
+ trashed: files.trashed.length,
2482
+ skipped: files.skipped.length,
2483
+ failed: files.failed.length,
2484
+ }),
2378
2485
  )
2379
2486
  : null,
2380
- h(LinkBtn, { onClick: () => api.copyJson(entry) }, t("audit.copyJson")),
2381
- ),
2382
- expanded
2383
- ? h(
2384
- "div",
2385
- { className: "dsh-srw-paths" },
2386
- pathGroups.map((/** @type {any} */ group) =>
2387
- group.paths.map((/** @type {any} */ path) =>
2388
- h(
2389
- "div",
2390
- {
2391
- key: `${group.kind}:${path}`,
2392
- className: "dsh-srw-path",
2393
- "data-kind": group.kind,
2394
- },
2395
- h("span", { className: "dsh-srw-path-name", title: path }, path),
2487
+ files?.rescueId
2488
+ ? h(
2489
+ "div",
2490
+ { className: "dsh-srw-dim" },
2491
+ t("audit.rescueId", { id: files.rescueId }),
2492
+ )
2493
+ : null,
2494
+ h(
2495
+ "div",
2496
+ { className: "dsh-srw-item-actions dsh-srw-audit-actions" },
2497
+ pathCount > 0
2498
+ ? h(
2499
+ LinkBtn,
2500
+ { onClick: () => api.toggleAuditPaths(entry.id) },
2501
+ expanded ? t("audit.pathsLess") : t("audit.paths", { n: pathCount }),
2502
+ )
2503
+ : null,
2504
+ h(LinkBtn, { onClick: () => api.copyJson(entry) }, t("audit.copyJson")),
2505
+ ),
2506
+ expanded
2507
+ ? h(
2508
+ "div",
2509
+ { className: "dsh-srw-paths" },
2510
+ pathGroups.map((/** @type {any} */ group) =>
2511
+ group.paths.map((/** @type {any} */ path) =>
2512
+ h(
2513
+ "div",
2514
+ {
2515
+ key: `${group.kind}:${path}`,
2516
+ className: "dsh-srw-path",
2517
+ "data-kind": group.kind,
2518
+ },
2519
+ h("span", { className: "dsh-srw-path-name", title: path }, path),
2520
+ ),
2396
2521
  ),
2397
2522
  ),
2398
- ),
2399
- )
2400
- : null,
2523
+ )
2524
+ : null,
2525
+ ),
2401
2526
  );
2402
2527
  }),
2403
2528
  );
@@ -3004,6 +3129,16 @@ window.__ModuleLoader__.load({
3004
3129
  ".dsh-srw-tab[data-active=true]{color:var(--dsw-alias-label-primary,inherit);border-bottom-color:var(--dsw-alias-brand-primary,#4f7cff);}",
3005
3130
  ".dsh-srw-tab-body{display:flex;flex-direction:column;gap:10px;}",
3006
3131
  ".dsh-srw-cuts{display:flex;flex-direction:column;gap:12px;}",
3132
+ // 时间线:竖线 + 圆点 + 卡片
3133
+ ".dsh-srw-timeline{display:flex;flex-direction:column;gap:0;position:relative;}",
3134
+ ".dsh-srw-tl-node{position:relative;display:flex;gap:12px;padding:0 0 14px 0;}",
3135
+ ".dsh-srw-tl-node::before{content:'';position:absolute;left:5px;top:14px;bottom:0;width:1px;background:var(--dsw-alias-border-secondary,rgba(128,128,128,.35));}",
3136
+ ".dsh-srw-tl-node:last-child{padding-bottom:0;}",
3137
+ ".dsh-srw-tl-node:last-child::before{display:none;}",
3138
+ ".dsh-srw-tl-dot{flex:none;width:11px;height:11px;margin-top:4px;border-radius:50%;background:var(--dsw-alias-brand-primary,#4f7cff);box-shadow:0 0 0 2px var(--dsw-alias-bg-primary,transparent);}",
3139
+ ".dsh-srw-tl-node[data-kind='rescue-restore'] .dsh-srw-tl-dot{background:var(--dsw-alias-label-secondary,GrayText);}",
3140
+ ".dsh-srw-tl-card{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1;}",
3141
+ ".dsh-srw-tl-head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;}",
3007
3142
  ".dsh-srw-cut{display:flex;flex-direction:column;gap:4px;}",
3008
3143
  ".dsh-srw-cut-head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;}",
3009
3144
  ".dsh-srw-strong{font-weight:600;}",
@@ -3662,6 +3797,8 @@ window.__ModuleLoader__.load({
3662
3797
  });
3663
3798
  rebuildSessionById(sessionId);
3664
3799
  let outcome = fillComposer(sessionId, summary.text || target.text || "");
3800
+ // 同源其它视图也要立刻隐藏这些行(它们不会被这次 append 唤醒)。
3801
+ broadcastCut(sessionId, summary);
3665
3802
  let text = t("toast.rewound", {
3666
3803
  n: summary.removedCount,
3667
3804
  tokens: formatCount(summary.tokens),
@@ -4142,6 +4279,57 @@ window.__ModuleLoader__.load({
4142
4279
  }
4143
4280
 
4144
4281
  /** 拉一次宿主 cuts 作为可见性兜底(静默失败)。 */
4282
+ /**
4283
+ * 把「这个会话刚回溯过」广播给同源其它视图(多标签/多窗口一致性)。
4284
+ * localStorage 的 storage 事件只在**其它** document 触发,不会回环到自己。
4285
+ * @param {any} sessionId
4286
+ * @param {any} summary
4287
+ */
4288
+ function broadcastCut(sessionId, summary) {
4289
+ try {
4290
+ if (!window.localStorage) return;
4291
+ window.localStorage.setItem(
4292
+ CUT_BROADCAST_KEY,
4293
+ JSON.stringify({
4294
+ sessionId: sessionId,
4295
+ targetSeq:
4296
+ summary && typeof summary.targetSeq === "number" ? summary.targetSeq : null,
4297
+ markerSeq:
4298
+ summary && typeof summary.markerSeq === "number" ? summary.markerSeq : null,
4299
+ at: Date.now(),
4300
+ }),
4301
+ );
4302
+ } catch (_error) {
4303
+ /* 存储不可用时退化为「只有本视图知道」 */
4304
+ }
4305
+ }
4306
+
4307
+ /**
4308
+ * 监听其它视图的广播:收到就按宿主 cuts 重新隐藏(宿主是权威来源,不信任广播内容)。
4309
+ * @returns {() => void} disposer
4310
+ */
4311
+ function installCutBroadcast() {
4312
+ /** @param {any} event */
4313
+ function onStorage(event) {
4314
+ if (!event || event.key !== CUT_BROADCAST_KEY) return;
4315
+ let payload = parseCutBroadcast(event.newValue);
4316
+ if (!payload) return;
4317
+ seedCuts(payload.sessionId);
4318
+ }
4319
+ try {
4320
+ window.addEventListener("storage", onStorage);
4321
+ } catch (_error) {
4322
+ return function noop() {};
4323
+ }
4324
+ return function dispose() {
4325
+ try {
4326
+ window.removeEventListener("storage", onStorage);
4327
+ } catch (_error) {
4328
+ /* ignore */
4329
+ }
4330
+ };
4331
+ }
4332
+
4145
4333
  function seedCuts(/** @type {any} */ sessionId) {
4146
4334
  return call("cuts", { sessionId: sessionId })
4147
4335
  .then((/** @type {any} */ value) => {
@@ -4195,6 +4383,9 @@ window.__ModuleLoader__.load({
4195
4383
 
4196
4384
  installChatVisibility(ctx, cutStore);
4197
4385
 
4386
+ // 多视图一致性:别的标签页回溯后,本视图按宿主 cuts 重新隐藏。
4387
+ ctx.effect(() => installCutBroadcast(), "session-rewind: cut broadcast");
4388
+
4198
4389
  ctx.effect(
4199
4390
  () =>
4200
4391
  installRowAction(ctx.slots, {
@@ -4426,6 +4617,9 @@ window.__ModuleLoader__.load({
4426
4617
  normalizePathList: normalizePathList,
4427
4618
  normalizeFiles: normalizeFiles,
4428
4619
  normalizePreview: normalizePreview,
4620
+ normalizeStateChanges: normalizeStateChanges,
4621
+ parseCutBroadcast: parseCutBroadcast,
4622
+ CUT_BROADCAST_KEY: CUT_BROADCAST_KEY,
4429
4623
  fileModeState: fileModeState,
4430
4624
  effectiveMode: effectiveMode,
4431
4625
  conflictPathsOf: conflictPathsOf,
package/lib/service.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from "./operations.js";
30
30
  import { findOrphans, pruneOrphans } from "./orphans.js";
31
31
  import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
32
+ import { stateChangeSummary } from "./state-changes.js";
32
33
  import { describeToolImpact, installRewindTool, listToolCandidates } from "./tool.js";
33
34
 
34
35
  /**
@@ -477,6 +478,8 @@ export class SessionRewindService extends TypertRemoteService {
477
478
  const tokens = this.estimateTokens(events, plan.shadowedSeqs);
478
479
  const target = targetContent(targetEvent);
479
480
  const subtitle = target.text.replace(/\s+/gu, " ").trim();
481
+ // 被撤回区间里改过的会话状态(plan/goal/todo/模型…):回溯不会回滚它们,只提示。
482
+ const stateChanges = stateChangeSummary(events, plan.surfaceStart, plan.surfaceEnd);
480
483
  /** @type {any} */
481
484
  let files = {
482
485
  available: false,
@@ -541,7 +544,9 @@ export class SessionRewindService extends TypertRemoteService {
541
544
  tokens,
542
545
  crossesOpenToolCall: hasOpenToolCalls(events, plan.targetSeq),
543
546
  reachable: true,
547
+ stateChanges,
544
548
  },
549
+ stateChanges,
545
550
  files,
546
551
  warnings,
547
552
  };
@@ -852,6 +857,37 @@ export class SessionRewindService extends TypertRemoteService {
852
857
  throw new RewindRequestError(ERROR_CODES.rescueNotFound, "a rescue id is required");
853
858
  }
854
859
  const result = await this.store.restoreRescue({ sessionId, cwd, rescueId });
860
+ // 审计:时间线里要把「从救援点恢复文件」和回溯事件放在同一条轴上。
861
+ try {
862
+ await this.store.appendAudit({
863
+ version: 1,
864
+ action: "rescue-restore",
865
+ id:
866
+ typeof this.store.newAuditId === "function"
867
+ ? this.store.newAuditId()
868
+ : `rr-${Date.now()}`,
869
+ time: Date.now(),
870
+ sessionId,
871
+ rescueId,
872
+ mode: "files",
873
+ targetSeq: null,
874
+ markerSeq: null,
875
+ removedCount: null,
876
+ tokens: null,
877
+ files: {
878
+ restored: result.restored ?? [],
879
+ trashed: [],
880
+ skipped: [],
881
+ failed: result.failed ?? [],
882
+ rescueId: result.safetyRescueId ?? null,
883
+ },
884
+ requestedBy: "ui",
885
+ });
886
+ } catch (error) {
887
+ this.ctx?.logger?.warn?.(
888
+ `session-rewind: rescue-restore audit failed: ${error instanceof Error ? error.message : String(error)}`,
889
+ );
890
+ }
855
891
  if (Array.isArray(result.failed) && result.failed.length > 0) {
856
892
  const error = new RewindRequestError(
857
893
  ERROR_CODES.filesFailed,
@@ -0,0 +1,71 @@
1
+ // @ts-check
2
+ // 「被撤回区间里改过哪些会话状态」的提示。
3
+ //
4
+ // 为什么需要:回溯只替换**模型可见的 surface**,日志与会话状态投影不动。所以区间里的
5
+ // `plan/mode`、`goal/change`、`todo/write` 这类状态事件在回溯后仍然生效——用户很容易误以为
6
+ // 「退回去就等于那些设置也退回去了」。这里只做事实提示,不做任何回滚。
7
+ //
8
+ // 未知事件类型一律忽略(AGENTS.md 约定 1:不写死内部实现,遇到不认识的保留)。
9
+
10
+ /** 已知的会话状态事件 → 文案键。 */
11
+ export const STATE_EVENT_LABELS = Object.freeze({
12
+ "plan/mode": "state.planMode",
13
+ "goal/change": "state.goal",
14
+ "todo/write": "state.todo",
15
+ "model/selection": "state.model",
16
+ "permission/preset": "state.permission",
17
+ "sandbox/mode": "state.sandbox",
18
+ "agent-preset/selected": "state.agentPreset",
19
+ "schedule/change": "state.schedule",
20
+ "working-directory/change": "state.workingDirectory",
21
+ });
22
+
23
+ /**
24
+ * @typedef {object} StateChange
25
+ * @property {string} type 事件类型
26
+ * @property {string} labelKey 文案键(`state.*`)
27
+ * @property {number} count 次数
28
+ * @property {number} firstSeq 第一次出现的 seq
29
+ */
30
+
31
+ /**
32
+ * 扫描 [fromSeq, toSeq] 区间里的会话状态事件,按出现顺序聚合。
33
+ * @param {any[]} events
34
+ * @param {number} fromSeq 含
35
+ * @param {number} toSeq 含;传 `Number.MAX_SAFE_INTEGER` 表示到日志末尾
36
+ * @returns {StateChange[]}
37
+ */
38
+ export function collectStateChanges(events, fromSeq, toSeq) {
39
+ const source = Array.isArray(events) ? events : [];
40
+ const start = typeof fromSeq === "number" ? fromSeq : 0;
41
+ const end = typeof toSeq === "number" ? toSeq : Number.MAX_SAFE_INTEGER;
42
+ /** @type {Map<string, StateChange>} */
43
+ const groups = new Map();
44
+ for (const event of source) {
45
+ if (!event || typeof event.seq !== "number") continue;
46
+ if (event.seq < start || event.seq > end) continue;
47
+ const type = event.type;
48
+ if (typeof type !== "string") continue;
49
+ const labelKey = /** @type {Record<string, string>} */ (STATE_EVENT_LABELS)[type];
50
+ if (labelKey === undefined) continue;
51
+ const existing = groups.get(type);
52
+ if (existing) existing.count += 1;
53
+ else groups.set(type, { type, labelKey, count: 1, firstSeq: event.seq });
54
+ }
55
+ return [...groups.values()].sort((left, right) => left.firstSeq - right.firstSeq);
56
+ }
57
+
58
+ /**
59
+ * 给提示用的简短摘要(不含本地化文案,只出 key 与次数)。
60
+ * @param {any[]} events
61
+ * @param {number} fromSeq
62
+ * @param {number} toSeq
63
+ * @returns {Array<{type: string, labelKey: string, count: number}>}
64
+ */
65
+ export function stateChangeSummary(events, fromSeq, toSeq) {
66
+ return collectStateChanges(events, fromSeq, toSeq).map((item) => ({
67
+ type: item.type,
68
+ labelKey: item.labelKey,
69
+ count: item.count,
70
+ }));
71
+ }
package/locale/en.json CHANGED
@@ -122,6 +122,20 @@
122
122
  "toast.stoppedTurn": ", and the running turn was stopped",
123
123
  "confirm.resend": "Send this message again right after rewinding",
124
124
  "confirm.resendHint": "Use this to retry a turn; to change the text, edit it in the composer instead.",
125
- "toast.resent": ", and the message was sent again"
125
+ "toast.resent": ", and the message was sent again",
126
+ "confirm.stateChanges": "Session state changed inside this range: {list}. Rewinding only withdraws messages; these stay as they are.",
127
+ "confirm.stateHint": "Adjust plan/goal/todo state by hand if you need it rolled back too.",
128
+ "state.planMode": "plan mode",
129
+ "state.goal": "goal",
130
+ "state.todo": "todo list",
131
+ "state.model": "model selection",
132
+ "state.permission": "permission preset",
133
+ "state.sandbox": "sandbox mode",
134
+ "state.agentPreset": "agent preset",
135
+ "state.schedule": "scheduled task",
136
+ "state.workingDirectory": "working directory",
137
+ "state.separator": ", ",
138
+ "timeline.kindRescue": "File restore",
139
+ "timeline.rescueMeta": "Restored {restored} file(s) from rescue {id}"
126
140
  }
127
141
  }
package/locale/zh.json CHANGED
@@ -122,6 +122,20 @@
122
122
  "toast.stoppedTurn": ",已停止运行中的回合",
123
123
  "confirm.resend": "回退后立即重发这条消息",
124
124
  "confirm.resendHint": "适合「重试这一轮」:回退后自动把原文发出去,想改内容就在输入框里编辑后再发。",
125
- "toast.resent": ",已重发这条消息"
125
+ "toast.resent": ",已重发这条消息",
126
+ "confirm.stateChanges": "这段区间里改过会话状态:{list};回溯只撤回对话,不会回滚这些状态",
127
+ "confirm.stateHint": "如需一并回退 plan/goal/待办等状态,请手动调整。",
128
+ "state.planMode": "计划模式",
129
+ "state.goal": "目标(goal)",
130
+ "state.todo": "待办清单",
131
+ "state.model": "模型选择",
132
+ "state.permission": "权限预设",
133
+ "state.sandbox": "沙箱模式",
134
+ "state.agentPreset": "智能体预设",
135
+ "state.schedule": "定时任务",
136
+ "state.workingDirectory": "工作目录",
137
+ "state.separator": "、",
138
+ "timeline.kindRescue": "文件恢复",
139
+ "timeline.rescueMeta": "从救援点恢复 {restored} 个文件 · {id}"
126
140
  }
127
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangyishuai/dsh-session-rewind",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "In-place conversation rewind for DSH: withdraw a user message and everything after it, then refill the composer",
5
5
  "keywords": [
6
6
  "dsh",