dsh-autorestart 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +354 -2
- package/client.js +575 -0
- package/cordis.patch.yml +33 -0
- package/icon.svg +16 -0
- package/index.js +1764 -0
- package/lib/checkpoint.js +803 -0
- package/lib/lifecycle.js +319 -0
- package/lib/restart.js +380 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -4
package/client.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart browser half: a compact "pending restart" pill and a
|
|
3
|
+
* matching dot in the left sidebar session row.
|
|
4
|
+
*
|
|
5
|
+
* Classic script loaded by dsh-client-modules via `window.__ModuleLoader__.load`.
|
|
6
|
+
* No JSX, no build step. Two surfaces read the Host's loopback-only health
|
|
7
|
+
* route (`GET /__dsh_autorestart/health?state=1`):
|
|
8
|
+
*
|
|
9
|
+
* - the pill in the composer tool row (`conversation.input.left` slot);
|
|
10
|
+
* - an 8px dot in the leading 16px state cell of a left-sidebar session row
|
|
11
|
+
* (`sidebar.session.row.leading` slot).
|
|
12
|
+
*
|
|
13
|
+
* Both are visible only for the session that is actually queued for the
|
|
14
|
+
* restart (the session id must appear in the route's `waiters` list), and both
|
|
15
|
+
* read ONE shared poller: the route is fetched once every 2s no matter how
|
|
16
|
+
* many surfaces are mounted.
|
|
17
|
+
*
|
|
18
|
+
* Two upgrades on top of the plain marker:
|
|
19
|
+
*
|
|
20
|
+
* 1. an "imminent" pulse: when the restart is pending AND the Host's idle
|
|
21
|
+
* gate is open (`allowRestart`), the dot of both surfaces breathes. One
|
|
22
|
+
* shared `@keyframes` rule is injected into the document head (guarded by
|
|
23
|
+
* an id, removed when the last subscriber leaves) and applied through the
|
|
24
|
+
* `animation` style property, so it never changes the layout. Users who
|
|
25
|
+
* ask for reduced motion never see it;
|
|
26
|
+
* 2. a click-to-open details panel: the pill is a keyboard-operable
|
|
27
|
+
* button that reveals why the restart is still pending, which sessions
|
|
28
|
+
* block it (by title when the Host provided one) and how long this
|
|
29
|
+
* session has been waiting.
|
|
30
|
+
*
|
|
31
|
+
* Everything here is side-effect free until `apply(ctx)` runs: no timers, no
|
|
32
|
+
* fetches, no DOM access at module scope.
|
|
33
|
+
*/
|
|
34
|
+
window.__ModuleLoader__.load({
|
|
35
|
+
id: 'dsh-autorestart',
|
|
36
|
+
factory: function (require) {
|
|
37
|
+
const React = require('react')
|
|
38
|
+
const h = React.createElement
|
|
39
|
+
const { useState, useEffect, useRef } = React
|
|
40
|
+
|
|
41
|
+
const NS = 'dshAutorestart'
|
|
42
|
+
const HEALTH_URL = '/__dsh_autorestart/health?state=1'
|
|
43
|
+
const POLL_MS = 2000
|
|
44
|
+
// The brand accent (blue-purple). The pending-restart marker is a brand
|
|
45
|
+
// signal, not a warning: no warning/yellow token is used here.
|
|
46
|
+
const BRAND = 'var(--dsw-alias-brand-primary, #5b8def)'
|
|
47
|
+
|
|
48
|
+
// The shared "imminent restart" pulse. The keyframes live in ONE <style>
|
|
49
|
+
// element so the two surfaces (pill dot + sidebar dot) never animate with
|
|
50
|
+
// two different rules.
|
|
51
|
+
const PULSE_STYLE_ID = 'dsh-autorestart-pulse'
|
|
52
|
+
const PULSE_ANIMATION_NAME = 'dsh-autorestart-pulse'
|
|
53
|
+
const PULSE_ANIMATION = PULSE_ANIMATION_NAME + ' 1.8s ease-in-out infinite'
|
|
54
|
+
// Only `transform` and `opacity` are animated: the dot must not resize and
|
|
55
|
+
// must never push the composer row around.
|
|
56
|
+
const PULSE_CSS = [
|
|
57
|
+
'@keyframes ' + PULSE_ANIMATION_NAME + ' {',
|
|
58
|
+
' 0%, 100% { transform: scale(1); opacity: 1; }',
|
|
59
|
+
' 50% { transform: scale(1.5); opacity: 0.55; }',
|
|
60
|
+
'}',
|
|
61
|
+
].join('\n')
|
|
62
|
+
|
|
63
|
+
const ZH = {
|
|
64
|
+
label: '待重启',
|
|
65
|
+
busyPrefix: ' · 被 ',
|
|
66
|
+
busySuffix: ' 个会话占用',
|
|
67
|
+
idle: ' · 空闲后自动重启',
|
|
68
|
+
tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
|
|
69
|
+
dotAria: '待重启',
|
|
70
|
+
panelHeading: '待重启',
|
|
71
|
+
panelStateBusy: '等待其他会话空闲后重启',
|
|
72
|
+
panelStateIdle: '等待空闲窗口后重启',
|
|
73
|
+
panelBlockedTitle: '占用会话:{title}',
|
|
74
|
+
panelBlockedGeneric: '有一个会话正在运行',
|
|
75
|
+
panelWaiting: '已等待 {duration}',
|
|
76
|
+
panelResume: '重启后本会话会自动继续进度',
|
|
77
|
+
durationSeconds: '{n} 秒',
|
|
78
|
+
durationMinutes: '{n} 分 {m} 秒',
|
|
79
|
+
durationHours: '{n} 小时 {m} 分',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const EN = {
|
|
83
|
+
label: 'Pending restart',
|
|
84
|
+
busyPrefix: ' · blocked by ',
|
|
85
|
+
busySuffix: ' session(s)',
|
|
86
|
+
idle: ' · restarts once idle',
|
|
87
|
+
tooltip:
|
|
88
|
+
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
|
|
89
|
+
dotAria: 'Pending restart',
|
|
90
|
+
panelHeading: 'Pending restart',
|
|
91
|
+
panelStateBusy: 'Waiting for the other sessions to become idle',
|
|
92
|
+
panelStateIdle: 'Waiting for the idle window',
|
|
93
|
+
panelBlockedTitle: 'Blocking session: {title}',
|
|
94
|
+
panelBlockedGeneric: 'One session is running',
|
|
95
|
+
panelWaiting: 'Waiting for {duration}',
|
|
96
|
+
panelResume: 'This session resumes its progress automatically after the restart.',
|
|
97
|
+
durationSeconds: '{n}s',
|
|
98
|
+
durationMinutes: '{n}m {m}s',
|
|
99
|
+
durationHours: '{n}h {m}m',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Bound during `apply`. The slots are registered without a locale
|
|
103
|
+
// namespace, so the components read the translator through this
|
|
104
|
+
// factory-scoped handle instead of a standard `t` prop.
|
|
105
|
+
let translate = null
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ONE shared poller, shared by every surface that shows the pending
|
|
109
|
+
* restart state (the composer pill and the sidebar session-row dot).
|
|
110
|
+
*
|
|
111
|
+
* The store lives at factory scope, so the first subscriber starts ONE
|
|
112
|
+
* 2s interval plus an immediate fetch, and the last one to leave clears
|
|
113
|
+
* the interval and aborts the in-flight request. A failed fetch keeps the
|
|
114
|
+
* last known value, so a transient error never blanks a live marker.
|
|
115
|
+
*/
|
|
116
|
+
const stateStore = {
|
|
117
|
+
info: null,
|
|
118
|
+
listeners: new Set(),
|
|
119
|
+
timer: null,
|
|
120
|
+
inFlight: false,
|
|
121
|
+
controller: null,
|
|
122
|
+
// When the pending state was FIRST observed, so the details panel can
|
|
123
|
+
// say how long this session has been waiting.
|
|
124
|
+
pendingSince: null,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function notifyListeners() {
|
|
128
|
+
stateStore.listeners.forEach((listener) => {
|
|
129
|
+
try {
|
|
130
|
+
listener(stateStore.info)
|
|
131
|
+
} catch {
|
|
132
|
+
// One broken listener must not stop the others.
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function pollOnce() {
|
|
138
|
+
if (stateStore.inFlight) return
|
|
139
|
+
stateStore.inFlight = true
|
|
140
|
+
try {
|
|
141
|
+
const response = await fetch(HEALTH_URL, {
|
|
142
|
+
cache: 'no-store',
|
|
143
|
+
signal: stateStore.controller ? stateStore.controller.signal : undefined,
|
|
144
|
+
})
|
|
145
|
+
if (!response.ok) return
|
|
146
|
+
const body = await response.json()
|
|
147
|
+
if (!body || typeof body !== 'object') return
|
|
148
|
+
stateStore.info = body
|
|
149
|
+
if (body.pendingRestart === true) {
|
|
150
|
+
if (stateStore.pendingSince === null) stateStore.pendingSince = Date.now()
|
|
151
|
+
} else {
|
|
152
|
+
stateStore.pendingSince = null
|
|
153
|
+
}
|
|
154
|
+
notifyListeners()
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignored on purpose: the marker must never break the composer or the
|
|
157
|
+
// sidebar. The last known value is kept.
|
|
158
|
+
} finally {
|
|
159
|
+
stateStore.inFlight = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Adds a listener and starts the shared poll when it is the first one.
|
|
165
|
+
* Returns the unsubscribe callback, which stops the shared poll when the
|
|
166
|
+
* last listener leaves.
|
|
167
|
+
*/
|
|
168
|
+
function subscribe(listener) {
|
|
169
|
+
stateStore.listeners.add(listener)
|
|
170
|
+
if (stateStore.listeners.size === 1) {
|
|
171
|
+
stateStore.controller = new AbortController()
|
|
172
|
+
pollOnce()
|
|
173
|
+
stateStore.timer = setInterval(pollOnce, POLL_MS)
|
|
174
|
+
}
|
|
175
|
+
return () => {
|
|
176
|
+
stateStore.listeners.delete(listener)
|
|
177
|
+
if (stateStore.listeners.size === 0) {
|
|
178
|
+
if (stateStore.timer !== null) {
|
|
179
|
+
clearInterval(stateStore.timer)
|
|
180
|
+
stateStore.timer = null
|
|
181
|
+
}
|
|
182
|
+
if (stateStore.controller) {
|
|
183
|
+
stateStore.controller.abort()
|
|
184
|
+
stateStore.controller = null
|
|
185
|
+
}
|
|
186
|
+
// The last subscriber left: the shared keyframes rule goes away too.
|
|
187
|
+
removePulseStyle()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Hook-like reader for the shared state: returns the current info and
|
|
194
|
+
* re-renders the caller whenever the shared poll lands a new value.
|
|
195
|
+
*/
|
|
196
|
+
function useSharedInfo(enabled) {
|
|
197
|
+
const active = enabled !== false
|
|
198
|
+
const [info, setInfo] = useState(stateStore.info)
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!active) return undefined
|
|
201
|
+
setInfo(stateStore.info)
|
|
202
|
+
return subscribe(setInfo)
|
|
203
|
+
}, [active])
|
|
204
|
+
return info
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Inject the shared pulse keyframes exactly once. The id keeps a second
|
|
209
|
+
* injection from duplicating the rule, and the element is removed again
|
|
210
|
+
* when the last subscriber leaves.
|
|
211
|
+
*/
|
|
212
|
+
function ensurePulseStyle() {
|
|
213
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
214
|
+
try {
|
|
215
|
+
if (document.getElementById(PULSE_STYLE_ID)) return
|
|
216
|
+
const style = document.createElement('style')
|
|
217
|
+
style.id = PULSE_STYLE_ID
|
|
218
|
+
style.textContent = PULSE_CSS
|
|
219
|
+
document.head.appendChild(style)
|
|
220
|
+
} catch {
|
|
221
|
+
// Without the rule the dot simply does not pulse; nothing else breaks.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Remove the shared pulse keyframes (the last subscriber left). */
|
|
226
|
+
function removePulseStyle() {
|
|
227
|
+
if (typeof document === 'undefined') return
|
|
228
|
+
try {
|
|
229
|
+
const node = document.getElementById(PULSE_STYLE_ID)
|
|
230
|
+
if (node && node.parentNode) node.parentNode.removeChild(node)
|
|
231
|
+
} catch {
|
|
232
|
+
// Nothing to clean up.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** True when the user asked the OS to reduce motion. */
|
|
237
|
+
function prefersReducedMotion() {
|
|
238
|
+
try {
|
|
239
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
240
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches === true
|
|
241
|
+
} catch {
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The dot style, with the pulse applied when the restart is imminent. */
|
|
247
|
+
function dotStyle(base, pulsing) {
|
|
248
|
+
if (!pulsing) return base
|
|
249
|
+
return {
|
|
250
|
+
...base,
|
|
251
|
+
animation: PULSE_ANIMATION,
|
|
252
|
+
transformOrigin: 'center',
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Fill `{name}` placeholders in a locale template. The locale API is not
|
|
258
|
+
* assumed to interpolate, so the panel does it itself.
|
|
259
|
+
*/
|
|
260
|
+
function fill(template, vars) {
|
|
261
|
+
if (typeof template !== 'string') return ''
|
|
262
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => (
|
|
263
|
+
vars && Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
|
|
264
|
+
))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** How long the pending restart has been waiting, as a locale string. */
|
|
268
|
+
function formatWaiting(ms, t) {
|
|
269
|
+
const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000))
|
|
270
|
+
if (totalSeconds < 60) return fill(t('durationSeconds'), { n: totalSeconds })
|
|
271
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
272
|
+
if (minutes < 60) return fill(t('durationMinutes'), { n: minutes, m: totalSeconds % 60 })
|
|
273
|
+
const hours = Math.floor(minutes / 60)
|
|
274
|
+
return fill(t('durationHours'), { n: hours, m: minutes % 60 })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The pill sits in the composer tool row, so it must fit the toolbar
|
|
278
|
+
// height (~22px) and never push that row around: `flex: 'none'` plus a
|
|
279
|
+
// fixed height, and the text is clipped with an ellipsis instead of
|
|
280
|
+
// wrapping. No absolute/fixed positioning, no negative margins. The
|
|
281
|
+
// border stays neutral; only the dot carries the brand accent.
|
|
282
|
+
const PILL_STYLE = {
|
|
283
|
+
display: 'inline-flex',
|
|
284
|
+
alignItems: 'center',
|
|
285
|
+
flex: 'none',
|
|
286
|
+
gap: 6,
|
|
287
|
+
height: 22,
|
|
288
|
+
boxSizing: 'border-box',
|
|
289
|
+
maxWidth: '100%',
|
|
290
|
+
padding: '0 8px',
|
|
291
|
+
borderRadius: 999,
|
|
292
|
+
fontSize: 12,
|
|
293
|
+
lineHeight: '20px',
|
|
294
|
+
whiteSpace: 'nowrap',
|
|
295
|
+
overflow: 'hidden',
|
|
296
|
+
textOverflow: 'ellipsis',
|
|
297
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
298
|
+
background: 'var(--dsw-alias-bg-layer-1, transparent)',
|
|
299
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.32))',
|
|
300
|
+
cursor: 'pointer',
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The wrapper is the pill's anchor: it is the only element that may
|
|
304
|
+
// position absolutely (the details panel), so it stays `relative` while
|
|
305
|
+
// the pill itself keeps its inline layout.
|
|
306
|
+
const WRAPPER_STYLE = {
|
|
307
|
+
position: 'relative',
|
|
308
|
+
display: 'inline-flex',
|
|
309
|
+
alignItems: 'center',
|
|
310
|
+
flex: 'none',
|
|
311
|
+
maxWidth: '100%',
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const DOT_STYLE = {
|
|
315
|
+
display: 'inline-block',
|
|
316
|
+
width: 6,
|
|
317
|
+
height: 6,
|
|
318
|
+
borderRadius: '50%',
|
|
319
|
+
flexShrink: 0,
|
|
320
|
+
background: BRAND,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The 8px brand dot that occupies the leading 16px state cell of a
|
|
324
|
+
// sidebar session row. `margin: 0 auto` centers it in that cell; it is
|
|
325
|
+
// icon-only, so the full text lives in the `title` tooltip.
|
|
326
|
+
const SIDEBAR_DOT_STYLE = {
|
|
327
|
+
display: 'block',
|
|
328
|
+
width: 8,
|
|
329
|
+
height: 8,
|
|
330
|
+
borderRadius: '50%',
|
|
331
|
+
background: BRAND,
|
|
332
|
+
margin: '0 auto',
|
|
333
|
+
flex: 'none',
|
|
334
|
+
boxSizing: 'border-box',
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The label + status text is one clipped line inside the pill.
|
|
338
|
+
const TEXT_STYLE = {
|
|
339
|
+
minWidth: 0,
|
|
340
|
+
overflow: 'hidden',
|
|
341
|
+
textOverflow: 'ellipsis',
|
|
342
|
+
whiteSpace: 'nowrap',
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The details panel: anchored to the wrapper, above the pill, left
|
|
346
|
+
// aligned. It uses the host theme tokens (background / border / label
|
|
347
|
+
// color) so it follows the app theme.
|
|
348
|
+
const PANEL_STYLE = {
|
|
349
|
+
position: 'absolute',
|
|
350
|
+
bottom: '100%',
|
|
351
|
+
left: 0,
|
|
352
|
+
zIndex: 30,
|
|
353
|
+
minWidth: 260,
|
|
354
|
+
maxWidth: 320,
|
|
355
|
+
marginBottom: 6,
|
|
356
|
+
padding: '8px 10px',
|
|
357
|
+
borderRadius: 8,
|
|
358
|
+
fontSize: 12,
|
|
359
|
+
lineHeight: '18px',
|
|
360
|
+
textAlign: 'left',
|
|
361
|
+
whiteSpace: 'normal',
|
|
362
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
363
|
+
background: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1, #fff))',
|
|
364
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.32))',
|
|
365
|
+
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.16)',
|
|
366
|
+
cursor: 'default',
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const PANEL_HEADING_STYLE = {
|
|
370
|
+
fontWeight: 600,
|
|
371
|
+
marginBottom: 4,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const PANEL_LINE_STYLE = {
|
|
375
|
+
margin: '1px 0',
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const PANEL_MUTED_STYLE = {
|
|
379
|
+
margin: '4px 0 0',
|
|
380
|
+
opacity: 0.75,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* One compact pill in the composer tool row, visible only while THIS
|
|
385
|
+
* session is waiting for a pending restart. Clicking it (or pressing
|
|
386
|
+
* Enter / Space) opens the details panel. Single root element.
|
|
387
|
+
*/
|
|
388
|
+
function RestartPill(props) {
|
|
389
|
+
const sessionId = props && props.sessionId !== undefined ? props.sessionId : null
|
|
390
|
+
const hasSession = sessionId !== null && sessionId !== ''
|
|
391
|
+
const info = useSharedInfo(hasSession)
|
|
392
|
+
const [open, setOpen] = useState(false)
|
|
393
|
+
const [, setTick] = useState(0)
|
|
394
|
+
const wrapperRef = useRef(null)
|
|
395
|
+
|
|
396
|
+
const pending = Boolean(info && info.pendingRestart === true)
|
|
397
|
+
// The restart is imminent: the Host's idle gate is open, so the restart
|
|
398
|
+
// happens as soon as this poll cycle ends.
|
|
399
|
+
const pulsing = pending && Boolean(info.allowRestart === true) && !prefersReducedMotion()
|
|
400
|
+
|
|
401
|
+
// The panel never outlives the pending state.
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!pending) setOpen(false)
|
|
404
|
+
}, [pending])
|
|
405
|
+
|
|
406
|
+
// Clicking outside the panel or pressing Escape closes it. Both
|
|
407
|
+
// listeners are removed when the panel closes and on unmount.
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!open) return undefined
|
|
410
|
+
const onPointerDown = (event) => {
|
|
411
|
+
const node = wrapperRef.current
|
|
412
|
+
if (node && event && event.target && typeof node.contains === 'function'
|
|
413
|
+
&& node.contains(event.target)) return
|
|
414
|
+
setOpen(false)
|
|
415
|
+
}
|
|
416
|
+
const onKeyDown = (event) => {
|
|
417
|
+
if (event && event.key === 'Escape') setOpen(false)
|
|
418
|
+
}
|
|
419
|
+
document.addEventListener('mousedown', onPointerDown, true)
|
|
420
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
421
|
+
return () => {
|
|
422
|
+
document.removeEventListener('mousedown', onPointerDown, true)
|
|
423
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
424
|
+
}
|
|
425
|
+
}, [open])
|
|
426
|
+
|
|
427
|
+
// A one-second tick keeps the "waiting for" duration in the panel fresh.
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (!open) return undefined
|
|
430
|
+
const timer = setInterval(() => setTick((value) => value + 1), 1000)
|
|
431
|
+
return () => clearInterval(timer)
|
|
432
|
+
}, [open])
|
|
433
|
+
|
|
434
|
+
if (!info || info.pendingRestart !== true) return null
|
|
435
|
+
|
|
436
|
+
const waiters = Array.isArray(info.waiters) ? info.waiters : []
|
|
437
|
+
const key = String(sessionId)
|
|
438
|
+
const waiting = waiters.some((waiter) => String(waiter) === key)
|
|
439
|
+
if (!waiting) return null
|
|
440
|
+
|
|
441
|
+
const blockedBy = Array.isArray(info.blockedBy) ? info.blockedBy : []
|
|
442
|
+
const others = blockedBy.filter((entry) => entry && String(entry.sessionId) !== key)
|
|
443
|
+
const otherBusy = others.length
|
|
444
|
+
|
|
445
|
+
const t = translate ?? ((name) => name)
|
|
446
|
+
const status =
|
|
447
|
+
otherBusy > 0 ? `${t('busyPrefix')}${otherBusy}${t('busySuffix')}` : t('idle')
|
|
448
|
+
|
|
449
|
+
if (pulsing) ensurePulseStyle()
|
|
450
|
+
|
|
451
|
+
const toggle = () => setOpen((value) => !value)
|
|
452
|
+
const onKeyDown = (event) => {
|
|
453
|
+
if (!event) return
|
|
454
|
+
const pressed = event.key
|
|
455
|
+
if (pressed === 'Enter' || pressed === ' ' || pressed === 'Spacebar') {
|
|
456
|
+
event.preventDefault()
|
|
457
|
+
toggle()
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const pendingSince = stateStore.pendingSince
|
|
462
|
+
const waitingMs = pendingSince === null || pendingSince === undefined
|
|
463
|
+
? 0
|
|
464
|
+
: Math.max(0, Date.now() - pendingSince)
|
|
465
|
+
|
|
466
|
+
const panelLines = [
|
|
467
|
+
h('div', { key: 'heading', style: PANEL_HEADING_STYLE }, t('panelHeading')),
|
|
468
|
+
h(
|
|
469
|
+
'div',
|
|
470
|
+
{ key: 'state', style: PANEL_LINE_STYLE },
|
|
471
|
+
otherBusy > 0 ? t('panelStateBusy') : t('panelStateIdle'),
|
|
472
|
+
),
|
|
473
|
+
]
|
|
474
|
+
others.forEach((entry, index) => {
|
|
475
|
+
const title = entry && typeof entry.title === 'string' ? entry.title.trim() : ''
|
|
476
|
+
panelLines.push(h(
|
|
477
|
+
'div',
|
|
478
|
+
{ key: 'blocked-' + index, style: PANEL_LINE_STYLE },
|
|
479
|
+
title ? fill(t('panelBlockedTitle'), { title }) : t('panelBlockedGeneric'),
|
|
480
|
+
))
|
|
481
|
+
})
|
|
482
|
+
panelLines.push(h(
|
|
483
|
+
'div',
|
|
484
|
+
{ key: 'waiting', style: PANEL_LINE_STYLE },
|
|
485
|
+
fill(t('panelWaiting'), { duration: formatWaiting(waitingMs, t) }),
|
|
486
|
+
))
|
|
487
|
+
panelLines.push(h(
|
|
488
|
+
'div',
|
|
489
|
+
{ key: 'resume', style: PANEL_MUTED_STYLE },
|
|
490
|
+
t('panelResume'),
|
|
491
|
+
))
|
|
492
|
+
|
|
493
|
+
return h(
|
|
494
|
+
'div',
|
|
495
|
+
{
|
|
496
|
+
ref: wrapperRef,
|
|
497
|
+
style: WRAPPER_STYLE,
|
|
498
|
+
role: 'status',
|
|
499
|
+
'aria-live': 'polite',
|
|
500
|
+
},
|
|
501
|
+
h(
|
|
502
|
+
'div',
|
|
503
|
+
{
|
|
504
|
+
style: PILL_STYLE,
|
|
505
|
+
title: t('tooltip'),
|
|
506
|
+
role: 'button',
|
|
507
|
+
tabIndex: 0,
|
|
508
|
+
'aria-haspopup': 'dialog',
|
|
509
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
510
|
+
onClick: toggle,
|
|
511
|
+
onKeyDown,
|
|
512
|
+
},
|
|
513
|
+
h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
|
|
514
|
+
h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
|
|
515
|
+
),
|
|
516
|
+
open
|
|
517
|
+
? h(
|
|
518
|
+
'div',
|
|
519
|
+
{
|
|
520
|
+
style: PANEL_STYLE,
|
|
521
|
+
role: 'dialog',
|
|
522
|
+
'aria-label': t('panelHeading'),
|
|
523
|
+
},
|
|
524
|
+
panelLines,
|
|
525
|
+
)
|
|
526
|
+
: null,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The brand dot in the leading 16px state cell of a left-sidebar session
|
|
532
|
+
* row, visible only while THAT session is waiting for a pending restart.
|
|
533
|
+
* Icon-only: the full explanation is the `title` tooltip. Single root
|
|
534
|
+
* element.
|
|
535
|
+
*/
|
|
536
|
+
function RestartDot(props) {
|
|
537
|
+
const sessionId = props && props.sessionId !== undefined ? props.sessionId : null
|
|
538
|
+
const hasSession = sessionId !== null && sessionId !== ''
|
|
539
|
+
const info = useSharedInfo(hasSession)
|
|
540
|
+
|
|
541
|
+
if (!info || info.pendingRestart !== true) return null
|
|
542
|
+
|
|
543
|
+
const waiters = Array.isArray(info.waiters) ? info.waiters : []
|
|
544
|
+
const key = String(sessionId)
|
|
545
|
+
const waiting = waiters.some((waiter) => String(waiter) === key)
|
|
546
|
+
if (!waiting) return null
|
|
547
|
+
|
|
548
|
+
const t = translate ?? ((name) => name)
|
|
549
|
+
const pulsing = info.allowRestart === true && !prefersReducedMotion()
|
|
550
|
+
if (pulsing) ensurePulseStyle()
|
|
551
|
+
|
|
552
|
+
return h('span', {
|
|
553
|
+
style: dotStyle(SIDEBAR_DOT_STYLE, pulsing),
|
|
554
|
+
title: t('tooltip'),
|
|
555
|
+
role: 'status',
|
|
556
|
+
'aria-live': 'polite',
|
|
557
|
+
'aria-label': t('dotAria'),
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
inject: ['slots', 'locale'],
|
|
563
|
+
apply(ctx) {
|
|
564
|
+
ctx.effect(
|
|
565
|
+
() => ctx.locale.register(NS, { zh: ZH, en: EN }),
|
|
566
|
+
'dsh-autorestart: client locale',
|
|
567
|
+
)
|
|
568
|
+
const t = ctx.locale.bind(NS)
|
|
569
|
+
translate = t
|
|
570
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'autorestart-restart-pill', order: 0 }, RestartPill))
|
|
571
|
+
ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ name: 'sidebar.session.row.leading', id: 'autorestart-restart-dot', order: 20 }, RestartDot))
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# dsh-autorestart bundle layer.
|
|
2
|
+
# Every key below is a documented default; override any of them in the profile's
|
|
3
|
+
# cordis.patch.yml with an `- id: dsh-autorestart` entry that repeats the full config.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: dsh-autorestart
|
|
6
|
+
name: dsh-autorestart
|
|
7
|
+
config:
|
|
8
|
+
enabled: true
|
|
9
|
+
# Empty = <DSH_HOME>/storages/dsh-autorestart/state.json
|
|
10
|
+
stateFile: ''
|
|
11
|
+
resumeOnBoot: true
|
|
12
|
+
recoveryDelayMs: 6000
|
|
13
|
+
maxSessions: 4
|
|
14
|
+
maxResumeAttempts: 2
|
|
15
|
+
deferPollMs: 3000
|
|
16
|
+
deferSoftMs: 300000
|
|
17
|
+
deferHardMs: 900000
|
|
18
|
+
deferPolicy: auto
|
|
19
|
+
# hard deadline only forces a restart over a running conversation when this is
|
|
20
|
+
# enabled; by default it keeps waiting for the other conversations to finish
|
|
21
|
+
forceRestartWhenBusy: false
|
|
22
|
+
# app = relaunch the DeepSeek Harness desktop app; command = run restartCommand
|
|
23
|
+
restartMode: app
|
|
24
|
+
restartCommand: ''
|
|
25
|
+
relaunchDelaySeconds: 2
|
|
26
|
+
# quiet window (ms) after the last conversation activity before a restart may fire
|
|
27
|
+
settleMs: 2000
|
|
28
|
+
# dsh web: reload the page when the host boot token changes
|
|
29
|
+
autoReloadPage: true
|
|
30
|
+
# hard-block shell commands that would kill/relaunch DSH by hand
|
|
31
|
+
blockManualRestart: true
|
|
32
|
+
healthPath: /__dsh_autorestart/health
|
|
33
|
+
continuationText: ''
|
package/icon.svg
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Autorestart">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="c-flame" x1="32" y1="54" x2="32" y2="10" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#ff7a18"/>
|
|
5
|
+
<stop offset="0.55" stop-color="#ffb020"/>
|
|
6
|
+
<stop offset="1" stop-color="#ffe07a"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
<linearGradient id="c-ring" x1="8" y1="32" x2="56" y2="32" gradientUnits="userSpaceOnUse">
|
|
9
|
+
<stop offset="0" stop-color="#5b8def"/>
|
|
10
|
+
<stop offset="1" stop-color="#8a6cff"/>
|
|
11
|
+
</linearGradient>
|
|
12
|
+
</defs>
|
|
13
|
+
<circle cx="32" cy="32" r="26" fill="none" stroke="url(#c-ring)" stroke-width="4" stroke-linecap="round" stroke-dasharray="128 36" transform="rotate(-35 32 32)"/>
|
|
14
|
+
<path d="M55 9l-1.2 12.4-12.2-2.4z" fill="#8a6cff"/>
|
|
15
|
+
<path d="M32 12c5.4 7.4 3.1 11.6.6 15.2-2.3 3.3-1.3 6.6 1.7 8.2 1.3-2.9 2.5-4.6 4.9-6.2 3.1 4 5.5 7.7 5.5 12.1 0 6.6-5.7 11.7-12.7 11.7S19.3 47.9 19.3 41.3c0-5.1 3.2-8.5 6.1-12.3 3.4-4.4 6.6-8.7 6.6-17zm0 22.3c-3.2 2.7-4.3 4.6-4.3 7.1 0 2.6 1.9 4.4 4.3 4.4s4.3-1.8 4.3-4.4c0-2.5-1.1-4.4-4.3-7.1z" fill="url(#c-flame)"/>
|
|
16
|
+
</svg>
|