dsh-session-rewind 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -66,7 +66,7 @@ loader.load({
66
66
  const CUTS_ROUTE = '/api/session-rewind.cuts';
67
67
  const DIAGNOSE_ROUTE = '/api/session-rewind.diagnose';
68
68
  const TAIL_ROUTE = '/api/session-rewind.tail';
69
- const BOOT_REVISION = '1.3.0';
69
+ const BOOT_REVISION = '1.3.2';
70
70
  const HIDDEN_ATTR = 'data-session-rewind-hidden';
71
71
 
72
72
  /** One per-message bridge per session, shared by every TurnRewind instance. */
@@ -870,6 +870,7 @@ loader.load({
870
870
  : `${tr('notice.failed')}(HTTP ${response.status})`),
871
871
  );
872
872
  error.status = response.status;
873
+ error.code = value !== null && typeof value === 'object' && typeof value.code === 'string' ? value.code : null;
873
874
  throw error;
874
875
  }
875
876
  return value;
@@ -949,12 +950,11 @@ loader.load({
949
950
  }
950
951
  };
951
952
 
952
- const runRewind = async (seq, restoreFiles, notice) => {
953
- notice(tr('notice.working'));
953
+ const runRewind = async (seq, restoreFiles, notice, fromTurn = null) => {
954
954
  try {
955
+ notice(tr('notice.working'));
955
956
  const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
956
957
  if (state.disposed) return;
957
- if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
958
958
  if (Array.isArray(result.hiddenTurns) && result.hiddenTurns.length > 0) {
959
959
  for (const turn of result.hiddenTurns) {
960
960
  if (Number.isInteger(turn)) state.hiddenTurns.add(turn);
@@ -982,16 +982,29 @@ loader.load({
982
982
  }
983
983
  notice(parts.length === 0 ? tr('notice.rewound') : `${tr('notice.rewound')} · ${parts.join(' · ')}`);
984
984
  } catch (error) {
985
+ const code = error !== null && typeof error === 'object' ? error.code ?? null : null;
985
986
  sendDiagnose(sessionId, {
986
987
  reason: 'rewind-failed',
987
988
  error: String(error && error.message ? error.message : error),
989
+ code,
988
990
  sequence: seq,
989
991
  });
992
+ if (code === 'not-on-surface') {
993
+ // Already withdrawn by an earlier rewind: hide the row and say so,
994
+ // instead of reporting a failure the user cannot act on.
995
+ const turn = Number.isInteger(fromTurn) ? fromTurn : null;
996
+ if (Number.isInteger(turn)) {
997
+ state.hiddenTurns.add(turn);
998
+ applyHiding();
999
+ }
1000
+ notice(tr('notice.already'));
1001
+ return;
1002
+ }
990
1003
  notice(error instanceof Error && error.message !== '' ? error.message : tr('notice.failed'));
991
1004
  }
992
1005
  };
993
1006
 
994
- const openPopover = (anchor, seq, notice) => {
1007
+ const openPopover = (anchor, seq, notice, turn = null) => {
995
1008
  closePopover();
996
1009
  const popover = document.createElement('div');
997
1010
  popover.setAttribute('data-session-rewind-popover', '');
@@ -1034,16 +1047,27 @@ loader.load({
1034
1047
  button.addEventListener('mouseleave', () => {
1035
1048
  button.style.background = 'transparent';
1036
1049
  });
1037
- button.addEventListener('click', onClick);
1050
+ button.addEventListener('click', () => {
1051
+ try {
1052
+ onClick();
1053
+ } catch (error) {
1054
+ sendDiagnose(sessionId, {
1055
+ reason: 'option-failed',
1056
+ error: String(error && error.message ? error.message : error),
1057
+ boot: BOOT_REVISION,
1058
+ sequence: seq,
1059
+ });
1060
+ }
1061
+ });
1038
1062
  return button;
1039
1063
  };
1040
1064
  popover.appendChild(makeButton(tr('popover.chat'), () => {
1041
1065
  closePopover();
1042
- void runRewind(seq, false, notice);
1066
+ void runRewind(seq, false, notice, turn);
1043
1067
  }));
1044
1068
  popover.appendChild(makeButton(tr('popover.both'), () => {
1045
1069
  closePopover();
1046
- void runRewind(seq, true, notice);
1070
+ void runRewind(seq, true, notice, turn);
1047
1071
  }));
1048
1072
  document.body.appendChild(popover);
1049
1073
  state.popover = popover;
@@ -1172,7 +1196,7 @@ loader.load({
1172
1196
  event.preventDefault();
1173
1197
  event.stopPropagation();
1174
1198
  sendDiagnose(sessionId, { reason: 'click', turn, seq, buttons: state.buttons.size, mapped: state.turnToSeq.size });
1175
- openPopover(button, seq, notice);
1199
+ openPopover(button, seq, notice, turn);
1176
1200
  });
1177
1201
  anchor.appendChild(button);
1178
1202
  state.buttons.set(row, button);
package/locale/en.json CHANGED
@@ -2,5 +2,6 @@
2
2
  "meta": {
3
3
  "title": "Session Rewind",
4
4
  "description": "Adds a rewind control to every finished Turn plus one entry in the session header. Pick a Turn to branch the conversation there into a new session, put that Turn's original prompt back in the composer so you can edit and resend it, and restore the workspace files that Turn changed to their pre-Turn content."
5
- }
5
+ },
6
+ "notice.already": "That message was already rewound; its row is now hidden."
6
7
  }
package/locale/zh.json CHANGED
@@ -2,5 +2,6 @@
2
2
  "meta": {
3
3
  "title": "会话回退",
4
4
  "description": "在每个已完成的回合旁加一个回退按钮,并在会话标题栏提供总入口:选中某个回合即可把对话从该回合处分叉成新会话,把该回合原本的输入放回输入框以便修改后重发,同时把该回合改动过的工作区文件恢复到修改前的内容。"
5
- }
5
+ },
6
+ "notice.already": "这条消息已经回退过了,已从界面隐藏。"
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-rewind",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "description": "Rewind a finished Turn: branch the conversation at any Turn (session header or per-Turn control) and restore every workspace file that Turn changed. · 回合回退:从任意已完成的回合重新开始,并把该回合改动过的工作区文件恢复原状。",
6
6
  "icon": "./icon.svg",
package/src/lib/route.js CHANGED
@@ -22,6 +22,7 @@ export const INPLACE_PATH = '/api/session-rewind.inplace';
22
22
  export const CUTS_PATH = '/api/session-rewind.cuts';
23
23
  export const DIAGNOSE_PATH = '/api/session-rewind.diagnose';
24
24
  export const TAIL_PATH = '/api/session-rewind.tail';
25
+ export const SURFACE_PATH = '/api/session-rewind.surface';
25
26
  export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
26
27
 
27
28
  /** A rewind request is a session id plus a Turn number or a seq. */
@@ -199,6 +200,58 @@ function targetOf(value) {
199
200
  return { sessionId, turn, seq };
200
201
  }
201
202
 
203
+ /**
204
+ * Build the surface route.
205
+ *
206
+ * `planInPlace` refuses a rewind when the target Seq is not a node of the live
207
+ * Session's surface, so when a click is refused the only useful question is what
208
+ * that surface actually contains. This exposes it read-only.
209
+ *
210
+ * @param {{
211
+ * surfaceOf: (sessionId: string) => { nodes?: readonly unknown[]; replaceGeneration?: number; contentGeneration?: number } | null,
212
+ * trustedHosts?: () => readonly string[],
213
+ * onError?: (error: unknown) => void,
214
+ * }} options
215
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
216
+ */
217
+ export function createSurfaceHandler({ surfaceOf, trustedHosts = () => [], onError = () => {} }) {
218
+ return async (request, response) => {
219
+ try {
220
+ if (request.method !== 'POST') {
221
+ writeText(response, 405, 'method not allowed');
222
+ return;
223
+ }
224
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
225
+ writeText(response, 403, 'forbidden');
226
+ return;
227
+ }
228
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
229
+ if (mediaType !== 'application/json') {
230
+ writeText(response, 415, 'content type must be application/json');
231
+ return;
232
+ }
233
+ const value = await readJsonBody(request);
234
+ if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
235
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
236
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
237
+ const live = surfaceOf(sessionId);
238
+ if (live === null) throw new RequestBoundaryError(409, 'this session is not open right now');
239
+ const nodes = Array.isArray(live.nodes) ? live.nodes.map((node) => (typeof node === 'number' ? node : null)) : [];
240
+ const tail = Number.isInteger(value.tail) && value.tail > 0 && value.tail <= 200 ? value.tail : 20;
241
+ writeJson(response, 200, {
242
+ ok: true,
243
+ count: nodes.length,
244
+ replaceGeneration: live.replaceGeneration ?? null,
245
+ contentGeneration: live.contentGeneration ?? null,
246
+ head: nodes.slice(0, 10),
247
+ tail: nodes.slice(-tail),
248
+ });
249
+ } catch (error) {
250
+ fail(response, error, onError);
251
+ }
252
+ };
253
+ }
254
+
202
255
  /**
203
256
  * Build the event-tail route.
204
257
  *
@@ -172,34 +172,38 @@ export function hiddenFromTurn(events) {
172
172
  /**
173
173
  * Turns whose rows belong to a withdrawn stretch of the log.
174
174
  *
175
- * `hiddenFromSeq` gives the first withdrawn Seq, but the Chat view keys rows by
176
- * Turn, not Seq — and a plain "turn >= first" rule would also hide the turns the
177
- * user starts *after* rewinding. The exact set is the turns spanned by the events
178
- * in `[startSeq, markerSeq)`: everything the marker replaced, and nothing after it.
175
+ * A Session can be rewound more than once, and each rewind appends its own
176
+ * marker, so the withdrawn turns are the union over every marker of the turns
177
+ * spanned by `[surfaceOp.startSeq, markerSeq)`. Taking only the earliest marker —
178
+ * as the first version did — leaves the turns withdrawn later still on screen.
179
179
  *
180
180
  * @param {readonly unknown[]} events
181
181
  * @returns {readonly number[]}
182
182
  */
183
183
  export function withdrawnTurns(events) {
184
- const startSeq = hiddenFromSeq(events);
185
- if (startSeq === null) return [];
186
- let markerSeq = null;
184
+ const spans = [];
187
185
  for (const event of events) {
188
186
  if (!isRecord(event) || event.type !== 'user/message') continue;
189
187
  if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
190
- if (typeof event.seq === 'number' && (markerSeq === null || event.seq < markerSeq)) markerSeq = event.seq;
188
+ if (typeof event.seq !== 'number') continue;
189
+ const op = event.surfaceOp;
190
+ if (!isRecord(op) || op.op !== 'replace' || typeof op.startSeq !== 'number') continue;
191
+ spans.push({ from: op.startSeq, before: event.seq });
191
192
  }
192
- if (markerSeq === null || markerSeq <= startSeq) return [];
193
+ if (spans.length === 0) return [];
193
194
  const turns = new Set();
194
195
  let current = null;
195
196
  for (const event of events) {
196
197
  if (!isRecord(event) || typeof event.seq !== 'number') continue;
197
198
  const turn = event.data?.turn;
198
- if (Number.isInteger(turn) && event.type === 'turn/start') current = turn;
199
- else if (Number.isInteger(turn) && event.type === 'turn/end') current = turn;
200
- if (event.seq < startSeq) continue;
201
- if (event.seq >= markerSeq) break;
202
- if (Number.isInteger(current)) turns.add(current);
199
+ if (Number.isInteger(turn) && (event.type === 'turn/start' || event.type === 'turn/end')) current = turn;
200
+ if (!Number.isInteger(current)) continue;
201
+ for (const span of spans) {
202
+ if (event.seq >= span.from && event.seq < span.before) {
203
+ turns.add(current);
204
+ break;
205
+ }
206
+ }
203
207
  }
204
208
  return [...turns].sort((left, right) => left - right);
205
209
  }
package/src/plugin.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  CUTS_PATH,
30
30
  DIAGNOSE_PATH,
31
31
  INPLACE_PATH,
32
+ SURFACE_PATH,
32
33
  TAIL_PATH,
33
34
  PLAN_PATH,
34
35
  RESTORE_PATH,
@@ -37,6 +38,7 @@ import {
37
38
  createCutsHandler,
38
39
  createDiagnoseHandler,
39
40
  createInplaceHandler,
41
+ createSurfaceHandler,
40
42
  createTailHandler,
41
43
  createPlanHandler,
42
44
  createRestoreHandler,
@@ -595,7 +597,7 @@ export function apply(ctx) {
595
597
  plugin: 'dsh-session-rewind',
596
598
  archive: { directory: archive.directory, ...archiveStats },
597
599
  client: { discovered: clientPath !== null, path: clientPath },
598
- routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
600
+ routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, SURFACE_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
599
601
  };
600
602
  },
601
603
  trustedHosts,
@@ -629,6 +631,12 @@ export function apply(ctx) {
629
631
  onError,
630
632
  });
631
633
 
634
+ const surfaceHandler = createSurfaceHandler({
635
+ surfaceOf: (sessionId) => liveAgentOf(ctx, sessionId)?.session?.surface ?? null,
636
+ trustedHosts,
637
+ onError,
638
+ });
639
+
632
640
  const tailHandler = createTailHandler({
633
641
  events: async (sessionId) => readEvents(ctx, sessionId),
634
642
  trustedHosts,
@@ -650,6 +658,10 @@ export function apply(ctx) {
650
658
  () => scope.webServer.register({ kind: 'exact', path: SELFCHECK_PATH, handler: statusHandler }),
651
659
  'session-rewind: selfcheck route',
652
660
  );
661
+ scope.effect(
662
+ () => scope.webServer.register({ kind: 'exact', path: SURFACE_PATH, handler: surfaceHandler }),
663
+ 'session-rewind: surface route',
664
+ );
653
665
  scope.effect(
654
666
  () => scope.webServer.register({ kind: 'exact', path: TAIL_PATH, handler: tailHandler }),
655
667
  'session-rewind: tail route',
@@ -8,6 +8,7 @@
8
8
 
9
9
  import assert from 'node:assert/strict';
10
10
  import { readFile } from 'node:fs/promises';
11
+ import { readFileSync } from 'node:fs';
11
12
 
12
13
  //#region stubs
13
14
  const listeners = new Map();
@@ -437,4 +438,15 @@ assert.match(textOf(headerTree), /session-rewind\.notice\.unavailable/u);
437
438
  assert.equal(forkCalls.length, 0);
438
439
  //#endregion
439
440
 
441
+ // --- popover wiring --------------------------------------------------------
442
+ // v1.3.1 shipped a popover whose option callbacks referenced a `turn` the
443
+ // enclosing function never declared. That throws inside a DOM listener, which the
444
+ // browser swallows: clicking a control did nothing at all. Pin the wiring.
445
+ {
446
+ const source = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8');
447
+ assert.match(source, /const openPopover = \(anchor, seq, notice, turn/u);
448
+ assert.match(source, /runRewind\(seq, false, notice, turn\)/u);
449
+ assert.match(source, /runRewind\(seq, true, notice, turn\)/u);
450
+ }
451
+
440
452
  console.log('client smoke: all assertions passed');
package/test/selftest.mjs CHANGED
@@ -116,6 +116,25 @@ assert.deepEqual(payload.source, { kind: MARKER_SOURCE_KIND });
116
116
  assert.deepEqual(withdrawnTurns(events), []);
117
117
  }
118
118
 
119
+ // --- two rewinds in one Session -------------------------------------------
120
+ // Each marker withdraws its own span; the union is what must disappear. Missing
121
+ // the second marker is what left the later turns on screen.
122
+ {
123
+ const twice = [
124
+ { seq: 1, type: 'turn/start', data: { turn: 1 } },
125
+ { seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'one' }] } },
126
+ { seq: 5, type: 'turn/end', data: { turn: 1 } },
127
+ { seq: 6, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 5 } },
128
+ { seq: 7, type: 'turn/start', data: { turn: 2 } },
129
+ { seq: 8, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'two' }] } },
130
+ { seq: 11, type: 'turn/end', data: { turn: 2 } },
131
+ { seq: 12, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 8, endSeq: 11 } },
132
+ { seq: 13, type: 'turn/start', data: { turn: 3 } },
133
+ { seq: 14, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'three' }] } },
134
+ ];
135
+ assert.deepEqual(withdrawnTurns(twice), [1, 2]);
136
+ }
137
+
119
138
  // --- archive -------------------------------------------------------------
120
139
  const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
121
140
  const archiveDir = join(root, 'archive');