@ray1270/dsh-restart 0.0.0-stage → 0.3.6

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,740 @@
1
+ /**
2
+ * dsh-restart — Client half (browser module).
3
+ *
4
+ * Four jobs, all in the page:
5
+ *
6
+ * 1. a restart entry in the Windows window-caption menubar, right beside 应用
7
+ * and 编辑 — where an app-wide action belongs
8
+ * 2. a refresh entry next to it: reload the page without touching the Host,
9
+ * which is what a client-plugin change actually needs
10
+ * 3. the same pair in the Session header utilities for every surface without
11
+ * that caption bar (macOS, Linux, the plain browser); it steps aside as
12
+ * soon as the caption entries are really on screen
13
+ * 4. putting the user back into the Session they were in before either action
14
+ *
15
+ * Restart and refresh are deliberately different shapes. A restart kills the
16
+ * shell and everything in flight, so it takes two clicks and shows what it
17
+ * would interrupt. A refresh only reloads this page — server-side work keeps
18
+ * running — so one click is the right weight. The only casualty is the unsent
19
+ * composer draft, and the tooltip says so instead of a modal stealing the click.
20
+ *
21
+ * The restart hand-off is server-side on purpose: the port changes on every
22
+ * restart, so the origin changes too and localStorage does not survive it. The
23
+ * refresh hand-off is the opposite case: the origin stays, so a localStorage
24
+ * marker is enough and the Host never has to hear about a reload.
25
+ *
26
+ * The caption menubar is not part of the Web client: the Desktop preload draws
27
+ * it into an open shadow root at the top of the window. A page module may only
28
+ * extend it by appending siblings, which is what happens here. The shape is
29
+ * probed, never assumed: without the host element or its shadow root the
30
+ * surface simply does not mount and the header pair stays.
31
+ *
32
+ * Written as a plain browser module (no JSX, no bundler) in the same
33
+ * `window.__ModuleLoader__` form the shipped client bundles use.
34
+ */
35
+
36
+ window.__ModuleLoader__.load({
37
+ id: '@ray1270/dsh-restart',
38
+ factory(require) {
39
+ const React = require('react')
40
+ const h = React.createElement
41
+
42
+ /** How long the second click stays armed. */
43
+ const ARM_WINDOW_MS = 4000
44
+ /** How long to keep asking whether the restart actually took effect. */
45
+ const EFFECT_TIMEOUT_MS = 12000
46
+ /** How long a failure stays readable before the label relaxes back. */
47
+ const FAIL_HOLD_MS = 6000
48
+ /** How long to wait before a reload, long enough to repaint the label. */
49
+ const REFRESH_DELAY_MS = 80
50
+ /** How long a refresh marker stays believable; a reload is not a slow trip. */
51
+ const REFRESH_MARKER_TTL_MS = 2 * 60 * 1000
52
+
53
+ /** Attribute the Desktop preload puts on the caption menubar host. */
54
+ const CAPTION_HOST_ATTRIBUTE = 'data-windows-menu'
55
+ /** Attribute this module puts on the wrapper it appends there. */
56
+ const CAPTION_GROUP_ATTRIBUTE = 'data-dsh-restart-caption-group'
57
+ /** Attribute this module puts on the restart button. */
58
+ const CAPTION_BUTTON_ATTRIBUTE = 'data-dsh-restart-caption'
59
+ /** Attribute this module puts on the refresh button. */
60
+ const CAPTION_REFRESH_ATTRIBUTE = 'data-dsh-refresh-caption'
61
+ /** localStorage key carrying the Session across a reload (same origin). */
62
+ const REFRESH_MARKER_KEY = '@ray1270/dsh-restart:resume'
63
+
64
+ const COLOR_MUTED = 'var(--dsw-alias-label-secondary, rgba(128,128,128,0.85))'
65
+ const COLOR_DANGER = 'var(--dsw-alias-state-error-primary, #d43a3a)'
66
+ const COLOR_OK = 'var(--dsw-alias-state-success-primary, #1a9e55)'
67
+ const COLOR_BORDER = 'var(--dsw-alias-separator-primary, rgba(128,128,128,0.35))'
68
+
69
+ const REFRESH_TITLE = '刷新界面:重新加载页面,仍停在当前会话'
70
+
71
+ /* ------------------------------------------------------------------ *
72
+ * One restart interaction, shared by both surfaces.
73
+ * ------------------------------------------------------------------ */
74
+
75
+ /**
76
+ * The state machine behind a restart entry.
77
+ *
78
+ * Phases: idle → checking → arming → running → failed. `failed` holds an
79
+ * explanation for a few seconds and then relaxes back to idle, so a
80
+ * transient problem does not leave a permanently red control.
81
+ *
82
+ * @param getSessionId - resolves the open Session at click time; the caption
83
+ * surface has no slot props, so the Session is read from the header
84
+ * control's tracking instead of captured when the machine is built.
85
+ */
86
+ function createRestartMachine(getSessionId) {
87
+ let state = { phase: 'idle', activeWork: null, message: null }
88
+ const listeners = new Set()
89
+ let armTimer = null
90
+ let relaxTimer = null
91
+ let disposed = false
92
+
93
+ const publish = (patch) => {
94
+ if (disposed) return
95
+ state = { ...state, ...patch }
96
+ for (const listener of [...listeners]) {
97
+ try {
98
+ listener(state)
99
+ } catch (error) {
100
+ console.error('dsh-restart: state listener failed', error)
101
+ }
102
+ }
103
+ }
104
+
105
+ const clearArm = () => {
106
+ if (armTimer !== null) clearTimeout(armTimer)
107
+ armTimer = null
108
+ }
109
+
110
+ const clearRelax = () => {
111
+ if (relaxTimer !== null) clearTimeout(relaxTimer)
112
+ relaxTimer = null
113
+ }
114
+
115
+ const fail = (message) => {
116
+ clearArm()
117
+ clearRelax()
118
+ publish({ phase: 'failed', activeWork: null, message })
119
+ relaxTimer = setTimeout(() => {
120
+ relaxTimer = null
121
+ publish({ phase: 'idle', message: null })
122
+ }, FAIL_HOLD_MS)
123
+ }
124
+
125
+ const disarm = () => {
126
+ clearArm()
127
+ publish({ phase: 'idle', activeWork: null, message: null })
128
+ }
129
+
130
+ /** Second click: the user has seen the counts and means it. */
131
+ const execute = async () => {
132
+ clearArm()
133
+ const sessionId = typeof getSessionId === 'function' ? getSessionId() : null
134
+ publish({ phase: 'running', message: null })
135
+ let scheduledAt = 0
136
+ try {
137
+ const response = await fetch('/api/dsh-restart/restart', {
138
+ method: 'POST',
139
+ headers: { 'content-type': 'application/json' },
140
+ body: JSON.stringify({ sessionId: typeof sessionId === 'string' ? sessionId : null, force: true }),
141
+ })
142
+ const payload = await response.json().catch(() => ({}))
143
+ if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`)
144
+ scheduledAt = Date.now()
145
+ } catch (error) {
146
+ fail(`重启请求失败:${error && error.message ? error.message : String(error)}`)
147
+ return
148
+ }
149
+
150
+ // The application should disappear shortly. If it still answers well past
151
+ // the helper's delay, the restart silently did not happen and saying so is
152
+ // better than a spinner that never resolves.
153
+ const watch = async () => {
154
+ if (Date.now() - scheduledAt > EFFECT_TIMEOUT_MS) {
155
+ fail('重启没有生效,应用仍在运行(详见 ~/.dsh/dsh-restart/helper.log)')
156
+ return
157
+ }
158
+ try {
159
+ const response = await fetch('/api/dsh-restart/status', { cache: 'no-store' })
160
+ if (response.ok) {
161
+ setTimeout(watch, 800)
162
+ return
163
+ }
164
+ // Anything but 200 means the Host is going down; stop watching.
165
+ } catch {
166
+ // A refused connection is the expected outcome: the page is about to die.
167
+ }
168
+ }
169
+ setTimeout(watch, 2500)
170
+ }
171
+
172
+ /** First click: pre-flight only. */
173
+ const preflight = async () => {
174
+ publish({ phase: 'checking', message: null })
175
+ try {
176
+ const response = await fetch('/api/dsh-restart/status', { cache: 'no-store' })
177
+ const payload = await response.json().catch(() => ({}))
178
+ if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`)
179
+ if (payload?.canRestart !== true) {
180
+ fail(payload?.reason ?? '当前环境不支持重启')
181
+ return
182
+ }
183
+ publish({ phase: 'arming', activeWork: payload.activeWork ?? null })
184
+ armTimer = setTimeout(disarm, ARM_WINDOW_MS)
185
+ } catch (error) {
186
+ fail(`无法连接 Host:${error && error.message ? error.message : String(error)}`)
187
+ }
188
+ }
189
+
190
+ return {
191
+ get state() {
192
+ return state
193
+ },
194
+ subscribe(listener) {
195
+ listeners.add(listener)
196
+ return () => listeners.delete(listener)
197
+ },
198
+ click() {
199
+ if (state.phase === 'checking' || state.phase === 'running') return
200
+ if (state.phase === 'arming') {
201
+ void execute()
202
+ return
203
+ }
204
+ void preflight()
205
+ },
206
+ dispose() {
207
+ disposed = true
208
+ clearArm()
209
+ clearRelax()
210
+ listeners.clear()
211
+ },
212
+ }
213
+ }
214
+
215
+ const workCount = (state) => state.activeWork?.total ?? 0
216
+
217
+ const HEADER_TITLE = (state, interrupting) => {
218
+ if (state.phase === 'arming') {
219
+ return interrupting > 0
220
+ ? `重启会中断 ${interrupting} 项进行中的任务(再点一次确认,${ARM_WINDOW_MS / 1000} 秒内有效)`
221
+ : `再点一次确认重启(${ARM_WINDOW_MS / 1000} 秒内有效)`
222
+ }
223
+ if (state.phase === 'running') return '正在重启 DeepSeek Harness'
224
+ if (state.phase === 'checking') return '正在检查是否可以重启'
225
+ if (state.phase === 'failed') return state.message ?? '重启不可用'
226
+ return '重启 DeepSeek Harness'
227
+ }
228
+
229
+ /* ------------------------------------------------------------------ *
230
+ * Refresh: a reload, not a restart.
231
+ * ------------------------------------------------------------------ */
232
+
233
+ /** The refresh marker, or null when there is none, it is stale, or storage is closed. */
234
+ function readRefreshMarker() {
235
+ if (typeof localStorage === 'undefined') return null
236
+ try {
237
+ const raw = localStorage.getItem(REFRESH_MARKER_KEY)
238
+ if (raw === null) return null
239
+ const marker = JSON.parse(raw)
240
+ const usable = marker !== null
241
+ && typeof marker === 'object'
242
+ && typeof marker.sessionId === 'string'
243
+ && marker.sessionId.length > 0
244
+ && typeof marker.at === 'number'
245
+ if (!usable || Date.now() - marker.at > REFRESH_MARKER_TTL_MS) {
246
+ localStorage.removeItem(REFRESH_MARKER_KEY)
247
+ return null
248
+ }
249
+ return marker
250
+ } catch {
251
+ // Storage may be closed (private mode, disabled cookies). Nothing to restore.
252
+ return null
253
+ }
254
+ }
255
+
256
+ function writeRefreshMarker(sessionId) {
257
+ if (typeof localStorage === 'undefined') return
258
+ try {
259
+ localStorage.setItem(REFRESH_MARKER_KEY, JSON.stringify({ sessionId, at: Date.now() }))
260
+ } catch (error) {
261
+ // The reload still happens; it just lands on the home page. Not worth
262
+ // failing the click over, but it should be explainable in the console.
263
+ console.warn('dsh-restart: could not remember the Session across a refresh', error)
264
+ }
265
+ }
266
+
267
+ function clearRefreshMarker() {
268
+ if (typeof localStorage === 'undefined') return
269
+ try {
270
+ localStorage.removeItem(REFRESH_MARKER_KEY)
271
+ } catch {
272
+ /* nothing to clear */
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Reload the page and ask the next load to reopen the Session.
278
+ *
279
+ * Client-side only: a reload keeps the origin, so the hand-off fits in
280
+ * localStorage and the Host never has to know a refresh happened. A missing
281
+ * Session (home page) reloads the same way, just without a target.
282
+ *
283
+ * @param getSessionId - resolves the open Session at click time.
284
+ * @returns whether a reload was scheduled; false when this realm has no page.
285
+ */
286
+ function refreshPage(getSessionId) {
287
+ const sessionId = typeof getSessionId === 'function' ? getSessionId() : null
288
+ if (typeof sessionId === 'string' && sessionId.length > 0) writeRefreshMarker(sessionId)
289
+ if (typeof location === 'undefined' || typeof location.reload !== 'function') return false
290
+ setTimeout(() => {
291
+ try {
292
+ location.reload()
293
+ } catch (error) {
294
+ console.error('dsh-restart: the page could not be reloaded', error)
295
+ }
296
+ }, REFRESH_DELAY_MS)
297
+ return true
298
+ }
299
+
300
+ /* ------------------------------------------------------------------ *
301
+ * Surface 1: the Session header pair (fallback seat).
302
+ * ------------------------------------------------------------------ */
303
+
304
+ const STYLE_BUTTON = {
305
+ display: 'inline-flex',
306
+ alignItems: 'center',
307
+ gap: '6px',
308
+ height: '26px',
309
+ padding: '0 8px',
310
+ border: '1px solid transparent',
311
+ borderRadius: '6px',
312
+ background: 'transparent',
313
+ color: COLOR_MUTED,
314
+ font: 'inherit',
315
+ fontSize: '12px',
316
+ lineHeight: 1,
317
+ cursor: 'pointer',
318
+ whiteSpace: 'nowrap',
319
+ transition: 'color 120ms ease, border-color 120ms ease, background-color 120ms ease, opacity 120ms ease',
320
+ }
321
+
322
+ /* ------------------------------------------------------------------ *
323
+ * Cross-surface state.
324
+ * ------------------------------------------------------------------ */
325
+
326
+ /** Whether the caption entries are really on screen; the header seat retreats while they are. */
327
+ const captionLive = {
328
+ value: false,
329
+ listeners: new Set(),
330
+ set(next) {
331
+ if (captionLive.value === next) return
332
+ captionLive.value = next
333
+ for (const listener of [...captionLive.listeners]) {
334
+ try {
335
+ listener(next)
336
+ } catch (error) {
337
+ console.error('dsh-restart: caption listener failed', error)
338
+ }
339
+ }
340
+ },
341
+ subscribe(listener) {
342
+ captionLive.listeners.add(listener)
343
+ return () => captionLive.listeners.delete(listener)
344
+ },
345
+ }
346
+
347
+ /**
348
+ * The open Session, as reported by the header seat.
349
+ *
350
+ * The header slot is the only surface that receives `sessionId`; the caption
351
+ * menubar is global. The header component keeps this value current for as
352
+ * long as a Conversation is on screen (it renders nothing once the caption
353
+ * entries took over), which makes a restart or refresh started from the
354
+ * caption bar land back on the same Session.
355
+ */
356
+ const sessionTracker = { current: null }
357
+
358
+ function useCaptionLive() {
359
+ const [live, setLive] = React.useState(captionLive.value)
360
+ React.useEffect(() => captionLive.subscribe(setLive), [])
361
+ return live
362
+ }
363
+
364
+ /**
365
+ * The header pair: 刷新 and 重启, same words as the caption bar.
366
+ *
367
+ * @param props - standard Session slot face; `sessionId` drives both hand-offs.
368
+ */
369
+ function HeaderActions(props) {
370
+ const propsRef = React.useRef(props)
371
+ propsRef.current = props
372
+
373
+ const machineRef = React.useRef(null)
374
+ if (machineRef.current === null) {
375
+ machineRef.current = createRestartMachine(() => propsRef.current?.sessionId)
376
+ }
377
+ const machine = machineRef.current
378
+
379
+ const caption = useCaptionLive()
380
+ const [state, setState] = React.useState(machine.state)
381
+ const [refreshing, setRefreshing] = React.useState(false)
382
+
383
+ React.useEffect(() => machine.subscribe(setState), [])
384
+
385
+ React.useEffect(() => () => machine.dispose(), [])
386
+
387
+ React.useEffect(() => {
388
+ const sessionId = props?.sessionId ?? null
389
+ sessionTracker.current = sessionId
390
+ return () => {
391
+ if (sessionTracker.current === sessionId) sessionTracker.current = null
392
+ }
393
+ }, [props?.sessionId])
394
+
395
+ // The caption entries are the primary seat; the header keeps only its
396
+ // Session tracking once they are on screen.
397
+ if (caption) return null
398
+
399
+ const refresh = () => {
400
+ if (refreshing) return
401
+ setRefreshing(true)
402
+ if (!refreshPage(() => propsRef.current?.sessionId)) {
403
+ setRefreshing(false)
404
+ return
405
+ }
406
+ // A blocked or cancelled reload must not leave a label that never resolves.
407
+ setTimeout(() => setRefreshing(false), 5000)
408
+ }
409
+
410
+ const interrupting = workCount(state)
411
+ const label = (() => {
412
+ if (state.phase === 'arming') return interrupting > 0 ? `确认重启 · ${interrupting} 项进行中` : '确认重启'
413
+ if (state.phase === 'running') return '重启中'
414
+ if (state.phase === 'checking') return '检查中'
415
+ if (state.phase === 'failed' && state.message) return state.message.length > 24 ? `${state.message.slice(0, 24)}…` : state.message
416
+ if (state.phase === 'failed') return '重启失败'
417
+ return '重启'
418
+ })()
419
+
420
+ const color = state.phase === 'arming' || state.phase === 'failed'
421
+ ? COLOR_DANGER
422
+ : state.phase === 'running'
423
+ ? COLOR_OK
424
+ : COLOR_MUTED
425
+
426
+ const title = HEADER_TITLE(state, interrupting)
427
+
428
+ return h(
429
+ 'span',
430
+ {
431
+ 'data-dsh-quick-actions': '',
432
+ style: { display: 'inline-flex', alignItems: 'center', gap: '2px' },
433
+ },
434
+ h(
435
+ 'button',
436
+ {
437
+ type: 'button',
438
+ onClick: refresh,
439
+ title: REFRESH_TITLE,
440
+ 'aria-label': REFRESH_TITLE,
441
+ 'data-dsh-refresh-control': refreshing ? 'running' : 'idle',
442
+ disabled: refreshing,
443
+ style: {
444
+ ...STYLE_BUTTON,
445
+ color: COLOR_MUTED,
446
+ cursor: refreshing ? 'default' : 'pointer',
447
+ opacity: refreshing ? 0.7 : 1,
448
+ },
449
+ },
450
+ refreshing ? '刷新中' : '刷新',
451
+ ),
452
+ h(
453
+ 'button',
454
+ {
455
+ type: 'button',
456
+ onClick: () => machine.click(),
457
+ title,
458
+ 'aria-label': title,
459
+ 'data-dsh-restart-control': state.phase,
460
+ disabled: state.phase === 'running' || state.phase === 'checking',
461
+ style: {
462
+ ...STYLE_BUTTON,
463
+ color,
464
+ borderColor: state.phase === 'arming' || state.phase === 'failed' ? COLOR_BORDER : 'transparent',
465
+ background: state.phase === 'arming' ? 'color-mix(in srgb, currentColor 8%, transparent)' : 'transparent',
466
+ cursor: state.phase === 'running' || state.phase === 'checking' ? 'default' : 'pointer',
467
+ opacity: state.phase === 'running' || state.phase === 'checking' ? 0.75 : 1,
468
+ },
469
+ },
470
+ h('span', { style: { maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis' } }, label),
471
+ ),
472
+ )
473
+ }
474
+
475
+ /* ------------------------------------------------------------------ *
476
+ * Surface 2: the Windows caption menubar.
477
+ * ------------------------------------------------------------------ */
478
+
479
+ /** The caption restart label; counts stay visible so a restart is never blind. */
480
+ function captionLabel(state) {
481
+ const interrupting = workCount(state)
482
+ if (state.phase === 'arming') return interrupting > 0 ? `确认重启 · ${interrupting} 项` : '确认重启'
483
+ if (state.phase === 'running') return '重启中'
484
+ if (state.phase === 'checking') return '检查中'
485
+ if (state.phase === 'failed') return '重启失败'
486
+ return '重启'
487
+ }
488
+
489
+ function captionTitle(state) {
490
+ const interrupting = workCount(state)
491
+ if (state.phase === 'arming') {
492
+ return interrupting > 0
493
+ ? `再点一次确认:重启会中断 ${interrupting} 项进行中的任务(${ARM_WINDOW_MS / 1000} 秒内有效)`
494
+ : `再点一次确认重启(${ARM_WINDOW_MS / 1000} 秒内有效)`
495
+ }
496
+ if (state.phase === 'running') return '正在重启 DeepSeek Harness'
497
+ if (state.phase === 'checking') return '正在检查是否可以重启'
498
+ if (state.phase === 'failed') return state.message ?? '重启不可用'
499
+ return '重启 DeepSeek Harness'
500
+ }
501
+
502
+ function paintCaptionButton(button, state) {
503
+ const danger = state.phase === 'arming' || state.phase === 'failed'
504
+ button.textContent = captionLabel(state)
505
+ button.title = captionTitle(state)
506
+ button.setAttribute('aria-label', button.title)
507
+ button.dataset.dshRestartPhase = state.phase
508
+ button.style.color = danger ? COLOR_DANGER : ''
509
+ button.style.background = state.phase === 'arming' ? 'color-mix(in srgb, currentColor 8%, transparent)' : ''
510
+ button.style.opacity = state.phase === 'checking' || state.phase === 'running' ? '0.7' : ''
511
+ }
512
+
513
+ const REFRESH_LABELS = { idle: '刷新', running: '刷新中', failed: '刷新失败' }
514
+
515
+ function paintCaptionRefresh(button, phase) {
516
+ button.dataset.dshRefreshPhase = phase
517
+ button.textContent = REFRESH_LABELS[phase] ?? REFRESH_LABELS.idle
518
+ button.style.color = phase === 'failed' ? COLOR_DANGER : ''
519
+ button.style.opacity = phase === 'running' ? '0.7' : ''
520
+ }
521
+
522
+ /**
523
+ * The caption refresh button.
524
+ *
525
+ * One click, no arming: nothing server-side stops when the page reloads, so
526
+ * the second click would only be ceremony. The label turns busy for the few
527
+ * milliseconds before the reload and relaxes again if the reload never comes.
528
+ */
529
+ function createCaptionRefresh(getSessionId) {
530
+ const button = document.createElement('button')
531
+ button.type = 'button'
532
+ button.setAttribute(CAPTION_REFRESH_ATTRIBUTE, '')
533
+ button.title = REFRESH_TITLE
534
+ button.setAttribute('aria-label', REFRESH_TITLE)
535
+ paintCaptionRefresh(button, 'idle')
536
+
537
+ let relaxTimer = null
538
+ button.addEventListener('click', () => {
539
+ if (button.dataset.dshRefreshPhase === 'running') return
540
+ paintCaptionRefresh(button, 'running')
541
+ if (relaxTimer !== null) clearTimeout(relaxTimer)
542
+ relaxTimer = setTimeout(() => {
543
+ relaxTimer = null
544
+ paintCaptionRefresh(button, 'idle')
545
+ }, FAIL_HOLD_MS)
546
+ if (refreshPage(getSessionId)) return
547
+ paintCaptionRefresh(button, 'failed')
548
+ })
549
+ return button
550
+ }
551
+
552
+ /** The open shadow root the Desktop preload draws its caption menubar into, if any. */
553
+ function findCaptionShadow() {
554
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return null
555
+ for (const host of document.querySelectorAll(`[${CAPTION_HOST_ATTRIBUTE}]`)) {
556
+ const shadow = host.shadowRoot
557
+ if (shadow !== null && shadow !== undefined) return shadow
558
+ }
559
+ return null
560
+ }
561
+
562
+ /**
563
+ * Append 刷新 and 重启 to the caption menubar.
564
+ *
565
+ * The preload mounts that bar when the frame is ready, which may be before or
566
+ * after this module runs, so a missing bar is waited for instead of treated
567
+ * as failure. Only direct children of `body` are watched: the bar is mounted
568
+ * there, and a subtree observer would walk the whole streaming conversation
569
+ * on every mutation.
570
+ *
571
+ * @param getSessionId - resolves the open Session when an entry is used.
572
+ * @returns disposer, or null when the page has no document at all.
573
+ */
574
+ function mountCaptionSurface(getSessionId) {
575
+ if (typeof document === 'undefined') return null
576
+
577
+ let group = null
578
+ let refreshButton = null
579
+ let button = null
580
+ let machine = null
581
+ let observer = null
582
+ let disposed = false
583
+
584
+ const attach = () => {
585
+ if (disposed || group !== null) return
586
+ const shadow = findCaptionShadow()
587
+ if (shadow === null) return
588
+ // A reloaded module may find the entries its previous generation left
589
+ // behind; that generation is about to be disposed, so replace them
590
+ // instead of adopting nodes whose owner still holds a reference.
591
+ for (const stale of shadow.querySelectorAll(`[${CAPTION_GROUP_ATTRIBUTE}]`)) stale.remove()
592
+ for (const stale of shadow.querySelectorAll(`[${CAPTION_BUTTON_ATTRIBUTE}], [${CAPTION_REFRESH_ATTRIBUTE}]`)) stale.remove()
593
+
594
+ group = document.createElement('div')
595
+ group.setAttribute(CAPTION_GROUP_ATTRIBUTE, '')
596
+ // Matches the preload's own item rhythm: 2px between menu items, same
597
+ // font, height and hover from the shadow stylesheet, one gap away from 编辑.
598
+ group.style.display = 'flex'
599
+ group.style.gap = '2px'
600
+ group.style.marginLeft = '2px'
601
+
602
+ refreshButton = createCaptionRefresh(getSessionId)
603
+
604
+ button = document.createElement('button')
605
+ button.type = 'button'
606
+ button.setAttribute(CAPTION_BUTTON_ATTRIBUTE, '')
607
+ button.style.cursor = 'default'
608
+
609
+ machine = createRestartMachine(getSessionId)
610
+ machine.subscribe((state) => paintCaptionButton(button, state))
611
+ paintCaptionButton(button, machine.state)
612
+ button.addEventListener('click', () => machine.click())
613
+
614
+ group.append(refreshButton, button)
615
+ shadow.appendChild(group)
616
+ captionLive.set(true)
617
+ observer?.disconnect()
618
+ observer = null
619
+ }
620
+
621
+ attach()
622
+ if (group === null && typeof document.body?.appendChild === 'function') {
623
+ observer = new MutationObserver(attach)
624
+ observer.observe(document.body, { childList: true })
625
+ }
626
+
627
+ return {
628
+ dispose() {
629
+ disposed = true
630
+ observer?.disconnect()
631
+ observer = null
632
+ machine?.dispose()
633
+ machine = null
634
+ button = null
635
+ refreshButton = null
636
+ group?.remove()
637
+ group = null
638
+ },
639
+ }
640
+ }
641
+
642
+ /* ------------------------------------------------------------------ *
643
+ * Session hand-off.
644
+ * ------------------------------------------------------------------ */
645
+
646
+ /**
647
+ * Reopen the Session the user was in before a restart or a refresh.
648
+ *
649
+ * Runs once per page load. The refresh marker is checked first because it is
650
+ * the newest intent and costs nothing: it lives in localStorage, since a
651
+ * reload keeps the origin. A restart marker lives on the Host (file), since
652
+ * the port — and with it the origin — changes. Either way the marker is
653
+ * short-lived and single-use; every failure path just leaves the user on the
654
+ * home page, which is where a reload or restart lands anyway. `openSession`
655
+ * is retried because the workspace and Session catalog mount after the first
656
+ * client plugins do.
657
+ *
658
+ * @param ctx - client root context (services: uiWorkspace).
659
+ */
660
+ function restorePendingSession(ctx) {
661
+ const MAX_ROUNDS = 20
662
+ let finished = false
663
+
664
+ const readRestartMarker = async () => {
665
+ const response = await fetch('/api/dsh-restart/pending', { cache: 'no-store' })
666
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
667
+ const pending = await response.json()
668
+ const sessionId = pending?.sessionId
669
+ return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null
670
+ }
671
+
672
+ const round = async (index) => {
673
+ if (finished) return
674
+ const retry = () => {
675
+ if (index < MAX_ROUNDS) setTimeout(() => void round(index + 1), 700)
676
+ }
677
+
678
+ let sessionId = null
679
+ let origin = 'restart'
680
+ const refresh = readRefreshMarker()
681
+ if (refresh !== null) {
682
+ sessionId = refresh.sessionId
683
+ origin = 'refresh'
684
+ } else {
685
+ try {
686
+ sessionId = await readRestartMarker()
687
+ } catch {
688
+ retry()
689
+ return
690
+ }
691
+ if (sessionId === null) return
692
+ }
693
+
694
+ const workspace = ctx.get('uiWorkspace')
695
+ if (!workspace || typeof workspace.openSession !== 'function') {
696
+ retry()
697
+ return
698
+ }
699
+ try {
700
+ workspace.openSession(sessionId)
701
+ } catch {
702
+ retry()
703
+ return
704
+ }
705
+ finished = true
706
+ if (origin === 'refresh') {
707
+ clearRefreshMarker()
708
+ return
709
+ }
710
+ // Consumed only after the navigation was accepted: a failed open leaves
711
+ // the marker behind for the next load instead of eating it.
712
+ fetch('/api/dsh-restart/pending/consume', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => {})
713
+ }
714
+
715
+ // Give the shell a moment to finish mounting panels before navigating.
716
+ setTimeout(() => void round(0), 1200)
717
+ }
718
+
719
+ function apply(ctx) {
720
+ const slots = ctx.slots
721
+ const caption = mountCaptionSurface(() => sessionTracker.current)
722
+ ctx.effect(() => () => caption?.dispose(), 'dsh-restart caption control')
723
+ ctx.effect(
724
+ () => slots.inject('conversation.session.header.utilities', () => slots.register(
725
+ {
726
+ name: 'conversation.session.header.utilities',
727
+ id: 'dsh-restart',
728
+ order: 900,
729
+ label: () => '刷新与重启',
730
+ },
731
+ HeaderActions,
732
+ )),
733
+ 'dsh-restart header control',
734
+ )
735
+ restorePendingSession(ctx)
736
+ }
737
+
738
+ return { inject: ['slots'], apply }
739
+ },
740
+ })