dsh-autorestart 0.3.5 → 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')
@@ -60,6 +541,11 @@ window.__ModuleLoader__.load({
60
541
  // is only the fallback for an older Host.
61
542
  const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
62
543
  const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
544
+ // The route that persists the one-time install choice.
545
+ const DEFAULT_CHOICE_URL = ROUTE_PREFIX + '/choice'
546
+ // The browser-side record of the one-time install choice. The prompt is
547
+ // shown only while this key is missing, so it is asked exactly once.
548
+ const BACKUP_CHOICE_KEY = 'dsh-autorestart:backup-choice-v1'
63
549
  const POLL_MS = 2000
64
550
  // The brand accent (blue-purple). The pending-restart marker is a brand
65
551
  // signal, not a warning: no warning/yellow token is used here.
@@ -80,77 +566,6 @@ window.__ModuleLoader__.load({
80
566
  '}',
81
567
  ].join('\n')
82
568
 
83
- const ZH = {
84
- label: '待重启',
85
- busyPrefix: ' · 被 ',
86
- busySuffix: ' 个会话占用',
87
- idle: ' · 空闲后自动重启',
88
- goalActive: ' · 被某个会话自动续跑的 goal 占着',
89
- goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
90
- parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
91
- parkButton: '停泊并重启',
92
- parkButtonAria: '停泊该会话的 goal,并立即请求重启',
93
- parkBusy: '停泊中…',
94
- parkError: '停泊失败:{message}',
95
- tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
96
- tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
97
- tooltipWithError: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度。{message}',
98
- dotAria: '待重启',
99
- panelHeading: '待重启',
100
- panelStateBusy: '等待其他会话空闲后重启',
101
- panelStateIdle: '等待空闲窗口后重启',
102
- panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
103
- panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
104
- panelBlockedTitle: '占用会话:{title}',
105
- panelBlockedGeneric: '有一个会话正在运行',
106
- panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
107
- panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
108
- panelParkedTitle: '已停泊的 goal(重启后自动继续):',
109
- panelParkedGoal: '会话 {session} · {goal}',
110
- panelWaiting: '已等待 {duration}',
111
- panelResume: '重启后本会话会自动继续进度',
112
- durationSeconds: '{n} 秒',
113
- durationMinutes: '{n} 分 {m} 秒',
114
- durationHours: '{n} 小时 {m} 分',
115
- }
116
-
117
- const EN = {
118
- label: 'Pending restart',
119
- busyPrefix: ' · blocked by ',
120
- busySuffix: ' session(s)',
121
- idle: ' · restarts once idle',
122
- goalActive: ' · held by a self-continuing goal',
123
- goalExhausted: ' · a goal ran out of rounds',
124
- parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
125
- parkButton: 'Park & restart',
126
- parkButtonAria: 'Park that session goal and request the restart now',
127
- parkBusy: 'Parking…',
128
- parkError: 'Park failed: {message}',
129
- tooltip:
130
- 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
131
- tooltipBusy:
132
- '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.',
133
- tooltipWithError:
134
- 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
135
- dotAria: 'Pending restart',
136
- panelHeading: 'Pending restart',
137
- panelStateBusy: 'Waiting for the other sessions to become idle',
138
- panelStateIdle: 'Waiting for the idle window',
139
- panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
140
- panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
141
- panelBlockedTitle: 'Blocking session: {title}',
142
- panelBlockedGeneric: 'One session is running',
143
- panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
144
- panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
145
- panelParkedTitle: 'Parked goals (they continue after the restart):',
146
- panelParkedGoal: 'session {session} · {goal}',
147
- panelWaiting: 'Waiting for {duration}',
148
- panelResume: 'This session resumes its progress automatically after the restart.',
149
- durationSeconds: '{n}s',
150
- durationMinutes: '{n}m {m}s',
151
- durationHours: '{n}h {m}m',
152
- }
153
-
154
569
  // Bound during `apply`. The slots are registered without a locale
155
570
  // namespace, so the components read the translator through this
156
571
  // factory-scoped handle instead of a standard `t` prop.
@@ -336,6 +751,49 @@ window.__ModuleLoader__.load({
336
751
  return DEFAULT_PARK_URL
337
752
  }
338
753
 
754
+ /**
755
+ * The route that persists the one-time install choice: the one the Host
756
+ * sent with the state payload, or the one derived from the health path.
757
+ */
758
+ function choiceUrlFor(info) {
759
+ const sent = info && typeof info.choiceUrl === 'string' ? info.choiceUrl.trim() : ''
760
+ if (sent.startsWith('/')) return sent
761
+ return DEFAULT_CHOICE_URL
762
+ }
763
+
764
+ /**
765
+ * Whether the one-time install prompt may be shown.
766
+ *
767
+ * It may be shown only while the browser has no record of a choice yet,
768
+ * i.e. while `localStorage['dsh-autorestart:backup-choice-v1']` is
769
+ * missing. A browser without a usable localStorage answers "no": the
770
+ * prompt must not come back on every reload there.
771
+ */
772
+ function backupChoicePending() {
773
+ try {
774
+ if (typeof window === 'undefined' || !window.localStorage) return false
775
+ return window.localStorage.getItem(BACKUP_CHOICE_KEY) === null
776
+ } catch {
777
+ return false
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Record the one-time choice in the browser.
783
+ *
784
+ * The key is written as soon as the user picks an option (or closes the
785
+ * prompt), so the question is asked exactly once.
786
+ */
787
+ function writeBackupChoice(value) {
788
+ try {
789
+ if (typeof window === 'undefined' || !window.localStorage) return false
790
+ window.localStorage.setItem(BACKUP_CHOICE_KEY, String(value))
791
+ return true
792
+ } catch {
793
+ return false
794
+ }
795
+ }
796
+
339
797
  /** A short, human readable description of a failed request. */
340
798
  function describeError(error) {
341
799
  if (!error) return 'unknown error'
@@ -486,6 +944,187 @@ window.__ModuleLoader__.load({
486
944
  color: 'var(--dsw-alias-danger, #d14343)',
487
945
  }
488
946
 
947
+ // The one-time install prompt. It is NOT a modal: it is an ordinary block
948
+ // in the composer dock, so the input box stays usable and nothing is
949
+ // blocked. It never disappears on its own.
950
+ //
951
+ // The look follows the established DSH plugin styling: one 14px/600
952
+ // heading, a muted subtitle, two selectable rows (a name + a note on the
953
+ // left, the action on the right) and a muted footnote. The primary option
954
+ // carries a "recommended" pill and a solid button; the secondary one is
955
+ // outlined. Only theme tokens are used, so light and dark themes both
956
+ // work.
957
+ const CHOICE_STYLE_ID = 'dsh-autorestart-choice'
958
+ const CHOICE_CSS = [
959
+ '.dsh-autorestart-choice{',
960
+ ' display:flex;',
961
+ ' flex-direction:column;',
962
+ ' gap:8px;',
963
+ ' width:min(560px,94vw);',
964
+ ' max-width:100%;',
965
+ ' box-sizing:border-box;',
966
+ ' margin:0 0 6px;',
967
+ ' padding:16px 18px;',
968
+ ' border:1px solid var(--dsw-alias-border-l1);',
969
+ ' border-radius:12px;',
970
+ ' background:var(--dsw-alias-bg-layer-1);',
971
+ ' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
972
+ ' color:var(--dsw-alias-label-secondary);',
973
+ ' font-size:13px;',
974
+ ' line-height:18px;',
975
+ '}',
976
+ '.dsh-autorestart-choice h3{',
977
+ ' margin:0;',
978
+ ' font-size:14px;',
979
+ ' font-weight:600;',
980
+ ' line-height:20px;',
981
+ ' color:var(--dsw-alias-label-primary);',
982
+ '}',
983
+ '.dsh-autorestart-choice p{margin:0;}',
984
+ '.dsh-autorestart-choice__head{',
985
+ ' display:flex;',
986
+ ' align-items:flex-start;',
987
+ ' justify-content:space-between;',
988
+ ' gap:10px;',
989
+ '}',
990
+ '.dsh-autorestart-choice__subtitle{',
991
+ ' font-size:12.5px;',
992
+ ' line-height:18px;',
993
+ ' color:var(--dsw-alias-label-secondary);',
994
+ '}',
995
+ '.dsh-autorestart-choice__options{',
996
+ ' display:flex;',
997
+ ' flex-direction:column;',
998
+ ' gap:10px;',
999
+ '}',
1000
+ '.dsh-autorestart-choice__row{',
1001
+ ' display:flex;',
1002
+ ' align-items:center;',
1003
+ ' justify-content:space-between;',
1004
+ ' gap:10px;',
1005
+ ' padding:10px 12px;',
1006
+ ' border:1px solid transparent;',
1007
+ ' border-radius:10px;',
1008
+ ' cursor:pointer;',
1009
+ ' transition:background-color .14s,border-color .14s;',
1010
+ '}',
1011
+ '.dsh-autorestart-choice__row:hover,',
1012
+ '.dsh-autorestart-choice__row:focus-within{',
1013
+ ' border-color:var(--dsw-alias-border-l1);',
1014
+ ' background:var(--dsw-alias-bg-layer-2, color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent));',
1015
+ '}',
1016
+ '.dsh-autorestart-choice__row:focus-visible{',
1017
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1018
+ ' outline-offset:2px;',
1019
+ '}',
1020
+ '.dsh-autorestart-choice__text{flex:1 1 auto;min-width:0;}',
1021
+ '.dsh-autorestart-choice__title{',
1022
+ ' display:flex;',
1023
+ ' align-items:center;',
1024
+ ' gap:6px;',
1025
+ ' min-width:0;',
1026
+ '}',
1027
+ '.dsh-autorestart-choice__name{',
1028
+ ' font-size:13px;',
1029
+ ' font-weight:600;',
1030
+ ' line-height:18px;',
1031
+ ' color:var(--dsw-alias-label-primary);',
1032
+ '}',
1033
+ '.dsh-autorestart-choice__hint{',
1034
+ ' display:block;',
1035
+ ' margin-top:2px;',
1036
+ ' font-size:12px;',
1037
+ ' line-height:17px;',
1038
+ ' color:var(--dsw-alias-label-secondary);',
1039
+ '}',
1040
+ '.dsh-autorestart-choice__badge{',
1041
+ ' flex:none;',
1042
+ ' display:inline-block;',
1043
+ ' padding:1px 7px;',
1044
+ ' border-radius:999px;',
1045
+ ' font-size:11px;',
1046
+ ' font-weight:600;',
1047
+ ' line-height:16px;',
1048
+ ' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1049
+ ' background:color-mix(in srgb, var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe)) 12%, transparent);',
1050
+ '}',
1051
+ '.dsh-autorestart-choice__cta{',
1052
+ ' flex:none;',
1053
+ ' min-width:28px;',
1054
+ ' min-height:28px;',
1055
+ ' padding:4px 12px;',
1056
+ ' border-radius:8px;',
1057
+ ' font:inherit;',
1058
+ ' font-size:12.5px;',
1059
+ ' font-weight:600;',
1060
+ ' line-height:18px;',
1061
+ ' cursor:pointer;',
1062
+ ' transition:background-color .14s,border-color .14s,color .14s;',
1063
+ '}',
1064
+ '.dsh-autorestart-choice__cta:focus-visible{',
1065
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1066
+ ' outline-offset:2px;',
1067
+ '}',
1068
+ '.dsh-autorestart-choice__cta--primary{',
1069
+ ' border:1px solid transparent;',
1070
+ ' background:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1071
+ ' color:var(--dsw-alias-bg-layer-1,#fff);',
1072
+ '}',
1073
+ '.dsh-autorestart-choice__cta--secondary{',
1074
+ ' border:1px solid var(--dsw-alias-border-l1);',
1075
+ ' background:transparent;',
1076
+ ' color:var(--dsw-alias-label-primary);',
1077
+ '}',
1078
+ '.dsh-autorestart-choice__cta--secondary:hover{',
1079
+ ' border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1080
+ ' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1081
+ '}',
1082
+ '.dsh-autorestart-choice__close{',
1083
+ ' flex:none;',
1084
+ ' width:28px;',
1085
+ ' height:28px;',
1086
+ ' padding:0;',
1087
+ ' border:1px solid transparent;',
1088
+ ' border-radius:8px;',
1089
+ ' background:transparent;',
1090
+ ' color:var(--dsw-alias-label-secondary);',
1091
+ ' font:inherit;',
1092
+ ' font-size:16px;',
1093
+ ' line-height:1;',
1094
+ ' cursor:pointer;',
1095
+ ' transition:background-color .14s;',
1096
+ '}',
1097
+ '.dsh-autorestart-choice__close:hover{',
1098
+ ' background:var(--dsw-alias-bg-layer-2, color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
1099
+ '}',
1100
+ '.dsh-autorestart-choice__close:focus-visible{',
1101
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
1102
+ ' outline-offset:2px;',
1103
+ '}',
1104
+ '.dsh-autorestart-choice__footnote{',
1105
+ ' margin-top:10px;',
1106
+ ' padding-top:10px;',
1107
+ ' border-top:1px solid var(--dsw-alias-border-l1);',
1108
+ ' font-size:11px;',
1109
+ ' line-height:16px;',
1110
+ ' color:var(--dsw-alias-label-secondary);',
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
+ '}',
1119
+ '@media (max-width:1023px){',
1120
+ ' .dsh-autorestart-choice{width:calc(100vw - 32px);}',
1121
+ '}',
1122
+ '@media (max-width:640px){',
1123
+ ' .dsh-autorestart-choice__row{flex-direction:column;align-items:stretch;}',
1124
+ ' .dsh-autorestart-choice__cta{width:100%;}',
1125
+ '}',
1126
+ ].join('\n')
1127
+
489
1128
  /**
490
1129
  * One compact pill in the composer tool row, visible only while THIS
491
1130
  * session is waiting for a pending restart. Clicking it (or pressing
@@ -681,18 +1320,2061 @@ window.__ModuleLoader__.load({
681
1320
  })
682
1321
  }
683
1322
 
684
- return {
685
- inject: ['slots', 'locale'],
686
- apply(ctx) {
687
- ctx.effect(
688
- () => ctx.locale.register(NS, { zh: ZH, en: EN }),
689
- 'dsh-autorestart: client locale',
1323
+ /**
1324
+ * Inject the prompt styles exactly once. The id keeps a second injection
1325
+ * from duplicating the rule.
1326
+ */
1327
+ function ensureChoiceStyle() {
1328
+ if (typeof document === 'undefined' || !document.head) return
1329
+ try {
1330
+ if (document.getElementById(CHOICE_STYLE_ID)) return
1331
+ const style = document.createElement('style')
1332
+ style.id = CHOICE_STYLE_ID
1333
+ style.textContent = CHOICE_CSS
1334
+ document.head.appendChild(style)
1335
+ } catch {
1336
+ // Without the stylesheet the prompt is still readable, just plainer.
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * The one-time install prompt.
1342
+ *
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.
1353
+ *
1354
+ * "Backup" is the verb and BOTH options share the "configuration" base:
1355
+ * the plugins are an addition to the configuration, never a rival of it.
1356
+ *
1357
+ * It is deliberately NOT a modal: it is an ordinary block in the composer
1358
+ * dock (`role="status"`), so the input box stays usable, nothing is
1359
+ * blocked, and it never disappears on its own — it stays until the user
1360
+ * picks an option or closes it. It is keyboard reachable (focusable, Esc
1361
+ * closes it) and adapts to a narrow window.
1362
+ *
1363
+ * The answer goes to the Host, which writes it into the profile
1364
+ * configuration (the same key the settings page edits).
1365
+ */
1366
+ function BackupChoicePanel() {
1367
+ const info = useSharedInfo(true)
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
+ )
1379
+ const [busy, setBusy] = useState(false)
1380
+ const [error, setError] = useState('')
1381
+ const t = translate ?? ((name) => name)
1382
+
1383
+ /**
1384
+ * Hand the FIRST answer to the Host.
1385
+ *
1386
+ * The browser records the choice FIRST (so the question is never asked
1387
+ * twice) and then posts it to the Host, which persists it in the
1388
+ * profile configuration. The SECOND question only appears once that
1389
+ * answer really left the browser.
1390
+ */
1391
+ const choose = (includePlugins) => {
1392
+ if (busy) return
1393
+ setBusy(true)
1394
+ setError('')
1395
+ writeBackupChoice(includePlugins ? 'plugins' : 'config-only')
1396
+ setStep('persisting')
1397
+ void (async () => {
1398
+ let failed = false
1399
+ try {
1400
+ const response = await fetch(choiceUrlFor(info), {
1401
+ method: 'POST',
1402
+ headers: { 'content-type': 'application/json' },
1403
+ body: JSON.stringify(buildChoiceBody({ includePlugins })),
1404
+ })
1405
+ if (!response.ok) {
1406
+ failed = true
1407
+ setError(fill(t('backupChoiceError'), { message: String(response.status) }))
1408
+ }
1409
+ } catch (failure) {
1410
+ failed = true
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
1450
+ } finally {
1451
+ setBusy(false)
1452
+ }
1453
+ setStep('done')
1454
+ })()
1455
+ }
1456
+
1457
+ /**
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".
1462
+ */
1463
+ const dismissBackup = () => {
1464
+ if (busy) return
1465
+ writeBackupChoice('dismissed')
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')
1483
+ }
1484
+
1485
+ /** Esc closes the prompt (the panel itself is focusable). */
1486
+ const onKeyDown = (event) => {
1487
+ if (!event) return
1488
+ if (event.key === 'Escape' || event.key === 'Esc') {
1489
+ if (step === 'sleep') dismissSleep()
1490
+ else dismissBackup()
1491
+ }
1492
+ }
1493
+
1494
+ if (step === 'done' || step === 'persisting') return null
1495
+ ensureChoiceStyle()
1496
+
1497
+ /**
1498
+ * One option row.
1499
+ *
1500
+ * The WHOLE row is the click target (and it is focusable, with the
1501
+ * radio semantics of a one-of-two choice), while the right side carries
1502
+ * the concrete action. The primary row wears a "recommended" pill and a
1503
+ * solid button; the secondary one an outlined button.
1504
+ */
1505
+ const optionRow = (key, name, hint, cta, variant, recommended, pick, dataAttribute) => {
1506
+ const onRowClick = () => pick()
1507
+ const onRowKeyDown = (event) => {
1508
+ if (!event) return
1509
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
1510
+ if (typeof event.preventDefault === 'function') event.preventDefault()
1511
+ pick()
1512
+ }
1513
+ }
1514
+ // The button inside the row must not trigger the row a second time.
1515
+ const onCtaClick = (event) => {
1516
+ if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
1517
+ pick()
1518
+ }
1519
+ return h(
1520
+ 'div',
1521
+ {
1522
+ key,
1523
+ className: 'dsh-autorestart-choice__row',
1524
+ role: 'radio',
1525
+ 'aria-checked': 'false',
1526
+ 'aria-label': name,
1527
+ tabIndex: busy ? -1 : 0,
1528
+ [dataAttribute]: key,
1529
+ onClick: onRowClick,
1530
+ onKeyDown: onRowKeyDown,
1531
+ },
1532
+ h(
1533
+ 'div',
1534
+ { className: 'dsh-autorestart-choice__text' },
1535
+ h(
1536
+ 'span',
1537
+ { className: 'dsh-autorestart-choice__title' },
1538
+ recommended
1539
+ ? h('span', { className: 'dsh-autorestart-choice__badge' }, t('backupChoiceRecommended'))
1540
+ : null,
1541
+ h('span', { className: 'dsh-autorestart-choice__name' }, name),
1542
+ ),
1543
+ h('span', { className: 'dsh-autorestart-choice__hint' }, hint),
1544
+ ),
1545
+ h(
1546
+ 'button',
1547
+ {
1548
+ type: 'button',
1549
+ className: 'dsh-autorestart-choice__cta dsh-autorestart-choice__cta--' + variant,
1550
+ [dataAttribute]: key,
1551
+ 'aria-label': cta,
1552
+ title: cta,
1553
+ disabled: busy,
1554
+ onClick: onCtaClick,
1555
+ },
1556
+ cta,
1557
+ ),
690
1558
  )
691
- const t = ctx.locale.bind(NS)
692
- translate = t
693
- ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'autorestart-restart-pill', order: 0 }, RestartPill))
694
- ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ name: 'sidebar.session.row.leading', id: 'autorestart-restart-dot', order: 20 }, RestartDot))
1559
+ }
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?
1622
+ return h(
1623
+ 'div',
1624
+ {
1625
+ className: 'dsh-autorestart-choice',
1626
+ role: 'status',
1627
+ 'aria-live': 'polite',
1628
+ tabIndex: 0,
1629
+ onKeyDown,
1630
+ 'data-dsh-autorestart-backup-choice': 'true',
1631
+ 'data-dsh-autorestart-choice-step': 'sleep',
1632
+ },
1633
+ h(
1634
+ 'div',
1635
+ { className: 'dsh-autorestart-choice__head' },
1636
+ h('h3', null, t('sleep.choice.title')),
1637
+ h(
1638
+ 'button',
1639
+ {
1640
+ type: 'button',
1641
+ className: 'dsh-autorestart-choice__close',
1642
+ 'aria-label': t('backupChoiceDismissAria'),
1643
+ title: t('backupChoiceDismissAria'),
1644
+ disabled: busy,
1645
+ onClick: dismissSleep,
1646
+ },
1647
+ '×',
1648
+ ),
1649
+ ),
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')),
1660
+ h(
1661
+ 'div',
1662
+ { className: 'dsh-autorestart-choice__options' },
1663
+ optionRow(
1664
+ 'keep',
1665
+ t('sleep.choice.keep'),
1666
+ t('sleep.choice.keepHint'),
1667
+ t('sleep.choice.keepCta'),
1668
+ 'primary',
1669
+ true,
1670
+ () => chooseSleep(true),
1671
+ 'data-sleep-choice',
1672
+ ),
1673
+ optionRow(
1674
+ 'hide',
1675
+ t('sleep.choice.hide'),
1676
+ t('sleep.choice.hideHint'),
1677
+ t('sleep.choice.hideCta'),
1678
+ 'secondary',
1679
+ false,
1680
+ () => chooseSleep(false),
1681
+ 'data-sleep-choice',
1682
+ ),
1683
+ ),
1684
+ h('p', { className: 'dsh-autorestart-choice__footnote' }, t('sleep.choice.footnote')),
1685
+ error === '' ? null : h('p', { role: 'alert' }, error),
1686
+ )
1687
+ }
1688
+
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))
695
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,
696
3378
  }
697
3379
  },
698
3380
  })