dsh-rewind-plugin 0.18.1 → 0.19.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.en.md CHANGED
@@ -159,14 +159,6 @@ This plugin deliberately stays lightweight and focused on one thing — "convers
159
159
  > This project and DeepSeek Harness are both in developer preview. Pin exact
160
160
  > versions in reproducible environments and review the behavior notes above.
161
161
 
162
- ## Client contract
163
-
164
- Third-party DOM plugins that need to know which transcript rows a rewind
165
- withdrew should consume the stable, locale-independent helpers exported from
166
- `dsh-rewind-plugin/client` — never parse
167
- `outcome.text`. The `data-dsh-rewind-hidden` attribute marks withdrawn rows
168
- (observational only). Details: [docs/contract/client-contract.md](docs/contract/client-contract.md).
169
-
170
162
  ## Known issues
171
163
 
172
164
  1. **Exported logs are complete** — a rewind only removes messages from the model context and the view; the exported session log (`/export`) still contains **withdrawn messages**. This plugin cannot alter exports.
package/README.md CHANGED
@@ -143,11 +143,6 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
143
143
  > 本项目与 DeepSeek Harness 均处于开发者预览阶段。可复现环境请 pin 精确版本,
144
144
  > 并阅读上述行为说明。
145
145
 
146
- ## 客户端契约
147
-
148
- 需要获知哪些转录行被回退撤回的第三方 DOM 插件,应使用 `dsh-rewind-plugin/client` 导出的稳定、与本地化无关的纯函数,切勿解析 `outcome.text`。`data-dsh-rewind-hidden` 属性标记被撤回的行(仅观测性)。
149
- 详见:[docs/contract/client-contract.zh.md](docs/contract/client-contract.zh.md)。
150
-
151
146
  ## 已知问题
152
147
 
153
148
  1. **导出的日志是完整内容**——回退只是把消息从模型上下文和视图中移除,`/export` 导出的会话日志包含**已撤回的消息**。本插件无法改动导出。
package/lib/client.js CHANGED
@@ -9,6 +9,7 @@ var __defProp = Object.defineProperty;
9
9
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
10
10
  var __getOwnPropNames = Object.getOwnPropertyNames;
11
11
  var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
12
13
  var __export = (target, all) => {
13
14
  for (var name2 in all)
14
15
  __defProp(target, name2, { get: all[name2], enumerable: true });
@@ -22,22 +23,72 @@ var __copyProps = (to, from, except, desc) => {
22
23
  return to;
23
24
  };
24
25
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
26
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
25
27
 
26
28
  // src/client/index.ts
27
29
  var index_exports = {};
28
30
  __export(index_exports, {
29
31
  apply: () => apply,
30
- hiddenSeqsOf: () => hiddenSeqsOf,
31
32
  inject: () => inject,
32
- name: () => name,
33
- targetSeqOfArgs: () => targetSeqOfArgs
33
+ name: () => name
34
34
  });
35
35
  module.exports = __toCommonJS(index_exports);
36
36
 
37
+ // src/client/candidates.ts
38
+ var PREVIEW_CHARS = 80;
39
+ var DEFAULT_CANDIDATE_LIMIT = 100;
40
+ function messagePreviewOf(message) {
41
+ const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
42
+ return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
43
+ }
44
+ function formatCandidateTime(time) {
45
+ const d = new Date(time);
46
+ const hh = String(d.getHours()).padStart(2, "0");
47
+ const mm = String(d.getMinutes()).padStart(2, "0");
48
+ return `${hh}:${mm}`;
49
+ }
50
+ function rewindCandidatesOf(snap, limit = DEFAULT_CANDIDATE_LIMIT) {
51
+ const candidates = [];
52
+ for (let i = snap.order.length - 1; i >= 0 && candidates.length < limit; i--) {
53
+ const key = snap.order[i];
54
+ if (key === void 0) continue;
55
+ const node = snap.nodes.get(key);
56
+ if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
57
+ candidates.push({
58
+ seq: node.data.seq,
59
+ time: node.data.time,
60
+ preview: messagePreviewOf(node.data)
61
+ });
62
+ }
63
+ return candidates;
64
+ }
65
+ function rewindCandidatesOfChat(snap) {
66
+ return rewindCandidatesOf(snap);
67
+ }
68
+ function rewindOptionsFromCandidates(candidates, t) {
69
+ return candidates.map((candidate) => ({
70
+ id: String(candidate.seq),
71
+ label: candidate.preview || t("popover.noText"),
72
+ detail: formatCandidateTime(candidate.time)
73
+ }));
74
+ }
75
+
37
76
  // src/client/hidden.ts
38
77
  var ENGINE_COMMAND = "rewind-plugin";
39
78
  var LEGACY_ENGINE_COMMAND = "rewind";
40
79
  var ENGINE_COMMANDS = /* @__PURE__ */ new Set([ENGINE_COMMAND, LEGACY_ENGINE_COMMAND]);
80
+ function chatNodesOf(chat) {
81
+ if (chat === void 0) return EMPTY_NODES;
82
+ const values = chat.nodes.values;
83
+ if (typeof values === "function") return [...values.call(chat.nodes)];
84
+ const nodes = [];
85
+ for (const key of chat.order) {
86
+ const node = chat.nodes.get(key);
87
+ if (node !== void 0) nodes.push(node);
88
+ }
89
+ return nodes;
90
+ }
91
+ var EMPTY_NODES = [];
41
92
  function resolveChatWatch(resolveView, sessionId, cb) {
42
93
  const view = resolveView(sessionId);
43
94
  return view?.subscribe?.(cb) ?? (() => {
@@ -48,9 +99,8 @@ function chatSnapshotOf(chatView) {
48
99
  }
49
100
  function messageTextAt(chat, seq) {
50
101
  if (chat === void 0) return void 0;
51
- for (const key of chat.order) {
52
- const node = chat.nodes.get(key);
53
- if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
102
+ for (const node of chatNodesOf(chat)) {
103
+ if (node.kind !== "user" && node.kind !== "steering") continue;
54
104
  const data = node.data;
55
105
  if (data.seq === seq) {
56
106
  return data.content?.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("");
@@ -60,9 +110,8 @@ function messageTextAt(chat, seq) {
60
110
  }
61
111
  function messageAttachmentsAt(chat, seq) {
62
112
  if (chat === void 0) return void 0;
63
- for (const key of chat.order) {
64
- const node = chat.nodes.get(key);
65
- if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
113
+ for (const node of chatNodesOf(chat)) {
114
+ if (node.kind !== "user" && node.kind !== "steering") continue;
66
115
  const data = node.data;
67
116
  if (data.seq !== seq) continue;
68
117
  const images = [];
@@ -100,141 +149,21 @@ function isPreviewCommand(command) {
100
149
  function isCandidateCommand(command) {
101
150
  return (command.args ?? "").includes("__candidates");
102
151
  }
103
- function coalesceCuts(spans) {
104
- if (spans.length < 2) return spans;
105
- const sorted = [...spans].sort((left, right) => left.start - right.start || left.end - right.end);
106
- const merged = [];
107
- for (const span of sorted) {
108
- const last = merged[merged.length - 1];
109
- if (last !== void 0 && span.start <= last.end) {
110
- if (span.end > last.end) last.end = span.end;
111
- continue;
112
- }
113
- merged.push({ start: span.start, end: span.end });
114
- }
115
- return merged;
116
- }
117
- function cutsContain(cuts, seq) {
118
- let low = 0;
119
- let high = cuts.length - 1;
120
- while (low <= high) {
121
- const middle = low + high >> 1;
122
- const cut = cuts[middle];
123
- if (seq < cut.start) high = middle - 1;
124
- else if (seq > cut.end) low = middle + 1;
125
- else return true;
126
- }
127
- return false;
128
- }
129
- function liveAnchorKeys(snap, cuts) {
130
- const live = /* @__PURE__ */ new Map();
131
- for (const key of snap.order) {
132
- const node = snap.nodes.get(key);
133
- if (node === void 0 || cutsContain(cuts, node.anchorSeq)) continue;
134
- const keys = live.get(node.anchorSeq);
135
- if (keys === void 0) live.set(node.anchorSeq, [key]);
136
- else keys.push(key);
137
- }
138
- return live;
139
- }
140
- var EMPTY_KEYS = [];
141
- function addWithdrawnTurnChrome(snap, cuts, hidden) {
142
- const locations = snap.locations;
143
- if (locations === void 0 || cuts.length === 0) return;
144
- let live;
145
- for (const key of snap.order) {
146
- const chrome = snap.nodes.get(key);
147
- if (chrome === void 0 || chrome.kind !== "turn-process") continue;
148
- const located = chrome.location?.turn;
149
- if (located?.start === void 0) continue;
150
- const turn = located.turn;
151
- if (typeof turn !== "number") continue;
152
- const keys = locations.getTurn(turn);
153
- if (keys.length === 0) continue;
154
- const rows = keys.map((member) => snap.nodes.get(member)).filter((member) => member !== void 0);
155
- if (rows.length !== keys.length) continue;
156
- if (!rows.every((member) => member.key === chrome.key || cutsContain(cuts, member.anchorSeq))) continue;
157
- live ?? (live = liveAnchorKeys(snap, cuts));
158
- if ((live.get(chrome.anchorSeq) ?? EMPTY_KEYS).some((holder) => holder !== chrome.key)) continue;
159
- hidden.add(chrome.anchorSeq);
160
- }
161
- }
162
- var hiddenSeqsMemo = /* @__PURE__ */ new WeakMap();
163
- function hiddenSeqsOf(snap) {
164
- const memo = hiddenSeqsMemo.get(snap);
165
- if (memo !== void 0) return new Set(memo);
166
- const hidden = /* @__PURE__ */ new Set();
167
- const spans = [];
168
- for (const key of snap.order) {
169
- const node = snap.nodes.get(key);
170
- if (node === void 0 || node.kind !== "command") continue;
152
+ function rewindCutsOf(nodes) {
153
+ const cuts = [];
154
+ for (const node of nodes) {
155
+ if (node.kind !== "command") continue;
171
156
  const command = node.data;
172
157
  if (command.name === null || !ENGINE_COMMANDS.has(command.name)) continue;
173
- if (isPreviewCommand(command) || isCandidateCommand(command)) {
174
- hidden.add(command.seq);
175
- continue;
176
- }
158
+ if (isPreviewCommand(command) || isCandidateCommand(command)) continue;
177
159
  if (command.outcome?.kind !== "success") continue;
178
160
  const marker = command.outcome.sourceEventSeq;
179
161
  if (marker === void 0) continue;
180
- hidden.add(command.seq);
181
162
  const target = targetSeqOfArgs(command.args);
182
- if (target !== void 0) {
183
- spans.push({ start: target, end: marker });
184
- }
185
- }
186
- const cuts = coalesceCuts(spans);
187
- for (const key of snap.order) {
188
- const node = snap.nodes.get(key);
189
- if (node === void 0) continue;
190
- if (cutsContain(cuts, node.anchorSeq)) hidden.add(node.anchorSeq);
191
- }
192
- try {
193
- addWithdrawnTurnChrome(snap, cuts, hidden);
194
- } catch {
163
+ if (target === void 0 || target > marker) continue;
164
+ cuts.push({ target, marker });
195
165
  }
196
- hiddenSeqsMemo.set(snap, new Set(hidden));
197
- return hidden;
198
- }
199
-
200
- // src/client/candidates.ts
201
- var PREVIEW_CHARS = 80;
202
- var DEFAULT_CANDIDATE_LIMIT = 100;
203
- function messagePreviewOf(message) {
204
- const text = message.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("").replace(/\s+/g, " ").trim();
205
- return text.length <= PREVIEW_CHARS ? text : `${text.slice(0, PREVIEW_CHARS - 1)}\u2026`;
206
- }
207
- function formatCandidateTime(time) {
208
- const d = new Date(time);
209
- const hh = String(d.getHours()).padStart(2, "0");
210
- const mm = String(d.getMinutes()).padStart(2, "0");
211
- return `${hh}:${mm}`;
212
- }
213
- function rewindCandidatesOf(snap, hidden, limit = DEFAULT_CANDIDATE_LIMIT) {
214
- const candidates = [];
215
- for (let i = snap.order.length - 1; i >= 0 && candidates.length < limit; i--) {
216
- const key = snap.order[i];
217
- if (key === void 0) continue;
218
- const node = snap.nodes.get(key);
219
- if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
220
- if (hidden.has(node.anchorSeq ?? node.data.seq)) continue;
221
- candidates.push({
222
- seq: node.data.seq,
223
- time: node.data.time,
224
- preview: messagePreviewOf(node.data)
225
- });
226
- }
227
- return candidates;
228
- }
229
- function rewindCandidatesOfChat(snap) {
230
- return rewindCandidatesOf(snap, hiddenSeqsOf(snap));
231
- }
232
- function rewindOptionsFromCandidates(candidates, t) {
233
- return candidates.map((candidate) => ({
234
- id: String(candidate.seq),
235
- label: candidate.preview || t("popover.noText"),
236
- detail: formatCandidateTime(candidate.time)
237
- }));
166
+ return cuts;
238
167
  }
239
168
 
240
169
  // src/client/styles.ts
@@ -503,12 +432,11 @@ function closePopover() {
503
432
  function findCommand(chat, match) {
504
433
  if (chat === void 0) return void 0;
505
434
  let found;
506
- for (const key of chat.order) {
507
- const node = chat.nodes.get(key);
508
- if (node !== void 0 && node.kind === "command") {
509
- const command = node.data;
510
- if (match(command)) found = command;
511
- }
435
+ for (const node of chatNodesOf(chat)) {
436
+ if (node.kind !== "command") continue;
437
+ const command = node.data;
438
+ if (!match(command)) continue;
439
+ if (found === void 0 || command.seq >= found.seq) found = command;
512
440
  }
513
441
  return found;
514
442
  }
@@ -516,12 +444,10 @@ function knownCommandSeqs(session, chatOf, match) {
516
444
  const known = /* @__PURE__ */ new Set();
517
445
  const chat = chatOf(session);
518
446
  if (chat === void 0) return known;
519
- for (const key of chat.order) {
520
- const node = chat.nodes.get(key);
521
- if (node !== void 0 && node.kind === "command") {
522
- const command = node.data;
523
- if (match(command)) known.add(command.seq);
524
- }
447
+ for (const node of chatNodesOf(chat)) {
448
+ if (node.kind !== "command") continue;
449
+ const command = node.data;
450
+ if (match(command)) known.add(command.seq);
525
451
  }
526
452
  return known;
527
453
  }
@@ -850,6 +776,293 @@ function openPopover(opts) {
850
776
  });
851
777
  }
852
778
 
779
+ // src/client/log.ts
780
+ var NS = "dsh-rewind";
781
+ var DEBUG_KEY = "dsh-rewind.debug";
782
+ var ALWAYS_ON = /* @__PURE__ */ new Set(["error", "warn"]);
783
+ function switchValue() {
784
+ try {
785
+ return window.localStorage.getItem(DEBUG_KEY) ?? "";
786
+ } catch {
787
+ return "";
788
+ }
789
+ }
790
+ function matches(value, ns) {
791
+ for (const entry of value.split(",")) {
792
+ const part = entry.trim();
793
+ if (part === "") continue;
794
+ if (part === "*" || part === `${NS}*`) return true;
795
+ if (part.endsWith("*")) {
796
+ if (ns.startsWith(part.slice(0, -1))) return true;
797
+ } else if (ns === part) {
798
+ return true;
799
+ }
800
+ }
801
+ return false;
802
+ }
803
+ function tag(scope) {
804
+ return `[${NS}:${scope}]`;
805
+ }
806
+ function log(level, scope, message, data) {
807
+ if (ALWAYS_ON.has(level)) {
808
+ console[level](tag(scope), message, data);
809
+ return;
810
+ }
811
+ if (!matches(switchValue(), `${NS}:${scope}`)) return;
812
+ console.info(tag(scope), message, data);
813
+ }
814
+ var rewindLog = {
815
+ error: (scope, message, data) => log("error", scope, message, data),
816
+ warn: (scope, message, data) => log("warn", scope, message, data),
817
+ info: (scope, message, data) => log("info", scope, message, data),
818
+ debug: (scope, message, data) => log("debug", scope, message, data)
819
+ };
820
+
821
+ // src/client/visibility.ts
822
+ var patches = /* @__PURE__ */ new Map();
823
+ var currentCleanup;
824
+ function installChatViewVisibility(ctx) {
825
+ let stopped = false;
826
+ const start = () => {
827
+ const scope = ctx.inject?.(["uiConversation"], (child) => {
828
+ if (!stopped) installOn(child.get("uiConversation"), ctx.sessions);
829
+ });
830
+ if (ctx.inject === void 0) {
831
+ installOn(ctx.get("uiConversation"), ctx.sessions);
832
+ }
833
+ return () => {
834
+ stopped = true;
835
+ currentCleanup?.();
836
+ currentCleanup = void 0;
837
+ if (typeof scope === "function") scope();
838
+ };
839
+ };
840
+ if (ctx.effect === void 0) {
841
+ start();
842
+ return;
843
+ }
844
+ ctx.effect(start, "dsh-rewind chat visibility");
845
+ }
846
+ function installOn(ui, sessions) {
847
+ currentCleanup?.();
848
+ currentCleanup = void 0;
849
+ if (ui?.views === void 0) return;
850
+ const views = ui.views;
851
+ const ensure = () => {
852
+ try {
853
+ const definition = views.entries().find((candidate) => candidate.target === "chat");
854
+ if (definition === void 0 || typeof definition.create !== "function") return;
855
+ const current = patches.get(definition);
856
+ const create = definition.create;
857
+ if (current !== void 0 && create === current.wrapper) return;
858
+ const wrapper = function wrappedChatBuilder() {
859
+ return new RewindVisibilityBuilder(create.call(definition));
860
+ };
861
+ const mutable = definition;
862
+ mutable.create = wrapper;
863
+ patches.set(definition, { original: create, wrapper });
864
+ rewindLog.info("visibility", "installed Chat snapshot visibility wrapper");
865
+ rebuildOpenSessions(ui, sessions);
866
+ } catch (error) {
867
+ rewindLog.error("visibility", "Chat visibility install probe failed", error);
868
+ }
869
+ };
870
+ let unsubscribe;
871
+ try {
872
+ unsubscribe = views.subscribe?.(ensure);
873
+ } catch (error) {
874
+ rewindLog.warn("visibility", "Chat view subscription failed", error);
875
+ }
876
+ ensure();
877
+ currentCleanup = () => {
878
+ unsubscribe?.();
879
+ restorePatches();
880
+ rebuildOpenSessions(ui, sessions);
881
+ };
882
+ }
883
+ function restorePatches() {
884
+ for (const [definition, record] of patches) {
885
+ const mutable = definition;
886
+ if (mutable.create === record.wrapper) mutable.create = record.original;
887
+ }
888
+ patches = /* @__PURE__ */ new Map();
889
+ }
890
+ function rebuildOpenSessions(ui, sessions) {
891
+ const state = sessions?.list?.getSnapshot();
892
+ if (state === void 0) return;
893
+ const ids = /* @__PURE__ */ new Set([...state.ids ?? [], ...Object.keys(state.byId ?? {})]);
894
+ for (const id of ids) {
895
+ const row = state.byId?.[id];
896
+ const retained = row?.retainedBy;
897
+ if (retained === void 0 || !Object.values(retained).some((count) => (count ?? 0) > 0)) continue;
898
+ try {
899
+ ui.binding?.(id)?.rebuild?.();
900
+ } catch (error) {
901
+ rewindLog.warn("visibility", `chat rebuild failed for ${id}`, error);
902
+ }
903
+ }
904
+ }
905
+ var RewindVisibilityBuilder = class {
906
+ /** @param inner - the real Chat builder. */
907
+ constructor(inner) {
908
+ __publicField(this, "inner", inner);
909
+ __publicField(this, "originals", /* @__PURE__ */ new Map());
910
+ __publicField(this, "commands", /* @__PURE__ */ new Map());
911
+ __publicField(this, "byTurn", /* @__PURE__ */ new Map());
912
+ __publicField(this, "turnProcess", /* @__PURE__ */ new Map());
913
+ __publicField(this, "derived", /* @__PURE__ */ new Map());
914
+ __publicField(this, "cuts", []);
915
+ __publicField(this, "cutSignature", "[]");
916
+ __publicField(this, "lastSignature", "[]");
917
+ }
918
+ get empty() {
919
+ return this.inner.empty;
920
+ }
921
+ replace(input) {
922
+ this.setAll(input.nodes);
923
+ this.lastSignature = this.cutSignature;
924
+ return this.inner.replace({ nodes: this.deriveAll(), timeline: input.timeline, ...changed(input) });
925
+ }
926
+ apply(input) {
927
+ const affected = /* @__PURE__ */ new Set();
928
+ let commandsChanged = false;
929
+ for (const node of input.upserts) {
930
+ const previous = this.originals.get(node.key);
931
+ const previousTurn = previous === void 0 ? void 0 : turnOf(previous);
932
+ const nextTurn = turnOf(node);
933
+ if (previousTurn !== void 0) affected.add(previousTurn);
934
+ if (nextTurn !== void 0) affected.add(nextTurn);
935
+ if (previous?.kind === "command" || node.kind === "command") commandsChanged = true;
936
+ this.setOriginal(node);
937
+ }
938
+ if (commandsChanged) this.refreshCuts();
939
+ if (this.cutSignature !== this.lastSignature) {
940
+ this.lastSignature = this.cutSignature;
941
+ return this.inner.replace({ nodes: this.deriveAll(), timeline: input.timeline, ...changed(input) });
942
+ }
943
+ const cuts = this.cuts;
944
+ const upserts = /* @__PURE__ */ new Map();
945
+ for (const node of input.upserts) upserts.set(node.key, this.deriveOne(node, cuts));
946
+ for (const turn of affected) {
947
+ const processKey = this.turnProcess.get(turn);
948
+ const base = processKey === void 0 ? void 0 : this.originals.get(processKey);
949
+ if (base === void 0) continue;
950
+ const next = this.deriveTurnProcess(base, cuts, turn, this.deriveOne(base, cuts));
951
+ if (this.derived.get(processKey) !== next) upserts.set(processKey, next);
952
+ }
953
+ const list = [...upserts.values()];
954
+ for (const node of list) this.derived.set(node.key, node);
955
+ return this.inner.apply({ upserts: list, timeline: input.timeline, ...changed(input) });
956
+ }
957
+ groupInput() {
958
+ return this.inner.groupInput?.();
959
+ }
960
+ publish() {
961
+ this.inner.publish?.();
962
+ }
963
+ setAll(nodes) {
964
+ this.originals.clear();
965
+ this.commands.clear();
966
+ this.byTurn.clear();
967
+ this.turnProcess.clear();
968
+ this.derived.clear();
969
+ for (const node of nodes) this.setOriginal(node);
970
+ this.refreshCuts();
971
+ }
972
+ setOriginal(node) {
973
+ const previous = this.originals.get(node.key);
974
+ if (previous !== void 0) this.unindex(previous);
975
+ this.originals.set(node.key, node);
976
+ if (node.kind === "command") this.commands.set(node.key, node);
977
+ const turn = turnOf(node);
978
+ if (turn === void 0) return;
979
+ const members = this.byTurn.get(turn) ?? /* @__PURE__ */ new Map();
980
+ members.set(node.key, node);
981
+ this.byTurn.set(turn, members);
982
+ if (node.kind === "turn-process") this.turnProcess.set(turn, node.key);
983
+ }
984
+ unindex(node) {
985
+ if (node.kind === "command") this.commands.delete(node.key);
986
+ const turn = turnOf(node);
987
+ if (turn === void 0) return;
988
+ const members = this.byTurn.get(turn);
989
+ if (members === void 0) return;
990
+ members.delete(node.key);
991
+ if (members.size === 0) this.byTurn.delete(turn);
992
+ if (node.kind === "turn-process" && this.turnProcess.get(turn) === node.key) {
993
+ this.turnProcess.delete(turn);
994
+ for (const member of members.values()) {
995
+ if (member.kind === "turn-process") {
996
+ this.turnProcess.set(turn, member.key);
997
+ break;
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+ refreshCuts() {
1003
+ this.cuts = rewindCutsOf(this.commands.values());
1004
+ this.cutSignature = JSON.stringify(
1005
+ this.cuts.map((cut) => `${cut.target}:${cut.marker}`).sort()
1006
+ );
1007
+ }
1008
+ deriveAll() {
1009
+ const cuts = this.cuts;
1010
+ const derived = /* @__PURE__ */ new Map();
1011
+ for (const node of this.originals.values()) derived.set(node.key, this.deriveOne(node, cuts));
1012
+ for (const [turn, key] of this.turnProcess) {
1013
+ const base = this.originals.get(key);
1014
+ if (base === void 0) continue;
1015
+ const current = derived.get(key) ?? this.deriveOne(base, cuts);
1016
+ derived.set(key, this.deriveTurnProcess(base, cuts, turn, current));
1017
+ }
1018
+ const result = [...derived.values()];
1019
+ this.derived = derived;
1020
+ return result;
1021
+ }
1022
+ deriveOne(node, cuts) {
1023
+ const base = this.originals.get(node.key) ?? node;
1024
+ if (base.kind === "command") {
1025
+ const command = base.data;
1026
+ if (command.name !== void 0 && command.name !== null && ENGINE_COMMANDS.has(command.name)) {
1027
+ const args = command.args ?? "";
1028
+ const probe = args.includes("preview") || args.includes("__candidates");
1029
+ const executed = command.outcome?.kind === "success" && command.outcome.sourceEventSeq !== void 0;
1030
+ if (probe || executed) return hidden(base);
1031
+ }
1032
+ return base;
1033
+ }
1034
+ const contained = cuts.some((cut) => cut.target <= base.anchorSeq && base.anchorSeq <= cut.marker);
1035
+ return contained ? hidden(base) : base;
1036
+ }
1037
+ deriveTurnProcess(base, cuts, turn = turnOf(base), current = this.deriveOne(base, cuts)) {
1038
+ if (turn === void 0 || current.visibility === "hidden") return current;
1039
+ const members = this.byTurn.get(turn);
1040
+ if (members === void 0) return current;
1041
+ let count = 0;
1042
+ let allHidden = true;
1043
+ for (const member of members.values()) {
1044
+ if (member.key === base.key) continue;
1045
+ count += 1;
1046
+ if (this.deriveOne(member, cuts).visibility !== "hidden") {
1047
+ allHidden = false;
1048
+ break;
1049
+ }
1050
+ }
1051
+ if (count === 0) return current;
1052
+ return allHidden ? hidden(base) : current;
1053
+ }
1054
+ };
1055
+ function hidden(node) {
1056
+ return node.visibility === "hidden" ? node : { ...node, visibility: "hidden" };
1057
+ }
1058
+ function turnOf(node) {
1059
+ const turn = node.location?.turn?.turn;
1060
+ return typeof turn === "number" ? turn : void 0;
1061
+ }
1062
+ function changed(input) {
1063
+ return input.changedTurns === void 0 ? {} : { changedTurns: input.changedTurns };
1064
+ }
1065
+
853
1066
  // src/client/portals.tsx
854
1067
  var import_react = require("react");
855
1068
  var import_react_dom = require("react-dom");
@@ -885,11 +1098,11 @@ function matchPendingRows(rows, steering) {
885
1098
  for (let i = 0; i < rows.length; i++) {
886
1099
  const row = rows[i];
887
1100
  const item = steering[i];
888
- matched.push(item !== void 0 && matches(item, row) ? item.id : null);
1101
+ matched.push(item !== void 0 && matches2(item, row) ? item.id : null);
889
1102
  }
890
1103
  return matched;
891
1104
  }
892
- function matches(item, row) {
1105
+ function matches2(item, row) {
893
1106
  if (item.attachments > 0) return row.attachments === item.attachments;
894
1107
  return row.text === (item.text ?? "");
895
1108
  }
@@ -899,48 +1112,6 @@ function retractSpan(steering, targetId) {
899
1112
  return steering.slice(index).map((item) => item.id);
900
1113
  }
901
1114
 
902
- // src/client/log.ts
903
- var NS = "dsh-rewind";
904
- var DEBUG_KEY = "dsh-rewind.debug";
905
- var ALWAYS_ON = /* @__PURE__ */ new Set(["error", "warn"]);
906
- function switchValue() {
907
- try {
908
- return window.localStorage.getItem(DEBUG_KEY) ?? "";
909
- } catch {
910
- return "";
911
- }
912
- }
913
- function matches2(value, ns) {
914
- for (const entry of value.split(",")) {
915
- const part = entry.trim();
916
- if (part === "") continue;
917
- if (part === "*" || part === `${NS}*`) return true;
918
- if (part.endsWith("*")) {
919
- if (ns.startsWith(part.slice(0, -1))) return true;
920
- } else if (ns === part) {
921
- return true;
922
- }
923
- }
924
- return false;
925
- }
926
- function tag(scope) {
927
- return `[${NS}:${scope}]`;
928
- }
929
- function log(level, scope, message, data) {
930
- if (ALWAYS_ON.has(level)) {
931
- console[level](tag(scope), message, data);
932
- return;
933
- }
934
- if (!matches2(switchValue(), `${NS}:${scope}`)) return;
935
- console.info(tag(scope), message, data);
936
- }
937
- var rewindLog = {
938
- error: (scope, message, data) => log("error", scope, message, data),
939
- warn: (scope, message, data) => log("warn", scope, message, data),
940
- info: (scope, message, data) => log("info", scope, message, data),
941
- debug: (scope, message, data) => log("debug", scope, message, data)
942
- };
943
-
944
1115
  // src/client/portals.tsx
945
1116
  var import_jsx_runtime = require("react/jsx-runtime");
946
1117
  function fillComposerEditable(text) {
@@ -1072,7 +1243,6 @@ function showHint(text) {
1072
1243
  }
1073
1244
  var COMPOSER_EDITABLE_SELECTOR = "[data-composer-input]";
1074
1245
  var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
1075
- var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
1076
1246
  var IGNORED_PORTAL_SCOPE = [
1077
1247
  "[data-composer-seat]",
1078
1248
  "[data-composer-input]",
@@ -1080,14 +1250,6 @@ var IGNORED_PORTAL_SCOPE = [
1080
1250
  "[data-sidebar-chat]",
1081
1251
  "[data-plugin-panel]"
1082
1252
  ].join(", ");
1083
- var GROUP_SHELL_SELECTOR = '[data-chat-anchor-key^="group:"]';
1084
- function resolveSeatAnchorSeq(element, chat) {
1085
- if (chat === void 0) return void 0;
1086
- const flowKey = element.dataset.chatAnchorKey;
1087
- const nodeKey = element.dataset.chatNodeKey;
1088
- const node = (flowKey !== void 0 ? chat.nodes.get(flowKey) : void 0) ?? (nodeKey !== void 0 ? chat.nodes.get(nodeKey) : void 0);
1089
- return node?.anchorSeq;
1090
- }
1091
1253
  var PENDING_SEAT_SELECTOR = "[data-pending-steering]";
1092
1254
  var MESSAGE_ATTACHMENT_SELECTOR = "[data-message-attachments]";
1093
1255
  function actionsContainerOf(row) {
@@ -1097,47 +1259,7 @@ function actionsContainerOf(row) {
1097
1259
  if (structural instanceof HTMLElement && structural.querySelector("button") !== null) return structural;
1098
1260
  return void 0;
1099
1261
  }
1100
- function hideWithdrawnSeats(chat, seats, hiddenSeqs, hidden) {
1101
- if (chat === void 0) return;
1102
- const shells = /* @__PURE__ */ new Map();
1103
- for (const seat of seats) {
1104
- if (seat.matches(GROUP_SHELL_SELECTOR)) continue;
1105
- const anchor = resolveSeatAnchorSeq(seat, chat);
1106
- const withdrawn = anchor !== void 0 && hiddenSeqs.has(anchor);
1107
- if (withdrawn) {
1108
- seat.style.display = "none";
1109
- seat.dataset.dshRewindHidden = "true";
1110
- hidden.add(seat);
1111
- } else if (hidden.has(seat)) {
1112
- seat.style.display = "";
1113
- delete seat.dataset.dshRewindHidden;
1114
- hidden.delete(seat);
1115
- }
1116
- if (seat.dataset.chatNodeKey === void 0) continue;
1117
- const shell = seat.parentElement?.closest(GROUP_SHELL_SELECTOR);
1118
- if (shell === null || shell === void 0) continue;
1119
- shells.set(shell, (shells.get(shell) ?? true) && withdrawn);
1120
- }
1121
- for (const [shell, allWithdrawn] of shells) {
1122
- if (allWithdrawn) {
1123
- shell.style.display = "none";
1124
- shell.dataset.dshRewindHidden = "true";
1125
- hidden.add(shell);
1126
- } else if (hidden.has(shell)) {
1127
- shell.style.display = "";
1128
- delete shell.dataset.dshRewindHidden;
1129
- hidden.delete(shell);
1130
- }
1131
- }
1132
- }
1133
- function showHiddenSeats(hidden) {
1134
- for (const element of hidden) {
1135
- element.style.display = "";
1136
- delete element.dataset.dshRewindHidden;
1137
- }
1138
- hidden.clear();
1139
- }
1140
- function collectTargets(chat, hiddenSeqs) {
1262
+ function collectTargets(chat) {
1141
1263
  const rows = /* @__PURE__ */ new Map();
1142
1264
  for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
1143
1265
  const key = element.dataset.chatAnchorKey;
@@ -1148,7 +1270,6 @@ function collectTargets(chat, hiddenSeqs) {
1148
1270
  const node = chat.nodes.get(key);
1149
1271
  if (node === void 0 || node.kind !== "user" && node.kind !== "steering") continue;
1150
1272
  const user = node.data;
1151
- if (hiddenSeqs.has(node.anchorSeq ?? user.seq)) continue;
1152
1273
  const row = rows.get(key);
1153
1274
  const actions = actionsContainerOf(row);
1154
1275
  if (actions === void 0) continue;
@@ -1159,9 +1280,9 @@ function collectTargets(chat, hiddenSeqs) {
1159
1280
  function isRewindInertSession(snapshot) {
1160
1281
  return snapshot.subagent !== null && snapshot.subagent !== void 0;
1161
1282
  }
1162
- function collectDurableTargets(snapshot, chat, hiddenSeqs) {
1283
+ function collectDurableTargets(snapshot, chat) {
1163
1284
  if (chat === void 0 || isRewindInertSession(snapshot)) return [];
1164
- return collectTargets(chat, hiddenSeqs);
1285
+ return collectTargets(chat);
1165
1286
  }
1166
1287
  function inboxOf(session) {
1167
1288
  return session.projections.faceOf(INBOX_PROJECTION).getSnapshot();
@@ -1210,7 +1331,6 @@ function sameTargets(left, right) {
1210
1331
  }
1211
1332
  function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchChat, t, subscribeLocale, setComposerText, restoreImages }) {
1212
1333
  const [targets, setTargets] = (0, import_react.useState)([]);
1213
- const hidden = (0, import_react.useRef)(/* @__PURE__ */ new Set());
1214
1334
  const [, forceRender] = (0, import_react.useReducer)((count) => count + 1, 0);
1215
1335
  (0, import_react.useEffect)(() => subscribeLocale(() => {
1216
1336
  forceRender();
@@ -1230,17 +1350,7 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1230
1350
  }
1231
1351
  const snapshot = session.getSnapshot();
1232
1352
  const chat = chatOf(session);
1233
- const hiddenSeqs = chat === void 0 ? /* @__PURE__ */ new Set() : hiddenSeqsOf(chat);
1234
- for (const element of hidden.current) if (!element.isConnected) hidden.current.delete(element);
1235
- if (hiddenSeqs.size > 0 || hidden.current.size > 0) {
1236
- hideWithdrawnSeats(
1237
- chat,
1238
- chat === void 0 ? [] : document.querySelectorAll(CHAT_SEAT_SELECTOR),
1239
- hiddenSeqs,
1240
- hidden.current
1241
- );
1242
- }
1243
- const durable = collectDurableTargets(snapshot, chat, hiddenSeqs);
1353
+ const durable = collectDurableTargets(snapshot, chat);
1244
1354
  const next = [...durable, ...collectPendingTargets(snapshot, steeringItemsOf(inboxOf(session)?.["next-step"]))];
1245
1355
  setTargets((current) => sameTargets(current, next) ? current : next);
1246
1356
  };
@@ -1268,26 +1378,17 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
1268
1378
  const unsubscribeChat = watchChat(sessionId, queueRefresh);
1269
1379
  const observer = new MutationObserver((records) => {
1270
1380
  for (const record of records) {
1271
- if (record.type === "attributes") {
1272
- const target = record.target;
1273
- if (target instanceof HTMLElement && hidden.current.has(target) && target.style.display !== "none") {
1274
- queueRefresh();
1275
- return;
1276
- }
1277
- continue;
1278
- }
1279
1381
  const node = record.target instanceof Element ? record.target : record.target.parentElement;
1280
1382
  if (node !== null && node.closest(IGNORED_PORTAL_SCOPE) !== null) continue;
1281
1383
  queueRefresh();
1282
1384
  return;
1283
1385
  }
1284
1386
  });
1285
- observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
1387
+ observer.observe(document.body, { childList: true, subtree: true });
1286
1388
  return () => {
1287
1389
  active = false;
1288
1390
  observer.disconnect();
1289
1391
  unsubscribeChat();
1290
- showHiddenSeats(hidden.current);
1291
1392
  unsubscribeInbox?.();
1292
1393
  unsubscribeInbox = void 0;
1293
1394
  inboxOwner = void 0;
@@ -1475,9 +1576,9 @@ async function candidatesOverRpc(connection, sessionId) {
1475
1576
  }
1476
1577
 
1477
1578
  // src/client/build-info.ts
1478
- var PLUGIN_VERSION = true ? "0.18.1" : "dev";
1579
+ var PLUGIN_VERSION = true ? "0.19.0" : "dev";
1479
1580
  var PLUGIN_PACKAGE = "dsh-rewind-plugin";
1480
- var BUILD_HASH = true ? "8a3779b4" : "dev";
1581
+ var BUILD_HASH = true ? "bccc8c9b" : "dev";
1481
1582
 
1482
1583
  // src/client/locales.ts
1483
1584
  var zh = {
@@ -1707,6 +1808,7 @@ function apply(ctx) {
1707
1808
  style.dataset.plugin = PLUGIN_PACKAGE;
1708
1809
  style.textContent = STYLE;
1709
1810
  document.head.appendChild(style);
1811
+ installChatViewVisibility(ctx);
1710
1812
  const sessionOf = (sessionId) => ctx.sessions.binding(sessionId)?.session;
1711
1813
  const isMainViewSession = (sessionId) => (ctx.sessions.list.getSnapshot().byId[sessionId]?.retainedBy.mainView ?? 0) > 0;
1712
1814
  const subscribeLocale = (cb) => ctx.locale.subscribe(cb);
@@ -43,7 +43,6 @@ export interface CandidateChat {
43
43
  /** A user/steering row subset; only fields the listing reads are real. */
44
44
  export interface CandidateUserNode {
45
45
  readonly kind: string;
46
- readonly anchorSeq?: number;
47
46
  readonly data: {
48
47
  readonly seq: number;
49
48
  readonly time: number;
@@ -64,15 +63,14 @@ export declare function messagePreviewOf(message: {
64
63
  export declare function formatCandidateTime(time: number): string;
65
64
  /**
66
65
  * List the selectable rewind candidates of a session chat snapshot: user and
67
- * steering rows still on the surface (not hidden by a previous rewind), the
68
- * newest `limit` kept, newest first — the top row is the default highlight,
66
+ * steering rows still on the surface (withdrawn rows are absent from `order`),
67
+ * the newest `limit` kept, newest first — the top row is the default highlight,
69
68
  * i.e. the most recent message and the most common rewind target.
70
69
  * @param snap - the session chat snapshot.
71
- * @param hidden - anchor seqs withdrawn by rewinds (from `hiddenSeqsOf`).
72
70
  * @param limit - maximum number of candidates to return.
73
71
  */
74
- export declare function rewindCandidatesOf(snap: CandidateChat, hidden: ReadonlySet<number>, limit?: number): RewindCandidate[];
75
- /** The candidates of a live chat snapshot, withdrawn rows already excluded. */
72
+ export declare function rewindCandidatesOf(snap: CandidateChat, limit?: number): RewindCandidate[];
73
+ /** The candidates of a live chat snapshot; withdrawn rows are already excluded from `order`. */
76
74
  export declare function rewindCandidatesOfChat(snap: CandidateChat): RewindCandidate[];
77
75
  /**
78
76
  * Map typed candidates to popupSelect rows. The list comes from the host over
@@ -1,7 +1,5 @@
1
1
  /**
2
- * Pure computation of the chat rows a rewind hides from the rendered
3
- * transcript. Extracted from the client plugin (`src/client/index.ts`) so the
4
- * multi-rewind cut logic stays unit-testable without a DOM.
2
+ * Pure Chat-snapshot readers and executed-rewind cut derivation.
5
3
  *
6
4
  * @module dsh-rewind/client/hidden
7
5
  */
@@ -15,35 +13,39 @@ export declare const ENGINE_COMMAND = "rewind-plugin";
15
13
  export declare const LEGACY_ENGINE_COMMAND = "rewind";
16
14
  /** Engine command names the client recognizes. */
17
15
  export declare const ENGINE_COMMANDS: ReadonlySet<string>;
18
- /** A chat-snapshot view node as the hiding / composer-refill logic reads it.
19
- * The harness's view node shape plus the `anchorSeq` the chat snapshot carries
20
- * (the plugin reads `anchorSeq` off each node; it is not declared on the
21
- * harness's `ConversationViewNode`). */
16
+ /** A Chat-snapshot view node as the client readers see it. */
22
17
  export interface ChatConversationViewNode {
23
18
  readonly key: string;
24
19
  readonly kind?: string;
25
20
  readonly data?: unknown;
26
21
  readonly anchorSeq: number;
27
- /** Owning Turn as the chat snapshot carries it; its `turn` and `start` are read
28
- * (see `addWithdrawnTurnChrome`). */
22
+ /** Owning Turn, when the node has one (used by the visibility summary pass). */
29
23
  readonly location?: {
30
24
  readonly turn?: {
31
25
  readonly turn?: unknown;
32
26
  readonly start?: unknown;
33
27
  };
34
28
  };
29
+ /** Chat-only row visibility; absent on older test doubles. */
30
+ readonly visibility?: 'visible' | 'hidden';
35
31
  }
36
- /** Minimal chat snapshot reader the hiding logic needs. */
32
+ /** Minimal Chat-snapshot reader the client helpers need. */
37
33
  export interface HiddenChat {
38
34
  readonly order: readonly string[];
39
35
  readonly nodes: {
40
36
  get(key: string): ChatConversationViewNode | undefined;
41
- };
42
- /** Turn-keyed node index; absent on older snapshots, which the chrome pass tolerates. */
43
- readonly locations?: {
44
- getTurn(turn: number): readonly string[];
37
+ /** Every materialized node, including hidden ones. Absent on older snapshots. */
38
+ values?(): readonly ChatConversationViewNode[];
45
39
  };
46
40
  }
41
+ /**
42
+ * Every materialized Chat node, preferring the store's own values (which also
43
+ * includes rows the visibility layer hid) and falling back to the visible
44
+ * order for older snapshots / test doubles.
45
+ * @param chat - the live chat snapshot, when available.
46
+ * @returns all nodes in store insertion order.
47
+ */
48
+ export declare function chatNodesOf(chat: HiddenChat | undefined): readonly ChatConversationViewNode[];
47
49
  /**
48
50
  * Reader for one session's live chat snapshot: the chat is served by the
49
51
  * `uiConversation` service's named "chat" view (contributed by
@@ -71,7 +73,7 @@ export declare function resolveChatWatch(resolveView: (sessionId: string) => {
71
73
  /**
72
74
  * Resolve the chat snapshot from the `uiConversation` "chat" view. The view's
73
75
  * `getSnapshot()` returns undefined until the named view is registered, which
74
- * degrades to `undefined` (no targets, no hiding — never a crash).
76
+ * degrades to `undefined` (no targets — never a crash).
75
77
  */
76
78
  export declare function chatSnapshotOf(chatView: {
77
79
  getSnapshot(): unknown;
@@ -131,26 +133,18 @@ export declare function targetSeqOfArgs(args: string | null | undefined): number
131
133
  * never trigger a fill.
132
134
  */
133
135
  export declare function isExecutedRewindCommand(node: CommandNode, seq: number): boolean;
136
+ /** One executed rewind's cut: the target seq through the marker's seq, inclusive. */
137
+ export interface RewindCut {
138
+ /** First withdrawn anchor seq. */
139
+ readonly target: number;
140
+ /** Marker log seq, the last withdrawn anchor seq. */
141
+ readonly marker: number;
142
+ }
134
143
  /**
135
- * True when an engine command node is a legacy candidate-list probe
136
- * (`__candidates`). New candidate lists travel over RPC; this only keeps
137
- * probe rows in historical sessions hidden.
138
- */
139
- export declare function isCandidateCommand(command: CommandNode): boolean;
140
- /**
141
- * Anchor seqs that must be hidden from the rendered transcript so the user sees
142
- * the conversation as the agent sees it: every legacy probe flow node (it only
143
- * exists to feed the popover) and every SUCCESSFUL executed `/rewind` command
144
- * row, plus every message a rewind withdrew — the target, everything after it,
145
- * and the (unrendered) marker.
146
- *
147
- * Each executed rewind cuts ONE span `[target, marker]`, and callers must keep
148
- * the spans SEPARATE: collapsing them to `[min target, max marker]` would hide a
149
- * still-on-surface gap of new traffic between an earlier marker and a later
150
- * target. Endpoints come from the command nodes (`sourceEventSeq` is the
151
- * marker's log seq; the outcome text carries the target seq).
152
- *
153
- * A Turn whose rows were ALL withdrawn also contributes its Turn-process summary
154
- * row, whose own anchor can precede the target (see `addWithdrawnTurnChrome`).
144
+ * Read every executed rewind cut from a node set. Internal probes never
145
+ * contribute; a success without a marker rewound nothing; a command whose
146
+ * args carry no `@<seq>` target has no recoverable span.
147
+ * @param nodes - every materialized Chat node.
148
+ * @returns one cut per marker-carrying successful engine command, in input order.
155
149
  */
156
- export declare function hiddenSeqsOf(snap: HiddenChat): Set<number>;
150
+ export declare function rewindCutsOf(nodes: Iterable<ChatConversationViewNode>): RewindCut[];
@@ -9,8 +9,7 @@
9
9
  * The command decoration (`ctx.commandUi.decorate`) makes a bare `/rewind` (or
10
10
  * its alias `/undo`) open the harness's own popupSelect instead of executing;
11
11
  * the parameterized `@<seq>` form stays the execution channel, while the
12
- * popover's impact and candidate reads go over the host RPC channel. The
13
- * exported contract is specified in `docs/contract/client-contract.md`.
12
+ * popover's impact and candidate reads go over the host RPC channel.
14
13
  *
15
14
  * @module dsh-rewind/client
16
15
  */
@@ -46,8 +45,3 @@ export interface ClientContext {
46
45
  * @param ctx - client root context carrying `slots`, `sessions`, `locale` and `commandUi`.
47
46
  */
48
47
  export declare function apply(ctx: ClientContext): void;
49
- /**
50
- * Public contract — rewind visibility. Stable, semver-protected; the rest of
51
- * this module is internal. See `docs/contract/client-contract.md`.
52
- */
53
- export { hiddenSeqsOf, targetSeqOfArgs, type HiddenChat } from './hidden.ts';
@@ -170,22 +170,6 @@ export declare function writeComposer(text: string, facade: ComposerDraftWriter
170
170
  * after switching sessions or restarting dsh.
171
171
  */
172
172
  export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', isMainViewSession: (sessionId: string) => boolean, chatOf: ChatOf, watchChat: ChatWatch, setComposerText: (sessionId: string, text: string) => boolean, restoreImages?: (sessionId: string, images: readonly MessageImageRef[]) => Promise<void>): Promise<void>;
173
- /**
174
- * The snapshot seq a rendered seat stands for.
175
- *
176
- * A seat carries two keys: `data-chat-anchor-key` (the FLOW key, which for a
177
- * step/process part is the composite `["<nodeKey>", "<part>"]`) and
178
- * `data-chat-node-key` (always the plain node key). Resolving only the flow key
179
- * made every grouped process row — the "正在分析/已完成分析" step rows, tool
180
- * groups — unresolvable, so a withdrawn one could never be hidden and stayed on
181
- * screen after a rewind. This mirrors the official viewport's own identity read
182
- * (`anchorKey === key || nodeKey === key`).
183
- *
184
- * @param element - one rendered seat.
185
- * @param chat - the live chat snapshot the seat belongs to.
186
- * @returns the node's `anchorSeq`, or undefined when the seat has no resolvable node.
187
- */
188
- export declare function resolveSeatAnchorSeq(element: HTMLElement, chat: HiddenChat | undefined): number | undefined;
189
173
  /**
190
174
  * Locate the actions container of a user/steering seat row — the element the
191
175
  * ↶ button portals into (the copy/branch IconActions row).
@@ -203,57 +187,12 @@ export declare function resolveSeatAnchorSeq(element: HTMLElement, chat: HiddenC
203
187
  * both durable and pending row shapes without a full React portal render.
204
188
  */
205
189
  export declare function actionsContainerOf(row: HTMLElement | undefined): HTMLElement | undefined;
206
- /**
207
- * Hide every rendered seat a rewind withdrew, plus the step/process GROUP shells
208
- * those seats empty.
209
- *
210
- * A seat's seq comes from `resolveSeatAnchorSeq`: `data-chat-anchor-key` (the
211
- * FLOW key, composite for a step/process part) with `data-chat-node-key` (always
212
- * the plain node key) as the fallback. A group shell is different: it carries
213
- * the composite `group:<json>` anchor, which resolves to no chat node — the
214
- * group is a view construct, not a message. It is therefore judged by its
215
- * members, which THIS batch already holds: a shell whose every member seat is
216
- * withdrawn has no live content left, so it is hidden too. That is what stops a
217
- * fully withdrawn "analysis completed" / "command executed" group from staying
218
- * on screen as an expandable empty row. A shell keeps one live member only if
219
- * that member is genuinely on the surface, in which case the shell stays; a
220
- * shell the batch carries no member of is left alone.
221
- *
222
- * A MEMBER is a real chat node seat — `ChatNodeSeat` is the one shape that
223
- * writes `data-chat-node-key` beside the anchor key. An anchor-only descendant
224
- * (the tool call tree's own `call:<callId>` row, or a third-party anchor) is
225
- * nested decoration: it resolves to no chat node, so it does NOT vote on the
226
- * shell's emptiness. Counting such a row as "still live" is what left a fully
227
- * withdrawn "command executed" tool group on screen as an empty expandable row
228
- * after every member below it had been hidden.
229
- *
230
- * Every hidden element carries `data-dsh-rewind-hidden` (see
231
- * `docs/contract/client-contract.md`).
232
- *
233
- * @param chat - the live chat snapshot the seats belong to.
234
- * @param seats - this refresh's seat batch (every `[data-chat-anchor-key]`).
235
- * @param hiddenSeqs - anchor seqs withdrawn by the executed rewinds.
236
- * @param hidden - elements this pass has hidden, so a re-created row is re-judged.
237
- */
238
- export declare function hideWithdrawnSeats(chat: HiddenChat | undefined, seats: Iterable<HTMLElement>, hiddenSeqs: ReadonlySet<number>, hidden: Set<HTMLElement>): void;
239
- /**
240
- * Undo every hide the given set recorded, so a live disable (or a session-slot
241
- * collapse) cannot leave the transcript truncated.
242
- *
243
- * The hide is the plugin's OWN mutation of host rows ({@link hideWithdrawnSeats}),
244
- * and nothing else would ever revert it: a disposed bridge runs no further
245
- * refresh, so rows it hid would stay hidden with no affordance to bring them
246
- * back.
247
- *
248
- * @param hidden - the elements this bridge currently hides; emptied.
249
- */
250
- export declare function showHiddenSeats(hidden: Set<HTMLElement>): void;
251
190
  /**
252
191
  * Collect the portal targets of one session: user rows × snapshot nodes.
253
192
  * Exported as a test seam — the DOM→targets pairing that drives the ↶ button
254
193
  * is otherwise only reachable through a full React portal render.
255
194
  */
256
- export declare function collectTargets(chat: HiddenChat, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
195
+ export declare function collectTargets(chat: HiddenChat): readonly PortalTarget[];
257
196
  /**
258
197
  * The session-kind slice the durable-target gate reads — the session
259
198
  * snapshot's `subagent` cell, the Harness's own runtime signal for a
@@ -285,9 +224,8 @@ export declare function isRewindInertSession(snapshot: SessionKindLike): boolean
285
224
  * pure, directly testable decision.
286
225
  * @param snapshot - the session snapshot carrying the `subagent` cell.
287
226
  * @param chat - the session's chat snapshot, or undefined while unavailable.
288
- * @param hiddenSeqs - anchor seqs withdrawn by previous rewinds.
289
227
  */
290
- export declare function collectDurableTargets(snapshot: SessionKindLike, chat: HiddenChat | undefined, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
228
+ export declare function collectDurableTargets(snapshot: SessionKindLike, chat: HiddenChat | undefined): readonly PortalTarget[];
291
229
  /**
292
230
  * The pending bubble row's compared text and its message-attachment block
293
231
  * count, read from a CLONE (the live row is never touched).
@@ -320,7 +258,7 @@ interface RewindPortalsProps extends RewindBridgeDeps {
320
258
  * synchronous full-transcript scan inside a commit microtask.
321
259
  *
322
260
  * The refresh is driven by the three inputs its targets are made of: the chat
323
- * snapshot (`hiddenSeqsOf`), the session's `inbox` projection (the pending
261
+ * snapshot's visible order, the session's `inbox` projection (the pending
324
262
  * occurrences — see `syncInboxSubscription`), and the DOM (row/actions mounts,
325
263
  * via the scoped observer).
326
264
  */
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Snapshot-level rewind visibility.
3
+ *
4
+ * DSH's Chat builder already supports hidden rows: a node stays in the node
5
+ * store (so the composer can read its text/images) but leaves `order`,
6
+ * `locations`, Turn navigation, and grouping whenever its `visibility` is
7
+ * `'hidden'`. This module wraps the registered `chat` view builder and marks
8
+ * the nodes inside an executed rewind's `[target, marker]` span hidden before
9
+ * the builder sees them.
10
+ *
11
+ * The seam is private: `uiConversation.views` is a first-party registry and
12
+ * `definition.create()` is not an extension point. Its shape is checked and it
13
+ * is restored on unload.
14
+ *
15
+ * @module dsh-rewind/client/visibility
16
+ */
17
+ import { type ChatConversationViewNode } from './hidden.ts';
18
+ interface BuilderLike {
19
+ readonly empty: unknown;
20
+ replace(input: BuilderInput): unknown;
21
+ apply(input: BuilderApplyInput): unknown;
22
+ groupInput?(): unknown;
23
+ publish?(): void;
24
+ }
25
+ interface BuilderInput {
26
+ readonly nodes: readonly ChatConversationViewNode[];
27
+ readonly timeline: unknown;
28
+ readonly changedTurns?: readonly number[];
29
+ }
30
+ interface BuilderApplyInput {
31
+ readonly upserts: readonly ChatConversationViewNode[];
32
+ readonly timeline: unknown;
33
+ readonly changedTurns?: readonly number[];
34
+ }
35
+ interface SessionsLike {
36
+ readonly list?: {
37
+ getSnapshot(): {
38
+ readonly ids?: readonly string[];
39
+ readonly byId?: Readonly<Record<string, {
40
+ readonly retainedBy?: Readonly<Record<string, number>>;
41
+ }>>;
42
+ };
43
+ };
44
+ }
45
+ /** Context subset the installer needs; structurally satisfied by the client root. */
46
+ export interface VisibilityContext {
47
+ get(name: string): unknown;
48
+ effect?(execute: () => (() => void) | void, label?: string): unknown;
49
+ inject?(names: readonly string[], callback: (scope: VisibilityContext) => void): unknown;
50
+ readonly sessions?: SessionsLike;
51
+ }
52
+ /**
53
+ * Wrap the registered `chat` view builder so rewind spans enter the builder as
54
+ * `visibility: 'hidden'`. Idempotent across registry changes and HMR.
55
+ * @param ctx - client root context.
56
+ */
57
+ export declare function installChatViewVisibility(ctx: VisibilityContext): void;
58
+ /**
59
+ * Structural wrapper over the registered Chat builder. It forwards every
60
+ * builder method, keeps the original node objects, and only changes
61
+ * `visibility`. A full replacement is used whenever the cut set changes;
62
+ * otherwise it stays on the builder's incremental `apply` path.
63
+ */
64
+ export declare class RewindVisibilityBuilder implements BuilderLike {
65
+ private readonly inner;
66
+ private originals;
67
+ private commands;
68
+ private byTurn;
69
+ private turnProcess;
70
+ private derived;
71
+ private cuts;
72
+ private cutSignature;
73
+ private lastSignature;
74
+ /** @param inner - the real Chat builder. */
75
+ constructor(inner: BuilderLike);
76
+ get empty(): unknown;
77
+ replace(input: BuilderInput): unknown;
78
+ apply(input: BuilderApplyInput): unknown;
79
+ groupInput(): unknown;
80
+ publish(): void;
81
+ private setAll;
82
+ private setOriginal;
83
+ private unindex;
84
+ private refreshCuts;
85
+ private deriveAll;
86
+ private deriveOne;
87
+ private deriveTurnProcess;
88
+ }
89
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "In-window conversation rewind with workspace file restore · 同窗口内对话回退并可还原工作区文件",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [