dsh-session-rewind 1.3.1 → 1.4.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
@@ -11,9 +11,7 @@ conversation at that Turn and restoring the files that Turn changed.
11
11
 
12
12
  | 位置 | 作用 |
13
13
  | --- | --- |
14
- | **你发出的那条消息旁的 ↶** | 点开选「仅回退对话」或「回退对话并恢复文件」。**就地回退**:这条消息及之后的对话内容从模型上下文撤回,原话立刻回到本会话输入框,改完直接重发。 |
15
- | 会话标题栏的 ↶(`conversation.session.header.actions`) | 列出本会话**所有**可回退的消息(第 N 轮 + 提问预览 + 文件数),↑↓ 选择、Enter 确认、Esc 返回;选中后同样是就地回退,也可以选「在新会话中分支」。 |
16
- | 每个已完成回合末尾的 `回退`(`conversation.chat.turnTail`) | 旧入口,保留兼容:就这一轮做分叉回退,面板里可勾选要恢复的文件。 |
14
+ | **你发出的那条消息旁的 ↶**(唯一入口,常显) | 点开选「仅回退对话」或「回退对话并恢复文件」。**就地回退**:这条消息及之后的对话内容从模型上下文里撤回,原话立刻回到本会话输入框,改完直接重发。回合结束(含自动终止/停止)后按钮自动出现。 |
17
15
 
18
16
  **核心行为(就地回退)**:选中你某条消息 → 追加一条 `surfaceOp: replace` 标记,把模型可见的
19
17
  surface 从这条消息起整体撤回 → 被撤回的行从对话里消失(轨迹面板仍可查,日志从未删改)→
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.1';
69
+ const BOOT_REVISION = '1.4.0';
70
70
  const HIDDEN_ATTR = 'data-session-rewind-hidden';
71
71
 
72
72
  /** One per-message bridge per session, shared by every TurnRewind instance. */
@@ -239,6 +239,9 @@ loader.load({
239
239
  '.dshSessionRewindModeText{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);',
240
240
  'font-family:var(--dsw-font-family);word-break:break-word;max-height:96px;overflow:auto}',
241
241
  '[data-session-rewind-hidden="true"]{display:none !important}',
242
+ '.dshSessionRewindHost{display:flex;justify-content:flex-end;align-items:center;gap:6px;',
243
+ 'padding:2px 6px 0;min-height:20px}',
244
+ '.dshSessionRewindHost:hover button{color:var(--dsw-alias-label-primary)}',
242
245
  ].join('\n');
243
246
  //#endregion
244
247
 
@@ -718,8 +721,14 @@ loader.load({
718
721
 
719
722
  if (sessionId === undefined || target === undefined) return null;
720
723
 
724
+ // This control is mounted for every finished Turn only so the per-message
725
+ // bridge above has a guaranteed mount point. Its own button was retired on
726
+ // request — the rewind control now lives next to the user's own message —
727
+ // so the output is kept invisible instead of dropping the component.
721
728
  return jsxs('span', {
722
729
  className: 'dshSessionRewind',
730
+ hidden: true,
731
+ style: { display: 'none' },
723
732
  ref: wrapRef,
724
733
  children: [
725
734
  jsxs('button', {
@@ -951,11 +960,10 @@ loader.load({
951
960
  };
952
961
 
953
962
  const runRewind = async (seq, restoreFiles, notice, fromTurn = null) => {
954
- notice(tr('notice.working'));
955
963
  try {
964
+ notice(tr('notice.working'));
956
965
  const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
957
966
  if (state.disposed) return;
958
- if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
959
967
  if (Array.isArray(result.hiddenTurns) && result.hiddenTurns.length > 0) {
960
968
  for (const turn of result.hiddenTurns) {
961
969
  if (Number.isInteger(turn)) state.hiddenTurns.add(turn);
@@ -1005,7 +1013,7 @@ loader.load({
1005
1013
  }
1006
1014
  };
1007
1015
 
1008
- const openPopover = (anchor, seq, notice) => {
1016
+ const openPopover = (anchor, seq, notice, turn = null) => {
1009
1017
  closePopover();
1010
1018
  const popover = document.createElement('div');
1011
1019
  popover.setAttribute('data-session-rewind-popover', '');
@@ -1048,7 +1056,18 @@ loader.load({
1048
1056
  button.addEventListener('mouseleave', () => {
1049
1057
  button.style.background = 'transparent';
1050
1058
  });
1051
- button.addEventListener('click', onClick);
1059
+ button.addEventListener('click', () => {
1060
+ try {
1061
+ onClick();
1062
+ } catch (error) {
1063
+ sendDiagnose(sessionId, {
1064
+ reason: 'option-failed',
1065
+ error: String(error && error.message ? error.message : error),
1066
+ boot: BOOT_REVISION,
1067
+ sequence: seq,
1068
+ });
1069
+ }
1070
+ });
1052
1071
  return button;
1053
1072
  };
1054
1073
  popover.appendChild(makeButton(tr('popover.chat'), () => {
@@ -1132,18 +1151,13 @@ loader.load({
1132
1151
  const seq = state.turnToSeq.get(turn);
1133
1152
  if (seq === undefined || seq === null) continue;
1134
1153
  if (row.querySelector('[data-session-rewind-btn]') !== null) continue;
1135
- const buttons = Array.from(row.querySelectorAll('button')).filter(
1136
- (button) => !button.hasAttribute('data-session-rewind-btn'),
1137
- );
1138
- let anchor = buttons.length > 0 ? buttons[buttons.length - 1].parentElement : null;
1139
- if (anchor === null || anchor === undefined) {
1140
- // A plain hover-less user bubble carries no buttons: host the control
1141
- // in its own container at the end of the row.
1142
- anchor = document.createElement('span');
1143
- anchor.setAttribute('data-session-rewind-host', '');
1144
- anchor.style.cssText = 'display:inline-flex;align-items:center;margin-left:6px;vertical-align:middle';
1145
- row.appendChild(anchor);
1146
- }
1154
+ // Always host the control in its own container at the end of the row.
1155
+ // The official action row only paints on hover, so putting the control
1156
+ // there made it invisible until the pointer was over the message.
1157
+ const anchor = document.createElement('div');
1158
+ anchor.setAttribute('data-session-rewind-host', '');
1159
+ anchor.className = 'dshSessionRewindHost';
1160
+ row.appendChild(anchor);
1147
1161
  const button = document.createElement('button');
1148
1162
  button.type = 'button';
1149
1163
  button.setAttribute('data-session-rewind-btn', '');
@@ -1172,7 +1186,7 @@ loader.load({
1172
1186
  'border-radius:4px',
1173
1187
  'background:transparent',
1174
1188
  'cursor:pointer',
1175
- 'color:inherit',
1189
+ 'color:var(--dsw-alias-label-secondary, currentColor)',
1176
1190
  'line-height:0',
1177
1191
  'vertical-align:middle',
1178
1192
  ].join(';');
@@ -1186,7 +1200,7 @@ loader.load({
1186
1200
  event.preventDefault();
1187
1201
  event.stopPropagation();
1188
1202
  sendDiagnose(sessionId, { reason: 'click', turn, seq, buttons: state.buttons.size, mapped: state.turnToSeq.size });
1189
- openPopover(button, seq, notice);
1203
+ openPopover(button, seq, notice, turn);
1190
1204
  });
1191
1205
  anchor.appendChild(button);
1192
1206
  state.buttons.set(row, button);
@@ -1710,17 +1724,8 @@ loader.load({
1710
1724
  } catch (error) {
1711
1725
  warn(error);
1712
1726
  }
1713
- try {
1714
- scope.slots.inject(HEADER_SLOT, () => scope.slots.register({
1715
- name: HEADER_SLOT,
1716
- id: PLUGIN_ID,
1717
- order: -1,
1718
- inject: () => ({ tr }),
1719
- }, headerComponent));
1720
- injected.push(HEADER_SLOT);
1721
- } catch (error) {
1722
- warn(error);
1723
- }
1727
+ // The session-header entry was retired: rewriting is now offered only on
1728
+ // the user's own message row, so no second entry point is registered.
1724
1729
  sendDiagnose('__boot__', {
1725
1730
  reason: 'apply',
1726
1731
  slots: injected,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-rewind",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
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",
@@ -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();
@@ -181,13 +182,13 @@ const context = {
181
182
  },
182
183
  };
183
184
  clientModule.apply(context);
184
- assert.equal(registrations.length, 2);
185
+ // Only the turn-tail slot is registered: it exists purely as a guaranteed mount
186
+ // point for the per-message bridge, and its own button is hidden. The header
187
+ // entry was retired so the rewind control has exactly one place — the user's row.
188
+ assert.equal(registrations.length, 1);
185
189
  const registration = registrations.find((entry) => entry.options.name === 'conversation.chat.turnTail');
186
- const headerRegistration = registrations.find((entry) => entry.options.name === 'conversation.session.header.actions');
187
190
  assert.ok(registration !== undefined, 'turn-tail registration');
188
- assert.ok(headerRegistration !== undefined, 'header registration');
189
- assert.equal(headerRegistration.options.id, 'session-rewind');
190
- assert.equal(headerRegistration.options.order, -1);
191
+ assert.equal(registrations.some((entry) => entry.options.name === 'conversation.session.header.actions'), false);
191
192
  assert.equal(registration.options.name, 'conversation.chat.turnTail');
192
193
  assert.equal(registration.options.id, 'session-rewind');
193
194
  assert.equal(registration.options.order, -1);
@@ -341,100 +342,21 @@ const noBoundary = registration.component({ ...props, turn: { turn: 3 }, seq: un
341
342
  assert.deepEqual(findAll(noBoundary, (node) => node.type === 'button'), []);
342
343
  //#endregion
343
344
 
344
- //#region session-header entry: candidate list -> mode -> fork
345
- const headerChildId = 'session-child-header';
346
- fetchCalls.length = 0;
347
- forkCalls.length = 0;
348
- opened.length = 0;
349
- sessionsService.fork = async (request) => {
350
- forkCalls.push(request);
351
- return headerChildId;
352
- };
353
-
354
- const headerProps = {
355
- sessionId: 'session-parent-1',
356
- useAccess: () => true,
357
- ctx: {
358
- get: (name) => {
359
- if (name === 'sessions') return sessionsService;
360
- if (name === 'uiWorkspace') return workspaceService;
361
- return undefined;
362
- },
363
- },
364
- };
365
-
366
- // The trigger renders even with nothing loaded yet.
367
- // `renderHeader()` forces one full render pass (the tree is built lazily).
368
- const renderHeader = () => {
369
- const tree = headerRegistration.component(headerProps);
370
- findAll(tree, () => false);
371
- return tree;
372
- };
373
- let headerTree = renderHeader();
374
- let headerTrigger = findAll(headerTree, (node) => node.type === 'button'
375
- && String(node.props.className ?? '').includes('dshSessionRewindHeaderBtn'));
376
- assert.equal(headerTrigger.length, 1);
377
-
378
- // Opening it asks the Host for the finished Turns.
379
- headerTrigger[0].props.onClick();
380
- await flush();
381
- assert.equal(fetchCalls.length, 1);
382
- assert.equal(fetchCalls[0].route, '/api/session-rewind.candidates');
383
- assert.deepEqual(fetchCalls[0].body, { sessionId: 'session-parent-1' });
384
- headerTree = renderHeader();
385
-
386
- let headerRounds = findAll(headerTree, (node) => node.type === 'button'
387
- && String(node.props.className ?? '').includes('dshSessionRewindRound'));
388
- assert.equal(headerRounds.length, 2, 'two candidate rounds');
389
- assert.match(textOf(headerTree), /session-rewind\.list\.round/u);
390
- assert.match(textOf(headerTree), /第二条提问/u);
391
- assert.match(textOf(headerTree), /session-rewind\.list\.files/u);
392
-
393
- // Choosing a round switches to the mode step: in-place is the primary action
394
- // and branching is still offered.
395
- headerRounds[0].props.onClick();
396
- assert.match(textOf(headerTree), /session-rewind\.mode\.inplace/u);
397
- assert.match(textOf(headerTree), /session-rewind\.mode\.inplaceFiles/u);
398
- assert.match(textOf(headerTree), /session-rewind\.mode\.fork/u);
399
- assert.match(textOf(headerTree), /session-rewind\.mode\.back/u);
400
-
401
- // Branch mode forks at that round's committed turn end, with no file write.
402
- const headerFork = findAll(headerTree, (node) => node.type === 'button'
403
- && String(node.props.children ?? '') === 'session-rewind.mode.fork');
404
- assert.equal(headerFork.length, 1);
405
- headerFork[0].props.onClick();
406
- await flush(12);
407
- assert.deepEqual(forkCalls, [{ sessionId: 'session-parent-1', atSeq: 28, increaseTitle: true }]);
408
- assert.deepEqual(opened, [headerChildId]);
409
- assert.deepEqual(
410
- JSON.parse(store.get(`dsh.conversation.${headerChildId}`)).draft,
411
- { text: '第二条提问:把登录页改成深色主题', references: [] },
412
- );
413
- assert.equal(fetchCalls.filter((call) => call.route === '/api/session-rewind.restore').length, 0);
414
-
415
- // "Rewind and restore" reaches the restore route first, then forks.
416
- fetchCalls.length = 0;
417
- forkCalls.length = 0;
418
- opened.length = 0;
419
- headerTrigger = findAll(renderHeader(), (node) => node.type === 'button'
420
- && String(node.props.className ?? '').includes('dshSessionRewindHeaderBtn'));
421
- headerTrigger[0].props.onClick();
422
- await flush();
423
- headerTree = renderHeader();
424
- headerRounds = findAll(headerTree, (node) => node.type === 'button'
425
- && String(node.props.className ?? '').includes('dshSessionRewindRound'));
426
- assert.equal(headerRounds.length, 2);
427
- headerRounds[0].props.onClick();
428
- headerTree = renderHeader();
429
- const restoreBoth = findAll(headerTree, (node) => node.type === 'button'
430
- && String(node.props.children ?? '') === 'session-rewind.mode.inplaceFiles');
431
- assert.equal(restoreBoth.length, 1);
432
- // In-place mode without a mount-less bridge must degrade to a visible error
433
- // instead of throwing (the DOM bridge only exists in a real page).
434
- restoreBoth[0].props.onClick();
435
- await flush(12);
436
- assert.match(textOf(headerTree), /session-rewind\.notice\.unavailable/u);
437
- assert.equal(forkCalls.length, 0);
345
+ //#region retired header entry
346
+ // The session-header entry is no longer registered: the rewind control lives on
347
+ // the user's own message row, so there must be exactly one place to click.
348
+ assert.equal(registrations.some((entry) => entry.options.name === 'conversation.session.header.actions'), false);
438
349
  //#endregion
439
350
 
351
+ // --- popover wiring --------------------------------------------------------
352
+ // v1.3.1 shipped a popover whose option callbacks referenced a `turn` the
353
+ // enclosing function never declared. That throws inside a DOM listener, which the
354
+ // browser swallows: clicking a control did nothing at all. Pin the wiring.
355
+ {
356
+ const source = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8');
357
+ assert.match(source, /const openPopover = \(anchor, seq, notice, turn/u);
358
+ assert.match(source, /runRewind\(seq, false, notice, turn\)/u);
359
+ assert.match(source, /runRewind\(seq, true, notice, turn\)/u);
360
+ }
361
+
440
362
  console.log('client smoke: all assertions passed');