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