dsh-autorestart 0.8.2 → 0.8.4

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,7 +219,7 @@
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: '待重启详情',
@@ -375,8 +375,10 @@
375
375
  'settings.backup.copyPath': '复制路径',
376
376
  'settings.backup.copied': '路径已复制',
377
377
  'settings.backup.copyFailed': '复制失败,请手动选中上面的路径',
378
- 'settings.backup.withPlugins': '含已装插件',
379
- 'settings.backup.withoutPlugins': '只有配置',
378
+ 'settings.backup.time': '备份时间',
379
+ 'settings.backup.size': '备份大小',
380
+ 'settings.backup.withPlugins': '(含已装插件)',
381
+ 'settings.backup.withoutPlugins': '(不含已装插件)',
380
382
  'settings.groups.report': '报告问题',
381
383
  'settings.groups.rescue': '出事了怎么办',
382
384
  'settings.rescue.hint': 'DSH 打不开的时候,用下面这两个脚本救自己(都不用 DSH 跑起来)。',
@@ -412,6 +414,9 @@
412
414
  'settings.report.issueTitle': '重启 / 开机自启相关的问题',
413
415
  'settings.report.issueBody': '请把刚才复制的诊断信息粘贴到下面。\n\n## 发生了什么\n\n\n## 期望什么\n\n\n## 怎么复现\n',
414
416
  'settings.error': '没能保存设置:{message}',
417
+ 'settings.saveNotice.saved': '已生效',
418
+ 'settings.saveNotice.failed': '没保存成功',
419
+ 'settings.saveNotice.dismiss': '关闭',
415
420
  'sleep.dryRun.title': '试运行:不会真的关机',
416
421
  'sleep.dryRun.body': '彩排结束,本来会执行这条命令(真的什么都没执行):',
417
422
  'sleep.dryRun.close': '知道了',
@@ -473,12 +478,32 @@
473
478
  'announcement.disabled': '按钮目前关闭,可在设置里打开。',
474
479
  'announcement.button': '知道了',
475
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': '已回来',
476
501
  }
477
502
 
478
503
  const EN = {
479
504
  label: 'Pending restart',
480
505
  busyPrefix: ' · waiting for ',
481
- busySuffix: ' task(s) to finish',
506
+ busySuffix: ' session(s)',
482
507
  idle: ' · restarts once idle',
483
508
  goalActive: ' · held by a self-continuing goal',
484
509
  goalExhausted: ' · a goal ran out of rounds',
@@ -491,7 +516,7 @@
491
516
  tooltip:
492
517
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
493
518
  tooltipBusy:
494
- '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.',
495
520
  tooltipWithError:
496
521
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
497
522
  dotAria: 'Pending restart',
@@ -543,7 +568,7 @@
543
568
  // The manual cancel of the capsule: the panel, the confirmation and the
544
569
  // readable reasons. The capsule's own text stays free of hints — the panel
545
570
  // is the discoverability.
546
- cancelPanelTasks: 'This restart is still waiting for {n} task(s) to finish',
571
+ cancelPanelTasks: 'Still waiting for {n} session(s)',
547
572
  cancelPanelOthers: '{n} other session(s) are waiting for this restart too',
548
573
  cancelPanelOthersAfter: '{n} session(s) are still waiting',
549
574
  cancelPanelAria: 'Pending restart details',
@@ -699,8 +724,10 @@
699
724
  'settings.backup.copyPath': 'Copy the path',
700
725
  'settings.backup.copied': 'The path is on the clipboard',
701
726
  'settings.backup.copyFailed': 'The copy failed — please select the path above by hand',
702
- 'settings.backup.withPlugins': 'includes the installed plugins',
703
- '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)',
704
731
  'settings.groups.report': 'Report a problem',
705
732
  'settings.groups.rescue': 'If DSH will not start',
706
733
  'settings.rescue.hint': 'When DSH does not come up, these two scripts rescue it for you (neither of them needs DSH running).',
@@ -736,6 +763,9 @@
736
763
  'settings.report.issueTitle': 'A problem with the restart / start at login',
737
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',
738
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',
739
769
  'sleep.dryRun.title': 'Dry run: the machine stays on',
740
770
  'sleep.dryRun.body': 'The rehearsal is over. This is the command it WOULD have run (nothing was executed):',
741
771
  'sleep.dryRun.close': 'Got it',
@@ -797,6 +827,17 @@
797
827
  'announcement.disabled': 'The button is currently switched off; you can turn it on in the settings.',
798
828
  'announcement.button': 'Got it',
799
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',
800
841
  }
801
842
 
802
843
  /**
@@ -1384,6 +1425,40 @@
1384
1425
  return families.size
1385
1426
  }
1386
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
+
1387
1462
  /**
1388
1463
  * How many of those tasks are carried by SUBAGENTS ONLY.
1389
1464
  *
@@ -1616,9 +1691,9 @@
1616
1691
  // capsule: the capsule cannot afford "16 分 37 秒".
1617
1692
  const duration = formatDurationMs(longWait.waitedMs, translate)
1618
1693
  const compact = formatCompactDurationMs(longWait.waitedMs, translate)
1619
- // The count is about TASKS, not blockers: a session and its own subagents
1620
- // are ONE task, exactly as the left sidebar counts them.
1621
- 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)
1622
1697
  // The tooltip: the whole story, every name, nothing cut.
1623
1698
  const sentence = fillTemplate(translate('longWaitTooltip'), { duration, blockers: summary.full })
1624
1699
  return {
@@ -1989,7 +2064,11 @@
1989
2064
  }
1990
2065
 
1991
2066
  /**
1992
- * 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".
1993
2072
  *
1994
2073
  * @param {number|null|undefined} bytes
1995
2074
  * @returns {string}
@@ -1997,21 +2076,150 @@
1997
2076
  function formatBackupSize(bytes) {
1998
2077
  const value = Number(bytes)
1999
2078
  if (!Number.isFinite(value) || value < 0) return ''
2000
- 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'
2001
2091
  }
2002
2092
 
2003
2093
  /**
2004
- * The local time of one backup.
2094
+ * The local time of one backup, to the minute.
2005
2095
  *
2006
2096
  * The directory name is UTC on purpose, so the panel shows the LOCAL time
2007
2097
  * the manifest carries: a fresh snapshot can never look like an old one.
2008
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
+ *
2009
2103
  * @param {string|null|undefined} atLocal
2010
2104
  * @returns {string}
2011
2105
  */
2012
2106
  function formatBackupTime(atLocal) {
2013
- if (typeof atLocal !== 'string' || atLocal === '') return ''
2014
- 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'
2015
2223
  }
2016
2224
 
2017
2225
  /**
@@ -2035,8 +2243,10 @@
2035
2243
  summarizeBlockers,
2036
2244
  truncateBlockerName,
2037
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.
2038
2247
  blockerTaskKeys,
2039
2248
  countBlockerTasks,
2249
+ waitingTaskCount,
2040
2250
  countSubagentTasks,
2041
2251
  resolveAccessRedirect,
2042
2252
  supervisorModeLabel,
@@ -2062,6 +2272,14 @@
2062
2272
  startupStatusLine,
2063
2273
  formatBackupSize,
2064
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,
2065
2283
  // The TRANSLATOR: the Host's one when it answers, the built-in dictionary
2066
2284
  // when it does not (a key name is never shown).
2067
2285
  makeTranslate,
@@ -2591,6 +2809,87 @@
2591
2809
  opacity: 0.85,
2592
2810
  }
2593
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
+
2594
2893
  // The details panel: anchored to the wrapper, above the pill, left
2595
2894
  // aligned. It uses the host theme tokens (background / border / label
2596
2895
  // color) so it follows the app theme.
@@ -2964,7 +3263,17 @@
2964
3263
  // whether a cancel is still honest. Both come from the Host: the pill
2965
3264
  // never guesses the stage and never invents progress.
2966
3265
  const stage = info && info.restartPhase && typeof info.restartPhase === 'object' ? info.restartPhase : null
2967
- const stageText = restartStageText(stage)
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)
2968
3277
  const cancelLocked = restartStageLocked(stage)
2969
3278
  // The TASK of every blocker: a session and its own subagents are ONE
2970
3279
  // task (the sidebar counts them once). The keys are aligned with the
@@ -2999,9 +3308,6 @@
2999
3308
  const busyBlockers = others.filter(
3000
3309
  (entry) => String(entry.reason) !== 'goal-active' && String(entry.reason) !== 'goal-exhausted',
3001
3310
  )
3002
- // The count is about TASKS, not blockers: a parent session and its own
3003
- // subagents are ONE task, exactly as the left sidebar counts them.
3004
- const otherBusy = countBlockerTasks(busyBlockers)
3005
3311
 
3006
3312
  // WHY the gate is closed: the Host reports the reason next to the
3007
3313
  // blockers. With no blocker but the reason "settling" the restart is
@@ -3013,6 +3319,18 @@
3013
3319
  // more line about it — how long it has been waiting and how many tasks
3014
3320
  // are running.
3015
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
3016
3334
  // The blockers of the ordinary list: the SAME rule as the long wait
3017
3335
  // (grouped by the session that created them).
3018
3336
  const blockers = summarizeBlockers(busyBlockers, { t })
@@ -3047,7 +3365,9 @@
3047
3365
  // The panel: how many tasks the restart waits for, and how many OTHER
3048
3366
  // sessions wait for the same restart. The count is about TASKS (a
3049
3367
  // session and its own subagents are one), like everywhere else.
3050
- 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
3051
3371
  const otherWaiters = waiters.filter((waiter) => String(waiter) !== key).length
3052
3372
  const sessionsWaiting = sessionsLeft === null ? otherWaiters : sessionsLeft
3053
3373
  // The session that asks for the cancel: without it the own request
@@ -3213,7 +3533,14 @@
3213
3533
  if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
3214
3534
  },
3215
3535
  },
3216
- 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
+ ),
3217
3544
  sessionsWaiting > 0
3218
3545
  ? h(
3219
3546
  'p',
@@ -3326,22 +3653,27 @@
3326
3653
  onKeyDown: onPillKeyDown,
3327
3654
  },
3328
3655
  h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
3329
- h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
3330
- // ONE MORE LINE: WHICH STAGE THE RESTART IS IN, with the real
3331
- // progress of the backup. It is the answer to "I clicked restart
3332
- // and nothing happened" — the pill says "waiting", then "backing up
3333
- // 120 MB of 156 MB", then "restarting".
3334
- stageText === ''
3335
- ? null
3336
- : h(
3337
- 'span',
3338
- {
3339
- style: LONG_WAIT_STYLE,
3340
- 'data-dsh-autorestart-stage': String(stage && stage.phase ? stage.phase : ''),
3341
- title: stageText,
3342
- },
3343
- stageText,
3344
- ),
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
+ ),
3345
3677
  // One more line for the long wait: how long the restart has been
3346
3678
  // waiting and how many tasks are running. It is only there when the
3347
3679
  // Host says so. The line carries NO name at all — it is one
@@ -3429,6 +3761,150 @@
3429
3761
  })
3430
3762
  }
3431
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
+
3432
3908
  /**
3433
3909
  * Inject the prompt styles exactly once. The id keeps a second injection
3434
3910
  * from duplicating the rule.
@@ -4399,6 +4875,37 @@
4399
4875
  ' font-size:13px;',
4400
4876
  '}',
4401
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
+ '}',
4402
4909
  // The folded "technical details": the command itself stays out of the
4403
4910
  // way until somebody asks for it.
4404
4911
  '.dsh-autorestart-settings__details{',
@@ -4577,6 +5084,321 @@
4577
5084
  * @param {object} props the Host's props: `t` (the translator of the
4578
5085
  * registered locale, injected by the section registration)
4579
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
+
4580
5402
  function AutorestartSection(props) {
4581
5403
  const status = useSleepStatus()
4582
5404
  const info = useSharedInfo(true)
@@ -4596,6 +5418,19 @@
4596
5418
  // accident.
4597
5419
  const [detailsOpen, setDetailsOpen] = useState(false)
4598
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
+ }, [])
4599
5434
 
4600
5435
  // The current values come from the Host, so the panel always shows what
4601
5436
  // is really in effect.
@@ -4805,6 +5640,9 @@
4805
5640
  * back with a fresh poll — so the controls never show a value the
4806
5641
  * Host did not take.
4807
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
+ *
4808
5646
  * @param {object} changes the keys to write
4809
5647
  */
4810
5648
  const save = async (changes) => {
@@ -4823,12 +5661,31 @@
4823
5661
  ? (body.message || body.error || String(response.status))
4824
5662
  : String(response.status)
4825
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)
4826
5667
  return
4827
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)
5679
+ return
5680
+ }
5681
+ // The receipt is there: now the window may say "Saved".
5682
+ publishSaveSaved()
4828
5683
  // Read the new values back from the Host.
4829
5684
  await pollSleepOnce()
4830
5685
  } catch (failure) {
4831
- setError(fill(t('settings.error'), { message: describeError(failure) }))
5686
+ const message = describeError(failure)
5687
+ setError(fill(t('settings.error'), { message }))
5688
+ publishSaveFailure(message)
4832
5689
  } finally {
4833
5690
  setBusy(false)
4834
5691
  }
@@ -4954,6 +5811,11 @@
4954
5811
  return h(
4955
5812
  'div',
4956
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),
4957
5819
  // The panel is split into two titled groups: the RESTART settings
4958
5820
  // come first, the SHUTDOWN settings after them. The two situations
4959
5821
  // are different, so they do not share one flat list.
@@ -5092,25 +5954,50 @@
5092
5954
  : null,
5093
5955
  backupItems.map((item, index) => h(
5094
5956
  'div',
5095
- { className: 'dsh-autorestart-settings__value', 'data-dsh-autorestart-backup': 'item' },
5096
- h(
5097
- 'span',
5098
- { 'data-dsh-autorestart-backup': 'time' },
5099
- formatBackupTime(item && item.atLocal),
5100
- ),
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.
5101
5964
  h(
5102
- 'span',
5103
- { 'data-dsh-autorestart-backup': 'size' },
5104
- 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
+ ),
5105
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.
5106
5980
  h(
5107
- 'span',
5108
- { 'data-dsh-autorestart-backup': 'plugins' },
5109
- item && item.includePlugins === true
5110
- ? t('settings.backup.withPlugins')
5111
- : 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
+ ),
5112
6000
  ),
5113
- index === 0 ? null : null,
5114
6001
  )),
5115
6002
  backupsInfo.dir === ''
5116
6003
  ? null
@@ -6735,6 +7622,15 @@
6735
7622
  // The countdown overlay: a frame-wide floating layer above every
6736
7623
  // column.
6737
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.
6738
7634
  },
6739
7635
  // The named exports of this module (the client module system hands
6740
7636
  // `factory(require) -> exports` to every importer).