dsh-session-rewind 1.3.2 → 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.2';
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', {
@@ -1142,18 +1151,13 @@ loader.load({
1142
1151
  const seq = state.turnToSeq.get(turn);
1143
1152
  if (seq === undefined || seq === null) continue;
1144
1153
  if (row.querySelector('[data-session-rewind-btn]') !== null) continue;
1145
- const buttons = Array.from(row.querySelectorAll('button')).filter(
1146
- (button) => !button.hasAttribute('data-session-rewind-btn'),
1147
- );
1148
- let anchor = buttons.length > 0 ? buttons[buttons.length - 1].parentElement : null;
1149
- if (anchor === null || anchor === undefined) {
1150
- // A plain hover-less user bubble carries no buttons: host the control
1151
- // in its own container at the end of the row.
1152
- anchor = document.createElement('span');
1153
- anchor.setAttribute('data-session-rewind-host', '');
1154
- anchor.style.cssText = 'display:inline-flex;align-items:center;margin-left:6px;vertical-align:middle';
1155
- row.appendChild(anchor);
1156
- }
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);
1157
1161
  const button = document.createElement('button');
1158
1162
  button.type = 'button';
1159
1163
  button.setAttribute('data-session-rewind-btn', '');
@@ -1182,7 +1186,7 @@ loader.load({
1182
1186
  'border-radius:4px',
1183
1187
  'background:transparent',
1184
1188
  'cursor:pointer',
1185
- 'color:inherit',
1189
+ 'color:var(--dsw-alias-label-secondary, currentColor)',
1186
1190
  'line-height:0',
1187
1191
  'vertical-align:middle',
1188
1192
  ].join(';');
@@ -1720,17 +1724,8 @@ loader.load({
1720
1724
  } catch (error) {
1721
1725
  warn(error);
1722
1726
  }
1723
- try {
1724
- scope.slots.inject(HEADER_SLOT, () => scope.slots.register({
1725
- name: HEADER_SLOT,
1726
- id: PLUGIN_ID,
1727
- order: -1,
1728
- inject: () => ({ tr }),
1729
- }, headerComponent));
1730
- injected.push(HEADER_SLOT);
1731
- } catch (error) {
1732
- warn(error);
1733
- }
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.
1734
1729
  sendDiagnose('__boot__', {
1735
1730
  reason: 'apply',
1736
1731
  slots: injected,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-rewind",
3
- "version": "1.3.2",
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",
@@ -182,13 +182,13 @@ const context = {
182
182
  },
183
183
  };
184
184
  clientModule.apply(context);
185
- 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);
186
189
  const registration = registrations.find((entry) => entry.options.name === 'conversation.chat.turnTail');
187
- const headerRegistration = registrations.find((entry) => entry.options.name === 'conversation.session.header.actions');
188
190
  assert.ok(registration !== undefined, 'turn-tail registration');
189
- assert.ok(headerRegistration !== undefined, 'header registration');
190
- assert.equal(headerRegistration.options.id, 'session-rewind');
191
- assert.equal(headerRegistration.options.order, -1);
191
+ assert.equal(registrations.some((entry) => entry.options.name === 'conversation.session.header.actions'), false);
192
192
  assert.equal(registration.options.name, 'conversation.chat.turnTail');
193
193
  assert.equal(registration.options.id, 'session-rewind');
194
194
  assert.equal(registration.options.order, -1);
@@ -342,100 +342,10 @@ const noBoundary = registration.component({ ...props, turn: { turn: 3 }, seq: un
342
342
  assert.deepEqual(findAll(noBoundary, (node) => node.type === 'button'), []);
343
343
  //#endregion
344
344
 
345
- //#region session-header entry: candidate list -> mode -> fork
346
- const headerChildId = 'session-child-header';
347
- fetchCalls.length = 0;
348
- forkCalls.length = 0;
349
- opened.length = 0;
350
- sessionsService.fork = async (request) => {
351
- forkCalls.push(request);
352
- return headerChildId;
353
- };
354
-
355
- const headerProps = {
356
- sessionId: 'session-parent-1',
357
- useAccess: () => true,
358
- ctx: {
359
- get: (name) => {
360
- if (name === 'sessions') return sessionsService;
361
- if (name === 'uiWorkspace') return workspaceService;
362
- return undefined;
363
- },
364
- },
365
- };
366
-
367
- // The trigger renders even with nothing loaded yet.
368
- // `renderHeader()` forces one full render pass (the tree is built lazily).
369
- const renderHeader = () => {
370
- const tree = headerRegistration.component(headerProps);
371
- findAll(tree, () => false);
372
- return tree;
373
- };
374
- let headerTree = renderHeader();
375
- let headerTrigger = findAll(headerTree, (node) => node.type === 'button'
376
- && String(node.props.className ?? '').includes('dshSessionRewindHeaderBtn'));
377
- assert.equal(headerTrigger.length, 1);
378
-
379
- // Opening it asks the Host for the finished Turns.
380
- headerTrigger[0].props.onClick();
381
- await flush();
382
- assert.equal(fetchCalls.length, 1);
383
- assert.equal(fetchCalls[0].route, '/api/session-rewind.candidates');
384
- assert.deepEqual(fetchCalls[0].body, { sessionId: 'session-parent-1' });
385
- headerTree = renderHeader();
386
-
387
- let headerRounds = findAll(headerTree, (node) => node.type === 'button'
388
- && String(node.props.className ?? '').includes('dshSessionRewindRound'));
389
- assert.equal(headerRounds.length, 2, 'two candidate rounds');
390
- assert.match(textOf(headerTree), /session-rewind\.list\.round/u);
391
- assert.match(textOf(headerTree), /第二条提问/u);
392
- assert.match(textOf(headerTree), /session-rewind\.list\.files/u);
393
-
394
- // Choosing a round switches to the mode step: in-place is the primary action
395
- // and branching is still offered.
396
- headerRounds[0].props.onClick();
397
- assert.match(textOf(headerTree), /session-rewind\.mode\.inplace/u);
398
- assert.match(textOf(headerTree), /session-rewind\.mode\.inplaceFiles/u);
399
- assert.match(textOf(headerTree), /session-rewind\.mode\.fork/u);
400
- assert.match(textOf(headerTree), /session-rewind\.mode\.back/u);
401
-
402
- // Branch mode forks at that round's committed turn end, with no file write.
403
- const headerFork = findAll(headerTree, (node) => node.type === 'button'
404
- && String(node.props.children ?? '') === 'session-rewind.mode.fork');
405
- assert.equal(headerFork.length, 1);
406
- headerFork[0].props.onClick();
407
- await flush(12);
408
- assert.deepEqual(forkCalls, [{ sessionId: 'session-parent-1', atSeq: 28, increaseTitle: true }]);
409
- assert.deepEqual(opened, [headerChildId]);
410
- assert.deepEqual(
411
- JSON.parse(store.get(`dsh.conversation.${headerChildId}`)).draft,
412
- { text: '第二条提问:把登录页改成深色主题', references: [] },
413
- );
414
- assert.equal(fetchCalls.filter((call) => call.route === '/api/session-rewind.restore').length, 0);
415
-
416
- // "Rewind and restore" reaches the restore route first, then forks.
417
- fetchCalls.length = 0;
418
- forkCalls.length = 0;
419
- opened.length = 0;
420
- headerTrigger = findAll(renderHeader(), (node) => node.type === 'button'
421
- && String(node.props.className ?? '').includes('dshSessionRewindHeaderBtn'));
422
- headerTrigger[0].props.onClick();
423
- await flush();
424
- headerTree = renderHeader();
425
- headerRounds = findAll(headerTree, (node) => node.type === 'button'
426
- && String(node.props.className ?? '').includes('dshSessionRewindRound'));
427
- assert.equal(headerRounds.length, 2);
428
- headerRounds[0].props.onClick();
429
- headerTree = renderHeader();
430
- const restoreBoth = findAll(headerTree, (node) => node.type === 'button'
431
- && String(node.props.children ?? '') === 'session-rewind.mode.inplaceFiles');
432
- assert.equal(restoreBoth.length, 1);
433
- // In-place mode without a mount-less bridge must degrade to a visible error
434
- // instead of throwing (the DOM bridge only exists in a real page).
435
- restoreBoth[0].props.onClick();
436
- await flush(12);
437
- assert.match(textOf(headerTree), /session-rewind\.notice\.unavailable/u);
438
- 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);
439
349
  //#endregion
440
350
 
441
351
  // --- popover wiring --------------------------------------------------------