dsh-autorestart 0.3.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -44,8 +44,489 @@
44
44
  *
45
45
  * Everything here is side-effect free until `apply(ctx)` runs: no timers, no
46
46
  * fetches, no DOM access at module scope.
47
+ *
48
+ * The graceful-shutdown ("sleep") half adds three more surfaces:
49
+ *
50
+ * - the shutdown BUTTON at the foot of the left sidebar, beside the
51
+ * Settings row (`sidebar.footer.action` slot): a moon icon and the label
52
+ * "收工关机". That slot is a ROOT slot, so the button is there even when
53
+ * no conversation is open. It renders NOTHING at all when the Host says
54
+ * the button must not be shown, so no empty space is left behind;
55
+ * - the COUNTDOWN CARD in the shell's overlay layer (`shell.overlay`
56
+ * slot): a FLOATING rounded card with a very big number, a hand drawn
57
+ * whale (inline SVG + CSS keyframes, no image and no other plugin) and
58
+ * one funny line per second — plus a "算了,别关" button that cancels
59
+ * the shutdown. The card NEVER covers the interface: it sits in a corner,
60
+ * it lets every click through (only the cancel button takes clicks), so
61
+ * the conversation stays readable and the sidebar stays usable while the
62
+ * machine counts down. While the sessions are still working, a small
63
+ * corner card takes the place of the countdown card, so nothing is
64
+ * covered up;
65
+ * - the SECOND install question: the first install asks about the backup
66
+ * scope, and only after that answer was persisted does it ask whether the
67
+ * shutdown button should stay.
68
+ *
69
+ * All of them read the Host's `GET/POST {prefix}/sleep` route and share ONE
70
+ * poller with the same 2s cadence as the pending-restart store.
71
+ */
72
+
73
+ /* ------------------------------------------------------------------ *
74
+ * The pure half of the graceful-shutdown ("sleep") feature.
75
+ *
76
+ * This file is a CLASSIC script: the client module loader evaluates a
77
+ * plugin bundle as a same-origin external classic script, so the ESM
78
+ * `export` keyword is NOT available here. The four pure helpers are
79
+ * therefore published in the two places a test can reach them:
80
+ *
81
+ * 1. as NAMED properties of this module's exports — the client module
82
+ * system materializes `factory(require) -> exports`, so the exports
83
+ * object carries `pickSleepCaption`, `shouldShowSleepButton`,
84
+ * `buildChoiceBody` and `formatRemainingMs`;
85
+ * 2. on the global namespace `globalThis.__dshAutorestartClient`, which
86
+ * exists as soon as this file is evaluated (no factory call needed).
87
+ *
88
+ * The copy lives in the two dictionaries below and is mirrored verbatim in
89
+ * locale/zh.json + locale/en.json (same words, same keys).
90
+ * ------------------------------------------------------------------ */
91
+
92
+ /**
93
+ * The browser-side record of the "keep the sleep button?" answer.
94
+ *
95
+ * The question is asked exactly once (on the first install), so the key is
96
+ * written as soon as the user answers — or closes the prompt.
97
+ */
98
+ const SLEEP_BUTTON_KEY = 'dsh-autorestart:sleep-button-v1'
99
+
100
+ /**
101
+ * The longest countdown the settings page offers (60 seconds).
102
+ *
103
+ * The first twenty seconds have a line each; every second above that uses
104
+ * the generic fallback line, so no offered length ever shows a blank.
105
+ */
106
+ const SLEEP_MAX_COUNTDOWN_SECONDS = 60
107
+
108
+ /** The countdown length the settings page offers (a dropdown). */
109
+ const SLEEP_COUNTDOWN_CHOICES = Object.freeze([0, 10, 20, 30, 60])
110
+
111
+ /** The countdown length used when the Host did not report one. */
112
+ const SLEEP_DEFAULT_COUNTDOWN_SECONDS = 20
113
+
114
+ /** How long the note of a finished rehearsal stays on screen (ms). */
115
+ const SLEEP_DRY_RUN_NOTICE_MS = 8_000
116
+
117
+ const ZH = {
118
+ label: '待重启',
119
+ busyPrefix: ' · 被 ',
120
+ busySuffix: ' 个会话占用',
121
+ idle: ' · 空闲后自动重启',
122
+ goalActive: ' · 被某个会话自动续跑的 goal 占着',
123
+ goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
124
+ parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
125
+ parkButton: '停泊并重启',
126
+ parkButtonAria: '停泊该会话的 goal,并立即请求重启',
127
+ parkBusy: '停泊中…',
128
+ parkError: '停泊失败:{message}',
129
+ tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
130
+ tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
131
+ tooltipWithError: '等待其他会话空闲后自动重启;重启后本会话会自动继续进度。{message}',
132
+ dotAria: '待重启',
133
+ panelHeading: '待重启',
134
+ panelStateBusy: '等待其他会话空闲后重启',
135
+ panelStateIdle: '等待空闲窗口后重启',
136
+ panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
137
+ panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
138
+ panelBlockedTitle: '占用会话:{title}',
139
+ panelBlockedGeneric: '有一个会话正在运行',
140
+ panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
141
+ panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
142
+ panelParkedTitle: '已停泊的 goal(重启后自动继续):',
143
+ panelParkedGoal: '会话 {session} · {goal}',
144
+ panelWaiting: '已等待 {duration}',
145
+ panelResume: '重启后本会话会自动继续进度',
146
+ durationSeconds: '{n} 秒',
147
+ durationMinutes: '{n} 分 {m} 秒',
148
+ durationHours: '{n} 小时 {m} 分',
149
+ // The one-time install prompt (the choice is asked exactly once).
150
+ // "Backup" is the verb and BOTH options share the "configuration" base:
151
+ // the plugins are an ADDITION to the configuration, never a rival of it.
152
+ backupChoiceTitle: '重启前自动备份:要不要把已安装的插件也一起备份?',
153
+ backupChoiceBody: '每次重启前会快照当前配置,万一重启后起不来就能自动回退。',
154
+ backupChoiceWithPlugins: '配置 + 已安装插件',
155
+ backupChoiceWithPluginsHint: '多约 3 秒、约 117 MB;插件把启动搞坏时也能回退。',
156
+ backupChoiceConfigOnly: '只备份配置',
157
+ backupChoiceConfigOnlyHint: '瞬间完成、约 66 KB;插件问题无法回退,需要手动重装。',
158
+ backupChoiceFootnote: '以后可在 设置 → 自动重启与关机 修改',
159
+ backupChoicePickPrimary: '用这个',
160
+ backupChoicePickSecondary: '只备份配置',
161
+ backupChoiceRecommended: '推荐',
162
+ backupChoiceDismissAria: '关闭这个一次性提示',
163
+ backupChoiceError: '保存失败:{message}',
164
+ // The graceful-shutdown (sleep) surfaces: the button at the foot of the
165
+ // left sidebar, the floating countdown card, the waiting card and the
166
+ // second install question.
167
+ 'sleep.button.label': '收工关机',
168
+ 'sleep.button.cancel': '取消关机',
169
+ 'sleep.button.aria': '收工关机:等所有会话收工后关机',
170
+ 'sleep.button.title': '等所有会话收工,再倒数 {n} 秒关机',
171
+ 'sleep.button.titleImmediate': '等所有会话收工后立即关机(不倒数)',
172
+ 'sleep.button.busy': '准备中…',
173
+ 'sleep.error': '没能联系上关机服务:{message}',
174
+ // The settings section: the entry in the settings sidebar.
175
+ 'nav': '自动重启与关机',
176
+ 'settings.seconds.label': '倒计时秒数',
177
+ 'settings.seconds.immediate': '不倒数(立即关机)',
178
+ 'settings.seconds.value': '{n} 秒',
179
+ 'settings.seconds.hint': '按下按钮后,等所有会话收工,再倒数这么多秒才关机;「不倒数」表示会话一收工就关机。',
180
+ 'settings.backup.label': '重启前备份',
181
+ 'settings.backup.hint': '每次重启前先快照一次当前配置;万一重启后起不来,能自动回退。',
182
+ 'settings.backupPlugins.label': '备份包含已安装插件',
183
+ 'settings.backupPlugins.hint': '关闭后只备份 DSH 自身状态,不含 profile 里的插件目录。',
184
+ 'settings.error': '没能保存设置:{message}',
185
+ 'sleep.dryRun.title': '试运行:不会真的关机',
186
+ 'sleep.dryRun.body': '彩排结束,本来会执行这条命令(真的什么都没执行):',
187
+ 'sleep.dryRun.close': '知道了',
188
+ 'sleep.countdown.caption.20': '鲸鱼娘开始收网了',
189
+ 'sleep.countdown.caption.19': '备份已经替你打包好',
190
+ 'sleep.countdown.caption.18': '没说完的话,再讲一句',
191
+ 'sleep.countdown.caption.17': '大家都在慢慢安静下来',
192
+ 'sleep.countdown.caption.16': '最后再巡一圈房间',
193
+ 'sleep.countdown.caption.15': '海面开始变暗了',
194
+ 'sleep.countdown.caption.14': '灯一盏一盏熄了',
195
+ 'sleep.countdown.caption.13': '房间已经安静下来',
196
+ 'sleep.countdown.caption.12': '还有一小会儿',
197
+ 'sleep.countdown.caption.11': '再看一眼屏幕吧',
198
+ 'sleep.countdown.caption.10': '鲸鱼娘在数最后几秒',
199
+ 'sleep.countdown.caption.9': '灯已经关了一半',
200
+ 'sleep.countdown.caption.8': '声音越来越小',
201
+ 'sleep.countdown.caption.7': '马上就到道别的时候了',
202
+ 'sleep.countdown.caption.6': '最后一口海水',
203
+ 'sleep.countdown.caption.5': '晚风越来越轻',
204
+ 'sleep.countdown.caption.4': '说声晚安吧',
205
+ 'sleep.countdown.caption.3': '真的只剩三秒啦',
206
+ 'sleep.countdown.caption.2': '还来得及说声再见',
207
+ 'sleep.countdown.caption.1': '晚安 🌙',
208
+ 'sleep.countdown.caption.default': '晚安 🌙',
209
+ 'sleep.countdown.remaining': '还剩 {n} 秒',
210
+ 'sleep.countdown.cancel': '算了,别关',
211
+ 'sleep.countdown.goodNight': '晚安 🌙',
212
+ 'sleep.countdown.hint': '再过 {n} 秒就关机,点「算了,别关」可以留下。',
213
+ 'sleep.waiting.title': '还在等大家收工',
214
+ 'sleep.waiting.body': '等这些会话忙完,就轮到关机啦:',
215
+ 'sleep.waiting.count': '还有 {n} 个会话在干活',
216
+ 'sleep.waiting.blocker': '· {title}',
217
+ 'sleep.waiting.none': '(正在确认还有谁在忙)',
218
+ 'sleep.waiting.cancel': '不关了',
219
+ 'sleep.shuttingDown.title': '晚安 🌙',
220
+ 'sleep.shuttingDown.body': '正在关机,鲸鱼娘把灯关好了。',
221
+ 'sleep.shuttingDown.dryRun': '试运行:不会真的关机(本来会执行 {command})',
222
+ 'sleep.shuttingDown.dismiss': '关掉这个提示',
223
+ 'sleep.choice.title': '收工关机按钮要不要留着?',
224
+ 'sleep.choice.body': '它待在左侧栏最底部、设置按钮旁边:点一下就等所有会话收工,倒数几秒后关机。',
225
+ 'sleep.choice.keep': '留着(推荐)',
226
+ 'sleep.choice.keepCta': '留着',
227
+ 'sleep.choice.keepHint': '左侧栏底部多一个月亮按钮,一键收工关机。',
228
+ 'sleep.choice.hide': '不要这个按钮',
229
+ 'sleep.choice.hideCta': '不要',
230
+ 'sleep.choice.hideHint': '界面保持原样;以后想用,再从设置里打开。',
231
+ 'sleep.choice.footnote': '以后可在 设置 → 自动重启与关机 修改',
232
+ 'sleep.choice.diagramLabel': '按钮出现在左侧栏最底部、设置按钮旁边',
233
+ 'sleep.choice.error': '保存失败:{message}',
234
+ // The one-time announcement of a new feature. An installation that already
235
+ // existed never sees the install prompt again, so this card is the only way
236
+ // a new feature reaches it.
237
+ 'announcement.title': '新功能:收工关机',
238
+ 'announcement.line1': '左侧栏最底部多了一个 🌙「收工关机」按钮。',
239
+ 'announcement.line2': '点一下 → 所有会话收工后倒数 {n} 秒关机。',
240
+ 'announcement.line2.generic': '点一下 → 所有会话收工后倒数几秒关机。',
241
+ 'announcement.line3': '想改行为,去「设置 → 自动重启与关机」。',
242
+ 'announcement.disabled': '按钮目前关闭,可在设置里打开。',
243
+ 'announcement.button': '知道了',
244
+ 'announcement.error': '没能记下来:{message}',
245
+ }
246
+
247
+ const EN = {
248
+ label: 'Pending restart',
249
+ busyPrefix: ' · blocked by ',
250
+ busySuffix: ' session(s)',
251
+ idle: ' · restarts once idle',
252
+ goalActive: ' · held by a self-continuing goal',
253
+ goalExhausted: ' · a goal ran out of rounds',
254
+ parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
255
+ parkButton: 'Park & restart',
256
+ parkButtonAria: 'Park that session goal and request the restart now',
257
+ parkBusy: 'Parking…',
258
+ parkError: 'Park failed: {message}',
259
+ tooltip:
260
+ 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
261
+ tooltipBusy:
262
+ 'Waits for the other sessions to become idle, then restarts automatically (the count includes work that is queued but not started yet); this session resumes its progress after the restart.',
263
+ tooltipWithError:
264
+ 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
265
+ dotAria: 'Pending restart',
266
+ panelHeading: 'Pending restart',
267
+ panelStateBusy: 'Waiting for the other sessions to become idle',
268
+ panelStateIdle: 'Waiting for the idle window',
269
+ panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
270
+ panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
271
+ panelBlockedTitle: 'Blocking session: {title}',
272
+ panelBlockedGeneric: 'One session is running',
273
+ panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
274
+ panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
275
+ panelParkedTitle: 'Parked goals (they continue after the restart):',
276
+ panelParkedGoal: 'session {session} · {goal}',
277
+ panelWaiting: 'Waiting for {duration}',
278
+ panelResume: 'This session resumes its progress automatically after the restart.',
279
+ durationSeconds: '{n}s',
280
+ durationMinutes: '{n}m {m}s',
281
+ durationHours: '{n}h {m}m',
282
+ // The one-time install prompt (the choice is asked exactly once).
283
+ // "Backup" is the verb and BOTH options share the "configuration" base:
284
+ // the plugins are an ADDITION to the configuration, never a rival of it.
285
+ backupChoiceTitle: 'Automatic pre-restart backup: include the installed plugins too?',
286
+ backupChoiceBody: 'A snapshot of the current configuration is taken before every restart, so a host that fails to come back can be rolled back automatically.',
287
+ backupChoiceWithPlugins: 'Configuration + installed plugins',
288
+ backupChoiceWithPluginsHint: 'About 3 seconds and 117 MB more; a plugin that breaks the boot can be rolled back too.',
289
+ backupChoiceConfigOnly: 'Configuration only',
290
+ backupChoiceConfigOnlyHint: 'Instant, about 66 KB; plugin problems cannot be rolled back and need a manual reinstall.',
291
+ backupChoiceFootnote: 'You can change this later in Settings → Auto-restart & shut down',
292
+ backupChoicePickPrimary: 'Use this',
293
+ backupChoicePickSecondary: 'Configuration only',
294
+ backupChoiceRecommended: 'Recommended',
295
+ backupChoiceDismissAria: 'Close this one-time prompt',
296
+ backupChoiceError: 'Could not save the choice: {message}',
297
+ // The graceful-shutdown (sleep) surfaces: the button at the foot of the
298
+ // left sidebar, the floating countdown card, the waiting card and the
299
+ // second install question.
300
+ 'sleep.button.label': 'Shut down',
301
+ 'sleep.button.cancel': 'Cancel the shutdown',
302
+ 'sleep.button.aria': 'Graceful shutdown: wait for every session to finish, then power off',
303
+ 'sleep.button.title': 'Waits for every session to finish, counts down {n} seconds, then powers off',
304
+ 'sleep.button.titleImmediate': 'Waits for every session to finish, then powers off right away (no countdown)',
305
+ 'sleep.button.busy': 'Getting ready…',
306
+ 'sleep.error': 'Could not reach the sleep service: {message}',
307
+ // The settings section: the entry in the settings sidebar.
308
+ 'nav': 'Auto-restart & shut down',
309
+ 'settings.seconds.label': 'Countdown seconds',
310
+ 'settings.seconds.immediate': 'No countdown (switch off right away)',
311
+ 'settings.seconds.value': '{n} seconds',
312
+ 'settings.seconds.hint': 'After the button: wait for every conversation to be done, count down this many seconds and switch off. "No countdown" switches off as soon as the conversations are done.',
313
+ 'settings.backup.label': 'Backup before a restart',
314
+ 'settings.backup.hint': 'Take a snapshot of the current configuration before every restart, so a host that cannot boot again is rolled back automatically.',
315
+ 'settings.backupPlugins.label': 'Include the installed plugins in the backup',
316
+ 'settings.backupPlugins.hint': 'Off: only the state of DSH itself is backed up — the plugin directory of the profile is left out.',
317
+ 'settings.error': 'Could not save the settings: {message}',
318
+ 'sleep.dryRun.title': 'Dry run: the machine stays on',
319
+ 'sleep.dryRun.body': 'The rehearsal is over. This is the command it WOULD have run (nothing was executed):',
320
+ 'sleep.dryRun.close': 'Got it',
321
+ 'sleep.countdown.caption.20': 'The whale is casting her net',
322
+ 'sleep.countdown.caption.19': 'Your backup is already packed',
323
+ 'sleep.countdown.caption.18': 'One unfinished thought, one more',
324
+ 'sleep.countdown.caption.17': 'Everyone is quieting down',
325
+ 'sleep.countdown.caption.16': 'One last lap around the room',
326
+ 'sleep.countdown.caption.15': 'The sea is going dark',
327
+ 'sleep.countdown.caption.14': 'One light at a time goes out',
328
+ 'sleep.countdown.caption.13': 'The room has gone quiet',
329
+ 'sleep.countdown.caption.12': 'A little bit left',
330
+ 'sleep.countdown.caption.11': 'One last look at the screen',
331
+ 'sleep.countdown.caption.10': 'The whale counts the last seconds',
332
+ 'sleep.countdown.caption.9': 'Half the lights are out',
333
+ 'sleep.countdown.caption.8': 'The voices get quieter',
334
+ 'sleep.countdown.caption.7': 'The goodbyes are close',
335
+ 'sleep.countdown.caption.6': 'One last sip of the sea',
336
+ 'sleep.countdown.caption.5': 'The night wind gets lighter',
337
+ 'sleep.countdown.caption.4': 'Say good night',
338
+ 'sleep.countdown.caption.3': 'Three seconds, really',
339
+ 'sleep.countdown.caption.2': 'Time for one last goodbye',
340
+ 'sleep.countdown.caption.1': 'Good night 🌙',
341
+ 'sleep.countdown.caption.default': 'Good night 🌙',
342
+ 'sleep.countdown.remaining': '{n} seconds left',
343
+ 'sleep.countdown.cancel': 'Never mind, keep it on',
344
+ 'sleep.countdown.goodNight': 'Good night 🌙',
345
+ 'sleep.countdown.hint': 'Powering off in {n} seconds — pick "Never mind, keep it on" to stay.',
346
+ 'sleep.waiting.title': 'Waiting for everyone to finish',
347
+ 'sleep.waiting.body': 'Once these sessions are done, the machine powers off:',
348
+ 'sleep.waiting.count': '{n} session(s) still working',
349
+ 'sleep.waiting.blocker': '· {title}',
350
+ 'sleep.waiting.none': '(checking who is still busy)',
351
+ 'sleep.waiting.cancel': 'Keep it on',
352
+ 'sleep.shuttingDown.title': 'Good night 🌙',
353
+ 'sleep.shuttingDown.body': 'Powering off — the whale turned off the lights.',
354
+ 'sleep.shuttingDown.dryRun': 'Dry run: nothing will really be powered off (it would have run {command})',
355
+ 'sleep.shuttingDown.dismiss': 'Close this notice',
356
+ 'sleep.choice.title': 'Keep the graceful shutdown button?',
357
+ 'sleep.choice.body': 'It lives at the very bottom of the left sidebar, next to the Settings button: one click waits for every session to finish, counts down and powers the machine off.',
358
+ 'sleep.choice.keep': 'Keep it (recommended)',
359
+ 'sleep.choice.keepCta': 'Keep it',
360
+ 'sleep.choice.keepHint': 'One moon button at the bottom of the left sidebar: one click to shut down gracefully.',
361
+ 'sleep.choice.hide': 'No button, thanks',
362
+ 'sleep.choice.hideCta': 'No button',
363
+ 'sleep.choice.hideHint': 'The interface stays as it is; turn it on again in the settings whenever you want it.',
364
+ 'sleep.choice.footnote': 'You can change this later in Settings → Auto-restart & shut down',
365
+ 'sleep.choice.diagramLabel': 'The button sits at the bottom of the left sidebar, next to Settings',
366
+ 'sleep.choice.error': 'Could not save the choice: {message}',
367
+ // The one-time announcement of a new feature. An installation that already
368
+ // existed never sees the install prompt again, so this card is the only way
369
+ // a new feature reaches it.
370
+ 'announcement.title': 'New feature: graceful shutdown',
371
+ 'announcement.line1': 'There is a new 🌙 "Shut down" button at the very bottom of the left sidebar.',
372
+ 'announcement.line2': 'Click it → once every conversation is done it counts down {n} seconds and powers the machine off.',
373
+ 'announcement.line2.generic': 'Click it → once every conversation is done it counts down a few seconds and powers the machine off.',
374
+ 'announcement.line3': 'To change that, go to "Settings → Auto-restart & shut down".',
375
+ 'announcement.disabled': 'The button is currently switched off; you can turn it on in the settings.',
376
+ 'announcement.button': 'Got it',
377
+ 'announcement.error': 'Could not record it: {message}',
378
+ }
379
+
380
+ /**
381
+ * Read one translated string out of whatever the caller handed over.
382
+ *
383
+ * The caller may pass a translator function (the DSH locale binding), a
384
+ * dictionary table (flat or nested) or a language code ('zh' / 'en'). A
385
+ * translator that answers with the key itself has no entry: the built-in
386
+ * dictionary is the fallback, so the copy is never hard-coded in a
387
+ * component.
388
+ *
389
+ * @param {Function|object|string} locale
390
+ * @param {string} key
391
+ * @returns {string}
392
+ */
393
+ function lookupSleepText(locale, key) {
394
+ if (typeof locale === 'function') {
395
+ try {
396
+ const value = locale(key)
397
+ if (typeof value === 'string' && value !== '' && value !== key) return value
398
+ } catch {
399
+ // A broken translator must not break the surface.
400
+ }
401
+ }
402
+ if (locale && typeof locale === 'object') {
403
+ const direct = locale[key]
404
+ if (typeof direct === 'string' && direct !== '') return direct
405
+ const path = key.split('.')
406
+ let node = locale
407
+ for (const part of path) {
408
+ if (!node || typeof node !== 'object') {
409
+ node = undefined
410
+ break
411
+ }
412
+ node = node[part]
413
+ }
414
+ if (typeof node === 'string' && node !== '') return node
415
+ }
416
+ if (typeof locale === 'string' && locale.toLowerCase().startsWith('en')) {
417
+ const english = EN[key]
418
+ if (typeof english === 'string' && english !== '') return english
419
+ }
420
+ const chinese = ZH[key]
421
+ if (typeof chinese === 'string' && chinese !== '') return chinese
422
+ return key
423
+ }
424
+
425
+ /**
426
+ * The countdown caption of one second.
427
+ *
428
+ * The first twenty seconds have their own line each, so the caption rotates
429
+ * while the number counts down. A LONGER countdown (30 or 60 seconds, both
430
+ * offered in the settings) falls back to one generic line that carries the
431
+ * remaining seconds, so every second of every offered length has a text.
432
+ * Seconds outside the countdown (0, or more than the longest offered
433
+ * countdown) use the "good night" line.
434
+ *
435
+ * @param {number} remainingSeconds
436
+ * @param {Function|object|string} [locale] a translator, a dictionary or a language code
437
+ * @returns {string}
438
+ */
439
+ function pickSleepCaption(remainingSeconds, locale) {
440
+ const raw = Number(remainingSeconds)
441
+ const seconds = Number.isFinite(raw) ? Math.ceil(raw) : 0
442
+ if (seconds >= 1 && seconds <= 20) {
443
+ return lookupSleepText(locale, 'sleep.countdown.caption.' + seconds)
444
+ }
445
+ if (seconds > 20 && seconds <= SLEEP_MAX_COUNTDOWN_SECONDS) {
446
+ return lookupSleepText(locale, 'sleep.countdown.remaining').replace(/\{n\}/g, String(seconds))
447
+ }
448
+ return lookupSleepText(locale, 'sleep.countdown.caption.default')
449
+ }
450
+
451
+ /**
452
+ * Whether the graceful-shutdown button may be shown.
453
+ *
454
+ * The Host's truth wins: an explicit `showSleepButton: false` or an
455
+ * unsupported host hides the button (no placeholder, no space). An unknown
456
+ * status does not forbid it — the caller decides with the local record
457
+ * then.
458
+ *
459
+ * @param {object|null|undefined} status the Host's sleep status
460
+ * @returns {boolean}
461
+ */
462
+ function shouldShowSleepButton(status) {
463
+ if (!status || typeof status !== 'object') return true
464
+ if (status.showSleepButton === false) return false
465
+ if (status.phase === 'unsupported') return false
466
+ const host = status.host
467
+ if (host && typeof host === 'object' && host.kind === 'unsupported') return false
468
+ return true
469
+ }
470
+
471
+ /**
472
+ * The body of the `POST {prefix}/choice` request.
473
+ *
474
+ * Only the answers that were really given are sent, so the two questions
475
+ * can also be answered one at a time.
476
+ *
477
+ * @param {{ includePlugins?: boolean, showSleepButton?: boolean }} [options]
478
+ * @returns {{ includePlugins?: boolean, showSleepButton?: boolean }}
479
+ */
480
+ function buildChoiceBody(options) {
481
+ const source = options && typeof options === 'object' ? options : {}
482
+ const body = {}
483
+ if (typeof source.includePlugins === 'boolean') body.includePlugins = source.includePlugins
484
+ if (typeof source.showSleepButton === 'boolean') body.showSleepButton = source.showSleepButton
485
+ return body
486
+ }
487
+
488
+ /**
489
+ * The whole seconds a countdown still has to run.
490
+ *
491
+ * Rounded UP, so a countdown that has 400 ms left still shows "1" and the
492
+ * number never skips a second.
493
+ *
494
+ * @param {number} ms
495
+ * @returns {number}
47
496
  */
48
- window.__ModuleLoader__.load({
497
+ function formatRemainingMs(ms) {
498
+ const raw = Number(ms)
499
+ if (!Number.isFinite(raw) || raw <= 0) return 0
500
+ return Math.ceil(raw / 1000)
501
+ }
502
+
503
+ /**
504
+ * The named exports of this module.
505
+ *
506
+ * The client module system materializes `factory(require) -> exports`, so
507
+ * these four helpers ARE the module's exports; they are mirrored on
508
+ * `globalThis.__dshAutorestartClient` for the tests that evaluate the file
509
+ * without a loader.
510
+ */
511
+ const SLEEP_EXPORTS = {
512
+ pickSleepCaption,
513
+ shouldShowSleepButton,
514
+ buildChoiceBody,
515
+ formatRemainingMs,
516
+ }
517
+
518
+ if (typeof globalThis !== 'undefined' && globalThis !== null) {
519
+ try {
520
+ globalThis.__dshAutorestartClient = Object.assign(globalThis.__dshAutorestartClient || {}, SLEEP_EXPORTS)
521
+ } catch {
522
+ // A frozen global must not break the plugin.
523
+ }
524
+ }
525
+
526
+ // The loader call is guarded so this file can also be evaluated outside a
527
+ // browser (the tests read the exports above without a loader).
528
+ const moduleLoader = typeof window !== 'undefined' && window.__ModuleLoader__ ? window.__ModuleLoader__ : null
529
+ if (moduleLoader) moduleLoader.load({
49
530
  id: 'dsh-autorestart',
50
531
  factory: function (require) {
51
532
  const React = require('react')
@@ -85,107 +566,6 @@ window.__ModuleLoader__.load({
85
566
  '}',
86
567
  ].join('\n')
87
568
 
88
- const ZH = {
89
- label: '待重启',
90
- busyPrefix: ' · 被 ',
91
- busySuffix: ' 个会话占用',
92
- idle: ' · 空闲后自动重启',
93
- goalActive: ' · 被某个会话自动续跑的 goal 占着',
94
- goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
95
- parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
96
- parkButton: '停泊并重启',
97
- parkButtonAria: '停泊该会话的 goal,并立即请求重启',
98
- parkBusy: '停泊中…',
99
- parkError: '停泊失败:{message}',
100
- tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
101
- tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
102
- tooltipWithError: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度。{message}',
103
- dotAria: '待重启',
104
- panelHeading: '待重启',
105
- panelStateBusy: '等待其他会话空闲后重启',
106
- panelStateIdle: '等待空闲窗口后重启',
107
- panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
108
- panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
109
- panelBlockedTitle: '占用会话:{title}',
110
- panelBlockedGeneric: '有一个会话正在运行',
111
- panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
112
- panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
113
- panelParkedTitle: '已停泊的 goal(重启后自动继续):',
114
- panelParkedGoal: '会话 {session} · {goal}',
115
- panelWaiting: '已等待 {duration}',
116
- panelResume: '重启后本会话会自动继续进度',
117
- durationSeconds: '{n} 秒',
118
- durationMinutes: '{n} 分 {m} 秒',
119
- durationHours: '{n} 小时 {m} 分',
120
- // The one-time install prompt (the choice is asked exactly once).
121
- // "Backup" is the verb and BOTH options share the "configuration" base:
122
- // the plugins are an ADDITION to the configuration, never a rival of it.
123
- backupChoiceTitle: '重启前自动备份:要不要把已安装的插件也一起备份?',
124
- backupChoiceBody: '每次重启前会快照当前配置,万一重启后起不来就能自动回退。',
125
- backupChoiceWithPlugins: '配置 + 已安装插件',
126
- backupChoiceWithPluginsHint: '多约 3 秒、约 117 MB;插件把启动搞坏时也能回退。',
127
- backupChoiceConfigOnly: '只备份配置',
128
- backupChoiceConfigOnlyHint: '瞬间完成、约 66 KB;插件问题无法回退,需要手动重装。',
129
- backupChoiceFootnote: '以后可在 设置 → 插件 → dsh-autorestart 修改',
130
- backupChoicePickPrimary: '用这个',
131
- backupChoicePickSecondary: '只备份配置',
132
- backupChoiceRecommended: '推荐',
133
- backupChoiceDismissAria: '关闭这个一次性提示',
134
- backupChoiceError: '保存失败:{message}',
135
- }
136
-
137
- const EN = {
138
- label: 'Pending restart',
139
- busyPrefix: ' · blocked by ',
140
- busySuffix: ' session(s)',
141
- idle: ' · restarts once idle',
142
- goalActive: ' · held by a self-continuing goal',
143
- goalExhausted: ' · a goal ran out of rounds',
144
- parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
145
- parkButton: 'Park & restart',
146
- parkButtonAria: 'Park that session goal and request the restart now',
147
- parkBusy: 'Parking…',
148
- parkError: 'Park failed: {message}',
149
- tooltip:
150
- 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
151
- tooltipBusy:
152
- 'Waits for the other sessions to become idle, then restarts automatically (the count includes work that is queued but not started yet); this session resumes its progress after the restart.',
153
- tooltipWithError:
154
- 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
155
- dotAria: 'Pending restart',
156
- panelHeading: 'Pending restart',
157
- panelStateBusy: 'Waiting for the other sessions to become idle',
158
- panelStateIdle: 'Waiting for the idle window',
159
- panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
160
- panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
161
- panelBlockedTitle: 'Blocking session: {title}',
162
- panelBlockedGeneric: 'One session is running',
163
- panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
164
- panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
165
- panelParkedTitle: 'Parked goals (they continue after the restart):',
166
- panelParkedGoal: 'session {session} · {goal}',
167
- panelWaiting: 'Waiting for {duration}',
168
- panelResume: 'This session resumes its progress automatically after the restart.',
169
- durationSeconds: '{n}s',
170
- durationMinutes: '{n}m {m}s',
171
- durationHours: '{n}h {m}m',
172
- // The one-time install prompt (the choice is asked exactly once).
173
- // "Backup" is the verb and BOTH options share the "configuration" base:
174
- // the plugins are an ADDITION to the configuration, never a rival of it.
175
- backupChoiceTitle: 'Automatic pre-restart backup: include the installed plugins too?',
176
- backupChoiceBody: 'A snapshot of the current configuration is taken before every restart, so a host that fails to come back can be rolled back automatically.',
177
- backupChoiceWithPlugins: 'Configuration + installed plugins',
178
- backupChoiceWithPluginsHint: 'About 3 seconds and 117 MB more; a plugin that breaks the boot can be rolled back too.',
179
- backupChoiceConfigOnly: 'Configuration only',
180
- backupChoiceConfigOnlyHint: 'Instant, about 66 KB; plugin problems cannot be rolled back and need a manual reinstall.',
181
- backupChoiceFootnote: 'You can change this later in Settings → Plugins → dsh-autorestart',
182
- backupChoicePickPrimary: 'Use this',
183
- backupChoicePickSecondary: 'Configuration only',
184
- backupChoiceRecommended: 'Recommended',
185
- backupChoiceDismissAria: 'Close this one-time prompt',
186
- backupChoiceError: 'Could not save the choice: {message}',
187
- }
188
-
189
569
  // Bound during `apply`. The slots are registered without a locale
190
570
  // namespace, so the components read the translator through this
191
571
  // factory-scoped handle instead of a standard `t` prop.
@@ -729,6 +1109,13 @@ window.__ModuleLoader__.load({
729
1109
  ' line-height:16px;',
730
1110
  ' color:var(--dsw-alias-label-secondary);',
731
1111
  '}',
1112
+ '.dsh-autorestart-choice__diagram{margin:2px 0 10px;}',
1113
+ '.dsh-autorestart-choice__diagram__art{',
1114
+ ' width:100%;',
1115
+ ' max-width:260px;',
1116
+ ' height:auto;',
1117
+ ' display:block;',
1118
+ '}',
732
1119
  '@media (max-width:1023px){',
733
1120
  ' .dsh-autorestart-choice{width:calc(100vw - 32px);}',
734
1121
  '}',
@@ -953,10 +1340,16 @@ window.__ModuleLoader__.load({
953
1340
  /**
954
1341
  * The one-time install prompt.
955
1342
  *
956
- * It asks ONE question — should the pre-restart backup also snapshot the
957
- * installed plugin tree? — and it is asked exactly once: the panel is
958
- * rendered only while `localStorage['dsh-autorestart:backup-choice-v1']`
959
- * is missing.
1343
+ * It asks TWO questions, one after the other:
1344
+ *
1345
+ * 1. should the pre-restart backup also snapshot the installed plugin
1346
+ * tree? (the first question — its behaviour is unchanged);
1347
+ * 2. should the graceful-shutdown button stay at the foot of the left
1348
+ * sidebar?
1349
+ *
1350
+ * The first answer is persisted BEFORE the second question appears: the
1351
+ * two questions are never stacked on top of each other, and the second
1352
+ * one only shows up after the first one really reached the Host.
960
1353
  *
961
1354
  * "Backup" is the verb and BOTH options share the "configuration" base:
962
1355
  * the plugins are an addition to the configuration, never a rival of it.
@@ -972,61 +1365,133 @@ window.__ModuleLoader__.load({
972
1365
  */
973
1366
  function BackupChoicePanel() {
974
1367
  const info = useSharedInfo(true)
975
- // The prompt is rendered only while the browser has no record yet.
976
- const [visible, setVisible] = useState(backupChoicePending)
1368
+ // 'backup' is the first question, 'sleep' the second one, 'persisting'
1369
+ // the short moment in between (the first answer is on its way to the
1370
+ // Host) and 'done' when there is nothing left to ask.
1371
+ //
1372
+ // The two questions are judged SEPARATELY: an installation that already
1373
+ // answered the backup question still has to be asked about the shutdown
1374
+ // button (that is how the arrow sketch ever reaches a user who
1375
+ // installed the plugin before this question existed).
1376
+ const [step, setStep] = useState(
1377
+ backupChoicePending() ? 'backup' : (sleepChoicePending() ? 'sleep' : 'done'),
1378
+ )
977
1379
  const [busy, setBusy] = useState(false)
978
1380
  const [error, setError] = useState('')
979
1381
  const t = translate ?? ((name) => name)
980
1382
 
981
1383
  /**
982
- * Hand the choice to the Host.
1384
+ * Hand the FIRST answer to the Host.
983
1385
  *
984
1386
  * The browser records the choice FIRST (so the question is never asked
985
1387
  * twice) and then posts it to the Host, which persists it in the
986
- * profile configuration.
1388
+ * profile configuration. The SECOND question only appears once that
1389
+ * answer really left the browser.
987
1390
  */
988
1391
  const choose = (includePlugins) => {
989
1392
  if (busy) return
990
1393
  setBusy(true)
991
1394
  setError('')
992
1395
  writeBackupChoice(includePlugins ? 'plugins' : 'config-only')
993
- setVisible(false)
1396
+ setStep('persisting')
994
1397
  void (async () => {
1398
+ let failed = false
995
1399
  try {
996
1400
  const response = await fetch(choiceUrlFor(info), {
997
1401
  method: 'POST',
998
1402
  headers: { 'content-type': 'application/json' },
999
- body: JSON.stringify({ includePlugins }),
1403
+ body: JSON.stringify(buildChoiceBody({ includePlugins })),
1000
1404
  })
1001
1405
  if (!response.ok) {
1406
+ failed = true
1002
1407
  setError(fill(t('backupChoiceError'), { message: String(response.status) }))
1003
1408
  }
1004
1409
  } catch (failure) {
1410
+ failed = true
1005
1411
  setError(fill(t('backupChoiceError'), { message: describeError(failure) }))
1412
+ } finally {
1413
+ setBusy(false)
1414
+ if (failed) {
1415
+ // The answer did not reach the Host: the first question stays
1416
+ // on screen with the reason, so it can be answered again.
1417
+ setStep('backup')
1418
+ return
1419
+ }
1420
+ // The first answer is persisted: the second question may follow.
1421
+ setStep(sleepChoicePending() ? 'sleep' : 'done')
1422
+ }
1423
+ })()
1424
+ }
1425
+
1426
+ /**
1427
+ * Hand the SECOND answer to the Host: should the shutdown button stay?
1428
+ */
1429
+ const chooseSleep = (showSleepButton) => {
1430
+ if (busy) return
1431
+ setBusy(true)
1432
+ setError('')
1433
+ writeSleepChoice(showSleepButton ? 'keep' : 'hide')
1434
+ void (async () => {
1435
+ try {
1436
+ const response = await fetch(choiceUrlFor(info), {
1437
+ method: 'POST',
1438
+ headers: { 'content-type': 'application/json' },
1439
+ body: JSON.stringify(buildChoiceBody({ showSleepButton })),
1440
+ })
1441
+ if (!response.ok) {
1442
+ setError(fill(t('sleep.choice.error'), { message: String(response.status) }))
1443
+ setStep('sleep')
1444
+ return
1445
+ }
1446
+ } catch (failure) {
1447
+ setError(fill(t('sleep.choice.error'), { message: describeError(failure) }))
1448
+ setStep('sleep')
1449
+ return
1006
1450
  } finally {
1007
1451
  setBusy(false)
1008
1452
  }
1453
+ setStep('done')
1009
1454
  })()
1010
1455
  }
1011
1456
 
1012
1457
  /**
1013
- * Close the prompt without answering: the default (the configuration
1014
- * AND the installed plugins) stays in place, and the question is not
1015
- * asked again.
1458
+ * Close the FIRST question without answering: the default (the
1459
+ * configuration AND the installed plugins) stays in place, the question
1460
+ * is not asked again — and the second one is not asked either, because
1461
+ * closing the prompt means "do not ask me again".
1016
1462
  */
1017
- const dismiss = () => {
1463
+ const dismissBackup = () => {
1018
1464
  if (busy) return
1019
1465
  writeBackupChoice('dismissed')
1020
- setVisible(false)
1466
+ writeSleepChoice('dismissed')
1467
+ // "Do not ask me again" also covers the new-feature announcement: the
1468
+ // install prompt is where the features of a fresh installation are
1469
+ // explained, so no announcement may follow it.
1470
+ void acknowledgeAnnouncement(ANNOUNCED_FEATURE).catch(() => {})
1471
+ setStep('done')
1472
+ }
1473
+
1474
+ /**
1475
+ * Close the SECOND question without answering: the button stays (the
1476
+ * default) and the question is not asked again.
1477
+ */
1478
+ const dismissSleep = () => {
1479
+ if (busy) return
1480
+ writeSleepChoice('dismissed')
1481
+ void acknowledgeAnnouncement(ANNOUNCED_FEATURE).catch(() => {})
1482
+ setStep('done')
1021
1483
  }
1022
1484
 
1023
1485
  /** Esc closes the prompt (the panel itself is focusable). */
1024
1486
  const onKeyDown = (event) => {
1025
1487
  if (!event) return
1026
- if (event.key === 'Escape' || event.key === 'Esc') dismiss()
1488
+ if (event.key === 'Escape' || event.key === 'Esc') {
1489
+ if (step === 'sleep') dismissSleep()
1490
+ else dismissBackup()
1491
+ }
1027
1492
  }
1028
1493
 
1029
- if (!visible) return null
1494
+ if (step === 'done' || step === 'persisting') return null
1030
1495
  ensureChoiceStyle()
1031
1496
 
1032
1497
  /**
@@ -1037,8 +1502,7 @@ window.__ModuleLoader__.load({
1037
1502
  * the concrete action. The primary row wears a "recommended" pill and a
1038
1503
  * solid button; the secondary one an outlined button.
1039
1504
  */
1040
- const optionRow = (key, name, hint, cta, variant, recommended) => {
1041
- const pick = () => choose(key === 'plugins')
1505
+ const optionRow = (key, name, hint, cta, variant, recommended, pick, dataAttribute) => {
1042
1506
  const onRowClick = () => pick()
1043
1507
  const onRowKeyDown = (event) => {
1044
1508
  if (!event) return
@@ -1061,7 +1525,7 @@ window.__ModuleLoader__.load({
1061
1525
  'aria-checked': 'false',
1062
1526
  'aria-label': name,
1063
1527
  tabIndex: busy ? -1 : 0,
1064
- 'data-backup-choice': key,
1528
+ [dataAttribute]: key,
1065
1529
  onClick: onRowClick,
1066
1530
  onKeyDown: onRowKeyDown,
1067
1531
  },
@@ -1083,7 +1547,7 @@ window.__ModuleLoader__.load({
1083
1547
  {
1084
1548
  type: 'button',
1085
1549
  className: 'dsh-autorestart-choice__cta dsh-autorestart-choice__cta--' + variant,
1086
- 'data-backup-choice': key,
1550
+ [dataAttribute]: key,
1087
1551
  'aria-label': cta,
1088
1552
  title: cta,
1089
1553
  disabled: busy,
@@ -1094,6 +1558,67 @@ window.__ModuleLoader__.load({
1094
1558
  )
1095
1559
  }
1096
1560
 
1561
+ // The FIRST question: the backup scope (its behaviour is unchanged).
1562
+ if (step === 'backup') {
1563
+ return h(
1564
+ 'div',
1565
+ {
1566
+ className: 'dsh-autorestart-choice',
1567
+ role: 'status',
1568
+ 'aria-live': 'polite',
1569
+ tabIndex: 0,
1570
+ onKeyDown,
1571
+ 'data-dsh-autorestart-backup-choice': 'true',
1572
+ 'data-dsh-autorestart-choice-step': 'backup',
1573
+ },
1574
+ h(
1575
+ 'div',
1576
+ { className: 'dsh-autorestart-choice__head' },
1577
+ h('h3', null, t('backupChoiceTitle')),
1578
+ h(
1579
+ 'button',
1580
+ {
1581
+ type: 'button',
1582
+ className: 'dsh-autorestart-choice__close',
1583
+ 'aria-label': t('backupChoiceDismissAria'),
1584
+ title: t('backupChoiceDismissAria'),
1585
+ disabled: busy,
1586
+ onClick: dismissBackup,
1587
+ },
1588
+ '×',
1589
+ ),
1590
+ ),
1591
+ h('p', { className: 'dsh-autorestart-choice__subtitle' }, t('backupChoiceBody')),
1592
+ h(
1593
+ 'div',
1594
+ { className: 'dsh-autorestart-choice__options' },
1595
+ optionRow(
1596
+ 'plugins',
1597
+ t('backupChoiceWithPlugins'),
1598
+ t('backupChoiceWithPluginsHint'),
1599
+ t('backupChoicePickPrimary'),
1600
+ 'primary',
1601
+ true,
1602
+ () => choose(true),
1603
+ 'data-backup-choice',
1604
+ ),
1605
+ optionRow(
1606
+ 'config-only',
1607
+ t('backupChoiceConfigOnly'),
1608
+ t('backupChoiceConfigOnlyHint'),
1609
+ t('backupChoicePickSecondary'),
1610
+ 'secondary',
1611
+ false,
1612
+ () => choose(false),
1613
+ 'data-backup-choice',
1614
+ ),
1615
+ ),
1616
+ h('p', { className: 'dsh-autorestart-choice__footnote' }, t('backupChoiceFootnote')),
1617
+ error === '' ? null : h('p', { role: 'alert' }, error),
1618
+ )
1619
+ }
1620
+
1621
+ // The SECOND question: should the graceful-shutdown button stay?
1097
1622
  return h(
1098
1623
  'div',
1099
1624
  {
@@ -1103,11 +1628,12 @@ window.__ModuleLoader__.load({
1103
1628
  tabIndex: 0,
1104
1629
  onKeyDown,
1105
1630
  'data-dsh-autorestart-backup-choice': 'true',
1631
+ 'data-dsh-autorestart-choice-step': 'sleep',
1106
1632
  },
1107
1633
  h(
1108
1634
  'div',
1109
1635
  { className: 'dsh-autorestart-choice__head' },
1110
- h('h3', null, t('backupChoiceTitle')),
1636
+ h('h3', null, t('sleep.choice.title')),
1111
1637
  h(
1112
1638
  'button',
1113
1639
  {
@@ -1116,50 +1642,1739 @@ window.__ModuleLoader__.load({
1116
1642
  'aria-label': t('backupChoiceDismissAria'),
1117
1643
  title: t('backupChoiceDismissAria'),
1118
1644
  disabled: busy,
1119
- onClick: dismiss,
1645
+ onClick: dismissSleep,
1120
1646
  },
1121
1647
  '×',
1122
1648
  ),
1123
1649
  ),
1124
- h('p', { className: 'dsh-autorestart-choice__subtitle' }, t('backupChoiceBody')),
1650
+ h(
1651
+ 'div',
1652
+ {
1653
+ className: 'dsh-autorestart-choice__diagram',
1654
+ role: 'img',
1655
+ 'aria-label': t('sleep.choice.diagramLabel'),
1656
+ },
1657
+ sleepDiagram(),
1658
+ ),
1659
+ h('p', { className: 'dsh-autorestart-choice__subtitle' }, t('sleep.choice.body')),
1125
1660
  h(
1126
1661
  'div',
1127
1662
  { className: 'dsh-autorestart-choice__options' },
1128
1663
  optionRow(
1129
- 'plugins',
1130
- t('backupChoiceWithPlugins'),
1131
- t('backupChoiceWithPluginsHint'),
1132
- t('backupChoicePickPrimary'),
1664
+ 'keep',
1665
+ t('sleep.choice.keep'),
1666
+ t('sleep.choice.keepHint'),
1667
+ t('sleep.choice.keepCta'),
1133
1668
  'primary',
1134
1669
  true,
1670
+ () => chooseSleep(true),
1671
+ 'data-sleep-choice',
1135
1672
  ),
1136
1673
  optionRow(
1137
- 'config-only',
1138
- t('backupChoiceConfigOnly'),
1139
- t('backupChoiceConfigOnlyHint'),
1140
- t('backupChoicePickSecondary'),
1674
+ 'hide',
1675
+ t('sleep.choice.hide'),
1676
+ t('sleep.choice.hideHint'),
1677
+ t('sleep.choice.hideCta'),
1141
1678
  'secondary',
1142
1679
  false,
1680
+ () => chooseSleep(false),
1681
+ 'data-sleep-choice',
1143
1682
  ),
1144
1683
  ),
1145
- h('p', { className: 'dsh-autorestart-choice__footnote' }, t('backupChoiceFootnote')),
1684
+ h('p', { className: 'dsh-autorestart-choice__footnote' }, t('sleep.choice.footnote')),
1146
1685
  error === '' ? null : h('p', { role: 'alert' }, error),
1147
1686
  )
1148
1687
  }
1149
1688
 
1150
- return {
1151
- inject: ['slots', 'locale'],
1152
- apply(ctx) {
1153
- ctx.effect(
1154
- () => ctx.locale.register(NS, { zh: ZH, en: EN }),
1155
- 'dsh-autorestart: client locale',
1156
- )
1157
- const t = ctx.locale.bind(NS)
1158
- translate = t
1159
- ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'autorestart-restart-pill', order: 0 }, RestartPill))
1160
- ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ name: 'sidebar.session.row.leading', id: 'autorestart-restart-dot', order: 20 }, RestartDot))
1161
- ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-backup-choice', order: 7, locale: NS }, BackupChoicePanel))
1689
+ /* ------------------------------------------------------------------ *
1690
+ * The graceful-shutdown (sleep) half.
1691
+ * ------------------------------------------------------------------ */
1692
+
1693
+ // The route that arms and cancels the shutdown. It is a sibling of the
1694
+ // health route and shares its prefix, so it is derived the very same way.
1695
+ const DEFAULT_SLEEP_URL = ROUTE_PREFIX + '/sleep'
1696
+
1697
+ /**
1698
+ * ONE shared poller for the sleep status, shared by the sidebar button
1699
+ * and the overlay.
1700
+ *
1701
+ * It follows the pattern of the pending-restart store: the first
1702
+ * subscriber starts ONE interval (the same POLL_MS cadence) plus an
1703
+ * immediate fetch, and the last one to leave clears the interval and
1704
+ * aborts the in-flight request. A failed fetch keeps the last known
1705
+ * status, so a transient error never blanks a live countdown.
1706
+ */
1707
+ const sleepStore = {
1708
+ info: null,
1709
+ listeners: new Set(),
1710
+ timer: null,
1711
+ inFlight: false,
1712
+ controller: null,
1713
+ // When the current status was received: the countdown runs on this
1714
+ // clock, so the big number keeps moving between two polls.
1715
+ at: 0,
1716
+ }
1717
+
1718
+ /**
1719
+ * Carry out one intent ('arm' or 'cancel').
1720
+ *
1721
+ * There is no ticket and no ceremony: the request carries nothing but the
1722
+ * action, so the desktop client and the hosted web client drive exactly
1723
+ * the same route.
1724
+ */
1725
+ async function driveSleep(intent) {
1726
+ return requestSleep(intent)
1727
+ }
1728
+
1729
+ /** Publish a new sleep status to every surface. */
1730
+ function setSleepInfo(body) {
1731
+ sleepStore.info = body
1732
+ sleepStore.at = Date.now()
1733
+ sleepStore.listeners.forEach((listener) => {
1734
+ try {
1735
+ listener(body)
1736
+ } catch {
1737
+ // One broken listener must not stop the others.
1738
+ }
1739
+ })
1740
+ }
1741
+
1742
+ async function pollSleepOnce() {
1743
+ if (sleepStore.inFlight) return
1744
+ sleepStore.inFlight = true
1745
+ try {
1746
+ const response = await fetch(DEFAULT_SLEEP_URL, {
1747
+ cache: 'no-store',
1748
+ signal: sleepStore.controller ? sleepStore.controller.signal : undefined,
1749
+ })
1750
+ if (!response.ok) return
1751
+ const body = await response.json()
1752
+ if (!body || typeof body !== 'object') return
1753
+ setSleepInfo(body)
1754
+ } catch {
1755
+ // Ignored on purpose: the last known status stays.
1756
+ } finally {
1757
+ sleepStore.inFlight = false
1758
+ }
1759
+ }
1760
+
1761
+ /**
1762
+ * Adds a listener and starts the shared poll when it is the first one.
1763
+ * The returned callback stops the poll when the last listener leaves.
1764
+ */
1765
+ function subscribeSleep(listener) {
1766
+ sleepStore.listeners.add(listener)
1767
+ if (sleepStore.listeners.size === 1) {
1768
+ sleepStore.controller = new AbortController()
1769
+ pollSleepOnce()
1770
+ sleepStore.timer = setInterval(pollSleepOnce, POLL_MS)
1771
+ }
1772
+ return () => {
1773
+ sleepStore.listeners.delete(listener)
1774
+ if (sleepStore.listeners.size === 0) {
1775
+ if (sleepStore.timer !== null) {
1776
+ clearInterval(sleepStore.timer)
1777
+ sleepStore.timer = null
1778
+ }
1779
+ if (sleepStore.controller) {
1780
+ sleepStore.controller.abort()
1781
+ sleepStore.controller = null
1782
+ }
1783
+ // The last sleep surface left: its stylesheet goes away with it, so
1784
+ // the document does not keep the CSS of a plugin that is gone.
1785
+ removeSleepStyle()
1786
+ }
1787
+ }
1788
+ }
1789
+
1790
+ /**
1791
+ * Ask the Host to arm or to cancel the shutdown.
1792
+ *
1793
+ * The answer carries the new status, so the surfaces do not have to wait
1794
+ * for the next poll. A refused request throws, and the caller shows the
1795
+ * reason.
1796
+ *
1797
+ * @param {'arm'|'cancel'} action
1798
+ * @returns {Promise<object>} the new sleep status
1799
+ */
1800
+ async function requestSleep(action) {
1801
+ const headers = { 'content-type': 'application/json' }
1802
+ const response = await fetch(DEFAULT_SLEEP_URL, {
1803
+ method: 'POST',
1804
+ headers,
1805
+ body: JSON.stringify({ action }),
1806
+ })
1807
+ let body = null
1808
+ try {
1809
+ body = await response.json()
1810
+ } catch {
1811
+ body = null
1812
+ }
1813
+ if (!response.ok) {
1814
+ const message = body && typeof body === 'object'
1815
+ ? (body.message || body.error || '')
1816
+ : ''
1817
+ const failure = new Error(message || String(response.status))
1818
+ failure.sleepStatus = response.status
1819
+ if (body && typeof body === 'object' && typeof body.reason === 'string') {
1820
+ failure.reason = body.reason
1821
+ }
1822
+ throw failure
1823
+ }
1824
+ if (body && typeof body === 'object') setSleepInfo(body)
1825
+ return body
1826
+ }
1827
+
1828
+ /**
1829
+ * The current sleep status, re-rendered whenever the shared poll lands. */
1830
+ function useSleepStatus() {
1831
+ const [status, setStatus] = useState(sleepStore.info)
1832
+ useEffect(() => {
1833
+ setStatus(sleepStore.info)
1834
+ return subscribeSleep(setStatus)
1835
+ }, [])
1836
+ return status
1837
+ }
1838
+
1839
+ /**
1840
+ * Whether the browser has no record of the sleep-button answer yet.
1841
+ *
1842
+ * The question is asked exactly once, so the key is written as soon as
1843
+ * the user answers — or closes the prompt.
1844
+ */
1845
+ function sleepChoicePending() {
1846
+ try {
1847
+ if (typeof window === 'undefined' || !window.localStorage) return false
1848
+ return window.localStorage.getItem(SLEEP_BUTTON_KEY) === null
1849
+ } catch {
1850
+ return false
1851
+ }
1852
+ }
1853
+
1854
+ /** Record the sleep-button answer in the browser. */
1855
+ function writeSleepChoice(value) {
1856
+ try {
1857
+ if (typeof window === 'undefined' || !window.localStorage) return false
1858
+ window.localStorage.setItem(SLEEP_BUTTON_KEY, String(value))
1859
+ return true
1860
+ } catch {
1861
+ return false
1862
+ }
1863
+ }
1864
+
1865
+ // The styles of the sleep surfaces. They live in ONE <style> element so
1866
+ // the button and the overlay never disagree, and every colour comes from
1867
+ // a theme token: light and dark themes both work.
1868
+ const SLEEP_STYLE_ID = 'dsh-autorestart-sleep'
1869
+ const SLEEP_CSS = [
1870
+ // The button at the foot of the left sidebar, beside Settings.
1871
+ // It follows the host's own row language (same hover token, same
1872
+ // radius token, icon + label), but it stays compact: the sidebar foot
1873
+ // is a tight place, and the Settings row must keep its own space.
1874
+ '.dsh-autorestart-sleep-button{',
1875
+ ' box-sizing:border-box;',
1876
+ ' display:inline-flex;',
1877
+ ' align-items:center;',
1878
+ ' gap:8px;',
1879
+ ' height:32px;',
1880
+ ' max-width:100%;',
1881
+ ' padding:0 10px 0 8px;',
1882
+ ' border:none;',
1883
+ ' border-radius:var(--dsw-radius-md,8px);',
1884
+ ' background:transparent;',
1885
+ ' color:var(--dsw-alias-label-primary);',
1886
+ ' font:inherit;',
1887
+ ' font-size:13px;',
1888
+ ' line-height:20px;',
1889
+ ' white-space:nowrap;',
1890
+ ' overflow:hidden;',
1891
+ ' flex:none;',
1892
+ ' cursor:pointer;',
1893
+ ' transition:background-color .14s,color .14s;',
1894
+ '}',
1895
+ '.dsh-autorestart-sleep-button:hover{',
1896
+ ' background:var(--dsw-alias-interactive-bg-hover);',
1897
+ '}',
1898
+ '.dsh-autorestart-sleep-button:focus-visible{',
1899
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1900
+ ' outline-offset:2px;',
1901
+ '}',
1902
+ '.dsh-autorestart-sleep-button:disabled{cursor:default;opacity:.65;}',
1903
+ '.dsh-autorestart-sleep-button--armed{',
1904
+ ' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1905
+ ' background:var(--dsw-alias-bg-module-platform,color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
1906
+ '}',
1907
+ // The narrow rail (the collapsed sidebar): the icon only, so the rail
1908
+ // keeps its width and the row does not push the Settings row away.
1909
+ '.dsh-autorestart-sleep-button--rail{',
1910
+ ' width:36px;',
1911
+ ' height:36px;',
1912
+ ' padding:0;',
1913
+ ' gap:0;',
1914
+ ' justify-content:center;',
1915
+ ' border-radius:50%;',
1916
+ '}',
1917
+ '.dsh-autorestart-sleep-button__icon{width:16px;height:16px;flex:none;display:block;}',
1918
+ '.dsh-autorestart-sleep-button__label{',
1919
+ ' white-space:nowrap;',
1920
+ ' overflow:hidden;',
1921
+ ' text-overflow:ellipsis;',
1922
+ '}',
1923
+ // The button and the reason a request was refused.
1924
+ '.dsh-autorestart-sleep-button__wrap{',
1925
+ ' display:flex;',
1926
+ ' flex-direction:column;',
1927
+ ' align-items:flex-start;',
1928
+ ' gap:2px;',
1929
+ ' min-width:0;',
1930
+ ' max-width:100%;',
1931
+ '}',
1932
+ '.dsh-autorestart-sleep-button__error{',
1933
+ ' max-width:100%;',
1934
+ ' padding:0 2px;',
1935
+ ' color:var(--dsw-alias-danger,#d14343);',
1936
+ ' font-size:11.5px;',
1937
+ ' line-height:15px;',
1938
+ ' text-align:left;',
1939
+ '}',
1940
+ '.dsh-autorestart-sleep-button__spinner{',
1941
+ ' width:12px;',
1942
+ ' height:12px;',
1943
+ ' flex:none;',
1944
+ ' border-radius:50%;',
1945
+ ' border:2px solid var(--dsw-alias-border-l1);',
1946
+ ' border-top-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1947
+ ' animation:dsh-autorestart-sleep-spin .8s linear infinite;',
1948
+ '}',
1949
+ // The countdown CARD: a floating corner card, never a full-screen
1950
+ // cover. It lets every click through (only the cancel button takes
1951
+ // clicks), so the conversation below stays readable and usable.
1952
+ '.dsh-autorestart-sleep-overlay{',
1953
+ ' position:fixed;',
1954
+ ' top:calc(var(--dsh-frame-chrome-top,0px) + 12px);',
1955
+ ' right:16px;',
1956
+ ' z-index:9998;',
1957
+ ' display:flex;',
1958
+ ' flex-direction:column;',
1959
+ ' align-items:center;',
1960
+ ' justify-content:flex-start;',
1961
+ ' gap:4px;',
1962
+ ' box-sizing:border-box;',
1963
+ ' width:max-content;',
1964
+ ' max-width:min(320px,calc(100vw - 32px));',
1965
+ ' padding:14px 18px 16px;',
1966
+ ' border:1px solid var(--dsw-alias-border-l1);',
1967
+ ' border-radius:16px;',
1968
+ ' background:var(--dsw-alias-bg-layer-1);',
1969
+ ' box-shadow:0 10px 30px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 22%, transparent);',
1970
+ ' color:var(--dsw-alias-label-primary);',
1971
+ ' text-align:center;',
1972
+ ' pointer-events:none;',
1973
+ '}',
1974
+ '.dsh-autorestart-sleep-overlay__number{',
1975
+ ' font-size:clamp(72px,9vmin,140px);',
1976
+ ' font-weight:700;',
1977
+ ' line-height:1;',
1978
+ ' letter-spacing:-.02em;',
1979
+ ' color:var(--dsw-alias-label-primary);',
1980
+ ' animation:dsh-autorestart-sleep-tick 1s ease-out;',
1981
+ '}',
1982
+ '.dsh-autorestart-sleep-overlay--urgent .dsh-autorestart-sleep-overlay__number{',
1983
+ ' color:var(--dsw-alias-danger,#d14343);',
1984
+ ' animation:dsh-autorestart-sleep-tick .7s ease-out;',
1985
+ '}',
1986
+ // The whale is a guest in the card now, not the star of a full screen.
1987
+ '.dsh-autorestart-sleep-overlay .dsh-autorestart-whale{width:min(30vmin,150px);}',
1988
+ '.dsh-autorestart-sleep-overlay__caption{',
1989
+ ' margin:4px 0 0;',
1990
+ ' font-size:14px;',
1991
+ ' font-weight:600;',
1992
+ ' line-height:1.4;',
1993
+ ' color:var(--dsw-alias-label-primary);',
1994
+ ' animation:dsh-autorestart-sleep-fade .5s ease-out;',
1995
+ '}',
1996
+ '.dsh-autorestart-sleep-overlay__hint{',
1997
+ ' margin:2px 0 0;',
1998
+ ' font-size:13px;',
1999
+ ' line-height:1.5;',
2000
+ ' color:var(--dsw-alias-label-secondary);',
2001
+ '}',
2002
+ '.dsh-autorestart-sleep-overlay__error{',
2003
+ ' margin:10px 0 0;',
2004
+ ' font-size:12.5px;',
2005
+ ' line-height:1.5;',
2006
+ ' color:var(--dsw-alias-danger,#d14343);',
2007
+ '}',
2008
+ '.dsh-autorestart-sleep-overlay__cancel{',
2009
+ ' margin-top:10px;',
2010
+ ' min-height:32px;',
2011
+ ' padding:6px 16px;',
2012
+ ' border:1px solid var(--dsw-alias-border-l1);',
2013
+ ' border-radius:9px;',
2014
+ ' background:transparent;',
2015
+ ' color:var(--dsw-alias-label-primary);',
2016
+ ' font:inherit;',
2017
+ ' font-size:13px;',
2018
+ ' font-weight:600;',
2019
+ ' line-height:20px;',
2020
+ ' cursor:pointer;',
2021
+ // The card itself lets every click through; the cancel button is the
2022
+ // one thing in it that takes a click.
2023
+ ' pointer-events:auto;',
2024
+ ' transition:background-color .14s,border-color .14s,color .14s;',
2025
+ '}',
2026
+ '.dsh-autorestart-sleep-overlay__cancel:hover{',
2027
+ ' border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
2028
+ ' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
2029
+ ' background:var(--dsw-alias-bg-layer-2,color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
2030
+ '}',
2031
+ '.dsh-autorestart-sleep-overlay__cancel:focus-visible{',
2032
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
2033
+ ' outline-offset:2px;',
2034
+ '}',
2035
+ '.dsh-autorestart-sleep-overlay__cancel:disabled{cursor:default;opacity:.65;}',
2036
+ // The waiting card: a soft corner card that never covers the reading.
2037
+ // Like the countdown card it lets every click through; only its
2038
+ // cancel button takes clicks.
2039
+ '.dsh-autorestart-sleep-waiting{',
2040
+ ' position:fixed;',
2041
+ ' right:16px;',
2042
+ ' bottom:16px;',
2043
+ ' z-index:9998;',
2044
+ ' box-sizing:border-box;',
2045
+ ' max-width:min(320px,calc(100vw - 32px));',
2046
+ ' padding:12px 14px;',
2047
+ ' border:1px solid var(--dsw-alias-border-l1);',
2048
+ ' border-radius:12px;',
2049
+ ' background:var(--dsw-alias-bg-layer-1);',
2050
+ ' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 18%, transparent);',
2051
+ ' color:var(--dsw-alias-label-secondary);',
2052
+ ' font-size:12.5px;',
2053
+ ' line-height:18px;',
2054
+ ' text-align:left;',
2055
+ ' pointer-events:none;',
2056
+ '}',
2057
+ '.dsh-autorestart-sleep-waiting__title{',
2058
+ ' margin:0 0 2px;',
2059
+ ' font-size:13.5px;',
2060
+ ' font-weight:600;',
2061
+ ' line-height:19px;',
2062
+ ' color:var(--dsw-alias-label-primary);',
2063
+ '}',
2064
+ '.dsh-autorestart-sleep-waiting__body{margin:0 0 6px;}',
2065
+ '.dsh-autorestart-sleep-waiting__list{margin:0 0 6px;padding-left:2px;list-style:none;}',
2066
+ '.dsh-autorestart-sleep-waiting__list li{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
2067
+ '.dsh-autorestart-sleep-waiting__none{margin:0 0 6px;opacity:.8;}',
2068
+ // The command a rehearsal WOULD have run: it has to stay readable,
2069
+ // whatever the shell command looks like.
2070
+ '.dsh-autorestart-sleep-dryrun__command{',
2071
+ ' display:block;',
2072
+ ' margin:0 0 8px;',
2073
+ ' padding:6px 8px;',
2074
+ ' border-radius:8px;',
2075
+ ' background:var(--dsw-alias-bg-layer-2,color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
2076
+ ' color:var(--dsw-alias-label-primary);',
2077
+ ' font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;',
2078
+ ' font-size:11.5px;',
2079
+ ' line-height:17px;',
2080
+ ' word-break:break-all;',
2081
+ '}',
2082
+ '.dsh-autorestart-sleep-waiting__count{margin:0 0 8px;opacity:.8;}',
2083
+ '.dsh-autorestart-sleep-waiting__cancel{',
2084
+ ' min-height:28px;',
2085
+ ' padding:4px 12px;',
2086
+ ' border:1px solid var(--dsw-alias-border-l1);',
2087
+ ' border-radius:8px;',
2088
+ ' background:transparent;',
2089
+ ' color:var(--dsw-alias-label-primary);',
2090
+ ' font:inherit;',
2091
+ ' font-size:12.5px;',
2092
+ ' font-weight:600;',
2093
+ ' line-height:18px;',
2094
+ ' cursor:pointer;',
2095
+ ' pointer-events:auto;',
2096
+ '}',
2097
+ '.dsh-autorestart-sleep-waiting__cancel:hover{',
2098
+ ' border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
2099
+ ' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
2100
+ '}',
2101
+ // The configuration page of the plugin row.
2102
+ '.dsh-autorestart-settings{display:flex;flex-direction:column;gap:14px;}',
2103
+ '.dsh-autorestart-settings__field{display:flex;flex-direction:column;gap:4px;}',
2104
+ '.dsh-autorestart-settings__label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);}',
2105
+ '.dsh-autorestart-settings__select{',
2106
+ ' min-height:30px;',
2107
+ ' padding:4px 8px;',
2108
+ ' border:1px solid var(--dsw-alias-border-l1);',
2109
+ ' border-radius:8px;',
2110
+ ' background:var(--dsw-alias-bg-layer-2,transparent);',
2111
+ ' color:var(--dsw-alias-label-primary);',
2112
+ ' font:inherit;',
2113
+ ' font-size:13px;',
2114
+ '}',
2115
+ '.dsh-autorestart-settings__hint{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);}',
2116
+ // The two backup switches.
2117
+ '.dsh-autorestart-settings__switch{',
2118
+ ' width:16px;',
2119
+ ' height:16px;',
2120
+ ' margin:0;',
2121
+ ' accent-color:var(--dsw-alias-brand-primary,#4d6bfe);',
2122
+ ' cursor:pointer;',
2123
+ '}',
2124
+ '.dsh-autorestart-settings__switch:disabled{cursor:not-allowed;opacity:.5;}',
2125
+ '.dsh-autorestart-settings__error{',
2126
+ ' margin:0;',
2127
+ ' color:var(--dsw-alias-danger,#d14343);',
2128
+ ' font-size:12.5px;',
2129
+ ' line-height:18px;',
2130
+ '}',
2131
+ // The whale.
2132
+ '.dsh-autorestart-whale{width:min(44vmin,240px);height:auto;display:block;overflow:visible;}',
2133
+ '.dsh-autorestart-whale__float{',
2134
+ ' animation:dsh-autorestart-whale-float 3.2s ease-in-out infinite;',
2135
+ ' transform-origin:90px 90px;',
2136
+ '}',
2137
+ '.dsh-autorestart-whale__eye{',
2138
+ ' animation:dsh-autorestart-whale-blink 4.6s ease-in-out infinite;',
2139
+ ' transform-origin:48px 74px;',
2140
+ '}',
2141
+ '.dsh-autorestart-whale__spout{animation:dsh-autorestart-whale-spout 3.6s ease-in-out infinite;}',
2142
+ '.dsh-autorestart-whale__z{',
2143
+ ' font-family:inherit;',
2144
+ ' font-size:15px;',
2145
+ ' font-weight:600;',
2146
+ ' fill:var(--dsw-alias-label-secondary);',
2147
+ ' animation:dsh-autorestart-whale-z 2.8s ease-out infinite;',
2148
+ '}',
2149
+ '.dsh-autorestart-whale__z--2{animation-delay:.9s;}',
2150
+ '.dsh-autorestart-whale__z--3{animation-delay:1.8s;}',
2151
+ '.dsh-autorestart-whale--urgent .dsh-autorestart-whale__float{animation-duration:1.1s;}',
2152
+ '.dsh-autorestart-whale--urgent .dsh-autorestart-whale__body,',
2153
+ '.dsh-autorestart-whale--urgent .dsh-autorestart-whale__tail,',
2154
+ '.dsh-autorestart-whale--urgent .dsh-autorestart-whale__fin{fill:var(--dsw-alias-danger,#d14343);}',
2155
+ // The keyframes.
2156
+ '@keyframes dsh-autorestart-sleep-spin{to{transform:rotate(360deg);}}',
2157
+ '@keyframes dsh-autorestart-sleep-tick{',
2158
+ ' 0%{transform:scale(.86);opacity:.55;}',
2159
+ ' 40%{transform:scale(1.04);opacity:1;}',
2160
+ ' 100%{transform:scale(1);opacity:1;}',
2161
+ '}',
2162
+ '@keyframes dsh-autorestart-sleep-fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}',
2163
+ '@keyframes dsh-autorestart-whale-float{',
2164
+ ' 0%,100%{transform:translateY(0) rotate(0deg);}',
2165
+ ' 50%{transform:translateY(-10px) rotate(-1.5deg);}',
2166
+ '}',
2167
+ '@keyframes dsh-autorestart-whale-blink{',
2168
+ ' 0%,88%,100%{transform:scaleY(1);}',
2169
+ ' 93%{transform:scaleY(.08);}',
2170
+ ' 97%{transform:scaleY(1);}',
2171
+ '}',
2172
+ '@keyframes dsh-autorestart-whale-spout{',
2173
+ ' 0%,70%,100%{opacity:.35;}',
2174
+ ' 35%{opacity:1;}',
2175
+ '}',
2176
+ '@keyframes dsh-autorestart-whale-z{',
2177
+ ' 0%{opacity:0;transform:translate(0,0) scale(.7);}',
2178
+ ' 25%{opacity:.9;}',
2179
+ ' 100%{opacity:0;transform:translate(12px,-26px) scale(1.15);}',
2180
+ '}',
2181
+ // A user who asked for less motion gets a still whale.
2182
+ '@media (prefers-reduced-motion: reduce){',
2183
+ ' .dsh-autorestart-whale__float,',
2184
+ ' .dsh-autorestart-whale__eye,',
2185
+ ' .dsh-autorestart-whale__spout,',
2186
+ ' .dsh-autorestart-whale__z,',
2187
+ ' .dsh-autorestart-sleep-overlay__number,',
2188
+ ' .dsh-autorestart-sleep-overlay__caption,',
2189
+ ' .dsh-autorestart-sleep-button__spinner{animation:none !important;}',
2190
+ '}',
2191
+ ].join('\n')
2192
+
2193
+ /** Inject the sleep styles exactly once. */
2194
+ function ensureSleepStyle() {
2195
+ if (typeof document === 'undefined' || !document.head) return
2196
+ try {
2197
+ if (document.getElementById(SLEEP_STYLE_ID)) return
2198
+ const style = document.createElement('style')
2199
+ style.id = SLEEP_STYLE_ID
2200
+ style.textContent = SLEEP_CSS
2201
+ document.head.appendChild(style)
2202
+ } catch {
2203
+ // Without the stylesheet the surfaces are still readable.
2204
+ }
2205
+ }
2206
+
2207
+ /**
2208
+ * Remove the sleep styles again.
2209
+ *
2210
+ * The stylesheet belongs to the sleep surfaces: when the last one leaves
2211
+ * (the plugin is unloaded, the conversation is closed), the CSS goes with
2212
+ * it instead of staying in the document forever.
2213
+ */
2214
+ function removeSleepStyle() {
2215
+ if (typeof document === 'undefined') return
2216
+ try {
2217
+ const node = document.getElementById(SLEEP_STYLE_ID)
2218
+ if (node && node.parentNode) node.parentNode.removeChild(node)
2219
+ } catch {
2220
+ // Nothing to clean up.
2221
+ }
2222
+ }
2223
+
2224
+ /** The moon of the sidebar button: an inline SVG, no external image. */
2225
+ function moonIcon() {
2226
+ return h(
2227
+ 'svg',
2228
+ {
2229
+ className: 'dsh-autorestart-sleep-button__icon',
2230
+ viewBox: '0 0 24 24',
2231
+ width: '16',
2232
+ height: '16',
2233
+ 'aria-hidden': 'true',
2234
+ focusable: 'false',
2235
+ },
2236
+ h('path', {
2237
+ d: 'M20.5 14.6A8.6 8.6 0 0 1 9.4 3.5a8.6 8.6 0 1 0 11.1 11.1z',
2238
+ fill: 'currentColor',
2239
+ }),
2240
+ h('circle', { cx: '16.5', cy: '6.5', r: '1.3', fill: 'var(--dsw-alias-brand-primary,#4d6bfe)' }),
2241
+ )
2242
+ }
2243
+
2244
+ /**
2245
+ * The graceful-shutdown button, at the foot of the left sidebar.
2246
+ *
2247
+ * It renders NOTHING at all when the Host says the button must not be
2248
+ * shown: no placeholder, no empty space.
2249
+ *
2250
+ * The sidebar hands the column state to the slot: `wide` is false while
2251
+ * the sidebar is collapsed into its narrow rail, and there the button
2252
+ * shows its icon only, so the rail keeps its width.
2253
+ *
2254
+ * @param {{ wide?: boolean }} [props] the sidebar's column state
2255
+ */
2256
+ /**
2257
+ * The settings section of the plugin (the "Auto-restart & shut down" entry
2258
+ * in the settings sidebar).
2259
+ *
2260
+ * The Host renders this section for the slot `settings.section` with the
2261
+ * id `dsh-autorestart`. It carries exactly the three settings that
2262
+ * matter:
2263
+ *
2264
+ * - how long the countdown runs (a dropdown),
2265
+ * - whether the pre-restart backup is taken at all (a switch),
2266
+ * - whether that backup also snapshots the installed plugins (a switch).
2267
+ *
2268
+ * A change is written to the profile configuration at once (through the
2269
+ * plugin's own choice route, which uses the official `configEditor`
2270
+ * service), and the section reads the new values back from the Host.
2271
+ *
2272
+ * @param {object} props the Host's props: `t` (the translator of the
2273
+ * registered locale, injected by the section registration)
2274
+ */
2275
+ function AutorestartSection(props) {
2276
+ const status = useSleepStatus()
2277
+ const info = useSharedInfo(true)
2278
+ const [busy, setBusy] = useState(false)
2279
+ const [error, setError] = useState('')
2280
+ const t = props && typeof props.t === 'function'
2281
+ ? props.t
2282
+ : (translate ?? ((name) => name))
2283
+
2284
+ // The current values come from the Host, so the panel always shows what
2285
+ // is really in effect.
2286
+ const reported = status && typeof status === 'object' && typeof status.countdownSeconds === 'number'
2287
+ ? status.countdownSeconds
2288
+ : SLEEP_DEFAULT_COUNTDOWN_SECONDS
2289
+ const seconds = SLEEP_COUNTDOWN_CHOICES.indexOf(reported) === -1
2290
+ ? SLEEP_DEFAULT_COUNTDOWN_SECONDS
2291
+ : reported
2292
+ // The two backup switches: the Host says what is in effect.
2293
+ const backupOnRestart = status && typeof status === 'object' && typeof status.backupOnRestart === 'boolean'
2294
+ ? status.backupOnRestart
2295
+ : true
2296
+ const backupIncludePlugins = status && typeof status === 'object' && typeof status.backupIncludePlugins === 'boolean'
2297
+ ? status.backupIncludePlugins
2298
+ : true
2299
+
2300
+ /**
2301
+ * Write one change into the profile configuration.
2302
+ *
2303
+ * The Host answers with the effective values, and the panel reads them
2304
+ * back with a fresh poll — so the controls never show a value the
2305
+ * Host did not take.
2306
+ *
2307
+ * @param {object} changes the keys to write
2308
+ */
2309
+ const save = async (changes) => {
2310
+ if (busy) return
2311
+ setBusy(true)
2312
+ setError('')
2313
+ try {
2314
+ const response = await fetch(choiceUrlFor(info), {
2315
+ method: 'POST',
2316
+ headers: { 'content-type': 'application/json' },
2317
+ body: JSON.stringify(changes),
2318
+ })
2319
+ const body = await response.json().catch(() => null)
2320
+ if (!response.ok) {
2321
+ const message = body && typeof body === 'object'
2322
+ ? (body.message || body.error || String(response.status))
2323
+ : String(response.status)
2324
+ setError(fill(t('settings.error'), { message }))
2325
+ return
2326
+ }
2327
+ // Read the new values back from the Host.
2328
+ await pollSleepOnce()
2329
+ } catch (failure) {
2330
+ setError(fill(t('settings.error'), { message: describeError(failure) }))
2331
+ } finally {
2332
+ setBusy(false)
2333
+ }
2334
+ }
2335
+
2336
+ ensureSleepStyle()
2337
+
2338
+ return h(
2339
+ 'div',
2340
+ { className: 'dsh-autorestart-settings' },
2341
+ h(
2342
+ 'label',
2343
+ { className: 'dsh-autorestart-settings__field' },
2344
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.seconds.label')),
2345
+ h(
2346
+ 'select',
2347
+ {
2348
+ className: 'dsh-autorestart-settings__select',
2349
+ value: String(seconds),
2350
+ disabled: busy,
2351
+ 'data-dsh-autorestart-setting': 'sleepCountdownSeconds',
2352
+ onChange: (event) => {
2353
+ const value = Number(event && event.target ? event.target.value : NaN)
2354
+ if (!Number.isFinite(value) || value === seconds) return
2355
+ void save({ sleepCountdownSeconds: value })
2356
+ },
2357
+ },
2358
+ SLEEP_COUNTDOWN_CHOICES.map((choice) => h(
2359
+ 'option',
2360
+ { key: String(choice), value: String(choice) },
2361
+ choice === 0 ? t('settings.seconds.immediate') : fill(t('settings.seconds.value'), { n: choice }),
2362
+ )),
2363
+ ),
2364
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.seconds.hint')),
2365
+ ),
2366
+ h(
2367
+ 'label',
2368
+ { className: 'dsh-autorestart-settings__field' },
2369
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backup.label')),
2370
+ h(
2371
+ 'input',
2372
+ {
2373
+ type: 'checkbox',
2374
+ className: 'dsh-autorestart-settings__switch',
2375
+ checked: backupOnRestart,
2376
+ disabled: busy,
2377
+ 'data-dsh-autorestart-setting': 'backupOnRestart',
2378
+ onChange: (event) => {
2379
+ const value = Boolean(event && event.target ? event.target.checked : false)
2380
+ if (value === backupOnRestart) return
2381
+ void save({ backupOnRestart: value })
2382
+ },
2383
+ },
2384
+ ),
2385
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backup.hint')),
2386
+ ),
2387
+ h(
2388
+ 'label',
2389
+ { className: 'dsh-autorestart-settings__field' },
2390
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backupPlugins.label')),
2391
+ h(
2392
+ 'input',
2393
+ {
2394
+ type: 'checkbox',
2395
+ className: 'dsh-autorestart-settings__switch',
2396
+ checked: backupIncludePlugins,
2397
+ // Without a pre-restart backup there is nothing to extend: the
2398
+ // switch is greyed out (and the Host never reads it).
2399
+ disabled: busy || !backupOnRestart,
2400
+ 'data-dsh-autorestart-setting': 'backupIncludePlugins',
2401
+ onChange: (event) => {
2402
+ const value = Boolean(event && event.target ? event.target.checked : false)
2403
+ if (value === backupIncludePlugins) return
2404
+ void save({ backupIncludePlugins: value })
2405
+ },
2406
+ },
2407
+ ),
2408
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backupPlugins.hint')),
2409
+ ),
2410
+ error === ''
2411
+ ? null
2412
+ : h('p', { className: 'dsh-autorestart-settings__error', role: 'alert' }, error),
2413
+ )
2414
+ }
2415
+
2416
+ function SleepButton(props) {
2417
+ const status = useSleepStatus()
2418
+ const [arming, setArming] = useState(false)
2419
+ const [error, setError] = useState('')
2420
+ const t = translate ?? ((name) => name)
2421
+
2422
+ // The Host's truth decides, and an UNKNOWN truth decides NOTHING: before
2423
+ // the first poll the button renders nothing at all, so a host that has
2424
+ // the button switched off never sees it flash for two seconds.
2425
+ const remoteKnown = Boolean(
2426
+ status && typeof status === 'object' && typeof status.showSleepButton === 'boolean',
2427
+ )
2428
+ const visible = remoteKnown && shouldShowSleepButton(status)
2429
+
2430
+ // The narrow rail shows the icon only; the wide column shows the label.
2431
+ const wide = !(props && props.wide === false)
2432
+
2433
+ // How long the countdown lasts (the settings page offers a dropdown).
2434
+ const reported = status && typeof status === 'object' && typeof status.countdownSeconds === 'number'
2435
+ ? status.countdownSeconds
2436
+ : SLEEP_DEFAULT_COUNTDOWN_SECONDS
2437
+ const countdownSeconds = SLEEP_COUNTDOWN_CHOICES.indexOf(reported) === -1
2438
+ ? SLEEP_DEFAULT_COUNTDOWN_SECONDS
2439
+ : reported
2440
+ const countdownHint = countdownSeconds > 0
2441
+ ? fill(t('sleep.button.title'), { n: countdownSeconds })
2442
+ : t('sleep.button.titleImmediate')
2443
+
2444
+ // The button is a TOGGLE: a click arms the shutdown, and a click while
2445
+ // the shutdown is armed cancels it. That way there is ALWAYS a way out,
2446
+ // even when the countdown screen is not (or no longer) on top.
2447
+ const armed = Boolean(
2448
+ status && typeof status === 'object' && (
2449
+ status.armed === true
2450
+ || status.phase === 'waiting'
2451
+ || status.phase === 'countdown'
2452
+ || status.phase === 'shutting-down'
2453
+ ),
2454
+ )
2455
+
2456
+ /**
2457
+ * Ask the Host to stand by: it waits for every session to finish and
2458
+ * then counts down.
2459
+ *
2460
+ * That is all a click does: the Host arms the shutdown, waits for the
2461
+ * busy conversations and counts down. Clicking again (or the cancel
2462
+ * button on the card) takes it back.
2463
+ */
2464
+ const arm = async () => {
2465
+ if (arming) return
2466
+ setArming(true)
2467
+ setError('')
2468
+ try {
2469
+ await driveSleep('arm')
2470
+ } catch (failure) {
2471
+ setError(fill(t('sleep.error'), { message: describeError(failure) }))
2472
+ } finally {
2473
+ setArming(false)
2474
+ }
2475
+ }
2476
+
2477
+ /** Take the shutdown back: the countdown stops and the machine stays on. */
2478
+ const cancel = async () => {
2479
+ if (arming) return
2480
+ setArming(true)
2481
+ setError('')
2482
+ try {
2483
+ await driveSleep('cancel')
2484
+ } catch (failure) {
2485
+ setError(fill(t('sleep.error'), { message: describeError(failure) }))
2486
+ } finally {
2487
+ setArming(false)
2488
+ }
2489
+ }
2490
+
2491
+ /**
2492
+ * The click itself is the action: the moon button arms the shutdown,
2493
+ * and while it is armed the same button cancels it again.
2494
+ *
2495
+ * @param {object} event the DOM event of the click
2496
+ */
2497
+ const handleClick = (event) => {
2498
+ if (armed) cancel()
2499
+ else arm()
2500
+ }
2501
+
2502
+ if (!visible) return null
2503
+ ensureSleepStyle()
2504
+
2505
+ const button = h(
2506
+ 'button',
2507
+ {
2508
+ type: 'button',
2509
+ className: 'dsh-autorestart-sleep-button'
2510
+ + (armed ? ' dsh-autorestart-sleep-button--armed' : '')
2511
+ + (wide ? '' : ' dsh-autorestart-sleep-button--rail'),
2512
+ 'aria-label': armed ? t('sleep.button.cancel') : t('sleep.button.aria'),
2513
+ title: error || (armed ? t('sleep.button.cancel') : countdownHint),
2514
+ disabled: arming,
2515
+ onClick: handleClick,
2516
+ 'data-dsh-autorestart-sleep-button': 'true',
2517
+ 'data-armed': armed ? 'true' : 'false',
2518
+ 'data-wide': wide ? 'true' : 'false',
2519
+ },
2520
+ arming
2521
+ ? h('span', { className: 'dsh-autorestart-sleep-button__spinner', 'aria-hidden': 'true' })
2522
+ : moonIcon(),
2523
+ // The narrow rail has no room for a label: the icon alone, with the
2524
+ // title and the aria-label telling what it does.
2525
+ wide
2526
+ ? h(
2527
+ 'span',
2528
+ { className: 'dsh-autorestart-sleep-button__label' },
2529
+ arming
2530
+ ? t('sleep.button.busy')
2531
+ : (armed ? t('sleep.button.cancel') : t('sleep.button.label')),
2532
+ )
2533
+ : null,
2534
+ )
2535
+
2536
+ // A refused request is never silent: the reason is written under the
2537
+ // button.
2538
+ if (!wide || error === '') return button
2539
+ return h(
2540
+ 'div',
2541
+ { className: 'dsh-autorestart-sleep-button__wrap' },
2542
+ button,
2543
+ h(
2544
+ 'span',
2545
+ { className: 'dsh-autorestart-sleep-button__error', role: 'alert' },
2546
+ error,
2547
+ ),
2548
+ )
2549
+ }
2550
+
2551
+ /**
2552
+ * A small sketch of the workspace with the shutdown button at the foot of
2553
+ * the left sidebar: the arrow points at the place the button appears.
2554
+ *
2555
+ * It carries no copy of its own: the words around it come from the
2556
+ * locale files.
2557
+ */
2558
+ function sleepDiagram() {
2559
+ return h(
2560
+ 'svg',
2561
+ {
2562
+ className: 'dsh-autorestart-choice__diagram__art',
2563
+ viewBox: '0 0 260 120',
2564
+ width: '260',
2565
+ height: '120',
2566
+ 'aria-hidden': 'true',
2567
+ focusable: 'false',
2568
+ },
2569
+ // The window.
2570
+ h('rect', {
2571
+ x: '6',
2572
+ y: '6',
2573
+ width: '248',
2574
+ height: '108',
2575
+ rx: '10',
2576
+ fill: 'var(--dsw-alias-bg-layer-2,transparent)',
2577
+ stroke: 'var(--dsw-alias-border-l1)',
2578
+ }),
2579
+ h('line', {
2580
+ x1: '6',
2581
+ y1: '24',
2582
+ x2: '254',
2583
+ y2: '24',
2584
+ stroke: 'var(--dsw-alias-border-l1)',
2585
+ }),
2586
+ // The left sidebar.
2587
+ h('line', {
2588
+ x1: '78',
2589
+ y1: '24',
2590
+ x2: '78',
2591
+ y2: '114',
2592
+ stroke: 'var(--dsw-alias-border-l1)',
2593
+ }),
2594
+ // Two session rows in the sidebar.
2595
+ h('rect', {
2596
+ x: '16',
2597
+ y: '36',
2598
+ width: '46',
2599
+ height: '7',
2600
+ rx: '3.5',
2601
+ fill: 'var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))',
2602
+ }),
2603
+ h('rect', {
2604
+ x: '16',
2605
+ y: '50',
2606
+ width: '52',
2607
+ height: '7',
2608
+ rx: '3.5',
2609
+ fill: 'var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))',
2610
+ }),
2611
+ // The button at the foot of the sidebar, right above the Settings row.
2612
+ h('rect', {
2613
+ x: '14',
2614
+ y: '80',
2615
+ width: '52',
2616
+ height: '18',
2617
+ rx: '6',
2618
+ fill: 'var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe))',
2619
+ }),
2620
+ h(
2621
+ 'g',
2622
+ { transform: 'translate(-4 45)' },
2623
+ h('path', {
2624
+ d: 'M28 44a6 6 0 0 1 8-8 6 6 0 1 0 7 7 6 6 0 0 1-8 8 6 6 0 0 1-7-7z',
2625
+ fill: 'var(--dsw-alias-bg-layer-1,#fff)',
2626
+ }),
2627
+ ),
2628
+ // The Settings row underneath it.
2629
+ h('circle', {
2630
+ cx: '20',
2631
+ cy: '107',
2632
+ r: '4.5',
2633
+ fill: 'none',
2634
+ stroke: 'var(--dsw-alias-label-secondary)',
2635
+ 'stroke-width': '1.5',
2636
+ }),
2637
+ h('rect', {
2638
+ x: '30',
2639
+ y: '103',
2640
+ width: '34',
2641
+ height: '7',
2642
+ rx: '3.5',
2643
+ fill: 'var(--dsw-alias-border-l2,var(--dsw-alias-border-l1))',
2644
+ }),
2645
+ // The arrow that points at the bottom-left corner.
2646
+ h('path', {
2647
+ d: 'M186 100 L74 92',
2648
+ fill: 'none',
2649
+ stroke: 'var(--dsw-alias-label-secondary)',
2650
+ 'stroke-width': '2',
2651
+ 'stroke-linecap': 'round',
2652
+ 'stroke-dasharray': '5 4',
2653
+ }),
2654
+ h('path', {
2655
+ d: 'M74 92 l12-1 -5 10 z',
2656
+ fill: 'var(--dsw-alias-label-secondary)',
2657
+ }),
2658
+ )
2659
+ }
2660
+
2661
+ /**
2662
+ * The whale: an inline SVG animated with CSS only.
2663
+ *
2664
+ * No external image and no dependency on another plugin: the whale is
2665
+ * drawn here, floats, blinks and blows sleepy Zs.
2666
+ */
2667
+ function whaleArt(urgent) {
2668
+ return h(
2669
+ 'svg',
2670
+ {
2671
+ className: 'dsh-autorestart-whale' + (urgent ? ' dsh-autorestart-whale--urgent' : ''),
2672
+ viewBox: '0 0 200 140',
2673
+ width: '200',
2674
+ height: '140',
2675
+ 'aria-hidden': 'true',
2676
+ focusable: 'false',
2677
+ },
2678
+ h(
2679
+ 'g',
2680
+ { className: 'dsh-autorestart-whale__float' },
2681
+ h('path', {
2682
+ className: 'dsh-autorestart-whale__tail',
2683
+ d: 'M142 84c14-16 26-30 44-38-6 12-9 22-9 30 0 8 3 18 9 30-18-8-30-16-44-22z',
2684
+ fill: 'var(--dsw-alias-brand-primary,#4d8bf0)',
2685
+ }),
2686
+ h('ellipse', {
2687
+ className: 'dsh-autorestart-whale__body',
2688
+ cx: '84',
2689
+ cy: '84',
2690
+ rx: '66',
2691
+ ry: '38',
2692
+ fill: 'var(--dsw-alias-brand-primary,#4d8bf0)',
2693
+ }),
2694
+ h('ellipse', {
2695
+ className: 'dsh-autorestart-whale__belly',
2696
+ cx: '90',
2697
+ cy: '97',
2698
+ rx: '50',
2699
+ ry: '23',
2700
+ fill: 'var(--dsw-alias-bg-layer-1,#fff)',
2701
+ opacity: '0.85',
2702
+ }),
2703
+ h('path', {
2704
+ className: 'dsh-autorestart-whale__fin',
2705
+ d: 'M72 110c8 10 18 14 30 12-6 8-16 12-26 10-8-2-12-11-4-22z',
2706
+ fill: 'var(--dsw-alias-brand-primary,#4d8bf0)',
2707
+ }),
2708
+ h(
2709
+ 'g',
2710
+ { className: 'dsh-autorestart-whale__eye' },
2711
+ h('circle', { cx: '48', cy: '74', r: '6.5', fill: 'var(--dsw-alias-bg-layer-1,#fff)' }),
2712
+ h('circle', { cx: '47', cy: '75', r: '3', fill: 'var(--dsw-alias-label-primary,#333)' }),
2713
+ ),
2714
+ h('path', {
2715
+ className: 'dsh-autorestart-whale__mouth',
2716
+ d: 'M28 88c6 6 14 8 22 6',
2717
+ fill: 'none',
2718
+ stroke: 'var(--dsw-alias-label-secondary,#888)',
2719
+ 'stroke-width': '2',
2720
+ 'stroke-linecap': 'round',
2721
+ }),
2722
+ h(
2723
+ 'g',
2724
+ { className: 'dsh-autorestart-whale__spout' },
2725
+ h('circle', { cx: '62', cy: '38', r: '5', fill: 'var(--dsw-alias-label-secondary,#888)', opacity: '0.7' }),
2726
+ h('circle', { cx: '74', cy: '28', r: '3.5', fill: 'var(--dsw-alias-label-secondary,#888)', opacity: '0.55' }),
2727
+ h('circle', { cx: '52', cy: '26', r: '3', fill: 'var(--dsw-alias-label-secondary,#888)', opacity: '0.45' }),
2728
+ ),
2729
+ ),
2730
+ h('text', { className: 'dsh-autorestart-whale__z dsh-autorestart-whale__z--1', x: '148', y: '46' }, 'Z'),
2731
+ h('text', { className: 'dsh-autorestart-whale__z dsh-autorestart-whale__z--2', x: '166', y: '30' }, 'Z'),
2732
+ h('text', { className: 'dsh-autorestart-whale__z dsh-autorestart-whale__z--3', x: '182', y: '16' }, 'Z'),
2733
+ )
2734
+ }
2735
+
2736
+ /**
2737
+ * The countdown overlay, in the shell's overlay layer.
2738
+ *
2739
+ * It renders NOTHING unless the Host really is counting down, waiting
2740
+ * for the sessions or already powering off.
2741
+ */
2742
+ function SleepOverlay() {
2743
+ const status = useSleepStatus()
2744
+ const [, setTick] = useState(0)
2745
+ // The overlay folds away locally as soon as the user cancels.
2746
+ const [hidden, setHidden] = useState(false)
2747
+ const [error, setError] = useState('')
2748
+ const [busy, setBusy] = useState(false)
2749
+ // The safety net of the "good night" screen: the user may close the
2750
+ // notice, and a screen that stays for too long offers the same exit.
2751
+ const [dismissed, setDismissed] = useState(false)
2752
+ const [stuck, setStuck] = useState(false)
2753
+ // The note of a finished rehearsal: what the host WOULD have run. It
2754
+ // folds away on its own (or when the user closes it).
2755
+ const [dryNotice, setDryNotice] = useState(null)
2756
+ const t = translate ?? ((name) => name)
2757
+
2758
+ const phase = status && typeof status === 'object' && typeof status.phase === 'string'
2759
+ ? status.phase
2760
+ : ''
2761
+
2762
+ // The last run the host reports. A REHEARSAL (dry run) says what it
2763
+ // would have done and executed nothing; a real failure says what went
2764
+ // wrong. The two never look alike, so the notice can never mistake one
2765
+ // for the other.
2766
+ const lastRun = status && typeof status === 'object' && status.lastRun && typeof status.lastRun === 'object'
2767
+ ? status.lastRun
2768
+ : null
2769
+ const dryRunAt = lastRun !== null && lastRun.dryRun === true && typeof lastRun.at === 'string'
2770
+ ? lastRun.at
2771
+ : ''
2772
+ const planned = lastRun !== null && typeof lastRun.command === 'string' && lastRun.command !== ''
2773
+ ? [lastRun.command].concat(Array.isArray(lastRun.args) ? lastRun.args : []).join(' ')
2774
+ : ''
2775
+
2776
+ // The shared poll runs every 2s, but the big number has to move every
2777
+ // second: the countdown owns a local 1s tick. The interval is cleared
2778
+ // when the phase changes and when the component leaves.
2779
+ useEffect(() => {
2780
+ if (phase !== 'countdown') return undefined
2781
+ const timer = setInterval(() => setTick((value) => value + 1), 1000)
2782
+ return () => clearInterval(timer)
2783
+ }, [phase])
2784
+
2785
+ // A NEW countdown always shows the overlay again: a cancellation that
2786
+ // happened earlier must never leave the layer folded away, or the next
2787
+ // countdown would run without the user being able to see (or stop) it.
2788
+ useEffect(() => {
2789
+ if (phase === 'countdown') setHidden(false)
2790
+ }, [phase])
2791
+
2792
+ // A finished rehearsal leaves a note: the command it WOULD have run.
2793
+ // Without it the user would see nothing at all and could not tell a
2794
+ // rehearsal from a real shutdown. The note folds away after eight
2795
+ // seconds (or when the user closes it); a new rehearsal shows it again.
2796
+ useEffect(() => {
2797
+ if (dryRunAt === '') return undefined
2798
+ setDryNotice({ at: dryRunAt, command: planned })
2799
+ const timer = setTimeout(() => setDryNotice(null), SLEEP_DRY_RUN_NOTICE_MS)
2800
+ return () => clearTimeout(timer)
2801
+ }, [dryRunAt])
2802
+
2803
+ /**
2804
+ * The time safety net of the "good night" screen.
2805
+ *
2806
+ * A machine that really powers off is killed by the system, so this
2807
+ * screen cannot last eight seconds — if it does, the command did not
2808
+ * work and the user gets a way out. The timer is cleared when the phase
2809
+ * changes and when the component leaves.
2810
+ */
2811
+ useEffect(() => {
2812
+ if (phase !== 'shutting-down') {
2813
+ setStuck(false)
2814
+ setDismissed(false)
2815
+ return undefined
2816
+ }
2817
+ const timer = setTimeout(() => setStuck(true), 8000)
2818
+ return () => clearTimeout(timer)
2819
+ }, [phase])
2820
+
2821
+ // The remaining time is measured from the moment the status arrived,
2822
+ // so the number keeps ticking between two polls.
2823
+ const remainingMs = status && typeof status.remainingMs === 'number' && Number.isFinite(status.remainingMs)
2824
+ ? status.remainingMs
2825
+ : 0
2826
+ const elapsed = sleepStore.at > 0 ? Date.now() - sleepStore.at : 0
2827
+ const seconds = formatRemainingMs(Math.max(0, remainingMs - elapsed))
2828
+
2829
+ // How long the countdown lasts (the settings page offers a dropdown).
2830
+ const reported = status && typeof status === 'object' && typeof status.countdownSeconds === 'number'
2831
+ ? status.countdownSeconds
2832
+ : SLEEP_DEFAULT_COUNTDOWN_SECONDS
2833
+ const countdownSeconds = SLEEP_COUNTDOWN_CHOICES.indexOf(reported) === -1
2834
+ ? SLEEP_DEFAULT_COUNTDOWN_SECONDS
2835
+ : reported
2836
+ // A countdown of zero seconds is NO countdown: the machine is switched
2837
+ // off the moment the gate opens, so there is no card to show (and no
2838
+ // whale). The waiting card takes over when the gate keeps it closed.
2839
+ const noCountdown = countdownSeconds === 0
2840
+
2841
+ /**
2842
+ * Cancel the shutdown.
2843
+ *
2844
+ * The layer folds away only when the Host CONFIRMS the cancellation
2845
+ * (`phase === 'cancelled'`): a request that fails leaves the countdown
2846
+ * on screen, so the user can simply try again instead of being stuck
2847
+ * behind a machine that is still counting down.
2848
+ */
2849
+ const cancel = async () => {
2850
+ if (busy) return
2851
+ setBusy(true)
2852
+ setError('')
2853
+ try {
2854
+ const body = await driveSleep('cancel')
2855
+ if (body && typeof body === 'object' && body.phase === 'cancelled') setHidden(true)
2856
+ } catch (failure) {
2857
+ // The countdown keeps running: the button can be pressed again.
2858
+ setHidden(false)
2859
+ setError(fill(t('sleep.error'), { message: describeError(failure) }))
2860
+ } finally {
2861
+ setBusy(false)
2862
+ }
2863
+ }
2864
+
2865
+ // The line that tells the user WHY the last request failed.
2866
+ const errorLine = () => (error === ''
2867
+ ? null
2868
+ : h('p', { className: 'dsh-autorestart-sleep-overlay__error', role: 'alert' }, error))
2869
+
2870
+ // The Host confirmed the cancellation: the layer is gone.
2871
+ if (hidden) return null
2872
+
2873
+ if (phase === 'waiting') {
2874
+ ensureSleepStyle()
2875
+ const blockers = status && Array.isArray(status.blockers) ? status.blockers : []
2876
+ return h(
2877
+ 'div',
2878
+ {
2879
+ className: 'dsh-autorestart-sleep-waiting',
2880
+ role: 'status',
2881
+ 'aria-live': 'polite',
2882
+ 'data-dsh-autorestart-sleep-waiting': 'true',
2883
+ // The card never eats a click: only its cancel button does.
2884
+ style: { pointerEvents: 'none' },
2885
+ },
2886
+ h('p', { className: 'dsh-autorestart-sleep-waiting__title' }, t('sleep.waiting.title')),
2887
+ h('p', { className: 'dsh-autorestart-sleep-waiting__body' }, t('sleep.waiting.body')),
2888
+ blockers.length === 0
2889
+ ? h('p', { className: 'dsh-autorestart-sleep-waiting__none' }, t('sleep.waiting.none'))
2890
+ : h(
2891
+ 'ul',
2892
+ { className: 'dsh-autorestart-sleep-waiting__list' },
2893
+ blockers.map((entry, index) => h(
2894
+ 'li',
2895
+ { key: 'blocker-' + index },
2896
+ fill(t('sleep.waiting.blocker'), {
2897
+ title: entry && typeof entry.title === 'string' && entry.title !== ''
2898
+ ? entry.title
2899
+ : t('panelBlockedGeneric'),
2900
+ }),
2901
+ )),
2902
+ ),
2903
+ h('p', { className: 'dsh-autorestart-sleep-waiting__count' }, blockers.length === 0
2904
+ ? null
2905
+ : fill(t('sleep.waiting.count'), { n: blockers.length })),
2906
+ errorLine(),
2907
+ h(
2908
+ 'button',
2909
+ {
2910
+ type: 'button',
2911
+ className: 'dsh-autorestart-sleep-waiting__cancel',
2912
+ onClick: cancel,
2913
+ disabled: busy,
2914
+ style: { pointerEvents: 'auto' },
2915
+ },
2916
+ t('sleep.waiting.cancel'),
2917
+ ),
2918
+ )
2919
+ }
2920
+
2921
+ if (phase === 'countdown') {
2922
+ // A countdown of zero seconds shows NO card at all: the machine is
2923
+ // switched off the moment the gate opens, so there is nothing to
2924
+ // count down and nothing to cancel.
2925
+ if (noCountdown) return null
2926
+ ensureSleepStyle()
2927
+ const urgent = seconds <= 3
2928
+ const hint = fill(t('sleep.countdown.hint'), { n: seconds })
2929
+ return h(
2930
+ 'div',
2931
+ {
2932
+ className: 'dsh-autorestart-sleep-overlay'
2933
+ + (urgent ? ' dsh-autorestart-sleep-overlay--urgent' : ''),
2934
+ role: 'dialog',
2935
+ 'aria-live': 'assertive',
2936
+ 'aria-label': hint,
2937
+ 'data-dsh-autorestart-sleep-countdown': 'true',
2938
+ // The card is a FLOATING card: it never covers the interface and
2939
+ // it never eats a click — only its cancel button does.
2940
+ style: { pointerEvents: 'none' },
2941
+ },
2942
+ // The key changes every second, so the number restarts its pulse
2943
+ // on every tick.
2944
+ h(
2945
+ 'div',
2946
+ { className: 'dsh-autorestart-sleep-overlay__number', key: 'number-' + seconds },
2947
+ String(Math.max(0, seconds)),
2948
+ ),
2949
+ whaleArt(urgent),
2950
+ h(
2951
+ 'p',
2952
+ { className: 'dsh-autorestart-sleep-overlay__caption', key: 'caption-' + seconds },
2953
+ pickSleepCaption(seconds, t),
2954
+ ),
2955
+ h(
2956
+ 'button',
2957
+ {
2958
+ type: 'button',
2959
+ className: 'dsh-autorestart-sleep-overlay__cancel',
2960
+ onClick: cancel,
2961
+ disabled: busy,
2962
+ style: { pointerEvents: 'auto' },
2963
+ },
2964
+ t('sleep.countdown.cancel'),
2965
+ ),
2966
+ h('p', { className: 'dsh-autorestart-sleep-overlay__hint' }, hint),
2967
+ errorLine(),
2968
+ )
2969
+ }
2970
+
2971
+ if (phase === 'shutting-down') {
2972
+ ensureSleepStyle()
2973
+ // The user closed the notice: the screen is gone, the Host keeps
2974
+ // whatever it thinks (this button never cancels anything).
2975
+ if (dismissed) return null
2976
+ // A dry run never powers the machine off, so the screen must say so
2977
+ // and offer the way out right away.
2978
+ const dryRun = (status && status.dryRun === true) || dryRunAt !== ''
2979
+ const closeButton = (dryRun || stuck)
2980
+ ? h(
2981
+ 'button',
2982
+ {
2983
+ type: 'button',
2984
+ className: 'dsh-autorestart-sleep-overlay__cancel',
2985
+ onClick: () => setDismissed(true),
2986
+ style: { pointerEvents: 'auto' },
2987
+ },
2988
+ t('sleep.shuttingDown.dismiss'),
2989
+ )
2990
+ : null
2991
+ return h(
2992
+ 'div',
2993
+ {
2994
+ className: 'dsh-autorestart-sleep-overlay dsh-autorestart-sleep-overlay--goodnight',
2995
+ role: 'status',
2996
+ 'aria-live': 'assertive',
2997
+ 'data-dsh-autorestart-sleep-shutdown': 'true',
2998
+ 'data-dsh-autorestart-sleep-dry-run': dryRun ? 'true' : 'false',
2999
+ // The good-night card is a floating card too: it never covers the
3000
+ // interface, only its dismiss button takes clicks.
3001
+ style: { pointerEvents: 'none' },
3002
+ },
3003
+ whaleArt(false),
3004
+ h(
3005
+ 'p',
3006
+ { className: 'dsh-autorestart-sleep-overlay__caption' },
3007
+ dryRun
3008
+ ? fill(t('sleep.shuttingDown.dryRun'), { command: planned })
3009
+ : t('sleep.shuttingDown.title'),
3010
+ ),
3011
+ h('p', { className: 'dsh-autorestart-sleep-overlay__hint' }, t('sleep.shuttingDown.body')),
3012
+ closeButton,
3013
+ )
3014
+ }
3015
+
3016
+ // The machine is idle again. A finished rehearsal leaves its note:
3017
+ // the command it WOULD have run. A real failure has no dryRun marker
3018
+ // and therefore never ends up here (the host says so in `message`).
3019
+ if (dryNotice !== null) {
3020
+ ensureSleepStyle()
3021
+ return h(
3022
+ 'div',
3023
+ {
3024
+ className: 'dsh-autorestart-sleep-waiting dsh-autorestart-sleep-dryrun',
3025
+ role: 'status',
3026
+ 'aria-live': 'polite',
3027
+ 'data-dsh-autorestart-sleep-dryrun': 'true',
3028
+ // The note is a FLOATING card: it never covers the interface and
3029
+ // it never eats a click — only its close button does.
3030
+ style: { pointerEvents: 'none' },
3031
+ },
3032
+ h('p', { className: 'dsh-autorestart-sleep-waiting__title' }, t('sleep.dryRun.title')),
3033
+ h('p', { className: 'dsh-autorestart-sleep-waiting__body' }, t('sleep.dryRun.body')),
3034
+ h(
3035
+ 'code',
3036
+ { className: 'dsh-autorestart-sleep-dryrun__command' },
3037
+ dryNotice.command,
3038
+ ),
3039
+ h(
3040
+ 'button',
3041
+ {
3042
+ type: 'button',
3043
+ className: 'dsh-autorestart-sleep-waiting__cancel',
3044
+ onClick: () => setDryNotice(null),
3045
+ style: { pointerEvents: 'auto' },
3046
+ },
3047
+ t('sleep.dryRun.close'),
3048
+ ),
3049
+ )
3050
+ }
3051
+
3052
+ return null
3053
+ }
3054
+
3055
+ /* ------------------------------------------------------------------ *
3056
+ * The one-time announcement of a new feature.
3057
+ * ------------------------------------------------------------------ */
3058
+
3059
+ // The route that reports the announcements the user has not seen yet and
3060
+ // records that it saw one. It is a sibling of the health route and shares
3061
+ // its prefix, so it is derived the very same way.
3062
+ const DEFAULT_ANNOUNCEMENTS_URL = ROUTE_PREFIX + '/announcements'
3063
+
3064
+ // The feature this client half can describe. The Host may know more
3065
+ // features, but only this one has a card here.
3066
+ const ANNOUNCED_FEATURE = 'sleep'
3067
+
3068
+ /**
3069
+ * The announcements the user has not seen yet.
3070
+ *
3071
+ * @returns {Promise<{ feature: string, version: string }[]>}
3072
+ */
3073
+ async function fetchAnnouncements() {
3074
+ const response = await fetch(DEFAULT_ANNOUNCEMENTS_URL, { cache: 'no-store' })
3075
+ if (!response.ok) return []
3076
+ const body = await response.json().catch(() => null)
3077
+ if (!body || typeof body !== 'object' || !Array.isArray(body.pending)) return []
3078
+ return body.pending.filter((entry) => entry && typeof entry.feature === 'string')
3079
+ }
3080
+
3081
+ /**
3082
+ * The sleep status, read ONCE for the announcement.
3083
+ *
3084
+ * The card quotes the countdown length that is really in effect (it is
3085
+ * never written into the copy) and says whether the moon button is
3086
+ * switched on.
3087
+ *
3088
+ * @returns {Promise<object|null>}
3089
+ */
3090
+ async function fetchSleepStatusOnce() {
3091
+ try {
3092
+ const response = await fetch(DEFAULT_SLEEP_URL, { cache: 'no-store' })
3093
+ if (!response.ok) return null
3094
+ const body = await response.json().catch(() => null)
3095
+ return body && typeof body === 'object' ? body : null
3096
+ } catch {
3097
+ return null
3098
+ }
3099
+ }
3100
+
3101
+ /**
3102
+ * Tell the Host that the user has seen the announcement of a feature.
3103
+ *
3104
+ * @param {string} feature
3105
+ * @returns {Promise<boolean>} true when the Host recorded it
3106
+ */
3107
+ async function acknowledgeAnnouncement(feature) {
3108
+ const response = await fetch(DEFAULT_ANNOUNCEMENTS_URL, {
3109
+ method: 'POST',
3110
+ headers: { 'content-type': 'application/json' },
3111
+ body: JSON.stringify({ feature }),
3112
+ })
3113
+ return response.ok === true
3114
+ }
3115
+
3116
+ // The look of the card follows the install prompt: one heading, three
3117
+ // lines of copy and one button. Only theme tokens are used, so light and
3118
+ // dark themes both work.
3119
+ const ANNOUNCEMENT_STYLE_ID = 'dsh-autorestart-announcement'
3120
+ const ANNOUNCEMENT_CSS = [
3121
+ '.dsh-autorestart-announcement{',
3122
+ ' display:flex;',
3123
+ ' flex-direction:column;',
3124
+ ' gap:6px;',
3125
+ ' width:min(560px,94vw);',
3126
+ ' max-width:100%;',
3127
+ ' box-sizing:border-box;',
3128
+ ' margin:0 0 6px;',
3129
+ ' padding:14px 16px;',
3130
+ ' border:1px solid var(--dsw-alias-border-l1);',
3131
+ ' border-radius:12px;',
3132
+ ' background:var(--dsw-alias-bg-layer-1);',
3133
+ ' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
3134
+ ' color:var(--dsw-alias-label-secondary);',
3135
+ ' font-size:13px;',
3136
+ ' line-height:18px;',
3137
+ '}',
3138
+ '.dsh-autorestart-announcement h3{',
3139
+ ' margin:0;',
3140
+ ' font-size:14px;',
3141
+ ' font-weight:600;',
3142
+ ' line-height:20px;',
3143
+ ' color:var(--dsw-alias-label-primary);',
3144
+ '}',
3145
+ '.dsh-autorestart-announcement p{margin:0;}',
3146
+ '.dsh-autorestart-announcement__note{',
3147
+ ' color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));',
3148
+ '}',
3149
+ '.dsh-autorestart-announcement__error{',
3150
+ ' color:var(--dsw-alias-state-error-fg,var(--dsw-alias-label-secondary));',
3151
+ '}',
3152
+ '.dsh-autorestart-announcement__cta{',
3153
+ ' align-self:flex-start;',
3154
+ ' margin-top:4px;',
3155
+ ' padding:5px 14px;',
3156
+ ' border:none;',
3157
+ ' border-radius:var(--dsw-radius-md,8px);',
3158
+ ' background:var(--dsw-alias-brand-primary,#5b8def);',
3159
+ ' color:var(--dsw-alias-label-on-primary,#fff);',
3160
+ ' font:inherit;',
3161
+ ' font-size:13px;',
3162
+ ' line-height:20px;',
3163
+ ' cursor:pointer;',
3164
+ '}',
3165
+ '.dsh-autorestart-announcement__cta:disabled{',
3166
+ ' opacity:.6;',
3167
+ ' cursor:default;',
3168
+ '}',
3169
+ '.dsh-autorestart-announcement__cta:focus-visible{',
3170
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
3171
+ ' outline-offset:2px;',
3172
+ '}',
3173
+ ].join('\n')
3174
+
3175
+ /**
3176
+ * Inject the announcement styles exactly once. The id keeps a second
3177
+ * injection from duplicating the rule.
3178
+ */
3179
+ function ensureAnnouncementStyle() {
3180
+ if (typeof document === 'undefined' || !document.head) return
3181
+ try {
3182
+ if (document.getElementById(ANNOUNCEMENT_STYLE_ID)) return
3183
+ const style = document.createElement('style')
3184
+ style.id = ANNOUNCEMENT_STYLE_ID
3185
+ style.textContent = ANNOUNCEMENT_CSS
3186
+ document.head.appendChild(style)
3187
+ } catch {
3188
+ // Without the stylesheet the card is still readable, just plainer.
3189
+ }
3190
+ }
3191
+
3192
+ /**
3193
+ * The one-time card that announces a new feature.
3194
+ *
3195
+ * The install prompt only ever reaches a FRESH installation, so an
3196
+ * installation that already existed would never learn about the new moon
3197
+ * button — the feature would simply not exist for it. This card is that
3198
+ * missing half: the Host reports the announcements the user has not seen
3199
+ * yet, and the card disappears for good when the user presses "知道了".
3200
+ *
3201
+ * It is an ordinary block in the composer dock (the same place as the
3202
+ * install prompt): it never covers the interface, it never touches the
3203
+ * countdown, and it stays until the user answers it. An unanswered
3204
+ * announcement comes back with the next start — it is never swallowed
3205
+ * silently.
3206
+ */
3207
+ function FeatureAnnouncementPanel() {
3208
+ const [state, setState] = useState({ loaded: false, pending: [], status: null, error: '' })
3209
+ const [busy, setBusy] = useState(false)
3210
+ const t = translate ?? ((name) => name)
3211
+
3212
+ useEffect(() => {
3213
+ let cancelled = false
3214
+ // The install prompt owns the dock while it is unanswered: a fresh
3215
+ // installation must never get two prompts at once.
3216
+ if (backupChoicePending() || sleepChoicePending()) return undefined
3217
+ const load = async () => {
3218
+ let pending = []
3219
+ let status = null
3220
+ try {
3221
+ const [announced, sleepStatus] = await Promise.all([
3222
+ fetchAnnouncements(),
3223
+ fetchSleepStatusOnce(),
3224
+ ])
3225
+ pending = announced
3226
+ status = sleepStatus
3227
+ } catch {
3228
+ // A failed request shows nothing: the announcement comes back
3229
+ // with the next start (it is never swallowed silently).
3230
+ }
3231
+ if (cancelled) return
3232
+ setState({ loaded: true, pending, status, error: '' })
3233
+ }
3234
+ void load()
3235
+ return () => { cancelled = true }
3236
+ }, [])
3237
+
3238
+ /**
3239
+ * The "知道了" button: the Host records the announcement and the card
3240
+ * disappears. A refused request keeps the card (with the reason), so
3241
+ * the announcement is never lost.
3242
+ */
3243
+ const acknowledge = () => {
3244
+ if (busy) return
3245
+ setBusy(true)
3246
+ setState((current) => ({ ...current, error: '' }))
3247
+ void (async () => {
3248
+ try {
3249
+ const recorded = await acknowledgeAnnouncement(ANNOUNCED_FEATURE)
3250
+ if (!recorded) throw new Error('the Host refused the announcement')
3251
+ } catch (failure) {
3252
+ setBusy(false)
3253
+ setState((current) => ({
3254
+ ...current,
3255
+ error: fill(t('announcement.error'), { message: describeError(failure) }),
3256
+ }))
3257
+ return
3258
+ }
3259
+ setBusy(false)
3260
+ setState((current) => ({
3261
+ ...current,
3262
+ pending: current.pending.filter((entry) => entry.feature !== ANNOUNCED_FEATURE),
3263
+ }))
3264
+ })()
3265
+ }
3266
+
3267
+ if (!state.loaded) return null
3268
+ const entry = state.pending.find((item) => item && item.feature === ANNOUNCED_FEATURE)
3269
+ if (!entry) return null
3270
+
3271
+ // The countdown length is read from the Host, never written into the
3272
+ // copy: the card always quotes what is really in effect.
3273
+ const seconds = state.status && typeof state.status.countdownSeconds === 'number'
3274
+ ? state.status.countdownSeconds
3275
+ : null
3276
+ const hidden = Boolean(state.status && state.status.showSleepButton === false)
3277
+ ensureAnnouncementStyle()
3278
+
3279
+ return h(
3280
+ 'div',
3281
+ {
3282
+ className: 'dsh-autorestart-announcement',
3283
+ role: 'status',
3284
+ 'data-dsh-autorestart-announcement': 'true',
3285
+ 'data-dsh-autorestart-announcement-feature': ANNOUNCED_FEATURE,
3286
+ },
3287
+ h('h3', { className: 'dsh-autorestart-announcement__title' }, t('announcement.title')),
3288
+ h('p', { className: 'dsh-autorestart-announcement__line' }, t('announcement.line1')),
3289
+ h(
3290
+ 'p',
3291
+ { className: 'dsh-autorestart-announcement__line' },
3292
+ seconds === null
3293
+ ? t('announcement.line2.generic')
3294
+ : fill(t('announcement.line2'), { n: seconds }),
3295
+ ),
3296
+ h('p', { className: 'dsh-autorestart-announcement__line' }, t('announcement.line3')),
3297
+ hidden
3298
+ ? h('p', { className: 'dsh-autorestart-announcement__note' }, t('announcement.disabled'))
3299
+ : null,
3300
+ state.error === ''
3301
+ ? null
3302
+ : h('p', { className: 'dsh-autorestart-announcement__error', role: 'alert' }, state.error),
3303
+ h(
3304
+ 'button',
3305
+ {
3306
+ type: 'button',
3307
+ className: 'dsh-autorestart-announcement__cta',
3308
+ 'data-dsh-autorestart-announcement-ack': 'true',
3309
+ disabled: busy,
3310
+ onClick: acknowledge,
3311
+ },
3312
+ t('announcement.button'),
3313
+ ),
3314
+ )
3315
+ }
3316
+
3317
+ return {
3318
+ inject: ['slots', 'locale'],
3319
+ apply(ctx) {
3320
+ ctx.effect(
3321
+ () => ctx.locale.register(NS, { zh: ZH, en: EN }),
3322
+ 'dsh-autorestart: client locale',
3323
+ )
3324
+ const t = ctx.locale.bind(NS)
3325
+ translate = t
3326
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'autorestart-restart-pill', order: 0 }, RestartPill))
3327
+ ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ name: 'sidebar.session.row.leading', id: 'autorestart-restart-dot', order: 20 }, RestartDot))
3328
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-backup-choice', order: 7, locale: NS }, BackupChoicePanel))
3329
+ // The one-time announcement of a new feature: the install prompt only
3330
+ // reaches a fresh installation, so an installation that already
3331
+ // existed would never hear of a new feature. The card sits in the
3332
+ // same dock (it never covers the interface and never touches the
3333
+ // countdown) and disappears for good once the user answered it.
3334
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-feature-announcement', order: 8, locale: NS }, FeatureAnnouncementPanel))
3335
+ // The graceful-shutdown button: a LIST slot at the foot of the left
3336
+ // sidebar, beside the Settings row. The slot is a ROOT slot, so the
3337
+ // button is there even when no conversation is open — and the
3338
+ // sidebar hands the column state to it, so the collapsed rail shows
3339
+ // the icon only.
3340
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'autorestart-sleep-button', order: 10, locale: NS }, SleepButton))
3341
+ // The settings section of the plugin: an entry of its own in the
3342
+ // settings sidebar ("Auto-restart & shut down"), right below the
3343
+ // Agent presets. The three controls live inside it, so the user does
3344
+ // not have to dig through the plugin details.
3345
+ //
3346
+ // The order is computed at registration time: right after
3347
+ // `agent-presets` when that section exists, otherwise after the
3348
+ // sections that are registered already; 1000 when there is none.
3349
+ // That is "naturally last" without hard-coding a number.
3350
+ ctx.slots.inject('settings.section', function* () {
3351
+ const existing = ctx.slots.entries('settings.section')
3352
+ const agentPresets = existing.find((entry) => entry.options.id === 'agent-presets')
3353
+ const maxOrder = existing.reduce((max, entry) => Math.max(max, entry.options.order ?? 0), 0)
3354
+ const order = agentPresets !== undefined
3355
+ ? (agentPresets.options.order ?? 0) + 1
3356
+ : existing.length > 0
3357
+ ? maxOrder + 1
3358
+ : 1000
3359
+ yield ctx.slots.register({
3360
+ name: 'settings.section',
3361
+ id: 'dsh-autorestart',
3362
+ order,
3363
+ label: () => t('nav'),
3364
+ locale: NS,
3365
+ inject: () => ({ t }),
3366
+ }, AutorestartSection)
3367
+ })
3368
+ // The countdown overlay: a frame-wide floating layer above every
3369
+ // column.
3370
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-sleep-overlay', order: 60 }, SleepOverlay))
1162
3371
  },
3372
+ // The named exports of this module (the client module system hands
3373
+ // `factory(require) -> exports` to every importer).
3374
+ pickSleepCaption,
3375
+ shouldShowSleepButton,
3376
+ buildChoiceBody,
3377
+ formatRemainingMs,
1163
3378
  }
1164
3379
  },
1165
3380
  })