dsh-session-rewind 0.0.0-stage → 1.2.8

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 ADDED
@@ -0,0 +1,1714 @@
1
+ /*
2
+ * DSH client-module artifact: the rewind control in the Chat turn tail.
3
+ *
4
+ * Shipped in the loader's closure-factory format so a local checkout installs
5
+ * with no build step and no third-party build dependency.
6
+ *
7
+ * Contract notes (verified against the DSH Web client):
8
+ * - `conversation.chat.turnTail` is a `kind: "list"`, `scope: "session"` slot
9
+ * that renders feature contributions immediately before a finished Turn's
10
+ * action row. Its slot props arrive as owner props and override the standard
11
+ * kit, so `turn` / `seq` are the Turn's own values, and the session-scoped
12
+ * standard kit supplies `sessionId` plus the session hooks;
13
+ * - a committed Turn cannot be edited in place: the session log is append-only
14
+ * and branching it is the supported operation. This control therefore uses
15
+ * the same `sessions.fork({ sessionId, atSeq })` + `uiWorkspace.openSession`
16
+ * idiom as the built-in branch button, then pre-seeds the new session's
17
+ * composer draft (the per-session key the Conversation store reads on shell
18
+ * creation) with the Turn's original prompt so it can be edited and resent;
19
+ * - the file half is two same-origin routes served by this package's Host half.
20
+ *
21
+ * Everything is feature-detected: if a future DSH renames a service, the slot or
22
+ * a hook, the affected piece degrades (or the whole control disappears) instead
23
+ * of raising inside the boot activation audit. The factory allocates nothing at
24
+ * top level and cannot throw, and the registration is guarded, so evaluating
25
+ * this file can never break the page boot.
26
+ */
27
+ (() => {
28
+ // The IIFE wrapper exists so every binding stays script-local: a revisioned
29
+ // re-load (HMR) of a classic script would otherwise die on the shared global
30
+ // lexical environment with "Identifier has already been declared".
31
+ const loader = typeof window === 'undefined' ? undefined : window.__ModuleLoader__;
32
+ if (loader === undefined || loader === null || typeof loader.load !== 'function') return;
33
+ loader.load({
34
+ id: 'dsh-session-rewind',
35
+ factory: (require) => {
36
+ const module = { exports: {} };
37
+ const exports = module.exports;
38
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
39
+
40
+ const safeRequire = (id) => {
41
+ try {
42
+ const value = require(id);
43
+ return value === undefined ? null : value;
44
+ } catch {
45
+ return null;
46
+ }
47
+ };
48
+
49
+ const React = safeRequire('react');
50
+ const jsxRuntime = safeRequire('react/jsx-runtime');
51
+
52
+ const jsx = jsxRuntime !== null && typeof jsxRuntime.jsx === 'function' ? jsxRuntime.jsx : null;
53
+ const jsxs = jsxRuntime !== null && typeof jsxRuntime.jsxs === 'function' ? jsxRuntime.jsxs : null;
54
+
55
+ /** The Chat turn tail: rendered before a finished Turn's action row. */
56
+ const SLOT_NAME = 'conversation.chat.turnTail';
57
+ /** The Chat header action row: the discoverable entry to the candidate list. */
58
+ const HEADER_SLOT = 'conversation.session.header.actions';
59
+ const PLUGIN_ID = 'session-rewind';
60
+ const NS = 'session-rewind';
61
+ const STYLE_ID = 'dsh-session-rewind/turn-tail';
62
+ const PLAN_ROUTE = '/api/session-rewind.plan';
63
+ const RESTORE_ROUTE = '/api/session-rewind.restore';
64
+ const CANDIDATES_ROUTE = '/api/session-rewind.candidates';
65
+ const INPLACE_ROUTE = '/api/session-rewind.inplace';
66
+ const CUTS_ROUTE = '/api/session-rewind.cuts';
67
+ const DIAGNOSE_ROUTE = '/api/session-rewind.diagnose';
68
+ const BOOT_REVISION = '1.2.5';
69
+
70
+ /** One per-message bridge per session, shared by every TurnRewind instance. */
71
+ const bridgeRegistry = new Map();
72
+ const NOTICE_MS = 5200;
73
+ /** The Conversation store's persistence prefix; one key per session. */
74
+ const DRAFT_STORE_KEY = 'dsh.conversation';
75
+
76
+ //#region dictionaries
77
+ const zh = {
78
+ 'button.label': '回退',
79
+ 'button.title': '从这个回合重新开始',
80
+ 'panel.title': '从这里重新开始',
81
+ 'panel.hint': '在该回合之后创建一份新会话;之后的消息不会出现在新会话中,原会话保持不变。',
82
+ 'panel.files': '同时恢复该回合改动的 {count} 个文件',
83
+ 'panel.planning': '正在检查可回退的内容…',
84
+ 'panel.noFiles': '该回合没有记录到文件改动',
85
+ 'panel.restoreOne': '恢复原内容',
86
+ 'panel.deleteOne': '删除(该回合新建)',
87
+ 'panel.skipOne': '无法恢复',
88
+ 'action.fork': '回退对话',
89
+ 'action.restore': '仅恢复文件',
90
+ 'action.cancel': '取消',
91
+ 'notice.restored': '已恢复 {count} 个文件',
92
+ 'notice.restoredPartial': '处理了 {ok} 个文件,{failed} 个失败',
93
+ 'notice.noFiles': '该回合没有可恢复的文件',
94
+ 'notice.failed': '回退失败',
95
+ 'notice.unavailable': '当前 DSH 版本不支持该操作',
96
+ 'notice.noSession': '会话服务不可用,无法分叉对话',
97
+ 'notice.forkUnavailable': '该回合还没有已提交的回合末尾,无法分叉',
98
+ 'header.label': '回退',
99
+ 'header.title': '回退到任意已完成的回合',
100
+ 'list.title': '选择要回退到的回合',
101
+ 'list.hint': '列表按时间倒序;↑↓ 选择,Enter 确认,Esc 关闭。',
102
+ 'list.loading': '正在读取可回退的回合…',
103
+ 'list.empty': '还没有已完成的回合可回退',
104
+ 'list.round': '第 {n} 轮',
105
+ 'list.files': '{count} 个文件',
106
+ 'mode.title': '第 {n} 轮',
107
+ 'mode.chatOnly': '仅回退对话',
108
+ 'mode.both': '回退并恢复 {count} 个文件',
109
+ 'mode.back': '返回列表',
110
+ 'mode.note': '回退会在该回合结束处新建一份会话,原会话保持不变。',
111
+ 'mode.inplace': '就地回退',
112
+ 'mode.inplaceFiles': '回退并恢复 {count} 个文件',
113
+ 'mode.fork': '在新会话中分支',
114
+ 'mode.inplaceNote': '就地回退会撤回这条消息及之后的内容,原话回到输入框,可以改完重发;日志只追加,不会删改。',
115
+ 'message.title': '回退到这条消息',
116
+ 'popover.chat': '仅回退对话',
117
+ 'popover.both': '回退对话并恢复文件',
118
+ 'notice.working': '正在回退…',
119
+ 'notice.rewound': '已回退',
120
+ 'notice.refilled': '原话已放回输入框',
121
+ 'notice.failed': '回退失败',
122
+ };
123
+ const en = {
124
+ 'button.label': 'Rewind',
125
+ 'button.title': 'Restart from this Turn',
126
+ 'panel.title': 'Restart from here',
127
+ 'panel.hint': 'Creates a new session that ends at this Turn. Later messages stay out of it and the original session is left untouched.',
128
+ 'panel.files': 'Also restore the {count} file(s) this Turn changed',
129
+ 'panel.planning': 'Checking what can be rewound…',
130
+ 'panel.noFiles': 'No file changes were recorded for this Turn',
131
+ 'panel.restoreOne': 'restore previous content',
132
+ 'panel.deleteOne': 'delete (created by this Turn)',
133
+ 'panel.skipOne': 'not restorable',
134
+ 'action.fork': 'Rewind conversation',
135
+ 'action.restore': 'Restore files only',
136
+ 'action.cancel': 'Cancel',
137
+ 'notice.restored': 'Restored {count} file(s)',
138
+ 'notice.restoredPartial': 'Handled {ok} file(s), {failed} failed',
139
+ 'notice.noFiles': 'This Turn has no restorable files',
140
+ 'notice.failed': 'Rewind failed',
141
+ 'notice.unavailable': 'This DSH version does not support that',
142
+ 'notice.noSession': 'The session service is unavailable, so the conversation cannot be branched',
143
+ 'notice.forkUnavailable': 'This Turn has no committed end yet, so it cannot be branched',
144
+ 'header.label': 'Rewind',
145
+ 'header.title': 'Rewind to any finished Turn',
146
+ 'list.title': 'Choose the Turn to rewind to',
147
+ 'list.hint': 'Newest first. ↑↓ to move, Enter to choose, Esc to close.',
148
+ 'list.loading': 'Reading the finished Turns…',
149
+ 'list.empty': 'No finished Turn to rewind to yet',
150
+ 'list.round': 'Turn {n}',
151
+ 'list.files': '{count} file(s)',
152
+ 'mode.title': 'Turn {n}',
153
+ 'mode.chatOnly': 'Rewind the conversation',
154
+ 'mode.both': 'Rewind and restore {count} file(s)',
155
+ 'mode.back': 'Back to the list',
156
+ 'mode.note': 'The rewind creates a new session that ends at this Turn; the original is left untouched.',
157
+ 'mode.inplace': 'Rewind in place',
158
+ 'mode.inplaceFiles': 'Rewind and restore {count} file(s)',
159
+ 'mode.fork': 'Branch into a new session',
160
+ 'mode.inplaceNote': 'An in-place rewind withdraws this message and everything after it, puts the text back in the composer so you can edit and resend, and leaves the log intact.',
161
+ 'message.title': 'Rewind to this message',
162
+ 'popover.chat': 'Rewind the conversation',
163
+ 'popover.both': 'Rewind and restore files',
164
+ 'notice.working': 'Rewinding…',
165
+ 'notice.rewound': 'Rewound',
166
+ 'notice.refilled': 'Original text is back in the composer',
167
+ 'notice.failed': 'Rewind failed',
168
+ };
169
+ //#endregion
170
+
171
+ //#region stylesheet
172
+ const SHEET = [
173
+ '.dshSessionRewind{position:relative;display:inline-flex;align-items:center;gap:6px;vertical-align:middle}',
174
+ '.dshSessionRewindTrigger{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 7px;',
175
+ 'border:0;border-radius:999px;background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary);',
176
+ 'font-family:var(--dsw-font-family);font-size:11px;line-height:16px}',
177
+ '.dshSessionRewindTrigger:hover:not(:disabled),.dshSessionRewindTrigger[aria-expanded="true"]{',
178
+ 'color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
179
+ '.dshSessionRewindTrigger:disabled{opacity:.55;cursor:default}',
180
+ '.dshSessionRewindGlyph{display:inline-flex;align-items:center;justify-content:center;flex:none}',
181
+ '.dshSessionRewindNotice{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
182
+ '.dshSessionRewindNoticeError{color:var(--dsw-alias-label-error,#b42318)}',
183
+ '.dshSessionRewindPanel{position:absolute;right:0;bottom:calc(100% + 8px);z-index:40;display:flex;',
184
+ 'flex-direction:column;gap:8px;width:340px;max-height:min(60vh,420px);overflow:auto;padding:11px;',
185
+ 'border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-base,#fff);',
186
+ 'box-shadow:0 16px 40px rgba(0,0,0,.22);text-align:left}',
187
+ '.dshSessionRewindTitle{font-size:12px;line-height:18px;font-weight:600;color:var(--dsw-alias-label-primary);',
188
+ 'font-family:var(--dsw-font-family)}',
189
+ '.dshSessionRewindHint{font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary);',
190
+ 'font-family:var(--dsw-font-family)}',
191
+ '.dshSessionRewindFiles{display:flex;flex-direction:column;gap:4px;padding-top:2px;',
192
+ 'border-top:1px solid var(--dsw-alias-border-l2)}',
193
+ '.dshSessionRewindFilesTitle{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);',
194
+ 'font-family:var(--dsw-font-family)}',
195
+ '.dshSessionRewindList{display:flex;flex-direction:column;gap:1px;margin:0;padding:0;list-style:none}',
196
+ '.dshSessionRewindRow{display:flex;align-items:center;gap:6px;padding:2px 0;min-width:0}',
197
+ '.dshSessionRewindCheck{flex:none;width:12px;height:12px;margin:0;accent-color:var(--dsw-alias-label-primary)}',
198
+ '.dshSessionRewindStub{flex:none;width:12px;height:12px}',
199
+ '.dshSessionRewindPath{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
200
+ 'font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}',
201
+ '.dshSessionRewindActionLabel{flex:none;font-size:10px;line-height:15px;color:var(--dsw-alias-label-secondary);',
202
+ 'font-family:var(--dsw-font-family)}',
203
+ '.dshSessionRewindActions{display:flex;align-items:center;justify-content:flex-end;gap:6px;',
204
+ 'padding-top:2px;border-top:1px solid var(--dsw-alias-border-l2)}',
205
+ '.dshSessionRewindButton{padding:4px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;',
206
+ 'background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;',
207
+ 'font-family:var(--dsw-font-family);font-size:11px;line-height:16px}',
208
+ '.dshSessionRewindButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
209
+ '.dshSessionRewindButton:disabled{opacity:.55;cursor:default}',
210
+ '.dshSessionRewindPrimary{border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-label-primary);',
211
+ 'color:var(--dsw-alias-bg-base,#fff)}',
212
+ '.dshSessionRewindPrimary:hover:not(:disabled){opacity:.9;background:var(--dsw-alias-label-primary)}',
213
+ '.dshSessionRewindError{font-size:11px;line-height:17px;color:var(--dsw-alias-label-error,#b42318);',
214
+ 'font-family:var(--dsw-font-family)}',
215
+ // Session-header entry
216
+ '.dshSessionRewindHeader{position:relative;display:inline-flex;align-items:center}',
217
+ '.dshSessionRewindHeaderBtn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;',
218
+ 'padding:0;border:0;border-radius:8px;background:transparent;cursor:pointer;',
219
+ 'color:var(--dsw-alias-label-secondary)}',
220
+ '.dshSessionRewindHeaderBtn:hover:not(:disabled),.dshSessionRewindHeaderBtn[aria-expanded="true"]{',
221
+ 'color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
222
+ '.dshSessionRewindHeaderBtn:disabled{opacity:.55;cursor:default}',
223
+ '.dshSessionRewindMenu{position:absolute;right:0;top:calc(100% + 6px);z-index:40;display:flex;',
224
+ 'flex-direction:column;gap:6px;width:380px;max-height:min(64vh,460px);overflow:auto;padding:10px;',
225
+ 'border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-base,#fff);',
226
+ 'box-shadow:0 16px 40px rgba(0,0,0,.22);text-align:left}',
227
+ '.dshSessionRewindRounds{display:flex;flex-direction:column;gap:1px;margin:0;padding:0;list-style:none}',
228
+ '.dshSessionRewindRound{display:flex;align-items:baseline;gap:8px;padding:5px 7px;border:0;border-radius:7px;',
229
+ 'background:transparent;cursor:pointer;text-align:left;width:100%;',
230
+ 'font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary)}',
231
+ '.dshSessionRewindRound:hover,.dshSessionRewindRound[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}',
232
+ '.dshSessionRewindRound:focus-visible{outline:2px solid var(--dsw-alias-label-secondary);outline-offset:-2px}',
233
+ '.dshSessionRewindRoundTurn{flex:none;font-size:10px;line-height:15px;color:var(--dsw-alias-label-secondary)}',
234
+ '.dshSessionRewindRoundText{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;',
235
+ 'white-space:nowrap;font-size:12px;line-height:17px}',
236
+ '.dshSessionRewindRoundFiles{flex:none;font-size:10px;line-height:15px;color:var(--dsw-alias-label-secondary)}',
237
+ '.dshSessionRewindModeText{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);',
238
+ 'font-family:var(--dsw-font-family);word-break:break-word;max-height:96px;overflow:auto}',
239
+ ].join('\n');
240
+ //#endregion
241
+
242
+ /** Send one structural report to the Host. Never throws, never blocks. */
243
+ function sendDiagnose(sessionId, payload) {
244
+ try {
245
+ void fetch(DIAGNOSE_ROUTE, {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json' },
248
+ body: JSON.stringify({ sessionId, report: payload }),
249
+ credentials: 'same-origin',
250
+ }).catch(() => undefined);
251
+ } catch {
252
+ /* diagnostics must never break the page */
253
+ }
254
+ }
255
+
256
+ function installStyle() {
257
+ if (typeof document === 'undefined') return;
258
+ const existing = document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`);
259
+ if (existing !== null && existing.textContent === SHEET) return;
260
+ const tag = existing ?? document.createElement('style');
261
+ if (existing !== null) {
262
+ // A revisioned reload must replace the previous text, not duplicate it.
263
+ tag.textContent = SHEET;
264
+ return;
265
+ }
266
+ tag.dataset.pluginCss = STYLE_ID;
267
+ tag.textContent = SHEET;
268
+ (document.head ?? document.documentElement)?.appendChild(tag);
269
+ }
270
+
271
+ function fill(template, params) {
272
+ const text = String(template);
273
+ if (params === null || params === undefined) return text;
274
+ return text.replace(/\{(\w+)\}/gu, (match, name) => {
275
+ const value = params[name];
276
+ return value === undefined || value === null ? match : String(value);
277
+ });
278
+ }
279
+
280
+ const prefersChinese = typeof navigator !== 'undefined'
281
+ && typeof navigator.language === 'string'
282
+ && navigator.language.toLowerCase().startsWith('zh');
283
+ const fallbackDict = prefersChinese ? zh : en;
284
+
285
+ function localMessage(key, params) {
286
+ const template = fallbackDict[key] ?? en[key] ?? key;
287
+ return fill(template, params);
288
+ }
289
+
290
+ /** The Turn's committed `turn/end` seq, when the Turn object exposes it. */
291
+ function endSeqOf(turn) {
292
+ if (turn === null || typeof turn !== 'object') return undefined;
293
+ const end = turn.end;
294
+ if (end !== null && typeof end === 'object' && typeof end.seq === 'number') return end.seq;
295
+ return undefined;
296
+ }
297
+
298
+ /** The Turn's own number, whichever field the Chat timeline exposes it on. */
299
+ function turnNumberOf(turn) {
300
+ if (typeof turn === 'number' && Number.isInteger(turn)) return turn;
301
+ if (turn === null || typeof turn !== 'object') return undefined;
302
+ for (const key of ['turn', 'number', 'index']) {
303
+ const value = turn[key];
304
+ if (typeof value === 'number' && Number.isInteger(value)) return value;
305
+ }
306
+ return undefined;
307
+ }
308
+
309
+ function RewindGlyph() {
310
+ return jsx('svg', {
311
+ viewBox: '0 0 16 16',
312
+ width: 12,
313
+ height: 12,
314
+ 'aria-hidden': 'true',
315
+ focusable: 'false',
316
+ children: jsx('path', {
317
+ d: 'M7.2 3.4 2.4 8l4.8 4.6zM13.6 3.4 8.8 8l4.8 4.6z',
318
+ fill: 'currentColor',
319
+ }),
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Pre-seed a freshly forked session's composer draft.
325
+ *
326
+ * The Conversation store persists one key per session and reads it while the
327
+ * session's shell is being created, which happens during
328
+ * `uiWorkspace.openSession`; writing the key before that call is what makes
329
+ * the original prompt show up in the composer, ready to edit.
330
+ */
331
+ function seedDraft(sessionId, text) {
332
+ if (typeof text !== 'string' || text === '') return;
333
+ try {
334
+ if (typeof localStorage === 'undefined') return;
335
+ const key = `${DRAFT_STORE_KEY}.${sessionId}`;
336
+ let stored = { draft: null, view: null, viewRequest: null };
337
+ const raw = localStorage.getItem(key);
338
+ if (raw !== null) {
339
+ try {
340
+ const parsed = JSON.parse(raw);
341
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) stored = parsed;
342
+ } catch {
343
+ /* an unreadable old value is simply replaced */
344
+ }
345
+ }
346
+ stored.draft = { text, references: [] };
347
+ localStorage.setItem(key, JSON.stringify(stored));
348
+ } catch {
349
+ /* a pre-filled draft is a convenience, never a requirement */
350
+ }
351
+ }
352
+
353
+ /**
354
+ * The rewind control for one finished Turn.
355
+ * @param {object} props Slot props: `sessionId`, `turn`, `seq`, `tr`, `useAccess`.
356
+ */
357
+ function TurnRewind(props) {
358
+ const tr = typeof props.tr === 'function' ? props.tr : (key, params) => localMessage(key, params);
359
+ const sessionId = typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : undefined;
360
+ const turnNumber = turnNumberOf(props.turn);
361
+ const endSeq = endSeqOf(props.turn);
362
+ const fallbackSeq = typeof props.seq === 'number' && Number.isInteger(props.seq) ? props.seq : undefined;
363
+ const target = endSeq ?? fallbackSeq;
364
+ const canFork = typeof props.useAccess === 'function' ? props.useAccess('session/fork') !== false : true;
365
+
366
+ const [open, setOpen] = React.useState(false);
367
+ const [planning, setPlanning] = React.useState(false);
368
+ const [plan, setPlan] = React.useState(null);
369
+ const [planError, setPlanError] = React.useState(null);
370
+ const [excluded, setExcluded] = React.useState([]);
371
+ const [notice, setNotice] = React.useState(null);
372
+ const [busy, setBusy] = React.useState(false);
373
+
374
+ const wrapRef = React.useRef(null);
375
+ const mountedRef = React.useRef(true);
376
+ React.useEffect(() => () => {
377
+ mountedRef.current = false;
378
+ }, []);
379
+
380
+ // The per-message bridge lives here because this control is rendered for
381
+ // every finished Turn, so it is guaranteed to mount: one ↶ per user
382
+ // message, the row hiding an executed in-place rewind needs, and the
383
+ // composer refill. Re-created when the session changes, reversed on
384
+ // unmount.
385
+ React.useEffect(() => {
386
+ if (sessionId === undefined) return undefined;
387
+ const existing = bridgeRegistry.get(sessionId);
388
+ if (existing !== undefined) {
389
+ existing.refs += 1;
390
+ return () => {
391
+ existing.refs -= 1;
392
+ if (existing.refs > 0) return;
393
+ bridgeRegistry.delete(sessionId);
394
+ try {
395
+ existing.bridge.dispose();
396
+ } catch (error) {
397
+ warn(error);
398
+ }
399
+ };
400
+ }
401
+ let bridge;
402
+ try {
403
+ bridge = createRowBridge({
404
+ ctx: props?.ctx,
405
+ sessionId,
406
+ tr,
407
+ inputActions: props?.inputActions,
408
+ });
409
+ } catch (error) {
410
+ sendDiagnose('__boot__', {
411
+ reason: 'bridge-failed',
412
+ error: String(error && error.message ? error.message : error),
413
+ boot: BOOT_REVISION,
414
+ });
415
+ warn(error);
416
+ return undefined;
417
+ }
418
+ const entry = { refs: 1, bridge };
419
+ bridgeRegistry.set(sessionId, entry);
420
+ sendDiagnose('__boot__', { reason: 'bridge-mounted', sessionId, boot: BOOT_REVISION });
421
+ return () => {
422
+ entry.refs -= 1;
423
+ if (entry.refs > 0) return;
424
+ bridgeRegistry.delete(sessionId);
425
+ try {
426
+ bridge.dispose();
427
+ } catch (error) {
428
+ warn(error);
429
+ }
430
+ };
431
+ }, [sessionId]);
432
+
433
+ React.useEffect(() => {
434
+ setPlan(null);
435
+ setPlanError(null);
436
+ setExcluded([]);
437
+ setNotice(null);
438
+ setOpen(false);
439
+ }, [sessionId, target]);
440
+
441
+ React.useEffect(() => {
442
+ if (!open) return undefined;
443
+ const onPointerDown = (event) => {
444
+ const root = wrapRef.current;
445
+ if (root === null || root === undefined) return;
446
+ if (typeof event?.target === 'object' && root.contains(event.target)) return;
447
+ setOpen(false);
448
+ };
449
+ const onKeyDown = (event) => {
450
+ if (event?.key === 'Escape') setOpen(false);
451
+ };
452
+ document.addEventListener('pointerdown', onPointerDown, true);
453
+ document.addEventListener('keydown', onKeyDown);
454
+ return () => {
455
+ document.removeEventListener('pointerdown', onPointerDown, true);
456
+ document.removeEventListener('keydown', onKeyDown);
457
+ };
458
+ }, [open]);
459
+
460
+ React.useEffect(() => {
461
+ if (notice === null) return undefined;
462
+ const timer = window.setTimeout(() => {
463
+ if (mountedRef.current) setNotice(null);
464
+ }, NOTICE_MS);
465
+ return () => window.clearTimeout(timer);
466
+ }, [notice]);
467
+
468
+ const serviceOf = (serviceName) => {
469
+ try {
470
+ const value = props?.[serviceName];
471
+ if (value !== undefined && value !== null) return value;
472
+ } catch {
473
+ /* fall through to the context */
474
+ }
475
+ try {
476
+ const fromContext = props?.ctx?.get?.(serviceName);
477
+ if (fromContext !== undefined && fromContext !== null) return fromContext;
478
+ } catch {
479
+ /* treat as unavailable */
480
+ }
481
+ return undefined;
482
+ };
483
+
484
+ const messageOf = (error) => {
485
+ if (error !== null && typeof error === 'object' && error.status === 503) return tr('notice.unavailable');
486
+ const message = error instanceof Error ? error.message : '';
487
+ return typeof message === 'string' && message !== '' ? message : tr('notice.failed');
488
+ };
489
+
490
+ const postJson = async (route, body) => {
491
+ const response = await fetch(route, {
492
+ method: 'POST',
493
+ headers: { 'Content-Type': 'application/json' },
494
+ body: JSON.stringify(body),
495
+ credentials: 'same-origin',
496
+ });
497
+ if (!response.ok) {
498
+ const detail = await response.text().catch(() => '');
499
+ const suffix = detail === '' ? '' : ` ${detail.slice(0, 160)}`;
500
+ const error = new Error(response.status === 503
501
+ ? tr('notice.unavailable')
502
+ : `${tr('notice.failed')}(HTTP ${response.status}${suffix})`);
503
+ error.status = response.status;
504
+ throw error;
505
+ }
506
+ const value = await response.json();
507
+ if (value === null || typeof value !== 'object') throw new Error(tr('notice.failed'));
508
+ return value;
509
+ };
510
+
511
+ const targetOf = () => {
512
+ if (sessionId === undefined) throw new Error(tr('notice.noSession'));
513
+ const payload = { sessionId };
514
+ if (turnNumber !== undefined) payload.turn = turnNumber;
515
+ else if (fallbackSeq !== undefined) payload.seq = fallbackSeq;
516
+ else throw new Error(tr('notice.forkUnavailable'));
517
+ return payload;
518
+ };
519
+
520
+ const loadPlan = async () => {
521
+ if (planning) return;
522
+ setPlanning(true);
523
+ setPlanError(null);
524
+ try {
525
+ const value = await postJson(PLAN_ROUTE, targetOf());
526
+ if (!mountedRef.current) return;
527
+ setPlan(value);
528
+ setExcluded([]);
529
+ } catch (error) {
530
+ if (!mountedRef.current) return;
531
+ setPlan(null);
532
+ setPlanError(messageOf(error));
533
+ } finally {
534
+ if (mountedRef.current) setPlanning(false);
535
+ }
536
+ };
537
+
538
+ const restoreFiles = async (files) => {
539
+ const payload = targetOf();
540
+ payload.paths = files.map((file) => file.path);
541
+ const value = await postJson(RESTORE_ROUTE, payload);
542
+ const results = Array.isArray(value?.results) ? value.results : [];
543
+ const ok = results.filter((result) => result.status === 'restored' || result.status === 'deleted').length;
544
+ return { ok, failed: results.length - ok };
545
+ };
546
+
547
+ const forkConversation = async () => {
548
+ const sessions = serviceOf('sessions');
549
+ if (sessions === undefined || typeof sessions.fork !== 'function') {
550
+ throw new Error(tr('notice.noSession'));
551
+ }
552
+ const boundary = Number.isInteger(plan?.seq) ? plan.seq : target;
553
+ if (!Number.isInteger(boundary)) throw new Error(tr('notice.forkUnavailable'));
554
+ const childId = await sessions.fork({ sessionId, atSeq: boundary, increaseTitle: true });
555
+ if (typeof childId !== 'string' || childId === '') throw new Error(tr('notice.failed'));
556
+ seedDraft(childId, typeof plan?.prompt === 'string' ? plan.prompt : '');
557
+ const workspace = serviceOf('uiWorkspace');
558
+ if (workspace !== undefined && typeof workspace.openSession === 'function') {
559
+ workspace.openSession(childId);
560
+ }
561
+ return childId;
562
+ };
563
+
564
+ const files = Array.isArray(plan?.files) ? plan.files : [];
565
+ const restorable = files.filter((file) => file.action === 'restore' || file.action === 'delete');
566
+ const selected = restorable.filter((file) => !excluded.includes(file.path));
567
+
568
+ const toggle = (path) => {
569
+ setExcluded((previous) => (previous.includes(path)
570
+ ? previous.filter((entry) => entry !== path)
571
+ : [...previous, path]));
572
+ };
573
+
574
+ const runFork = async () => {
575
+ if (busy) return;
576
+ setBusy(true);
577
+ setNotice(null);
578
+ try {
579
+ if (selected.length > 0) await restoreFiles(selected);
580
+ await forkConversation();
581
+ } catch (error) {
582
+ if (mountedRef.current) setNotice({ kind: 'error', text: messageOf(error) });
583
+ } finally {
584
+ if (mountedRef.current) setBusy(false);
585
+ }
586
+ };
587
+
588
+ const runRestoreOnly = async () => {
589
+ if (busy) return;
590
+ if (selected.length === 0) {
591
+ setNotice({ kind: 'error', text: tr('notice.noFiles') });
592
+ return;
593
+ }
594
+ setBusy(true);
595
+ setNotice(null);
596
+ try {
597
+ const outcome = await restoreFiles(selected);
598
+ if (!mountedRef.current) return;
599
+ setOpen(false);
600
+ setNotice({
601
+ kind: outcome.failed === 0 ? 'ok' : 'error',
602
+ text: outcome.failed === 0
603
+ ? tr('notice.restored', { count: outcome.ok })
604
+ : tr('notice.restoredPartial', { ok: outcome.ok, failed: outcome.failed }),
605
+ });
606
+ } catch (error) {
607
+ if (mountedRef.current) setNotice({ kind: 'error', text: messageOf(error) });
608
+ } finally {
609
+ if (mountedRef.current) setBusy(false);
610
+ }
611
+ };
612
+
613
+ const onTrigger = () => {
614
+ if (busy) return;
615
+ if (open) {
616
+ setOpen(false);
617
+ return;
618
+ }
619
+ setOpen(true);
620
+ setNotice(null);
621
+ void loadPlan();
622
+ };
623
+
624
+ const renderFileRow = (file) => {
625
+ const selectable = file.action === 'restore' || file.action === 'delete';
626
+ return jsxs('li', {
627
+ className: 'dshSessionRewindRow',
628
+ key: file.path,
629
+ children: [
630
+ selectable
631
+ ? jsx('input', {
632
+ type: 'checkbox',
633
+ className: 'dshSessionRewindCheck',
634
+ checked: !excluded.includes(file.path),
635
+ disabled: busy,
636
+ onChange: () => toggle(file.path),
637
+ 'aria-label': file.displayPath,
638
+ })
639
+ : jsx('span', { className: 'dshSessionRewindStub', 'aria-hidden': 'true' }),
640
+ jsx('span', {
641
+ className: 'dshSessionRewindPath',
642
+ title: file.path,
643
+ children: file.displayPath,
644
+ }),
645
+ jsx('span', {
646
+ className: 'dshSessionRewindActionLabel',
647
+ children: tr(file.action === 'delete'
648
+ ? 'panel.deleteOne'
649
+ : file.action === 'restore' ? 'panel.restoreOne' : 'panel.skipOne'),
650
+ }),
651
+ ],
652
+ });
653
+ };
654
+
655
+ const renderBody = () => {
656
+ if (planning) return jsx('div', { className: 'dshSessionRewindHint', children: tr('panel.planning') });
657
+ if (planError !== null) return jsx('div', { className: 'dshSessionRewindError', children: planError });
658
+ if (files.length === 0) return jsx('div', { className: 'dshSessionRewindHint', children: tr('panel.noFiles') });
659
+ return jsxs('div', {
660
+ className: 'dshSessionRewindFiles',
661
+ children: [
662
+ jsx('div', {
663
+ className: 'dshSessionRewindFilesTitle',
664
+ children: tr('panel.files', { count: files.length }),
665
+ }),
666
+ jsx('ul', { className: 'dshSessionRewindList', children: files.map(renderFileRow) }),
667
+ ],
668
+ });
669
+ };
670
+
671
+ const renderPanel = () => jsxs('div', {
672
+ className: 'dshSessionRewindPanel',
673
+ role: 'dialog',
674
+ 'aria-label': tr('panel.title'),
675
+ children: [
676
+ jsx('div', { className: 'dshSessionRewindTitle', children: tr('panel.title') }),
677
+ jsx('div', { className: 'dshSessionRewindHint', children: tr('panel.hint') }),
678
+ renderBody(),
679
+ jsxs('div', {
680
+ className: 'dshSessionRewindActions',
681
+ children: [
682
+ canFork
683
+ ? jsx('button', {
684
+ type: 'button',
685
+ className: 'dshSessionRewindButton dshSessionRewindPrimary',
686
+ disabled: busy,
687
+ onClick: () => {
688
+ void runFork();
689
+ },
690
+ children: tr('action.fork'),
691
+ })
692
+ : null,
693
+ restorable.length > 0
694
+ ? jsx('button', {
695
+ type: 'button',
696
+ className: 'dshSessionRewindButton',
697
+ disabled: busy,
698
+ onClick: () => {
699
+ void runRestoreOnly();
700
+ },
701
+ children: tr('action.restore'),
702
+ })
703
+ : null,
704
+ jsx('button', {
705
+ type: 'button',
706
+ className: 'dshSessionRewindButton',
707
+ disabled: busy,
708
+ onClick: () => setOpen(false),
709
+ children: tr('action.cancel'),
710
+ }),
711
+ ],
712
+ }),
713
+ ],
714
+ });
715
+
716
+ if (sessionId === undefined || target === undefined) return null;
717
+
718
+ return jsxs('span', {
719
+ className: 'dshSessionRewind',
720
+ ref: wrapRef,
721
+ children: [
722
+ jsxs('button', {
723
+ type: 'button',
724
+ className: 'dshSessionRewindTrigger',
725
+ title: tr('button.title'),
726
+ 'aria-haspopup': 'dialog',
727
+ 'aria-expanded': open ? 'true' : 'false',
728
+ disabled: busy,
729
+ onClick: onTrigger,
730
+ children: [
731
+ jsx('span', { className: 'dshSessionRewindGlyph', children: jsx(RewindGlyph, {}) }),
732
+ jsx('span', { children: tr('button.label') }),
733
+ ],
734
+ }),
735
+ notice === null || open
736
+ ? null
737
+ : jsx('span', {
738
+ className: notice.kind === 'error'
739
+ ? 'dshSessionRewindNotice dshSessionRewindNoticeError'
740
+ : 'dshSessionRewindNotice',
741
+ children: notice.text,
742
+ }),
743
+ open ? renderPanel() : null,
744
+ ],
745
+ });
746
+ }
747
+
748
+ /** One-line preview of a message body for the candidate list. */
749
+ function previewOf(value) {
750
+ if (typeof value !== 'string') return '—';
751
+ const text = value.replace(/\s+/gu, ' ').trim();
752
+ if (text === '') return '—';
753
+ return text.length > 72 ? `${text.slice(0, 72)}…` : text;
754
+ }
755
+
756
+ /**
757
+ * The per-message rewind bridge.
758
+ *
759
+ * DSH has no slot for an action row inside a user message, so this adds one
760
+ * small control per rendered user row at the DOM level — the same seam the
761
+ * reference `dsh-rewind` uses. Everything is feature-detected and reversed on
762
+ * dispose: rows it hides are restored, buttons it adds are removed, and the
763
+ * observer is disconnected. It never makes a DOM change on its own; the user
764
+ * must click a button.
765
+ */
766
+ function createRowBridge({ ctx, sessionId, tr, inputActions }) {
767
+ const state = {
768
+ disposed: false,
769
+ turnToSeq: new Map(),
770
+ cutTurn: null,
771
+ buttons: new Map(),
772
+ hiddenRows: new Map(),
773
+ observer: null,
774
+ scheduled: 0,
775
+ popover: null,
776
+ };
777
+
778
+ const errors = [];
779
+ const seen = new Set();
780
+
781
+ /**
782
+ * Describe one element's structure without any message text, so the page
783
+ * can tell the Host what it actually renders. Used only for diagnostics.
784
+ */
785
+ const describeNode = (element, depth) => {
786
+ if (element === null || element === undefined || depth < 0) return null;
787
+ const attrs = {};
788
+ for (const attribute of Array.from(element.attributes ?? [])) {
789
+ if (!String(attribute.name).startsWith('data-')) continue;
790
+ attrs[attribute.name] = String(attribute.value).slice(0, 80);
791
+ }
792
+ const children = [];
793
+ for (const child of Array.from(element.children ?? []).slice(0, 8)) {
794
+ const described = describeNode(child, depth - 1);
795
+ if (described !== null) children.push(described);
796
+ }
797
+ return {
798
+ tag: String(element.tagName ?? '').toLowerCase(),
799
+ cls: String(element.className ?? '').slice(0, 120),
800
+ attrs,
801
+ ...(children.length === 0 ? {} : { children }),
802
+ };
803
+ };
804
+
805
+ /** Send one structural snapshot to the Host (no text, memory only). */
806
+ const report = (reason) => {
807
+ let payload;
808
+ try {
809
+ const userRows = Array.from(document.querySelectorAll('[data-chat-flow-kind="user"]'));
810
+ const turnRows = Array.from(document.querySelectorAll('[data-chat-turn]'));
811
+ const mappedRows = userRows.filter((row) => {
812
+ const turn = Number(row.getAttribute('data-chat-turn') ?? row.closest('[data-chat-turn]')?.getAttribute('data-chat-turn'));
813
+ return Number.isInteger(turn) && state.turnToSeq.has(turn);
814
+ });
815
+ payload = {
816
+ reason,
817
+ mounted: true,
818
+ url: String(window.location?.pathname ?? ''),
819
+ counts: {
820
+ userRows: userRows.length,
821
+ turnRows: turnRows.length,
822
+ mappedRows: mappedRows.length,
823
+ mapped: state.turnToSeq.size,
824
+ injected: state.buttons.size,
825
+ glyphs: Array.from(state.buttons.values()).filter((b) => b.querySelector('svg') !== null).length,
826
+ },
827
+ sampleUserRow: userRows[0] === undefined ? null : describeNode(userRows[0], 3),
828
+ sampleTurnRow: turnRows[0] === undefined ? null : describeNode(turnRows[0], 2),
829
+ errors: errors.slice(0, 5),
830
+ };
831
+ } catch (error) {
832
+ payload = { reason, mounted: true, fatal: String(error && error.message ? error.message : error) };
833
+ }
834
+ try {
835
+ void fetch(DIAGNOSE_ROUTE, {
836
+ method: 'POST',
837
+ headers: { 'Content-Type': 'application/json' },
838
+ body: JSON.stringify({ sessionId, report: payload }),
839
+ credentials: 'same-origin',
840
+ }).catch(() => undefined);
841
+ } catch {
842
+ /* diagnostics must never break the page */
843
+ }
844
+ };
845
+
846
+ const serviceOf = (name) => {
847
+ try {
848
+ const value = ctx?.get?.(name);
849
+ return value === undefined ? undefined : value;
850
+ } catch {
851
+ return undefined;
852
+ }
853
+ };
854
+
855
+ const postJson = async (route, body) => {
856
+ const response = await fetch(route, {
857
+ method: 'POST',
858
+ headers: { 'Content-Type': 'application/json' },
859
+ body: JSON.stringify(body),
860
+ credentials: 'same-origin',
861
+ });
862
+ const value = await response.json().catch(() => null);
863
+ if (!response.ok) {
864
+ const error = new Error(
865
+ (value !== null && typeof value === 'object' && typeof value.message === 'string'
866
+ ? value.message
867
+ : `${tr('notice.failed')}(HTTP ${response.status})`),
868
+ );
869
+ error.status = response.status;
870
+ throw error;
871
+ }
872
+ return value;
873
+ };
874
+
875
+ /** Put a message body back into this session's composer. */
876
+ const setComposer = (text) => {
877
+ if (typeof text !== 'string' || text === '') return false;
878
+ try {
879
+ if (inputActions !== undefined && inputActions !== null && typeof inputActions.setDraft === 'function') {
880
+ inputActions.setDraft(text);
881
+ if (typeof inputActions.persistDraft === 'function') inputActions.persistDraft();
882
+ return true;
883
+ }
884
+ } catch {
885
+ /* fall through to the facade */
886
+ }
887
+ try {
888
+ const conversation = serviceOf('conversation');
889
+ const sessions = serviceOf('sessions');
890
+ const scope = typeof sessions?.scope === 'function' ? sessions.scope(sessionId) : undefined;
891
+ const input = conversation?.input;
892
+ if (input !== undefined && scope !== undefined) {
893
+ input.for(scope).setDraft(text);
894
+ if (typeof input.for(scope).focus === 'function') input.for(scope).focus();
895
+ return true;
896
+ }
897
+ } catch {
898
+ /* fall through to the raw element */
899
+ }
900
+ try {
901
+ const element = document.querySelector('[data-composer-input]');
902
+ if (element === null) return false;
903
+ element.textContent = text;
904
+ return true;
905
+ } catch {
906
+ return false;
907
+ }
908
+ };
909
+
910
+ /** Hide or restore the rendered rows at or after the executed cut. */
911
+ const applyHiding = () => {
912
+ if (state.disposed) return;
913
+ const rows = document.querySelectorAll('[data-chat-turn]');
914
+ for (const row of rows) {
915
+ const turn = Number(row.getAttribute('data-chat-turn'));
916
+ if (!Number.isInteger(turn)) continue;
917
+ const shouldHide = state.cutTurn !== null && turn >= state.cutTurn;
918
+ const already = state.hiddenRows.get(row);
919
+ if (shouldHide && already === undefined) {
920
+ state.hiddenRows.set(row, row.style.display);
921
+ row.style.display = 'none';
922
+ } else if (!shouldHide && already !== undefined) {
923
+ row.style.display = already;
924
+ state.hiddenRows.delete(row);
925
+ }
926
+ }
927
+ };
928
+
929
+ const closePopover = () => {
930
+ if (state.popover !== null) {
931
+ try {
932
+ state.popover.remove();
933
+ } catch {
934
+ /* already gone */
935
+ }
936
+ state.popover = null;
937
+ }
938
+ };
939
+
940
+ const runRewind = async (seq, restoreFiles, notice) => {
941
+ notice(tr('notice.working'));
942
+ try {
943
+ const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
944
+ if (state.disposed) return;
945
+ if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
946
+ applyHiding();
947
+ const filled = setComposer(typeof result.prompt === 'string' ? result.prompt : '');
948
+ sendDiagnose(sessionId, {
949
+ reason: 'rewind',
950
+ filled,
951
+ markerSeq: result.markerSeq,
952
+ hiddenFromTurn: result.hiddenFromTurn,
953
+ promptChars: typeof result.prompt === 'string' ? result.prompt.length : 0,
954
+ restore: result.restore ?? null,
955
+ injected: state.buttons.size,
956
+ hiddenRows: state.hiddenRows.size,
957
+ });
958
+ const restore = result.restore;
959
+ const parts = [];
960
+ if (filled) parts.push(tr('notice.refilled'));
961
+ if (restore !== undefined && (restore.restored > 0 || restore.deleted > 0)) {
962
+ parts.push(tr('notice.restored', { count: restore.restored + restore.deleted }));
963
+ }
964
+ notice(parts.length === 0 ? tr('notice.rewound') : `${tr('notice.rewound')} · ${parts.join(' · ')}`);
965
+ } catch (error) {
966
+ sendDiagnose(sessionId, {
967
+ reason: 'rewind-failed',
968
+ error: String(error && error.message ? error.message : error),
969
+ sequence: seq,
970
+ });
971
+ notice(error instanceof Error && error.message !== '' ? error.message : tr('notice.failed'));
972
+ }
973
+ };
974
+
975
+ const openPopover = (anchor, seq, notice) => {
976
+ closePopover();
977
+ const popover = document.createElement('div');
978
+ popover.setAttribute('data-session-rewind-popover', '');
979
+ popover.style.cssText = [
980
+ 'position:fixed',
981
+ 'z-index:60',
982
+ 'display:flex',
983
+ 'flex-direction:column',
984
+ 'gap:4px',
985
+ 'min-width:196px',
986
+ 'padding:6px',
987
+ 'border:1px solid var(--dsw-alias-border-l2)',
988
+ 'border-radius:10px',
989
+ 'background:var(--dsw-alias-bg-base,#fff)',
990
+ 'box-shadow:0 12px 28px rgba(0,0,0,.18)',
991
+ ].join(';');
992
+ const rect = typeof anchor.getBoundingClientRect === 'function' ? anchor.getBoundingClientRect() : null;
993
+ if (rect !== null) {
994
+ popover.style.left = `${String(Math.max(8, Math.round(rect.left) - 120))}px`;
995
+ popover.style.top = `${String(Math.round(rect.bottom) + 6)}px`;
996
+ }
997
+ const makeButton = (label, onClick) => {
998
+ const button = document.createElement('button');
999
+ button.type = 'button';
1000
+ button.textContent = label;
1001
+ button.style.cssText = [
1002
+ 'padding:4px 9px',
1003
+ 'border:0',
1004
+ 'border-radius:7px',
1005
+ 'background:transparent',
1006
+ 'cursor:pointer',
1007
+ 'text-align:left',
1008
+ 'font-family:var(--dsw-font-family)',
1009
+ 'font-size:12px',
1010
+ 'color:var(--dsw-alias-label-primary)',
1011
+ ].join(';');
1012
+ button.addEventListener('mouseenter', () => {
1013
+ button.style.background = 'var(--dsw-alias-interactive-bg-hover)';
1014
+ });
1015
+ button.addEventListener('mouseleave', () => {
1016
+ button.style.background = 'transparent';
1017
+ });
1018
+ button.addEventListener('click', onClick);
1019
+ return button;
1020
+ };
1021
+ popover.appendChild(makeButton(tr('popover.chat'), () => {
1022
+ closePopover();
1023
+ void runRewind(seq, false, notice);
1024
+ }));
1025
+ popover.appendChild(makeButton(tr('popover.both'), () => {
1026
+ closePopover();
1027
+ void runRewind(seq, true, notice);
1028
+ }));
1029
+ document.body.appendChild(popover);
1030
+ state.popover = popover;
1031
+ const onOutside = (event) => {
1032
+ if (state.popover === null) return;
1033
+ if (typeof event?.target === 'object' && state.popover.contains(event.target)) return;
1034
+ closePopover();
1035
+ document.removeEventListener('pointerdown', onOutside, true);
1036
+ };
1037
+ const onKey = (event) => {
1038
+ if (event?.key !== 'Escape') return;
1039
+ closePopover();
1040
+ document.removeEventListener('keydown', onKey);
1041
+ };
1042
+ document.addEventListener('pointerdown', onOutside, true);
1043
+ document.addEventListener('keydown', onKey);
1044
+ };
1045
+
1046
+ const notice = (text) => {
1047
+ try {
1048
+ if (typeof inputActions?.persistDraft === 'function') return;
1049
+ } catch {
1050
+ /* ignore */
1051
+ }
1052
+ // A transient status line pinned to the chat, so a failure is visible.
1053
+ let tag = document.querySelector('[data-session-rewind-notice]');
1054
+ if (tag === null) {
1055
+ tag = document.createElement('div');
1056
+ tag.setAttribute('data-session-rewind-notice', '');
1057
+ tag.style.cssText = [
1058
+ 'position:fixed',
1059
+ 'left:50%',
1060
+ 'bottom:96px',
1061
+ 'transform:translateX(-50%)',
1062
+ 'z-index:70',
1063
+ 'padding:4px 10px',
1064
+ 'border-radius:999px',
1065
+ 'background:var(--dsw-alias-bg-base,#fff)',
1066
+ 'border:1px solid var(--dsw-alias-border-l2)',
1067
+ 'color:var(--dsw-alias-label-primary)',
1068
+ 'font-family:var(--dsw-font-family)',
1069
+ 'font-size:11px',
1070
+ 'box-shadow:0 8px 20px rgba(0,0,0,.16)',
1071
+ ].join(';');
1072
+ document.body.appendChild(tag);
1073
+ }
1074
+ tag.textContent = text;
1075
+ window.clearTimeout(notice.timer);
1076
+ notice.timer = window.setTimeout(() => {
1077
+ try {
1078
+ tag.remove();
1079
+ } catch {
1080
+ /* already gone */
1081
+ }
1082
+ }, 4200);
1083
+ };
1084
+
1085
+ const turnOfRow = (row) => {
1086
+ const own = Number(row.getAttribute('data-chat-turn'));
1087
+ if (Number.isInteger(own)) return own;
1088
+ const holder = typeof row.closest === 'function' ? row.closest('[data-chat-turn]') : null;
1089
+ const inherited = Number(holder?.getAttribute('data-chat-turn'));
1090
+ return Number.isInteger(inherited) ? inherited : undefined;
1091
+ };
1092
+
1093
+ const inject = () => {
1094
+ if (state.disposed) return;
1095
+ const rows = Array.from(document.querySelectorAll('[data-chat-flow-kind="user"]'));
1096
+ for (const row of rows) {
1097
+ const turn = turnOfRow(row);
1098
+ if (!Number.isInteger(turn)) continue;
1099
+ const seq = state.turnToSeq.get(turn);
1100
+ if (seq === undefined || seq === null) continue;
1101
+ if (row.querySelector('[data-session-rewind-btn]') !== null) continue;
1102
+ const buttons = Array.from(row.querySelectorAll('button')).filter(
1103
+ (button) => !button.hasAttribute('data-session-rewind-btn'),
1104
+ );
1105
+ let anchor = buttons.length > 0 ? buttons[buttons.length - 1].parentElement : null;
1106
+ if (anchor === null || anchor === undefined) {
1107
+ // A plain hover-less user bubble carries no buttons: host the control
1108
+ // in its own container at the end of the row.
1109
+ anchor = document.createElement('span');
1110
+ anchor.setAttribute('data-session-rewind-host', '');
1111
+ anchor.style.cssText = 'display:inline-flex;align-items:center;margin-left:6px;vertical-align:middle';
1112
+ row.appendChild(anchor);
1113
+ }
1114
+ const button = document.createElement('button');
1115
+ button.type = 'button';
1116
+ button.setAttribute('data-session-rewind-btn', '');
1117
+ button.title = tr('message.title');
1118
+ button.setAttribute('aria-label', tr('message.title'));
1119
+ // Same 16x16 / stroke-2 undo glyph the app's own icon set uses, and
1120
+ // `color: inherit` so it takes the action row's own colour instead of
1121
+ // inventing one.
1122
+ button.innerHTML = [
1123
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="none"',
1124
+ ' stroke="currentColor" stroke-width="2" stroke-linecap="round"',
1125
+ ' stroke-linejoin="round" aria-hidden="true" focusable="false">',
1126
+ '<path d="M9 14 4 9l5-5" />',
1127
+ '<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v.5" />',
1128
+ '</svg>',
1129
+ ].join('');
1130
+ button.style.cssText = [
1131
+ 'display:inline-flex',
1132
+ 'align-items:center',
1133
+ 'justify-content:center',
1134
+ 'flex:none',
1135
+ 'width:20px',
1136
+ 'height:20px',
1137
+ 'padding:0',
1138
+ 'border:0',
1139
+ 'border-radius:4px',
1140
+ 'background:transparent',
1141
+ 'cursor:pointer',
1142
+ 'color:inherit',
1143
+ 'line-height:0',
1144
+ 'vertical-align:middle',
1145
+ ].join(';');
1146
+ button.addEventListener('mouseenter', () => {
1147
+ button.style.background = 'var(--dsw-alias-interactive-bg-hover)';
1148
+ });
1149
+ button.addEventListener('mouseleave', () => {
1150
+ button.style.background = 'transparent';
1151
+ });
1152
+ button.addEventListener('click', (event) => {
1153
+ event.preventDefault();
1154
+ event.stopPropagation();
1155
+ openPopover(button, seq, notice);
1156
+ });
1157
+ anchor.appendChild(button);
1158
+ state.buttons.set(row, button);
1159
+ }
1160
+ };
1161
+
1162
+ let reporting = false;
1163
+ const schedule = () => {
1164
+ if (state.disposed || state.scheduled !== 0) return;
1165
+ state.scheduled = window.setTimeout(() => {
1166
+ state.scheduled = 0;
1167
+ let injected = state.buttons.size;
1168
+ try {
1169
+ inject();
1170
+ applyHiding();
1171
+ } catch (error) {
1172
+ errors.push(String(error && error.message ? error.message : error));
1173
+ }
1174
+ // A message sent after the last fetch has no target yet: re-read the
1175
+ // candidate map (throttled) so the newest row gets a control too.
1176
+ try {
1177
+ const unmapped = Array.from(document.querySelectorAll('[data-chat-flow-kind="user"]')).some(
1178
+ (row) => row.querySelector('[data-session-rewind-btn]') === null,
1179
+ );
1180
+ if (unmapped && Date.now() - candidatesAt > 1500) void loadCandidates();
1181
+ } catch {
1182
+ /* ignore */
1183
+ }
1184
+ // Report once when the page has rows but nothing could be attached, so
1185
+ // the Host can see why without a browser inspector.
1186
+ if (!reporting && state.buttons.size > 0) {
1187
+ reporting = true;
1188
+ report('attached');
1189
+ } else if (!reporting && state.buttons.size === 0 && injected === 0) {
1190
+ const marker = `${String(document.querySelectorAll('[data-chat-flow-kind="user"]').length)}:${String(state.turnToSeq.size)}`;
1191
+ if (!seen.has(marker)) {
1192
+ seen.add(marker);
1193
+ report('no-attach');
1194
+ }
1195
+ }
1196
+ }, 120);
1197
+ };
1198
+
1199
+ let candidatesAt = 0;
1200
+ const loadCandidates = async () => {
1201
+ candidatesAt = Date.now();
1202
+ try {
1203
+ const value = await postJson(CANDIDATES_ROUTE, { sessionId });
1204
+ if (state.disposed) return;
1205
+ const next = new Map();
1206
+ for (const candidate of Array.isArray(value.turns) ? value.turns : []) {
1207
+ if (Number.isInteger(candidate?.turn) && Number.isInteger(candidate?.userSeq)) {
1208
+ next.set(candidate.turn, candidate.userSeq);
1209
+ }
1210
+ }
1211
+ state.turnToSeq = next;
1212
+ schedule();
1213
+ } catch {
1214
+ /* the bridge simply stays empty */
1215
+ }
1216
+ };
1217
+
1218
+ const loadCuts = async () => {
1219
+ try {
1220
+ const value = await postJson(CUTS_ROUTE, { sessionId });
1221
+ if (state.disposed) return;
1222
+ state.cutTurn = Number.isInteger(value.hiddenFromTurn) ? value.hiddenFromTurn : null;
1223
+ applyHiding();
1224
+ } catch {
1225
+ /* ignore */
1226
+ }
1227
+ };
1228
+
1229
+ try {
1230
+ state.observer = new window.MutationObserver(() => {
1231
+ schedule();
1232
+ });
1233
+ state.observer.observe(document.body, { childList: true, subtree: true });
1234
+ } catch {
1235
+ state.observer = null;
1236
+ }
1237
+ void loadCandidates();
1238
+ void loadCuts();
1239
+ schedule();
1240
+ window.setTimeout(() => {
1241
+ if (!state.disposed) report('mount');
1242
+ }, 2600);
1243
+
1244
+ return {
1245
+ /** Re-read the cut and the candidate map after an external rewind. */
1246
+ refresh() {
1247
+ void loadCandidates();
1248
+ void loadCuts();
1249
+ },
1250
+ /** Run one in-place rewind from any UI that already knows the seq. */
1251
+ rewind(seq, restoreFiles) {
1252
+ return runRewind(seq, restoreFiles === true, notice);
1253
+ },
1254
+ dispose() {
1255
+ state.disposed = true;
1256
+ closePopover();
1257
+ try {
1258
+ state.observer?.disconnect();
1259
+ } catch {
1260
+ /* ignore */
1261
+ }
1262
+ window.clearTimeout(state.scheduled);
1263
+ for (const button of state.buttons.values()) {
1264
+ try {
1265
+ button.remove();
1266
+ } catch {
1267
+ /* ignore */
1268
+ }
1269
+ }
1270
+ state.buttons.clear();
1271
+ for (const [row, display] of state.hiddenRows) {
1272
+ try {
1273
+ row.style.display = display;
1274
+ } catch {
1275
+ /* ignore */
1276
+ }
1277
+ }
1278
+ state.hiddenRows.clear();
1279
+ try {
1280
+ document.querySelector('[data-session-rewind-notice]')?.remove();
1281
+ } catch {
1282
+ /* ignore */
1283
+ }
1284
+ },
1285
+ };
1286
+ }
1287
+
1288
+ /**
1289
+ * The Chat header entry: every finished Turn of this Session as a rewind
1290
+ * target, so a rewind does not require scrolling back to the Turn itself.
1291
+ * Keyboard: ↑↓ move, Enter choose, Esc back/close.
1292
+ */
1293
+ function SessionRewind(props) {
1294
+ const tr = typeof props.tr === 'function' ? props.tr : (key, params) => localMessage(key, params);
1295
+ const sessionId = typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : undefined;
1296
+ const canFork = typeof props.useAccess === 'function' ? props.useAccess('session/fork') !== false : true;
1297
+
1298
+ const [open, setOpen] = React.useState(false);
1299
+ const [loading, setLoading] = React.useState(false);
1300
+ const [rounds, setRounds] = React.useState([]);
1301
+ const [active, setActive] = React.useState(0);
1302
+ const [chosen, setChosen] = React.useState(null);
1303
+ const [busy, setBusy] = React.useState(false);
1304
+ const [error, setError] = React.useState(null);
1305
+ const wrapRef = React.useRef(null);
1306
+ const mountedRef = React.useRef(true);
1307
+
1308
+ React.useEffect(() => () => {
1309
+ mountedRef.current = false;
1310
+ }, []);
1311
+
1312
+ React.useEffect(() => {
1313
+ if (!open) return undefined;
1314
+ const onPointerDown = (event) => {
1315
+ const root = wrapRef.current;
1316
+ if (root === null || root === undefined) return;
1317
+ if (typeof event?.target === 'object' && root.contains(event.target)) return;
1318
+ setOpen(false);
1319
+ setChosen(null);
1320
+ };
1321
+ document.addEventListener('pointerdown', onPointerDown, true);
1322
+ return () => document.removeEventListener('pointerdown', onPointerDown, true);
1323
+ }, [open]);
1324
+
1325
+ React.useEffect(() => {
1326
+ if (!open) return undefined;
1327
+ const onKeyDown = (event) => {
1328
+ const key = event?.key;
1329
+ if (key === 'Escape') {
1330
+ if (chosen !== null) setChosen(null);
1331
+ else setOpen(false);
1332
+ return;
1333
+ }
1334
+ if (chosen !== null) return;
1335
+ if (key === 'ArrowDown' || key === 'ArrowUp') {
1336
+ setActive((previous) => {
1337
+ const size = rounds.length;
1338
+ if (size === 0) return 0;
1339
+ return key === 'ArrowDown' ? (previous + 1) % size : (previous - 1 + size) % size;
1340
+ });
1341
+ return;
1342
+ }
1343
+ if (key === 'Enter') {
1344
+ const candidate = rounds[active];
1345
+ if (candidate !== undefined) setChosen(candidate);
1346
+ }
1347
+ };
1348
+ document.addEventListener('keydown', onKeyDown);
1349
+ return () => document.removeEventListener('keydown', onKeyDown);
1350
+ }, [open, chosen, rounds, active]);
1351
+
1352
+ const serviceOf = (serviceName) => {
1353
+ try {
1354
+ const value = props?.ctx?.get?.(serviceName);
1355
+ if (value !== undefined && value !== null) return value;
1356
+ } catch {
1357
+ /* treat as unavailable */
1358
+ }
1359
+ return undefined;
1360
+ };
1361
+
1362
+ const messageOf = (failure) => {
1363
+ if (failure !== null && typeof failure === 'object' && failure.status === 503) return tr('notice.unavailable');
1364
+ const message = failure instanceof Error ? failure.message : '';
1365
+ return typeof message === 'string' && message !== '' ? message : tr('notice.failed');
1366
+ };
1367
+
1368
+ const postJson = async (route, body) => {
1369
+ const response = await fetch(route, {
1370
+ method: 'POST',
1371
+ headers: { 'Content-Type': 'application/json' },
1372
+ body: JSON.stringify(body),
1373
+ credentials: 'same-origin',
1374
+ });
1375
+ if (!response.ok) {
1376
+ const error = new Error(response.status === 503
1377
+ ? tr('notice.unavailable')
1378
+ : `${tr('notice.failed')}(HTTP ${response.status})`);
1379
+ error.status = response.status;
1380
+ throw error;
1381
+ }
1382
+ const value = await response.json();
1383
+ if (value === null || typeof value !== 'object') throw new Error(tr('notice.failed'));
1384
+ return value;
1385
+ };
1386
+
1387
+ const loadRounds = async () => {
1388
+ if (sessionId === undefined) return;
1389
+ setLoading(true);
1390
+ setError(null);
1391
+ try {
1392
+ const value = await postJson(CANDIDATES_ROUTE, { sessionId });
1393
+ if (!mountedRef.current) return;
1394
+ const list = Array.isArray(value.turns)
1395
+ ? value.turns.filter((item) => item !== null && typeof item === 'object' && Number.isInteger(item.seq))
1396
+ : [];
1397
+ setRounds(list);
1398
+ setActive(0);
1399
+ } catch (failure) {
1400
+ if (!mountedRef.current) return;
1401
+ setRounds([]);
1402
+ setError(messageOf(failure));
1403
+ } finally {
1404
+ if (mountedRef.current) setLoading(false);
1405
+ }
1406
+ };
1407
+
1408
+ const executeInPlace = async (candidate, withFiles) => {
1409
+ // The bridge is owned by the turn-tail component; here we only borrow it.
1410
+ const bridge = bridgeRegistry.get(sessionId)?.bridge ?? null;
1411
+ const target = Number.isInteger(candidate?.userSeq) ? candidate.userSeq : null;
1412
+ if (target === null) {
1413
+ setError(tr('notice.forkUnavailable'));
1414
+ return;
1415
+ }
1416
+ if (bridge === null) {
1417
+ setError(tr('notice.unavailable'));
1418
+ return;
1419
+ }
1420
+ setBusy(true);
1421
+ setError(null);
1422
+ try {
1423
+ await bridge.rewind(target, withFiles);
1424
+ if (mountedRef.current) {
1425
+ setOpen(false);
1426
+ setChosen(null);
1427
+ }
1428
+ } finally {
1429
+ if (mountedRef.current) setBusy(false);
1430
+ }
1431
+ };
1432
+
1433
+ const execute = async (candidate, withFiles) => {
1434
+ if (busy) return;
1435
+ const sessions = serviceOf('sessions');
1436
+ if (sessions === undefined || typeof sessions.fork !== 'function') {
1437
+ setError(tr('notice.noSession'));
1438
+ return;
1439
+ }
1440
+ setBusy(true);
1441
+ setError(null);
1442
+ try {
1443
+ if (withFiles) await postJson(RESTORE_ROUTE, { sessionId, turn: candidate.turn });
1444
+ const childId = await sessions.fork({ sessionId, atSeq: candidate.seq, increaseTitle: true });
1445
+ if (typeof childId !== 'string' || childId === '') throw new Error(tr('notice.failed'));
1446
+ seedDraft(childId, typeof candidate.prompt === 'string' ? candidate.prompt : '');
1447
+ const workspace = serviceOf('uiWorkspace');
1448
+ if (workspace !== undefined && typeof workspace.openSession === 'function') {
1449
+ workspace.openSession(childId);
1450
+ }
1451
+ if (mountedRef.current) {
1452
+ setOpen(false);
1453
+ setChosen(null);
1454
+ }
1455
+ } catch (failure) {
1456
+ if (mountedRef.current) setError(messageOf(failure));
1457
+ } finally {
1458
+ if (mountedRef.current) setBusy(false);
1459
+ }
1460
+ };
1461
+
1462
+ if (sessionId === undefined || !canFork) return null;
1463
+
1464
+ const renderRounds = () => {
1465
+ if (loading) return jsx('div', { className: 'dshSessionRewindHint', children: tr('list.loading') });
1466
+ if (rounds.length === 0) return jsx('div', { className: 'dshSessionRewindHint', children: tr('list.empty') });
1467
+ return jsx('ul', {
1468
+ className: 'dshSessionRewindRounds',
1469
+ children: rounds.map((candidate, index) => jsx('li', {
1470
+ key: `${String(candidate.turn)}:${String(candidate.seq)}`,
1471
+ children: jsxs('button', {
1472
+ type: 'button',
1473
+ className: 'dshSessionRewindRound',
1474
+ 'data-active': index === active ? 'true' : 'false',
1475
+ disabled: busy,
1476
+ onClick: () => {
1477
+ setActive(index);
1478
+ setChosen(candidate);
1479
+ },
1480
+ onMouseEnter: () => setActive(index),
1481
+ children: [
1482
+ jsx('span', {
1483
+ className: 'dshSessionRewindRoundTurn',
1484
+ children: tr('list.round', { n: candidate.turn }),
1485
+ }),
1486
+ jsx('span', {
1487
+ className: 'dshSessionRewindRoundText',
1488
+ children: previewOf(candidate.prompt),
1489
+ }),
1490
+ jsx('span', {
1491
+ className: 'dshSessionRewindRoundFiles',
1492
+ children: tr('list.files', { count: candidate.files?.count ?? 0 }),
1493
+ }),
1494
+ ],
1495
+ }),
1496
+ })),
1497
+ });
1498
+ };
1499
+
1500
+ const renderChosen = () => {
1501
+ const count = typeof chosen.files?.count === 'number' ? chosen.files.count : 0;
1502
+ return [
1503
+ jsx('div', {
1504
+ className: 'dshSessionRewindTitle',
1505
+ key: 'title',
1506
+ children: tr('mode.title', { n: chosen.turn }),
1507
+ }),
1508
+ jsx('p', {
1509
+ className: 'dshSessionRewindModeText',
1510
+ key: 'text',
1511
+ children: previewOf(chosen.prompt),
1512
+ }),
1513
+ jsx('div', {
1514
+ className: 'dshSessionRewindHint',
1515
+ key: 'note',
1516
+ children: tr('mode.note'),
1517
+ }),
1518
+ jsx('div', {
1519
+ className: 'dshSessionRewindHint',
1520
+ key: 'inplaceNote',
1521
+ children: tr('mode.inplaceNote'),
1522
+ }),
1523
+ jsxs('div', {
1524
+ className: 'dshSessionRewindActions',
1525
+ key: 'actions',
1526
+ children: [
1527
+ jsx('button', {
1528
+ type: 'button',
1529
+ className: 'dshSessionRewindButton dshSessionRewindPrimary',
1530
+ disabled: busy,
1531
+ onClick: () => {
1532
+ void executeInPlace(chosen, false);
1533
+ },
1534
+ children: tr('mode.inplace'),
1535
+ }),
1536
+ count > 0
1537
+ ? jsx('button', {
1538
+ type: 'button',
1539
+ className: 'dshSessionRewindButton',
1540
+ disabled: busy,
1541
+ onClick: () => {
1542
+ void executeInPlace(chosen, true);
1543
+ },
1544
+ children: tr('mode.inplaceFiles', { count }),
1545
+ })
1546
+ : null,
1547
+ jsx('button', {
1548
+ type: 'button',
1549
+ className: 'dshSessionRewindButton',
1550
+ disabled: busy,
1551
+ onClick: () => {
1552
+ void execute(chosen, false);
1553
+ },
1554
+ children: tr('mode.fork'),
1555
+ }),
1556
+ jsx('button', {
1557
+ type: 'button',
1558
+ className: 'dshSessionRewindButton',
1559
+ disabled: busy,
1560
+ onClick: () => setChosen(null),
1561
+ children: tr('mode.back'),
1562
+ }),
1563
+ ],
1564
+ }),
1565
+ ];
1566
+ };
1567
+
1568
+ const renderMenu = () => jsxs('div', {
1569
+ className: 'dshSessionRewindMenu',
1570
+ role: 'dialog',
1571
+ 'aria-label': chosen === null ? tr('list.title') : tr('mode.title', { n: chosen.turn }),
1572
+ children: [
1573
+ error === null ? null : jsx('div', { key: 'error', className: 'dshSessionRewindError', children: error }),
1574
+ chosen === null
1575
+ ? jsx('div', { key: 'title', className: 'dshSessionRewindTitle', children: tr('list.title') })
1576
+ : null,
1577
+ chosen === null
1578
+ ? jsx('div', { key: 'hint', className: 'dshSessionRewindHint', children: tr('list.hint') })
1579
+ : null,
1580
+ chosen === null ? renderRounds() : renderChosen(),
1581
+ ],
1582
+ });
1583
+
1584
+ return jsxs('span', {
1585
+ className: 'dshSessionRewindHeader',
1586
+ ref: wrapRef,
1587
+ children: [
1588
+ jsx('button', {
1589
+ type: 'button',
1590
+ className: 'dshSessionRewindHeaderBtn',
1591
+ title: tr('header.title'),
1592
+ 'aria-label': tr('header.title'),
1593
+ 'aria-haspopup': 'dialog',
1594
+ 'aria-expanded': open ? 'true' : 'false',
1595
+ disabled: busy,
1596
+ onClick: () => {
1597
+ if (busy) return;
1598
+ if (open) {
1599
+ setOpen(false);
1600
+ setChosen(null);
1601
+ return;
1602
+ }
1603
+ setOpen(true);
1604
+ setError(null);
1605
+ void loadRounds();
1606
+ },
1607
+ children: jsx(RewindGlyph, {}),
1608
+ }),
1609
+ open ? renderMenu() : null,
1610
+ ],
1611
+ });
1612
+ }
1613
+
1614
+ /**
1615
+ * @param {object} ctx DSH Cordis client context.
1616
+ */
1617
+ function apply(ctx) {
1618
+ if (jsx === null || jsxs === null || React === null) return;
1619
+ if (typeof React.useState !== 'function'
1620
+ || typeof React.useRef !== 'function'
1621
+ || typeof React.useEffect !== 'function') return;
1622
+
1623
+ try {
1624
+ installStyle();
1625
+ } catch {
1626
+ /* a missing <head> only costs styling */
1627
+ }
1628
+
1629
+ let boundTranslate = null;
1630
+ const tr = (key, params) => {
1631
+ if (boundTranslate !== null) {
1632
+ try {
1633
+ return boundTranslate(key, params);
1634
+ } catch {
1635
+ /* fall back to the bundled dictionaries */
1636
+ }
1637
+ }
1638
+ return localMessage(key, params);
1639
+ };
1640
+
1641
+ try {
1642
+ ctx.inject(['locale'], (scope) => {
1643
+ try {
1644
+ scope.effect(() => scope.locale.register(NS, { zh, en }), 'session-rewind: dictionaries');
1645
+ const bound = scope.locale.bind(NS);
1646
+ if (typeof bound === 'function') boundTranslate = bound;
1647
+ } catch {
1648
+ /* bundled dictionaries remain in charge */
1649
+ }
1650
+ });
1651
+ } catch {
1652
+ /* the locale service is optional */
1653
+ }
1654
+
1655
+ // `ctx` is handed to the components so they can reach `sessions` /
1656
+ // `uiWorkspace` lazily: resolving them at registration time would pin a
1657
+ // service instance that a plugin reload has already replaced.
1658
+ const component = (props) => jsx(TurnRewind, { ...props, ctx, tr });
1659
+ const headerComponent = (props) => jsx(SessionRewind, { ...props, ctx, tr });
1660
+
1661
+ try {
1662
+ ctx.inject(['slots'], (scope) => {
1663
+ const injected = [];
1664
+ try {
1665
+ scope.slots.inject(SLOT_NAME, () => scope.slots.register({
1666
+ name: SLOT_NAME,
1667
+ id: PLUGIN_ID,
1668
+ order: -1,
1669
+ inject: () => ({ tr }),
1670
+ }, component));
1671
+ injected.push(SLOT_NAME);
1672
+ } catch (error) {
1673
+ warn(error);
1674
+ }
1675
+ try {
1676
+ scope.slots.inject(HEADER_SLOT, () => scope.slots.register({
1677
+ name: HEADER_SLOT,
1678
+ id: PLUGIN_ID,
1679
+ order: -1,
1680
+ inject: () => ({ tr }),
1681
+ }, headerComponent));
1682
+ injected.push(HEADER_SLOT);
1683
+ } catch (error) {
1684
+ warn(error);
1685
+ }
1686
+ sendDiagnose('__boot__', {
1687
+ reason: 'apply',
1688
+ slots: injected,
1689
+ boot: BOOT_REVISION,
1690
+ hasDocument: typeof document !== 'undefined',
1691
+ });
1692
+ });
1693
+ } catch (error) {
1694
+ warn(error);
1695
+ sendDiagnose('__boot__', { reason: 'apply-failed', error: String(error && error.message ? error.message : error), boot: BOOT_REVISION });
1696
+ }
1697
+ }
1698
+
1699
+ function warn(error) {
1700
+ try {
1701
+ // eslint-disable-next-line no-console
1702
+ console.warn('[dsh-session-rewind]', error);
1703
+ } catch {
1704
+ /* nothing else to do */
1705
+ }
1706
+ }
1707
+
1708
+ exports.apply = apply;
1709
+ /** The Chat turn-tail slot is the only service this plugin needs. */
1710
+ exports.inject = [];
1711
+ return module.exports;
1712
+ },
1713
+ });
1714
+ })();