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 +1 -3
- package/lib/client.js +35 -30
- package/package.json +1 -1
- package/test/client-smoke.mjs +21 -99
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.
|
|
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',
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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:
|
|
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
|
-
|
|
1714
|
-
|
|
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
|
+
"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",
|
package/test/client-smoke.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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');
|