dsh-autorestart 0.8.1 → 0.8.3

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
@@ -155,7 +155,7 @@
155
155
  const ZH = {
156
156
  label: '待重启',
157
157
  busyPrefix: ' · 等待 ',
158
- busySuffix: ' 个任务结束',
158
+ busySuffix: ' 个会话',
159
159
  idle: ' · 空闲后自动重启',
160
160
  goalActive: ' · 被某个自动续跑的 goal 占着',
161
161
  goalExhausted: ' · 某个任务的 goal 轮次额度已用完,无法续跑',
@@ -166,7 +166,7 @@
166
166
  parkBusy: '停泊中…',
167
167
  parkError: '停泊失败:{message}',
168
168
  tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
169
- tooltipBusy: '等待其他任务结束(计数含“排队但还没开始”的任务)后自动重启;重启后本会话会自动继续进度',
169
+ tooltipBusy: '等待其他会话结束(计数含“排队但还没开始”的会话)后自动重启;重启后本会话会自动继续进度',
170
170
  tooltipWithError: '等待其他会话空闲后自动重启;重启后本会话会自动继续进度。{message}',
171
171
  dotAria: '待重启',
172
172
  panelHeading: '待重启',
@@ -219,12 +219,13 @@
219
219
  // The manual cancel of the capsule: the panel, the confirmation and the
220
220
  // readable reasons. The capsule's own text stays free of hints — the panel
221
221
  // is the discoverability.
222
- cancelPanelTasks: '这次重启还在等 {n} 个任务结束',
222
+ cancelPanelTasks: '还在等 {n} 个会话',
223
223
  cancelPanelOthers: '还有 {n} 个会话也在等这次重启',
224
224
  cancelPanelOthersAfter: '还剩 {n} 个会话在等',
225
225
  cancelPanelAria: '待重启详情',
226
226
  cancelButton: '取消我的这次重启',
227
227
  cancelButtonAria: '取消我的这次重启',
228
+ cancelLocked: '正在重启,已经不能取消了',
228
229
  cancelConfirmTitle: '取消你的这次重启?',
229
230
  cancelConfirmOnlyMe: '取消后这次重启就不会发生了。',
230
231
  cancelConfirmOthers: '还有 {n} 个会话也在等这次重启——取消只影响你自己,他们仍会重启。',
@@ -374,8 +375,10 @@
374
375
  'settings.backup.copyPath': '复制路径',
375
376
  'settings.backup.copied': '路径已复制',
376
377
  'settings.backup.copyFailed': '复制失败,请手动选中上面的路径',
377
- 'settings.backup.withPlugins': '含已装插件',
378
- 'settings.backup.withoutPlugins': '只有配置',
378
+ 'settings.backup.time': '备份时间',
379
+ 'settings.backup.size': '备份大小',
380
+ 'settings.backup.withPlugins': '(含已装插件)',
381
+ 'settings.backup.withoutPlugins': '(不含已装插件)',
379
382
  'settings.groups.report': '报告问题',
380
383
  'settings.groups.rescue': '出事了怎么办',
381
384
  'settings.rescue.hint': 'DSH 打不开的时候,用下面这两个脚本救自己(都不用 DSH 跑起来)。',
@@ -411,6 +414,9 @@
411
414
  'settings.report.issueTitle': '重启 / 开机自启相关的问题',
412
415
  'settings.report.issueBody': '请把刚才复制的诊断信息粘贴到下面。\n\n## 发生了什么\n\n\n## 期望什么\n\n\n## 怎么复现\n',
413
416
  'settings.error': '没能保存设置:{message}',
417
+ 'settings.saveNotice.saved': '已生效',
418
+ 'settings.saveNotice.failed': '没保存成功',
419
+ 'settings.saveNotice.dismiss': '关闭',
414
420
  'sleep.dryRun.title': '试运行:不会真的关机',
415
421
  'sleep.dryRun.body': '彩排结束,本来会执行这条命令(真的什么都没执行):',
416
422
  'sleep.dryRun.close': '知道了',
@@ -472,12 +478,32 @@
472
478
  'announcement.disabled': '按钮目前关闭,可在设置里打开。',
473
479
  'announcement.button': '知道了',
474
480
  'announcement.error': '没能记下来:{message}',
481
+ // THE COPY OF THE COMPOSER ROW. It is one SHORT line and never a sentence:
482
+ // the long stage sentence was clipped away there (user report,
483
+ // 2026-10-29). While the gate waits the pill keeps its own composed line
484
+ // ("待重启 · 空闲后自动重启" / "待重启 · 等待 N 个会话"); the three
485
+ // other stages REPLACE that line with one of these.
486
+ 'restart.stage.snapshotting': '重启前备份中',
487
+ 'restart.stage.restarting': '正在重启…',
488
+ 'restart.stage.back': '已回来',
489
+ // The centred overlay of a restart that is REALLY RUNNING (the backup, the
490
+ // shutdown, the return): one short line per stage, never wrapped, never cut
491
+ // with an ellipsis (the card shrinks its font instead). The numbers come
492
+ // from the Host's real measurements. There is no copy for `waiting`: no
493
+ // card is drawn while the gate merely waits.
494
+ 'restart.overlay.snapshotting': 'DSH 重启前备份中',
495
+ // The unit travels WITH the number (KB or MB, chosen by the size): a 92 KB
496
+ // profile must never be rounded down to "0 MB".
497
+ 'restart.overlay.size': '{done} / {total}',
498
+ 'restart.overlay.sizeDone': '{done}',
499
+ 'restart.overlay.restarting': '正在重启…',
500
+ 'restart.overlay.back': '已回来',
475
501
  }
476
502
 
477
503
  const EN = {
478
504
  label: 'Pending restart',
479
505
  busyPrefix: ' · waiting for ',
480
- busySuffix: ' task(s) to finish',
506
+ busySuffix: ' session(s)',
481
507
  idle: ' · restarts once idle',
482
508
  goalActive: ' · held by a self-continuing goal',
483
509
  goalExhausted: ' · a goal ran out of rounds',
@@ -490,7 +516,7 @@
490
516
  tooltip:
491
517
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
492
518
  tooltipBusy:
493
- '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.',
519
+ 'Waits for the other sessions to finish (the count includes work that is queued but not started yet), then restarts automatically; this session resumes its progress after the restart.',
494
520
  tooltipWithError:
495
521
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
496
522
  dotAria: 'Pending restart',
@@ -542,12 +568,13 @@
542
568
  // The manual cancel of the capsule: the panel, the confirmation and the
543
569
  // readable reasons. The capsule's own text stays free of hints — the panel
544
570
  // is the discoverability.
545
- cancelPanelTasks: 'This restart is still waiting for {n} task(s) to finish',
571
+ cancelPanelTasks: 'Still waiting for {n} session(s)',
546
572
  cancelPanelOthers: '{n} other session(s) are waiting for this restart too',
547
573
  cancelPanelOthersAfter: '{n} session(s) are still waiting',
548
574
  cancelPanelAria: 'Pending restart details',
549
575
  cancelButton: 'Cancel my restart',
550
576
  cancelButtonAria: 'Cancel my restart',
577
+ cancelLocked: 'Restarting — this can no longer be cancelled',
551
578
  cancelConfirmTitle: 'Cancel your restart?',
552
579
  cancelConfirmOnlyMe: 'After this, the restart will not happen.',
553
580
  cancelConfirmOthers: '{n} other session(s) are waiting for this restart too — cancelling only affects you; they will still restart.',
@@ -697,8 +724,10 @@
697
724
  'settings.backup.copyPath': 'Copy the path',
698
725
  'settings.backup.copied': 'The path is on the clipboard',
699
726
  'settings.backup.copyFailed': 'The copy failed — please select the path above by hand',
700
- 'settings.backup.withPlugins': 'includes the installed plugins',
701
- 'settings.backup.withoutPlugins': 'configuration only',
727
+ 'settings.backup.time': 'Backup time',
728
+ 'settings.backup.size': 'Backup size',
729
+ 'settings.backup.withPlugins': '(plugins included)',
730
+ 'settings.backup.withoutPlugins': '(plugins excluded)',
702
731
  'settings.groups.report': 'Report a problem',
703
732
  'settings.groups.rescue': 'If DSH will not start',
704
733
  'settings.rescue.hint': 'When DSH does not come up, these two scripts rescue it for you (neither of them needs DSH running).',
@@ -734,6 +763,9 @@
734
763
  'settings.report.issueTitle': 'A problem with the restart / start at login',
735
764
  'settings.report.issueBody': 'Please paste the diagnostics you just copied below.\n\n## What happened\n\n\n## What you expected\n\n\n## How to reproduce\n',
736
765
  'settings.error': 'Could not save the settings: {message}',
766
+ 'settings.saveNotice.saved': 'Saved',
767
+ 'settings.saveNotice.failed': 'Not saved',
768
+ 'settings.saveNotice.dismiss': 'Dismiss',
737
769
  'sleep.dryRun.title': 'Dry run: the machine stays on',
738
770
  'sleep.dryRun.body': 'The rehearsal is over. This is the command it WOULD have run (nothing was executed):',
739
771
  'sleep.dryRun.close': 'Got it',
@@ -795,6 +827,17 @@
795
827
  'announcement.disabled': 'The button is currently switched off; you can turn it on in the settings.',
796
828
  'announcement.button': 'Got it',
797
829
  'announcement.error': 'Could not record it: {message}',
830
+ // THE COPY OF THE COMPOSER ROW (see the Chinese block): one short line,
831
+ // never a sentence.
832
+ 'restart.stage.snapshotting': 'Backing up…',
833
+ 'restart.stage.restarting': 'Restarting…',
834
+ 'restart.stage.back': 'Back',
835
+ // The centred overlay of a running restart.
836
+ 'restart.overlay.snapshotting': 'Backing up before DSH restart',
837
+ 'restart.overlay.size': '{done} / {total}',
838
+ 'restart.overlay.sizeDone': '{done}',
839
+ 'restart.overlay.restarting': 'Restarting…',
840
+ 'restart.overlay.back': 'Back again',
798
841
  }
799
842
 
800
843
  /**
@@ -1382,6 +1425,40 @@
1382
1425
  return families.size
1383
1426
  }
1384
1427
 
1428
+ /**
1429
+ * THE number of tasks the pending restart waits for — ONE source for every
1430
+ * surface of the interface.
1431
+ *
1432
+ * THE DEFECT THIS FIXES (user report, 2026-10-29)
1433
+ *
1434
+ * The capsule said "还有 1 个会话在忙" while the panel it opens said
1435
+ * "这次重启还在等 0 个会话". Two numbers, ONE fact: the capsule counted
1436
+ * the blockers itself, the panel re-counted a FILTERED copy of them (the own
1437
+ * session dropped out, the goal blockers dropped out) — so the same pending
1438
+ * restart was "1" on one line and "0" on the next.
1439
+ *
1440
+ * THE RULE
1441
+ *
1442
+ * The Host counts the TASKS that hold the gate and publishes the number as
1443
+ * `busyCount` on its state route. Everything reads THAT number — the
1444
+ * capsule, its panel and the long-wait line — so the two can never disagree
1445
+ * again. Only a payload without the number (an older Host, a test fixture)
1446
+ * falls back to counting the blockers, and then both surfaces count the SAME
1447
+ * list.
1448
+ *
1449
+ * A blocker WITHOUT a readable name is still a task: the count never drops
1450
+ * to 0 just because the name is missing.
1451
+ *
1452
+ * @param {any} info the state payload of the Host
1453
+ * @param {any[]} [entries] the list to count when the Host sent no number
1454
+ * @returns {number}
1455
+ */
1456
+ function waitingTaskCount(info, entries) {
1457
+ const host = info && typeof info === 'object' ? info.busyCount : undefined
1458
+ if (typeof host === 'number' && Number.isFinite(host) && host >= 0) return Math.floor(host)
1459
+ return countBlockerTasks(Array.isArray(entries) ? entries : [])
1460
+ }
1461
+
1385
1462
  /**
1386
1463
  * How many of those tasks are carried by SUBAGENTS ONLY.
1387
1464
  *
@@ -1614,9 +1691,9 @@
1614
1691
  // capsule: the capsule cannot afford "16 分 37 秒".
1615
1692
  const duration = formatDurationMs(longWait.waitedMs, translate)
1616
1693
  const compact = formatCompactDurationMs(longWait.waitedMs, translate)
1617
- // The count is about TASKS, not blockers: a session and its own subagents
1618
- // are ONE task, exactly as the left sidebar counts them.
1619
- const count = countBlockerTasks(blockedBy)
1694
+ // THE same number the capsule and its panel show (the Host's `busyCount`
1695
+ // when it sends one): a session and its own subagents are ONE task.
1696
+ const count = waitingTaskCount(info, blockedBy)
1620
1697
  // The tooltip: the whole story, every name, nothing cut.
1621
1698
  const sentence = fillTemplate(translate('longWaitTooltip'), { duration, blockers: summary.full })
1622
1699
  return {
@@ -1987,7 +2064,11 @@
1987
2064
  }
1988
2065
 
1989
2066
  /**
1990
- * The size of one backup, in MB (the manifest carries the byte count).
2067
+ * The size of one backup, with the unit that fits it (the manifest carries
2068
+ * the byte count).
2069
+ *
2070
+ * 92 KB stays 92 KB and 156 MB stays 156 MB: the tenth is kept only when it
2071
+ * says something ("117.7 MB"), never as a pointless "156.0 MB".
1991
2072
  *
1992
2073
  * @param {number|null|undefined} bytes
1993
2074
  * @returns {string}
@@ -1995,21 +2076,150 @@
1995
2076
  function formatBackupSize(bytes) {
1996
2077
  const value = Number(bytes)
1997
2078
  if (!Number.isFinite(value) || value < 0) return ''
1998
- return (value / (1024 * 1024)).toFixed(1) + ' MB'
2079
+ const units = [
2080
+ { size: 1024 * 1024 * 1024, suffix: 'GB' },
2081
+ { size: 1024 * 1024, suffix: 'MB' },
2082
+ { size: 1024, suffix: 'KB' },
2083
+ ]
2084
+ for (const unit of units) {
2085
+ if (value < unit.size) continue
2086
+ const scaled = Math.round((value / unit.size) * 10) / 10
2087
+ // 156.0 → 156, 117.7 → 117.7: the tenth only when it says something.
2088
+ return (Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(1)) + ' ' + unit.suffix
2089
+ }
2090
+ return Math.round(value) + ' B'
1999
2091
  }
2000
2092
 
2001
2093
  /**
2002
- * The local time of one backup.
2094
+ * The local time of one backup, to the minute.
2003
2095
  *
2004
2096
  * The directory name is UTC on purpose, so the panel shows the LOCAL time
2005
2097
  * the manifest carries: a fresh snapshot can never look like an old one.
2006
2098
  *
2099
+ * The OFFSET is dropped on purpose (user report, 2026-10-29): the string is
2100
+ * already the local time of the machine that took the copy, so "+08:00" is
2101
+ * noise to whoever reads it — and the seconds never say anything either.
2102
+ *
2007
2103
  * @param {string|null|undefined} atLocal
2008
2104
  * @returns {string}
2009
2105
  */
2010
2106
  function formatBackupTime(atLocal) {
2011
- if (typeof atLocal !== 'string' || atLocal === '') return ''
2012
- return atLocal.replace('T', ' ')
2107
+ if (typeof atLocal !== 'string' || atLocal.trim() === '') return ''
2108
+ const text = atLocal.trim().replace('T', ' ')
2109
+ // "2026-10-29 22:08:46+08:00" → "2026-10-29 22:08" (date and minute only).
2110
+ const matched = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.exec(text)
2111
+ if (matched) return matched[0]
2112
+ // Anything else: at least never show an offset.
2113
+ return text.replace(/ ?([+-]\d{2}:?\d{2}|Z)$/, '')
2114
+ }
2115
+
2116
+ /**
2117
+ * THE COPY OF THE COMPOSER ROW: one SHORT line, never a sentence.
2118
+ *
2119
+ * WHY (user report, 2026-10-29): the pill sits in the composer tool row — a
2120
+ * single clipped line inside a 22px toolbar. The long stage sentence
2121
+ * ("正在等待所有会话空闲…(还有 1 个会话在忙)") was cut off on a narrow
2122
+ * window and the ellipsis ate exactly the part that mattered.
2123
+ *
2124
+ * SO the pill now shows ONE of these, and nothing else:
2125
+ *
2126
+ * while the gate waits 待重启 · 空闲后自动重启 (the composed line of
2127
+ * the pill itself — it already carries the short
2128
+ * count of the tasks that hold the gate)
2129
+ * while the snapshot runs 重启前备份中
2130
+ * while the host goes down 正在重启…
2131
+ * after the new host is up 已回来 (1.5s, then the pill is plain)
2132
+ *
2133
+ * The detail — the number of busy conversations, the megabytes of the copy,
2134
+ * the progress bar — lives in the CENTRED OVERLAY (`restart.overlay.*`),
2135
+ * where there is room for it. The words come from the same dictionaries as
2136
+ * everything else, so no surface has a second source of truth.
2137
+ */
2138
+ const RESTART_PILL_STAGE_KEYS = Object.freeze({
2139
+ // `waiting` is deliberately MISSING: while the gate waits the pill already
2140
+ // says "待重启 · 等待 N 个…" / "待重启 · 空闲后自动重启", and saying
2141
+ // the stage as well would put two lines into a one-line row.
2142
+ snapshotting: 'restart.stage.snapshotting',
2143
+ restarting: 'restart.stage.restarting',
2144
+ back: 'restart.stage.back',
2145
+ })
2146
+
2147
+ /**
2148
+ * The locale keys of the centred overlay.
2149
+ *
2150
+ * `waiting` is deliberately MISSING: while the gate waits for the other
2151
+ * conversations there is NOTHING in progress yet, so no card is drawn at all
2152
+ * (user report, 2026-10-29: "不是应该触发备份才触发吗?备份完就消失" — a card
2153
+ * that said "等待会话空闲" hung on screen for ten minutes while the request
2154
+ * merely queued). The waiting count belongs to the pill, which is where it
2155
+ * always was: "待重启 · 等待 N 个会话".
2156
+ */
2157
+ const RESTART_OVERLAY_KEYS = Object.freeze({
2158
+ snapshotting: 'restart.overlay.snapshotting',
2159
+ restarting: 'restart.overlay.restarting',
2160
+ back: 'restart.overlay.back',
2161
+ })
2162
+
2163
+ /**
2164
+ * Is this a stage the centred overlay draws at all?
2165
+ *
2166
+ * @param {unknown} phase
2167
+ * @returns {boolean}
2168
+ */
2169
+ function isRestartOverlayPhase(phase) {
2170
+ return typeof phase === 'string' && Object.prototype.hasOwnProperty.call(RESTART_OVERLAY_KEYS, phase)
2171
+ }
2172
+
2173
+ /**
2174
+ * THE COPY OF THE COMPOSER ROW for a stage ('' = the pill keeps its own
2175
+ * composed line, "待重启 · …").
2176
+ *
2177
+ * @param {{ phase?: string }|null|undefined} stage the `restartPhase` payload
2178
+ * @param {Function} [translator] the translator (the built-in one otherwise)
2179
+ * @returns {string}
2180
+ */
2181
+ function restartPillStageText(stage, translator) {
2182
+ const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
2183
+ if (!Object.prototype.hasOwnProperty.call(RESTART_PILL_STAGE_KEYS, phase)) return ''
2184
+ const t = typeof translator === 'function' ? translator : makeTranslate(null)
2185
+ return t(RESTART_PILL_STAGE_KEYS[phase])
2186
+ }
2187
+
2188
+ /**
2189
+ * The short line of the centred overlay for a stage ('' when there is none).
2190
+ *
2191
+ * @param {{ phase?: string }|null|undefined} stage
2192
+ * @param {Function} [translator]
2193
+ * @returns {string}
2194
+ */
2195
+ function restartOverlayTitle(stage, translator) {
2196
+ const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
2197
+ if (!isRestartOverlayPhase(phase)) return ''
2198
+ const t = typeof translator === 'function' ? translator : makeTranslate(null)
2199
+ return t(RESTART_OVERLAY_KEYS[phase])
2200
+ }
2201
+
2202
+ /**
2203
+ * A SIZE, WITH THE UNIT THAT FITS IT: "92 KB", "156 MB", "12.3 MB", "0 B".
2204
+ *
2205
+ * WHY the unit is chosen here (user report, 2026-10-29): a hosted profile is
2206
+ * only 92 KB. Printed in megabytes that is "0.1 MB" at best and "0 MB" after
2207
+ * rounding — the size of a real backup would have looked like nothing at all.
2208
+ * So: below 1 MB the number is KB, below 1 KB it is B, and a whole number of
2209
+ * megabytes keeps no ".0".
2210
+ *
2211
+ * @param {number|null|undefined} bytes
2212
+ * @returns {string} '' when the Host did not measure anything
2213
+ */
2214
+ function formatOverlaySize(bytes) {
2215
+ // No measurement is NOT a measurement of zero: a missing byte count must
2216
+ // never be printed as "0" (Number(null) is 0, which is exactly the trap).
2217
+ if (bytes === null || bytes === undefined || bytes === '') return ''
2218
+ const value = Number(bytes)
2219
+ if (!Number.isFinite(value) || value < 0) return ''
2220
+ if (value < 1024) return Math.round(value) + ' B'
2221
+ if (value < 1024 * 1024) return Math.round(value / 1024) + ' KB'
2222
+ return String(Math.round((value / (1024 * 1024)) * 10) / 10) + ' MB'
2013
2223
  }
2014
2224
 
2015
2225
  /**
@@ -2033,8 +2243,10 @@
2033
2243
  summarizeBlockers,
2034
2244
  truncateBlockerName,
2035
2245
  // The task count: one rule, four surfaces (mirrored from lib/blockers.js).
2246
+ // `waitingTaskCount` is THE number the capsule and its panel both show.
2036
2247
  blockerTaskKeys,
2037
2248
  countBlockerTasks,
2249
+ waitingTaskCount,
2038
2250
  countSubagentTasks,
2039
2251
  resolveAccessRedirect,
2040
2252
  supervisorModeLabel,
@@ -2060,6 +2272,14 @@
2060
2272
  startupStatusLine,
2061
2273
  formatBackupSize,
2062
2274
  formatBackupTime,
2275
+ // The stage of a restart on the two surfaces that have room for it: the
2276
+ // composer row (one short line) and the centred overlay (the detail).
2277
+ isRestartOverlayPhase,
2278
+ restartPillStageText,
2279
+ restartOverlayTitle,
2280
+ // The size with the unit that fits it ("92 KB", "156 MB"): a hosted profile
2281
+ // is small enough that megabytes would round it to "0 MB".
2282
+ formatOverlaySize,
2063
2283
  // The TRANSLATOR: the Host's one when it answers, the built-in dictionary
2064
2284
  // when it does not (a key name is never shown).
2065
2285
  makeTranslate,
@@ -2134,6 +2354,44 @@
2134
2354
  // factory-scoped handle instead of a standard `t` prop.
2135
2355
  let translate = null
2136
2356
 
2357
+ /**
2358
+ * THE STAGE LINE of a running restart, in the reader's language.
2359
+ *
2360
+ * The Host sends BOTH languages (`text: { zh, en }`) with every stage, so
2361
+ * the pill and the settings page need no locale key that an older Host
2362
+ * might not ship — and there is exactly ONE sentence per stage instead of
2363
+ * two that could drift apart.
2364
+ *
2365
+ * @param {object|null} stage the `restartPhase` of the state payload
2366
+ * @returns {string} the sentence, or '' when there is no restart
2367
+ */
2368
+ function restartStageText(stage) {
2369
+ if (!stage || typeof stage !== 'object') return ''
2370
+ const text = stage.text && typeof stage.text === 'object' ? stage.text : null
2371
+ if (!text) return ''
2372
+ const lang = typeof document !== 'undefined' && document.documentElement && document.documentElement.lang
2373
+ ? String(document.documentElement.lang)
2374
+ : (typeof navigator !== 'undefined' && navigator.language ? String(navigator.language) : 'en')
2375
+ const zh = lang.toLowerCase().indexOf('zh') === 0
2376
+ const picked = zh ? (text.zh || text.en) : (text.en || text.zh)
2377
+ return typeof picked === 'string' ? picked : ''
2378
+ }
2379
+
2380
+ /**
2381
+ * May this restart still be called off?
2382
+ *
2383
+ * It is the HOST that decides (`cancellable`): once the host is on its
2384
+ * way out, a cancel button would be a promise the plugin cannot keep, so
2385
+ * it is shown disabled together with the reason.
2386
+ *
2387
+ * @param {object|null} stage
2388
+ * @returns {boolean}
2389
+ */
2390
+ function restartStageLocked(stage) {
2391
+ if (!stage || typeof stage !== 'object') return false
2392
+ return stage.cancellable === false
2393
+ }
2394
+
2137
2395
  /**
2138
2396
  * ONE shared poller, shared by every surface that shows the pending
2139
2397
  * restart state (the composer pill and the sidebar session-row dot).
@@ -2551,6 +2809,87 @@
2551
2809
  opacity: 0.85,
2552
2810
  }
2553
2811
 
2812
+ // HOW LONG THE "已回来" CARD STAYS after the new host is up (ms).
2813
+ const RESTART_OVERLAY_BACK_MS = 1500
2814
+ // The id of the injected stylesheet, so a second mount never duplicates it.
2815
+ const RESTART_OVERLAY_STYLE_ID = 'dsh-autorestart-restart-overlay'
2816
+
2817
+ // THE CENTRED OVERLAY of a running restart.
2818
+ //
2819
+ // WHY IT EXISTS (user report, 2026-10-29): the stage line lived in the
2820
+ // composer row, a single clipped line inside a 22px toolbar — on a narrow
2821
+ // window "正在等待所有会话空闲…(还有 1 个会话在忙)" was cut off, and an
2822
+ // ellipsis ate exactly the part that mattered. The detail therefore moved
2823
+ // OUT of the composer row onto the middle of the interface: a fixed 320px
2824
+ // card, one short line per stage, a real progress bar for the backup.
2825
+ //
2826
+ // TWO RULES:
2827
+ // 1. IT NEVER COVERS THE INPUT. `pointer-events: none` and a translation
2828
+ // only — every click goes through to the interface below, and the
2829
+ // card sits in the middle of the conversation, not over the composer;
2830
+ // 2. NO TEXT IS EVER CUT. The card keeps its line unwrapped, has no
2831
+ // `text-overflow` at all, and shrinks its font on a narrow window
2832
+ // (`clamp`), so the number of the backup is never replaced by "…".
2833
+ const RESTART_OVERLAY_CSS = [
2834
+ '.dsh-autorestart-restart-overlay{',
2835
+ ' position:fixed;',
2836
+ ' top:44%;',
2837
+ ' left:50%;',
2838
+ ' transform:translate(-50%,-50%);',
2839
+ ' z-index:9997;',
2840
+ ' box-sizing:border-box;',
2841
+ ' width:min(320px,calc(100vw - 32px));',
2842
+ ' padding:14px 16px;',
2843
+ ' border:1px solid var(--dsw-alias-border-l1);',
2844
+ ' border-radius:14px;',
2845
+ ' background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 88%, transparent);',
2846
+ ' box-shadow:0 10px 30px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
2847
+ ' color:var(--dsw-alias-label-primary);',
2848
+ ' text-align:center;',
2849
+ ' pointer-events:none;',
2850
+ '}',
2851
+ '.dsh-autorestart-restart-overlay__title{',
2852
+ ' margin:0;',
2853
+ ' font-size:clamp(11px,2.6vw,14px);',
2854
+ ' font-weight:600;',
2855
+ ' line-height:1.4;',
2856
+ ' white-space:nowrap;',
2857
+ ' overflow:visible;',
2858
+ '}',
2859
+ '.dsh-autorestart-restart-overlay__detail{',
2860
+ ' margin:4px 0 0;',
2861
+ ' font-size:clamp(10px,2.2vw,12.5px);',
2862
+ ' line-height:1.5;',
2863
+ ' color:var(--dsw-alias-label-secondary);',
2864
+ ' white-space:nowrap;',
2865
+ ' overflow:visible;',
2866
+ '}',
2867
+ '.dsh-autorestart-restart-overlay__bar{',
2868
+ ' margin:9px 0 0;',
2869
+ ' height:6px;',
2870
+ ' border-radius:999px;',
2871
+ ' background:color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent);',
2872
+ ' overflow:hidden;',
2873
+ '}',
2874
+ '.dsh-autorestart-restart-overlay__bar-fill{',
2875
+ ' height:100%;',
2876
+ ' border-radius:999px;',
2877
+ ' background:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#5b8def));',
2878
+ ' transition:width .3s ease-out;',
2879
+ '}',
2880
+ '.dsh-autorestart-restart-overlay__size{',
2881
+ ' display:flex;',
2882
+ ' align-items:baseline;',
2883
+ ' justify-content:space-between;',
2884
+ ' gap:8px;',
2885
+ ' margin:6px 0 0;',
2886
+ ' font-size:clamp(10px,2.2vw,12px);',
2887
+ ' line-height:1.5;',
2888
+ ' color:var(--dsw-alias-label-secondary);',
2889
+ ' white-space:nowrap;',
2890
+ '}',
2891
+ ].join('\n')
2892
+
2554
2893
  // The details panel: anchored to the wrapper, above the pill, left
2555
2894
  // aligned. It uses the host theme tokens (background / border / label
2556
2895
  // color) so it follows the app theme.
@@ -2613,10 +2952,14 @@
2613
2952
  cursor: 'pointer',
2614
2953
  }
2615
2954
 
2616
- // The park button lives inside the pill and reuses the pill's own button
2617
- // look (same tokens, same border, same radius), only smaller so the pill
2618
- // stays a one-line chip. It is inline (no absolute/fixed positioning, no
2619
- // negative margins) and never becomes a second element in the slot.
2955
+ // The same button, greyed out: used while the host is already restarting,
2956
+ // when a cancel can no longer be honoured.
2957
+ const PANEL_BUTTON_DISABLED_STYLE = {
2958
+ ...PANEL_BUTTON_STYLE,
2959
+ cursor: 'not-allowed',
2960
+ opacity: 0.55,
2961
+ }
2962
+
2620
2963
  const PARK_BUTTON_STYLE = {
2621
2964
  ...PILL_STYLE,
2622
2965
  height: 18,
@@ -2916,6 +3259,22 @@
2916
3259
  if (!waiting && !withdrawn) return null
2917
3260
 
2918
3261
  const blockedBy = Array.isArray(info.blockedBy) ? info.blockedBy : []
3262
+ // WHICH STAGE THE RESTART IS IN (waiting / backing up / restarting) and
3263
+ // whether a cancel is still honest. Both come from the Host: the pill
3264
+ // never guesses the stage and never invents progress.
3265
+ const stage = info && info.restartPhase && typeof info.restartPhase === 'object' ? info.restartPhase : null
3266
+ // THE COPY OF THE COMPOSER ROW: ONE short line, never a sentence.
3267
+ // waiting (or no stage at all): the pill keeps its own composed line —
3268
+ // "待重启 · 空闲后自动重启" / "待重启 · 等待 N 个…"
3269
+ // (the noun of that count belongs to the capsule's own copy);
3270
+ // snapshotting / restarting / back: the stage REPLACES that line —
3271
+ // "重启前备份中" / "正在重启…" / "已回来", and nothing else.
3272
+ // The sentence that used to sit here ("正在等待所有会话空闲…(还有 1 个
3273
+ // 会话在忙)") was clipped away by the composer row (user report,
3274
+ // 2026-10-29). The detail — the count of busy conversations, the
3275
+ // megabytes, the progress bar — is the CENTRED OVERLAY.
3276
+ const pillStageText = restartPillStageText(stage, t)
3277
+ const cancelLocked = restartStageLocked(stage)
2919
3278
  // The TASK of every blocker: a session and its own subagents are ONE
2920
3279
  // task (the sidebar counts them once). The keys are aligned with the
2921
3280
  // list, so the filter below can ask "is this MY task?".
@@ -2949,9 +3308,6 @@
2949
3308
  const busyBlockers = others.filter(
2950
3309
  (entry) => String(entry.reason) !== 'goal-active' && String(entry.reason) !== 'goal-exhausted',
2951
3310
  )
2952
- // The count is about TASKS, not blockers: a parent session and its own
2953
- // subagents are ONE task, exactly as the left sidebar counts them.
2954
- const otherBusy = countBlockerTasks(busyBlockers)
2955
3311
 
2956
3312
  // WHY the gate is closed: the Host reports the reason next to the
2957
3313
  // blockers. With no blocker but the reason "settling" the restart is
@@ -2963,6 +3319,18 @@
2963
3319
  // more line about it — how long it has been waiting and how many tasks
2964
3320
  // are running.
2965
3321
  const longWait = formatLongWaitNoteParts(info, t)
3322
+ // THE ONE NUMBER both surfaces show (user report, 2026-10-29: the
3323
+ // capsule said "还有 1 个会话在忙" while the panel it opens said
3324
+ // "这次重启还在等 0 个会话"). The Host counts the tasks that hold
3325
+ // the gate and publishes `busyCount`; the capsule AND its panel read
3326
+ // that very value, so the two can never disagree again. A blocker
3327
+ // WITHOUT a readable name is still counted.
3328
+ const longWaitBlockers = info && info.longWait && Array.isArray(info.longWait.blockedBy)
3329
+ ? info.longWait.blockedBy
3330
+ : []
3331
+ const countingBlockers = others.length > 0 || !longWait.hasLongWait ? others : longWaitBlockers
3332
+ const waitingCount = waitingTaskCount(info, countingBlockers)
3333
+ const otherBusy = waitingCount
2966
3334
  // The blockers of the ordinary list: the SAME rule as the long wait
2967
3335
  // (grouped by the session that created them).
2968
3336
  const blockers = summarizeBlockers(busyBlockers, { t })
@@ -2997,7 +3365,9 @@
2997
3365
  // The panel: how many tasks the restart waits for, and how many OTHER
2998
3366
  // sessions wait for the same restart. The count is about TASKS (a
2999
3367
  // session and its own subagents are one), like everywhere else.
3000
- const taskCount = longWait.hasLongWait ? longWait.count : countBlockerTasks(others)
3368
+ // The SAME variable the capsule used: the panel can never claim a
3369
+ // different count than the line it was opened from.
3370
+ const taskCount = waitingCount
3001
3371
  const otherWaiters = waiters.filter((waiter) => String(waiter) !== key).length
3002
3372
  const sessionsWaiting = sessionsLeft === null ? otherWaiters : sessionsLeft
3003
3373
  // The session that asks for the cancel: without it the own request
@@ -3163,7 +3533,14 @@
3163
3533
  if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
3164
3534
  },
3165
3535
  },
3166
- h('p', { style: { margin: 0 } }, fill(t('cancelPanelTasks'), { n: taskCount })),
3536
+ // THE SAME number the capsule shows (one variable, one number).
3537
+ // A restart that waits for nobody says so in words — "还在等 0 个
3538
+ // 会话" is not a sentence anybody writes.
3539
+ h(
3540
+ 'p',
3541
+ { style: { margin: 0 } },
3542
+ taskCount > 0 ? fill(t('cancelPanelTasks'), { n: taskCount }) : t('panelStateIdle'),
3543
+ ),
3167
3544
  sessionsWaiting > 0
3168
3545
  ? h(
3169
3546
  'p',
@@ -3233,16 +3610,22 @@
3233
3610
  'button',
3234
3611
  {
3235
3612
  type: 'button',
3236
- style: PANEL_BUTTON_STYLE,
3237
- title: t('cancelButtonAria'),
3238
- 'aria-label': t('cancelButtonAria'),
3239
- disabled: cancelling,
3613
+ style: cancelLocked ? PANEL_BUTTON_DISABLED_STYLE : PANEL_BUTTON_STYLE,
3614
+ // A cancel once the host is restarting would be a promise the
3615
+ // plugin cannot keep: the button is greyed out and the reason
3616
+ // is in its tooltip.
3617
+ title: cancelLocked
3618
+ ? t('cancelLocked') + (stage && stage.cancelReason ? ' · ' + stage.cancelReason : '')
3619
+ : t('cancelButtonAria'),
3620
+ 'aria-label': cancelLocked ? t('cancelLocked') : t('cancelButtonAria'),
3621
+ 'aria-disabled': cancelLocked ? 'true' : 'false',
3622
+ disabled: cancelling || cancelLocked,
3240
3623
  onClick: (event) => {
3241
3624
  if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
3242
3625
  setConfirmOpen(true)
3243
3626
  },
3244
3627
  },
3245
- t('cancelButton'),
3628
+ cancelLocked ? t('cancelLocked') : t('cancelButton'),
3246
3629
  ),
3247
3630
  )
3248
3631
  : null
@@ -3270,7 +3653,27 @@
3270
3653
  onKeyDown: onPillKeyDown,
3271
3654
  },
3272
3655
  h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
3273
- h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
3656
+ // THE ONE VISIBLE LINE OF THE COMPOSER ROW — short, never a
3657
+ // sentence:
3658
+ // waiting (or no stage at all): the composed line of the pill —
3659
+ // "待重启 · 空闲后自动重启" / "待重启 · 等待 N 个…";
3660
+ // snapshotting / restarting / back: the stage REPLACES it —
3661
+ // "重启前备份中" / "正在重启…" / "已回来".
3662
+ // The long sentence that used to live here was clipped away by the
3663
+ // composer row (user report, 2026-10-29): the detail is the CENTRED
3664
+ // OVERLAY, and the pill is one clipped line again.
3665
+ h(
3666
+ 'span',
3667
+ {
3668
+ style: TEXT_STYLE,
3669
+ 'data-dsh-autorestart-copy': 'true',
3670
+ 'data-dsh-autorestart-stage': String(stage && stage.phase ? stage.phase : ''),
3671
+ },
3672
+ pillStageText === ''
3673
+ ? h('b', { key: 'label' }, t('label'))
3674
+ : h('b', { key: 'stage' }, pillStageText),
3675
+ pillStageText === '' ? status : null,
3676
+ ),
3274
3677
  // One more line for the long wait: how long the restart has been
3275
3678
  // waiting and how many tasks are running. It is only there when the
3276
3679
  // Host says so. The line carries NO name at all — it is one
@@ -3358,6 +3761,150 @@
3358
3761
  })
3359
3762
  }
3360
3763
 
3764
+ /**
3765
+ * Inject the overlay stylesheet exactly once. The id keeps a second
3766
+ * injection from duplicating the rule.
3767
+ */
3768
+ function ensureRestartOverlayStyle() {
3769
+ if (typeof document === 'undefined' || !document.head) return
3770
+ try {
3771
+ if (document.getElementById(RESTART_OVERLAY_STYLE_ID)) return
3772
+ const style = document.createElement('style')
3773
+ style.id = RESTART_OVERLAY_STYLE_ID
3774
+ style.textContent = RESTART_OVERLAY_CSS
3775
+ document.head.appendChild(style)
3776
+ } catch {
3777
+ // Without the stylesheet the card is still readable, just plainer.
3778
+ }
3779
+ }
3780
+
3781
+ /**
3782
+ * THE CENTRED OVERLAY OF A RESTART — the stage, on the interface itself.
3783
+ *
3784
+ * WHY (first report): the stage lived in the composer row, one clipped line
3785
+ * inside a 22px toolbar, where the sentence was cut off by an ellipsis. The
3786
+ * detail therefore moved onto the middle of the conversation.
3787
+ *
3788
+ * WHAT IS DRAWN, AND WHEN — the card follows the Host EXACTLY, with no time
3789
+ * floor of its own (the owner's decision, 2026-10-29: "浮层不用保底时间,
3790
+ * 短就算了,不用硬加长"): a 92 KB backup is copied in ~5 ms and the card is
3791
+ * gone just as fast; a slow backup keeps it on screen for as long as it
3792
+ * takes. Nothing here invents a duration.
3793
+ * snapshotting "DSH 重启前备份中" + the real bar + "12.3 MB / 40 MB" + %
3794
+ * restarting "正在重启…" (the host is on its way out)
3795
+ * back "已回来", for 1.5s, and only for a restart THIS page saw
3796
+ * waiting/idle NOTHING: the gate merely waits, nothing is in progress
3797
+ *
3798
+ * A running stage counts only while the restart is really PENDING
3799
+ * (pendingRestart): the stage a previous restart left in the state file is
3800
+ * not an action, and a card for it would be a lie.
3801
+ *
3802
+ * IT LIVES ON THIS PAGE ONLY (no localStorage, no global marker), it never
3803
+ * covers the input (pointer-events: none), and it never cuts a word.
3804
+ */
3805
+ function RestartOverlay() {
3806
+ const info = useSharedInfo(true)
3807
+ const t = makeTranslate(translate)
3808
+ // The stage said goodbye: the card folds away once, and only once.
3809
+ const [backGone, setBackGone] = useState(false)
3810
+ // DID THIS PAGE WATCH A RESTART IN PROGRESS? A page opened afterwards
3811
+ // reads a LEFTOVER stage from the state file and must draw nothing.
3812
+ const [sawRunning, setSawRunning] = useState(false)
3813
+
3814
+ const stage = info && info.restartPhase && typeof info.restartPhase === 'object' ? info.restartPhase : null
3815
+ const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
3816
+ // A RESTART THAT IS REALLY PENDING: the request says "something is going
3817
+ // on right now", the state file alone does not.
3818
+ const pending = Boolean(info && info.pendingRestart === true)
3819
+ // THE ONLY TWO STAGES THAT ARE "SOMETHING IS REALLY HAPPENING": the
3820
+ // snapshot is being written, or the host is on its way out.
3821
+ const running = pending && (phase === 'snapshotting' || phase === 'restarting')
3822
+
3823
+ useEffect(() => {
3824
+ if (running) setSawRunning(true)
3825
+ }, [running])
3826
+
3827
+ // "已回来" is a MOMENT, not a state: 1.5s and then the card is gone.
3828
+ useEffect(() => {
3829
+ if (phase !== 'back') {
3830
+ setBackGone(false)
3831
+ return undefined
3832
+ }
3833
+ const timer = setTimeout(() => setBackGone(true), RESTART_OVERLAY_BACK_MS)
3834
+ return () => clearTimeout(timer)
3835
+ }, [phase])
3836
+
3837
+ // THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS.
3838
+ const visible = running || (phase === 'back' && sawRunning && !backGone)
3839
+ if (!visible) return null
3840
+
3841
+ const progress = stage.progress && typeof stage.progress === 'object' ? stage.progress : null
3842
+ const bytes = progress !== null && Number.isFinite(Number(progress.bytes)) ? Number(progress.bytes) : null
3843
+ const total = progress !== null && Number.isFinite(Number(progress.total)) ? Number(progress.total) : null
3844
+ const percent = progress !== null && Number.isFinite(Number(progress.percent))
3845
+ ? Math.max(0, Math.min(100, Math.round(Number(progress.percent))))
3846
+ : null
3847
+
3848
+ const title = restartOverlayTitle(stage, t)
3849
+
3850
+ let bar = null
3851
+ let detail = null
3852
+ if (phase === 'snapshotting') {
3853
+ // The numbers are the ones the copy loop really measured; when there is
3854
+ // no measurement yet, the card shows no number at all (an invented
3855
+ // progress bar is worse than none). The unit fits the size, so a 92 KB
3856
+ // profile is "92 KB" and never "0 MB".
3857
+ const done = bytes === null ? '' : formatOverlaySize(bytes)
3858
+ const all = total === null ? '' : formatOverlaySize(total)
3859
+ const sizeText = done === ''
3860
+ ? ''
3861
+ : (all === '' ? fill(t('restart.overlay.sizeDone'), { done }) : fill(t('restart.overlay.size'), { done, total: all }))
3862
+ bar = h(
3863
+ 'div',
3864
+ {
3865
+ className: 'dsh-autorestart-restart-overlay__bar',
3866
+ role: 'progressbar',
3867
+ 'aria-valuemin': 0,
3868
+ 'aria-valuemax': 100,
3869
+ 'aria-valuenow': percent === null ? 0 : percent,
3870
+ },
3871
+ h('div', {
3872
+ className: 'dsh-autorestart-restart-overlay__bar-fill',
3873
+ 'data-dsh-autorestart-restart-overlay-bar': 'true',
3874
+ style: { width: (percent === null ? 0 : percent) + '%' },
3875
+ }),
3876
+ )
3877
+ detail = sizeText === ''
3878
+ ? null
3879
+ : h(
3880
+ 'p',
3881
+ { className: 'dsh-autorestart-restart-overlay__size' },
3882
+ h('span', { 'data-dsh-autorestart-restart-overlay-size': 'true' }, sizeText),
3883
+ h('span', { 'data-dsh-autorestart-restart-overlay-percent': 'true' }, percent === null ? '' : percent + '%'),
3884
+ )
3885
+ }
3886
+
3887
+ ensureRestartOverlayStyle()
3888
+
3889
+ return h(
3890
+ 'div',
3891
+ {
3892
+ className: 'dsh-autorestart-restart-overlay',
3893
+ role: 'status',
3894
+ 'aria-live': 'polite',
3895
+ 'data-dsh-autorestart-restart-overlay': 'true',
3896
+ 'data-dsh-autorestart-restart-overlay-phase': phase,
3897
+ },
3898
+ h(
3899
+ 'p',
3900
+ { className: 'dsh-autorestart-restart-overlay__title', 'data-dsh-autorestart-restart-overlay-title': 'true' },
3901
+ title,
3902
+ ),
3903
+ bar,
3904
+ detail,
3905
+ )
3906
+ }
3907
+
3361
3908
  /**
3362
3909
  * Inject the prompt styles exactly once. The id keeps a second injection
3363
3910
  * from duplicating the rule.
@@ -4328,6 +4875,37 @@
4328
4875
  ' font-size:13px;',
4329
4876
  '}',
4330
4877
  '.dsh-autorestart-settings__hint{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);}',
4878
+ // THE BACKUP LIST (user report, 2026-10-29: the old one-liner
4879
+ // "2026-10-29 22:08:46+08:00 156.0 MB 含已装插件" was unreadable).
4880
+ // One GROUP per backup, a labelled row for the time and one for the
4881
+ // size, and enough air between two groups that two copies never read
4882
+ // as one block.
4883
+ '.dsh-autorestart-settings__backup{',
4884
+ ' display:flex;',
4885
+ ' flex-direction:column;',
4886
+ ' gap:3px;',
4887
+ ' padding-top:8px;',
4888
+ ' border-top:1px solid var(--dsw-alias-border-l1);',
4889
+ '}',
4890
+ '.dsh-autorestart-settings__backup + .dsh-autorestart-settings__backup{margin-top:12px;}',
4891
+ '.dsh-autorestart-settings__backup-row{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;}',
4892
+ '.dsh-autorestart-settings__backup-label{',
4893
+ ' flex:none;',
4894
+ ' font-size:12.5px;',
4895
+ ' line-height:18px;',
4896
+ ' color:var(--dsw-alias-label-secondary);',
4897
+ '}',
4898
+ '.dsh-autorestart-settings__backup-value{',
4899
+ ' font-size:13px;',
4900
+ ' line-height:18px;',
4901
+ ' color:var(--dsw-alias-label-primary);',
4902
+ ' font-variant-numeric:tabular-nums;',
4903
+ '}',
4904
+ '.dsh-autorestart-settings__backup-note{',
4905
+ ' font-size:12px;',
4906
+ ' line-height:18px;',
4907
+ ' color:var(--dsw-alias-label-secondary);',
4908
+ '}',
4331
4909
  // The folded "technical details": the command itself stays out of the
4332
4910
  // way until somebody asks for it.
4333
4911
  '.dsh-autorestart-settings__details{',
@@ -4506,6 +5084,321 @@
4506
5084
  * @param {object} props the Host's props: `t` (the translator of the
4507
5085
  * registered locale, injected by the section registration)
4508
5086
  */
5087
+ /* ---------------------------------------------------------------- *
5088
+ * THE RECEIPT OF A SETTINGS CHANGE
5089
+ *
5090
+ * A setting is written the moment the user picks it ("change it and it
5091
+ * is in effect"), which left the user unable to tell whether it was
5092
+ * really stored. This is the missing half: ONE `sticky` line at the TOP
5093
+ * OF THE SETTINGS PANEL that says "Saved 21:40:12" once the HOST
5094
+ * answered `ok: true`, or "Not saved — <the reason>" in red when it
5095
+ * refused.
5096
+ *
5097
+ * It is drawn INSIDE the panel (a child of the panel's own tree), never
5098
+ * on the window: a window overlay put it on top of the conversation
5099
+ * area, outside the dialog it belongs to.
5100
+ *
5101
+ * The rules it keeps:
5102
+ *
5103
+ * - NOTHING is announced before the Host's receipt: rendering the
5104
+ * page, polling it and clicking a control all stay silent;
5105
+ * - a failure is never swallowed and never fades: it stays until the
5106
+ * next change succeeds or the user closes it;
5107
+ * - a success fades out on its own after a moment, and three changes
5108
+ * in a row update the SAME line instead of stacking new ones;
5109
+ * - the line lives exactly as long as the panel does (closing the
5110
+ * settings throws it away).
5111
+ * ---------------------------------------------------------------- */
5112
+
5113
+ /** The id of the one stylesheet of the receipt line. */
5114
+ const SAVE_NOTICE_STYLE_ID = 'dsh-autorestart-save-notice'
5115
+ /** How long the green line stays; the red one stays for ever. */
5116
+ const SAVE_NOTICE_HIDE_MS = 3000
5117
+
5118
+ /**
5119
+ * The receipt on screen right now (one for the whole panel: two changes
5120
+ * never produce two lines).
5121
+ *
5122
+ * `notice` is `null` until a save came back from the Host, and it is
5123
+ * `{ kind: 'ok' | 'failed', at, atText, reason }` afterwards.
5124
+ */
5125
+ const saveNoticeStore = {
5126
+ notice: null,
5127
+ listeners: new Set(),
5128
+ timer: null,
5129
+ }
5130
+
5131
+ /** Tell every mounted receipt about the current one. */
5132
+ function notifySaveNotice() {
5133
+ saveNoticeStore.listeners.forEach((listener) => {
5134
+ try {
5135
+ listener(saveNoticeStore.notice)
5136
+ } catch {
5137
+ // One broken listener must not stop the others.
5138
+ }
5139
+ })
5140
+ }
5141
+
5142
+ /** The local clock time of a receipt, as HH:MM:SS. */
5143
+ function formatSaveNoticeClock(at) {
5144
+ const moment = new Date(at)
5145
+ const pad = (value) => (value < 10 ? '0' + value : String(value))
5146
+ return pad(moment.getHours()) + ':' + pad(moment.getMinutes()) + ':' + pad(moment.getSeconds())
5147
+ }
5148
+
5149
+ /**
5150
+ * Publish one receipt.
5151
+ *
5152
+ * A pending fade-out of the previous receipt is dropped first, so a
5153
+ * second change restarts the clock on the SAME line (test: "two saves,
5154
+ * one line").
5155
+ */
5156
+ function publishSaveNotice(notice) {
5157
+ if (saveNoticeStore.timer !== null) {
5158
+ clearTimeout(saveNoticeStore.timer)
5159
+ saveNoticeStore.timer = null
5160
+ }
5161
+ saveNoticeStore.notice = notice
5162
+ notifySaveNotice()
5163
+ // A failure stays: only the user closes it (or a save that works).
5164
+ if (notice.kind !== 'ok') return
5165
+ const timer = setTimeout(() => {
5166
+ saveNoticeStore.timer = null
5167
+ // A newer receipt replaced this one while the timer ran.
5168
+ if (saveNoticeStore.notice !== notice) return
5169
+ saveNoticeStore.notice = null
5170
+ notifySaveNotice()
5171
+ }, SAVE_NOTICE_HIDE_MS)
5172
+ // In a browser this is a number; under Node it is a Timeout that must
5173
+ // not hold the process open (the tests run in one).
5174
+ if (timer && typeof timer.unref === 'function') timer.unref()
5175
+ saveNoticeStore.timer = timer
5176
+ }
5177
+
5178
+ /**
5179
+ * The Host took the change (`ok: true`): this is the ONE moment the
5180
+ * page may say "Saved".
5181
+ */
5182
+ function publishSaveSaved() {
5183
+ const at = Date.now()
5184
+ publishSaveNotice({ kind: 'ok', at, atText: formatSaveNoticeClock(at), reason: '' })
5185
+ }
5186
+
5187
+ /**
5188
+ * The Host refused the change: say so, with the reason it gave, and
5189
+ * keep saying it.
5190
+ *
5191
+ * @param {string} reason the error of the Host answer (or of the request)
5192
+ */
5193
+ function publishSaveFailure(reason) {
5194
+ const at = Date.now()
5195
+ publishSaveNotice({
5196
+ kind: 'failed',
5197
+ at,
5198
+ atText: formatSaveNoticeClock(at),
5199
+ reason: typeof reason === 'string' ? reason : '',
5200
+ })
5201
+ }
5202
+
5203
+ /** Close the receipt: only the user does that, with the close button. */
5204
+ function clearSaveNotice() {
5205
+ if (saveNoticeStore.timer !== null) {
5206
+ clearTimeout(saveNoticeStore.timer)
5207
+ saveNoticeStore.timer = null
5208
+ }
5209
+ if (saveNoticeStore.notice === null) return
5210
+ saveNoticeStore.notice = null
5211
+ notifySaveNotice()
5212
+ }
5213
+
5214
+ /** The receipt on screen, re-rendered whenever it changes. */
5215
+ function useSaveNotice() {
5216
+ const [notice, setNotice] = useState(saveNoticeStore.notice)
5217
+ useEffect(() => {
5218
+ setNotice(saveNoticeStore.notice)
5219
+ saveNoticeStore.listeners.add(setNotice)
5220
+ return () => { saveNoticeStore.listeners.delete(setNotice) }
5221
+ }, [])
5222
+ return notice
5223
+ }
5224
+
5225
+ // THE RECEIPT OF A SETTINGS CHANGE IS A STRIP OF THE SETTINGS PANEL.
5226
+ //
5227
+ // It used to be a WINDOW overlay (`shell.overlay`), and that was wrong:
5228
+ // the user saw it floating over the conversation area — outside the
5229
+ // dialog it belonged to. It is a child of the panel itself now: a
5230
+ // `sticky` strip at the top of the panel, so it stays visible while the
5231
+ // settings are scrolled and it can never leave the dialog.
5232
+ //
5233
+ // The strip COVERS the first line of the panel for its 30px (the wrapper
5234
+ // is sticky with height 0) instead of pushing the form down, so nothing
5235
+ // jumps when it appears or fades.
5236
+ const SAVE_NOTICE_CSS = [
5237
+ '.dsh-autorestart-save-notice{',
5238
+ ' position:sticky;',
5239
+ ' top:0;',
5240
+ ' z-index:5;',
5241
+ ' height:0;',
5242
+ ' pointer-events:none;',
5243
+ '}',
5244
+ '.dsh-autorestart-save-notice__bar{',
5245
+ ' display:flex;',
5246
+ ' align-items:center;',
5247
+ ' gap:8px;',
5248
+ ' box-sizing:border-box;',
5249
+ ' height:30px;',
5250
+ ' max-width:100%;',
5251
+ ' padding:0 4px 0 10px;',
5252
+ ' border:1px solid transparent;',
5253
+ ' border-radius:8px;',
5254
+ ' font-size:13px;',
5255
+ ' line-height:18px;',
5256
+ ' white-space:nowrap;',
5257
+ ' pointer-events:auto;',
5258
+ ' box-shadow:0 2px 8px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 14%, transparent);',
5259
+ '}',
5260
+ // The green strip: a light green ground with green text.
5261
+ '.dsh-autorestart-save-notice--ok .dsh-autorestart-save-notice__bar{',
5262
+ ' border-color:color-mix(in srgb, var(--dsw-alias-state-success,#1a7f37) 38%, transparent);',
5263
+ ' background:color-mix(in srgb, var(--dsw-alias-state-success,#1a7f37) 12%, var(--dsw-alias-bg-layer-1));',
5264
+ ' color:var(--dsw-alias-state-success-fg,var(--dsw-alias-state-success,#1a7f37));',
5265
+ '}',
5266
+ // The red one: a light red ground with red text. It does not fade.
5267
+ '.dsh-autorestart-save-notice--failed .dsh-autorestart-save-notice__bar{',
5268
+ ' border-color:color-mix(in srgb, var(--dsw-alias-danger,#d14343) 38%, transparent);',
5269
+ ' background:color-mix(in srgb, var(--dsw-alias-danger,#d14343) 12%, var(--dsw-alias-bg-layer-1));',
5270
+ ' color:var(--dsw-alias-danger,#d14343);',
5271
+ '}',
5272
+ '.dsh-autorestart-save-notice__title{flex:none;font-weight:600;}',
5273
+ // The clock time sits at the RIGHT of the strip, in small type.
5274
+ '.dsh-autorestart-save-notice__time{',
5275
+ ' flex:none;',
5276
+ ' margin-left:auto;',
5277
+ ' font-size:11.5px;',
5278
+ ' opacity:.75;',
5279
+ ' font-variant-numeric:tabular-nums;',
5280
+ '}',
5281
+ // The reason is the only text that may be cut, and even then the whole
5282
+ // text is in the tooltip: the short copy itself is never cut.
5283
+ '.dsh-autorestart-save-notice__reason{',
5284
+ ' flex:0 1 auto;',
5285
+ ' min-width:0;',
5286
+ ' overflow:hidden;',
5287
+ ' text-overflow:ellipsis;',
5288
+ '}',
5289
+ '.dsh-autorestart-save-notice__close{',
5290
+ ' flex:none;',
5291
+ ' width:20px;',
5292
+ ' height:20px;',
5293
+ ' padding:0;',
5294
+ ' border:none;',
5295
+ ' border-radius:6px;',
5296
+ ' background:transparent;',
5297
+ ' color:inherit;',
5298
+ ' font:inherit;',
5299
+ ' font-size:14px;',
5300
+ ' line-height:18px;',
5301
+ ' cursor:pointer;',
5302
+ '}',
5303
+ '.dsh-autorestart-save-notice__close:hover{background:color-mix(in srgb, currentColor 14%, transparent);}',
5304
+ '.dsh-autorestart-save-notice__close:focus-visible{',
5305
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
5306
+ ' outline-offset:2px;',
5307
+ '}',
5308
+ // The fade is decoration: a system that asks for less motion gets none.
5309
+ '@media (prefers-reduced-motion: no-preference){',
5310
+ ' .dsh-autorestart-save-notice__bar{animation:dsh-autorestart-save-notice-in .16s ease-out;}',
5311
+ '}',
5312
+ '@keyframes dsh-autorestart-save-notice-in{',
5313
+ ' from{opacity:0;transform:translateY(-3px);}',
5314
+ ' to{opacity:1;transform:translateY(0);}',
5315
+ '}',
5316
+ ].join('\n')
5317
+
5318
+ /** Inject the stylesheet of the receipt exactly once. */
5319
+ function ensureSaveNoticeStyle() {
5320
+ if (typeof document === 'undefined' || !document.head) return
5321
+ try {
5322
+ if (document.getElementById(SAVE_NOTICE_STYLE_ID)) return
5323
+ const style = document.createElement('style')
5324
+ style.id = SAVE_NOTICE_STYLE_ID
5325
+ style.textContent = SAVE_NOTICE_CSS
5326
+ document.head.appendChild(style)
5327
+ } catch {
5328
+ // Without the stylesheet the line is still readable, just plainer.
5329
+ }
5330
+ }
5331
+
5332
+ /**
5333
+ * THE RECEIPT OF A SETTINGS CHANGE, drawn INSIDE the settings panel.
5334
+ *
5335
+ * It is a child of the panel itself (never a window overlay): the DOM
5336
+ * ancestor of this strip IS the settings section, so it can not float
5337
+ * over the conversation area. `SAVE_NOTICE_CSS` makes it a `sticky`
5338
+ * strip at the top of the panel, so it stays visible while the settings
5339
+ * are scrolled, and it covers the first line instead of pushing the
5340
+ * form down.
5341
+ *
5342
+ * It renders NOTHING until a save came back from the Host — the first
5343
+ * render and every poll are silent.
5344
+ *
5345
+ * @param {(key: string) => string} t the translator of the panel
5346
+ * @param {object|null} notice the receipt on screen (`null` = none)
5347
+ */
5348
+ function saveNoticeBar(t, notice) {
5349
+ if (!notice) return null
5350
+ ensureSaveNoticeStyle()
5351
+ const saved = notice.kind === 'ok'
5352
+ return h(
5353
+ 'div',
5354
+ {
5355
+ className: 'dsh-autorestart-save-notice'
5356
+ + (saved ? ' dsh-autorestart-save-notice--ok' : ' dsh-autorestart-save-notice--failed'),
5357
+ role: saved ? 'status' : 'alert',
5358
+ 'aria-live': saved ? 'polite' : 'assertive',
5359
+ 'data-dsh-autorestart-save-notice': saved ? 'ok' : 'failed',
5360
+ 'data-dsh-autorestart-save-notice-at': notice.atText,
5361
+ },
5362
+ h(
5363
+ 'div',
5364
+ { className: 'dsh-autorestart-save-notice__bar' },
5365
+ // LEFT: the short copy ("已生效" / "Saved").
5366
+ h(
5367
+ 'span',
5368
+ { className: 'dsh-autorestart-save-notice__title' },
5369
+ saved ? t('settings.saveNotice.saved') : t('settings.saveNotice.failed'),
5370
+ ),
5371
+ // The reason of a failure (the only text that may be cut, and the
5372
+ // tooltip carries all of it).
5373
+ saved || notice.reason === ''
5374
+ ? null
5375
+ : h(
5376
+ 'span',
5377
+ {
5378
+ className: 'dsh-autorestart-save-notice__reason',
5379
+ 'data-dsh-autorestart-save-notice-reason': 'true',
5380
+ title: notice.reason,
5381
+ },
5382
+ notice.reason,
5383
+ ),
5384
+ // RIGHT: the small clock time, then the close button.
5385
+ h('span', { className: 'dsh-autorestart-save-notice__time' }, notice.atText),
5386
+ h(
5387
+ 'button',
5388
+ {
5389
+ type: 'button',
5390
+ className: 'dsh-autorestart-save-notice__close',
5391
+ 'data-dsh-autorestart-save-notice-close': 'true',
5392
+ onClick: clearSaveNotice,
5393
+ title: t('settings.saveNotice.dismiss'),
5394
+ 'aria-label': t('settings.saveNotice.dismiss'),
5395
+ },
5396
+ '×',
5397
+ ),
5398
+ ),
5399
+ )
5400
+ }
5401
+
4509
5402
  function AutorestartSection(props) {
4510
5403
  const status = useSleepStatus()
4511
5404
  const info = useSharedInfo(true)
@@ -4525,6 +5418,19 @@
4525
5418
  // accident.
4526
5419
  const [detailsOpen, setDetailsOpen] = useState(false)
4527
5420
  const t = makeTranslate(props && typeof props.t === 'function' ? props.t : translate)
5421
+ // THE RECEIPT OF A SETTINGS CHANGE, on screen right now. It belongs to
5422
+ // THIS panel (it is not a window overlay), so it is read here and
5423
+ // drawn here.
5424
+ const saveNotice = useSaveNotice()
5425
+
5426
+ // The receipt lives exactly as long as this panel is on screen: a page
5427
+ // that is closed (or reopened) never shows a "Saved" of an earlier
5428
+ // visit. The mount clearing also covers a Host that keeps the section
5429
+ // mounted while it is hidden.
5430
+ useEffect(() => {
5431
+ clearSaveNotice()
5432
+ return () => { clearSaveNotice() }
5433
+ }, [])
4528
5434
 
4529
5435
  // The current values come from the Host, so the panel always shows what
4530
5436
  // is really in effect.
@@ -4734,6 +5640,9 @@
4734
5640
  * back with a fresh poll — so the controls never show a value the
4735
5641
  * Host did not take.
4736
5642
  *
5643
+ * ONLY the Host's answer (`ok: true`) makes the window say "Saved":
5644
+ * picking a value, rendering the page and polling it stay silent.
5645
+ *
4737
5646
  * @param {object} changes the keys to write
4738
5647
  */
4739
5648
  const save = async (changes) => {
@@ -4752,12 +5661,31 @@
4752
5661
  ? (body.message || body.error || String(response.status))
4753
5662
  : String(response.status)
4754
5663
  setError(fill(t('settings.error'), { message }))
5664
+ // The Host refused the write: the red line keeps the reason
5665
+ // until the next change works (or the user closes it).
5666
+ publishSaveFailure(message)
5667
+ return
5668
+ }
5669
+ if (!body || typeof body !== 'object' || body.ok !== true) {
5670
+ // The request came back, but the Host did not confirm it: a
5671
+ // "saved" line here would be a lie.
5672
+ const reason = body && typeof body === 'object'
5673
+ ? String(body.message || body.error || '')
5674
+ : ''
5675
+ setError(fill(t('settings.error'), {
5676
+ message: reason === '' ? String(response.status) : reason,
5677
+ }))
5678
+ publishSaveFailure(reason)
4755
5679
  return
4756
5680
  }
5681
+ // The receipt is there: now the window may say "Saved".
5682
+ publishSaveSaved()
4757
5683
  // Read the new values back from the Host.
4758
5684
  await pollSleepOnce()
4759
5685
  } catch (failure) {
4760
- setError(fill(t('settings.error'), { message: describeError(failure) }))
5686
+ const message = describeError(failure)
5687
+ setError(fill(t('settings.error'), { message }))
5688
+ publishSaveFailure(message)
4761
5689
  } finally {
4762
5690
  setBusy(false)
4763
5691
  }
@@ -4883,6 +5811,11 @@
4883
5811
  return h(
4884
5812
  'div',
4885
5813
  { className: 'dsh-autorestart-settings' },
5814
+ // THE RECEIPT: a `sticky` strip at the TOP of this panel. It is a
5815
+ // child of the panel (the DOM ancestor of the message IS the
5816
+ // settings section), so it can not float over the conversation area
5817
+ // again — and it stays visible while the settings are scrolled.
5818
+ saveNoticeBar(t, saveNotice),
4886
5819
  // The panel is split into two titled groups: the RESTART settings
4887
5820
  // come first, the SHUTDOWN settings after them. The two situations
4888
5821
  // are different, so they do not share one flat list.
@@ -4897,6 +5830,28 @@
4897
5830
  { className: 'dsh-autorestart-settings__group-title' },
4898
5831
  t('settings.groups.restart'),
4899
5832
  ),
5833
+ // THE STATUS LINE: what the restart is doing right now, next to the
5834
+ // settings that control it. It lives here (the pill only exists
5835
+ // while a restart is pending), so a slow backup, "restarting" and
5836
+ // "back — carrying on" are visible where the user went looking.
5837
+ restartStageText(info && info.restartPhase) === ''
5838
+ ? null
5839
+ : h(
5840
+ 'div',
5841
+ {
5842
+ className: 'dsh-autorestart-settings__status',
5843
+ 'data-dsh-autorestart-stage': String(
5844
+ info && info.restartPhase && info.restartPhase.phase ? info.restartPhase.phase : '',
5845
+ ),
5846
+ style: {
5847
+ margin: '2px 0 8px',
5848
+ fontSize: 12,
5849
+ lineHeight: '16px',
5850
+ color: 'var(--dsw-alias-label-secondary, inherit)',
5851
+ },
5852
+ },
5853
+ restartStageText(info && info.restartPhase),
5854
+ ),
4900
5855
  h(
4901
5856
  'label',
4902
5857
  { className: 'dsh-autorestart-settings__field' },
@@ -4999,25 +5954,50 @@
4999
5954
  : null,
5000
5955
  backupItems.map((item, index) => h(
5001
5956
  'div',
5002
- { className: 'dsh-autorestart-settings__value', 'data-dsh-autorestart-backup': 'item' },
5003
- h(
5004
- 'span',
5005
- { 'data-dsh-autorestart-backup': 'time' },
5006
- formatBackupTime(item && item.atLocal),
5007
- ),
5957
+ {
5958
+ className: 'dsh-autorestart-settings__backup',
5959
+ 'data-dsh-autorestart-backup': 'item',
5960
+ key: 'backup-' + index,
5961
+ },
5962
+ // WHEN it was taken. No timezone offset: what is shown is
5963
+ // already the local clock of the machine that took the copy.
5008
5964
  h(
5009
- 'span',
5010
- { 'data-dsh-autorestart-backup': 'size' },
5011
- formatBackupSize(item && item.bytes),
5965
+ 'div',
5966
+ { className: 'dsh-autorestart-settings__backup-row' },
5967
+ h(
5968
+ 'span',
5969
+ { className: 'dsh-autorestart-settings__backup-label' },
5970
+ t('settings.backup.time'),
5971
+ ),
5972
+ h(
5973
+ 'span',
5974
+ { className: 'dsh-autorestart-settings__backup-value', 'data-dsh-autorestart-backup': 'time' },
5975
+ formatBackupTime(item && item.atLocal),
5976
+ ),
5012
5977
  ),
5978
+ // HOW BIG it is — and whether the plugins are in it, because
5979
+ // that is WHY it is that big: the note belongs to the size.
5013
5980
  h(
5014
- 'span',
5015
- { 'data-dsh-autorestart-backup': 'plugins' },
5016
- item && item.includePlugins === true
5017
- ? t('settings.backup.withPlugins')
5018
- : t('settings.backup.withoutPlugins'),
5981
+ 'div',
5982
+ { className: 'dsh-autorestart-settings__backup-row' },
5983
+ h(
5984
+ 'span',
5985
+ { className: 'dsh-autorestart-settings__backup-label' },
5986
+ t('settings.backup.size'),
5987
+ ),
5988
+ h(
5989
+ 'span',
5990
+ { className: 'dsh-autorestart-settings__backup-value', 'data-dsh-autorestart-backup': 'size' },
5991
+ formatBackupSize(item && item.bytes),
5992
+ ),
5993
+ h(
5994
+ 'span',
5995
+ { className: 'dsh-autorestart-settings__backup-note', 'data-dsh-autorestart-backup': 'plugins' },
5996
+ item && item.includePlugins === true
5997
+ ? t('settings.backup.withPlugins')
5998
+ : t('settings.backup.withoutPlugins'),
5999
+ ),
5019
6000
  ),
5020
- index === 0 ? null : null,
5021
6001
  )),
5022
6002
  backupsInfo.dir === ''
5023
6003
  ? null
@@ -6642,6 +7622,15 @@
6642
7622
  // The countdown overlay: a frame-wide floating layer above every
6643
7623
  // column.
6644
7624
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-sleep-overlay', order: 60 }, SleepOverlay))
7625
+ // THE CENTRED OVERLAY of a running restart: the stage (waiting / the
7626
+ // backup with its real megabytes and its progress bar / restarting /
7627
+ // back) in the middle of the interface, so the composer row can stay
7628
+ // one short clipped line. It draws NOTHING when no restart is staged.
7629
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-restart-overlay', order: 55, locale: NS }, RestartOverlay))
7630
+ // NOTE: the receipt of a settings change is NOT registered here. It
7631
+ // is a `sticky` strip INSIDE the settings panel (see
7632
+ // `saveNoticeBar`), because a window overlay put it on top of the
7633
+ // conversation area instead of inside the dialog it belongs to.
6645
7634
  },
6646
7635
  // The named exports of this module (the client module system hands
6647
7636
  // `factory(require) -> exports` to every importer).