dsh-client-auto-continue 0.5.2 → 0.5.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.
@@ -0,0 +1,547 @@
1
+ /**
2
+ * The auto-continue settings card: edits the `auto-continue` namespace fields
3
+ * from the plugin-configuration section (the `settings.plugin.item` seat).
4
+ *
5
+ * Self-contained card chrome (disclosure header, staged fields, save/discard
6
+ * footer) following the plugin-card store pattern of the DSH plugin
7
+ * configuration section; styles live in `styles.ts` and use the DSH design
8
+ * tokens so the card follows the active theme.
9
+ */
10
+ import { useEffect, useState, type ReactNode } from 'react';
11
+ import { createSnapshotStore, type SettingsScope, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
12
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
13
+ import {
14
+ pausedSessions,
15
+ readTodayStats,
16
+ resetTodayStats,
17
+ unpauseSession,
18
+ type AutoContinueSettings,
19
+ } from './engine.ts';
20
+ import type { SettingsCardKey } from './locales.ts';
21
+ import {
22
+ booleanField,
23
+ CardForm,
24
+ numberField,
25
+ textField,
26
+ type CardActions,
27
+ type CardFieldState,
28
+ type CardShell,
29
+ } from './settings-form.ts';
30
+ import { injectStyles } from './styles.ts';
31
+
32
+ // Styles must land during factory materialization so the module system's
33
+ // style bookkeeping (HMR) owns them.
34
+ injectStyles();
35
+
36
+ /** What the auto-continue card renders. */
37
+ export interface AutoContinueSettingsCardState extends CardShell {
38
+ paused: CardFieldState;
39
+ continueText: CardFieldState;
40
+ continueTextMaxTokens: CardFieldState;
41
+ graceMs: CardFieldState;
42
+ cooldownMs: CardFieldState;
43
+ maxConsecutive: CardFieldState;
44
+ scanOnBoot: CardFieldState;
45
+ scanLimit: CardFieldState;
46
+ freshMs: CardFieldState;
47
+ reconnectScanDelayMs: CardFieldState;
48
+ reconnectBackoffMs: CardFieldState;
49
+ verbose: CardFieldState;
50
+ classify: CardFieldState;
51
+ backoffFactor: CardFieldState;
52
+ backoffMaxMs: CardFieldState;
53
+ notify: CardFieldState;
54
+ }
55
+
56
+ /** The registration-side face the card's slot entry injects. */
57
+ export interface AutoContinueSettingsCardFace extends CardActions {
58
+ hooks: {
59
+ /** Card snapshot bound by the renderer as useAutoContinueSettingsCard. */
60
+ autoContinueSettingsCard: SnapshotStore<AutoContinueSettingsCardState>;
61
+ };
62
+ }
63
+
64
+ /** Bridges the `auto-continue` scope onto the card's staged form. */
65
+ export class AutoContinueSettingsCardController {
66
+ private readonly form: CardForm<AutoContinueSettings>;
67
+ private readonly store: SnapshotStore<AutoContinueSettingsCardState>;
68
+
69
+ /**
70
+ * @param scope - the bound settings scope for the `auto-continue` namespace.
71
+ */
72
+ constructor(scope: SettingsScope<AutoContinueSettings>) {
73
+ this.form = new CardForm(scope, [
74
+ booleanField('paused'),
75
+ textField('continueText'),
76
+ textField('continueTextMaxTokens'),
77
+ numberField('graceMs', 0),
78
+ numberField('cooldownMs', 0),
79
+ numberField('maxConsecutive', 1),
80
+ booleanField('scanOnBoot'),
81
+ numberField('scanLimit', 1),
82
+ numberField('freshMs', 0),
83
+ numberField('reconnectScanDelayMs', 0),
84
+ numberField('reconnectBackoffMs', 0),
85
+ booleanField('verbose'),
86
+ booleanField('classify'),
87
+ numberField('backoffFactor', 1),
88
+ numberField('backoffMaxMs', 0),
89
+ booleanField('notify'),
90
+ ]);
91
+ this.store = this.form.bind(() => this.projection(), createSnapshotStore);
92
+ }
93
+
94
+ private projection(): AutoContinueSettingsCardState {
95
+ return {
96
+ ...this.form.shell(),
97
+ paused: this.form.field('paused'),
98
+ continueText: this.form.field('continueText'),
99
+ continueTextMaxTokens: this.form.field('continueTextMaxTokens'),
100
+ graceMs: this.form.field('graceMs'),
101
+ cooldownMs: this.form.field('cooldownMs'),
102
+ maxConsecutive: this.form.field('maxConsecutive'),
103
+ scanOnBoot: this.form.field('scanOnBoot'),
104
+ scanLimit: this.form.field('scanLimit'),
105
+ freshMs: this.form.field('freshMs'),
106
+ reconnectScanDelayMs: this.form.field('reconnectScanDelayMs'),
107
+ reconnectBackoffMs: this.form.field('reconnectBackoffMs'),
108
+ verbose: this.form.field('verbose'),
109
+ classify: this.form.field('classify'),
110
+ backoffFactor: this.form.field('backoffFactor'),
111
+ backoffMaxMs: this.form.field('backoffMaxMs'),
112
+ notify: this.form.field('notify'),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Build the face the card's slot registration injects.
118
+ * @returns the card's snapshot and its form actions.
119
+ */
120
+ inject(): AutoContinueSettingsCardFace {
121
+ return { hooks: { autoContinueSettingsCard: this.store }, ...this.form.actions() };
122
+ }
123
+ }
124
+
125
+ /** Props the renderer binds for the auto-continue plugin-configuration card. */
126
+ export type AutoContinueSettingsCardProps =
127
+ PropsRuntime<'settings.plugin.item'> & PropsLocale<'auto-continue'> & InjectFace<AutoContinueSettingsCardFace>;
128
+
129
+ /** Card chrome: a disclosure header naming the plugin and what its settings govern, the controls, and the save that writes them. */
130
+ function SettingsCard(props: {
131
+ t: (key: SettingsCardKey) => string;
132
+ titleKey: SettingsCardKey;
133
+ descriptionKey: SettingsCardKey;
134
+ state: CardShell;
135
+ onSave: () => void;
136
+ onDiscard: () => void;
137
+ children: ReactNode;
138
+ }) {
139
+ const [open, setOpen] = useState(false);
140
+ const { state } = props;
141
+ if (!state.available) return null;
142
+ const title = props.t(props.titleKey);
143
+ const blocked = !state.dirty || state.invalid || state.saving;
144
+ return (
145
+ <li className={open ? 'dshAcCard dshAcCardOpen' : 'dshAcCard'}>
146
+ <button
147
+ type="button"
148
+ className="dshAcHeader"
149
+ aria-expanded={open}
150
+ aria-label={`${props.t(open ? 'chrome.collapse' : 'chrome.expand')}: ${title}`}
151
+ title={props.t(props.descriptionKey)}
152
+ onClick={() => setOpen(!open)}
153
+ >
154
+ <span className="dshAcHeadText">
155
+ <span className="dshAcName">{title}</span>
156
+ <span className="dshAcDescription">{props.t(props.descriptionKey)}</span>
157
+ </span>
158
+ {state.dirty ? (
159
+ <span className="dshAcPending" title={props.t('chrome.unsaved')}>
160
+ {props.t('chrome.unsaved')}
161
+ </span>
162
+ ) : null}
163
+ <span className={open ? 'dshAcChevron dshAcChevronOpen' : 'dshAcChevron'}>▾</span>
164
+ </button>
165
+ {open ? (
166
+ <div className="dshAcBody">
167
+ {!state.writable ? (
168
+ <p className="dshAcReadOnly" role="status">{props.t('chrome.readOnly')}</p>
169
+ ) : null}
170
+ {props.children}
171
+ <div className="dshAcFooter">
172
+ {state.failed ? (
173
+ <p className="dshAcFailed" role="status">{props.t('chrome.saveFailed')}</p>
174
+ ) : null}
175
+ <button
176
+ type="button"
177
+ className="dshAcDiscard"
178
+ disabled={!state.dirty || state.saving}
179
+ onClick={props.onDiscard}
180
+ >
181
+ {props.t('chrome.discard')}
182
+ </button>
183
+ <button type="button" className="dshAcSave" disabled={blocked} onClick={props.onSave}>
184
+ {props.t(!state.saving ? 'chrome.save' : 'chrome.saving')}
185
+ </button>
186
+ </div>
187
+ </div>
188
+ ) : null}
189
+ </li>
190
+ );
191
+ }
192
+
193
+ /** Props every field control needs regardless of its value type. */
194
+ interface FieldProps {
195
+ id: string;
196
+ label: string;
197
+ hint: string;
198
+ text: string;
199
+ overridden: boolean;
200
+ invalid: boolean;
201
+ disabled: boolean;
202
+ t: (key: SettingsCardKey) => string;
203
+ onEdit: (text: string) => void;
204
+ onReset: () => void;
205
+ }
206
+
207
+ /** A staged value field; `numeric` only hints the keypad, which drafts a field accepts is decided by its spec. */
208
+ function ValueField(props: FieldProps & { numeric?: boolean; placeholder?: string }) {
209
+ return (
210
+ <div className="dshAcField">
211
+ <div className="dshAcHead">
212
+ <label className="dshAcLabel" htmlFor={props.id}>{props.label}</label>
213
+ {props.overridden ? (
214
+ <span className="dshAcBadges">
215
+ <span className="dshAcBadge">{props.t('chrome.overridden')}</span>
216
+ <button type="button" className="dshAcReset" disabled={props.disabled} onClick={props.onReset}>
217
+ {props.t('chrome.reset')}
218
+ </button>
219
+ </span>
220
+ ) : null}
221
+ </div>
222
+ <input
223
+ id={props.id}
224
+ className={props.invalid ? 'dshAcInput dshAcInputInvalid' : 'dshAcInput'}
225
+ type="text"
226
+ inputMode={props.numeric === true ? 'numeric' : undefined}
227
+ aria-invalid={props.invalid || undefined}
228
+ value={props.text}
229
+ placeholder={props.placeholder ?? ''}
230
+ disabled={props.disabled}
231
+ onChange={(event) => props.onEdit(event.target.value)}
232
+ />
233
+ <p className={props.invalid ? 'dshAcInvalid' : 'dshAcHint'}>
234
+ {props.invalid ? props.t('chrome.invalidNumber') : props.hint}
235
+ </p>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ /** A staged boolean field: inherit / on / off. */
241
+ function BooleanField(props: FieldProps) {
242
+ return (
243
+ <div className="dshAcField">
244
+ <div className="dshAcHead">
245
+ <label className="dshAcLabel" htmlFor={props.id}>{props.label}</label>
246
+ {props.overridden ? (
247
+ <span className="dshAcBadges">
248
+ <span className="dshAcBadge">{props.t('chrome.overridden')}</span>
249
+ <button type="button" className="dshAcReset" disabled={props.disabled} onClick={props.onReset}>
250
+ {props.t('chrome.reset')}
251
+ </button>
252
+ </span>
253
+ ) : null}
254
+ </div>
255
+ <select
256
+ id={props.id}
257
+ className="dshAcSelect"
258
+ value={props.text}
259
+ disabled={props.disabled}
260
+ onChange={(event) => props.onEdit(event.target.value)}
261
+ >
262
+ <option value="">{props.t('chrome.inherit')}</option>
263
+ <option value="true">{props.t('chrome.on')}</option>
264
+ <option value="false">{props.t('chrome.off')}</option>
265
+ </select>
266
+ <p className="dshAcHint">{props.hint}</p>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ /** 实时面板: 今日统计 + 已暂停会话。浏览器本地状态, 每 5 秒刷新一次。 */
272
+ function LivePanels(props: { t: (key: SettingsCardKey) => string }) {
273
+ const { t } = props;
274
+ const [, refresh] = useState(0);
275
+ useEffect(() => {
276
+ const timer = setInterval(() => refresh((value) => value + 1), 5000);
277
+ return () => clearInterval(timer);
278
+ }, []);
279
+ const stats = readTodayStats();
280
+ const hasStats = stats.sent + stats.skipped + stats.recovered + stats.failed + stats.gaveUp > 0;
281
+ const codes = Object.entries(stats.byCode)
282
+ .sort((a, b) => b[1] - a[1])
283
+ .slice(0, 5);
284
+ const paused = pausedSessions();
285
+ return (
286
+ <>
287
+ <section className="dshAcPanel">
288
+ <div className="dshAcPanelHead">
289
+ <span className="dshAcPanelTitle">{t('stats.title')}</span>
290
+ {hasStats ? (
291
+ <button
292
+ type="button"
293
+ className="dshAcReset"
294
+ onClick={() => {
295
+ resetTodayStats();
296
+ refresh((value) => value + 1);
297
+ }}
298
+ >
299
+ {t('stats.reset')}
300
+ </button>
301
+ ) : null}
302
+ </div>
303
+ {!hasStats ? (
304
+ <p className="dshAcHint">{t('stats.empty')}</p>
305
+ ) : (
306
+ <>
307
+ <dl className="dshAcStats">
308
+ <div><dt>{t('stats.sent')}</dt><dd>{stats.sent}</dd></div>
309
+ <div><dt>{t('stats.recovered')}</dt><dd>{stats.recovered}</dd></div>
310
+ <div><dt>{t('stats.failed')}</dt><dd>{stats.failed}</dd></div>
311
+ <div><dt>{t('stats.skipped')}</dt><dd>{stats.skipped}</dd></div>
312
+ <div><dt>{t('stats.gaveUp')}</dt><dd>{stats.gaveUp}</dd></div>
313
+ </dl>
314
+ {codes.length > 0 ? (
315
+ <div className="dshAcCodes">
316
+ <span className="dshAcHint">{t('stats.byCode')}:</span>
317
+ {codes.map(([code, count]) => (
318
+ <span key={code} className="dshAcCode">
319
+ {code} ×{count}
320
+ </span>
321
+ ))}
322
+ </div>
323
+ ) : null}
324
+ </>
325
+ )}
326
+ </section>
327
+ <section className="dshAcPanel">
328
+ <div className="dshAcPanelHead">
329
+ <span className="dshAcPanelTitle">{t('pause.title')}</span>
330
+ {paused.length > 0 ? (
331
+ <button
332
+ type="button"
333
+ className="dshAcReset"
334
+ onClick={() => {
335
+ for (const item of paused) unpauseSession(item.sessionId);
336
+ refresh((value) => value + 1);
337
+ }}
338
+ >
339
+ {t('pause.clearAll')}
340
+ </button>
341
+ ) : null}
342
+ </div>
343
+ {paused.length === 0 ? (
344
+ <p className="dshAcHint">{t('pause.none')}</p>
345
+ ) : (
346
+ <ul className="dshAcPauseList">
347
+ {paused.map((item) => (
348
+ <li key={item.sessionId}>
349
+ <span className="dshAcPauseId">{item.sessionId.slice(0, 8)}…</span>
350
+ <span className="dshAcHint">
351
+ {Math.max(1, Math.ceil((item.until - Date.now()) / 60000))} {t('pause.minutes')}
352
+ </span>
353
+ <button
354
+ type="button"
355
+ className="dshAcReset"
356
+ onClick={() => {
357
+ unpauseSession(item.sessionId);
358
+ refresh((value) => value + 1);
359
+ }}
360
+ >
361
+ {t('pause.unpause')}
362
+ </button>
363
+ </li>
364
+ ))}
365
+ </ul>
366
+ )}
367
+ </section>
368
+ </>
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Render the auto-continue card.
374
+ * @param props - locale copy, the card snapshot, and its form actions.
375
+ * @returns the card.
376
+ */
377
+ export function AutoContinueSettingsCard(props: AutoContinueSettingsCardProps) {
378
+ const { t } = props;
379
+ const state = props.useAutoContinueSettingsCard((snapshot) => snapshot);
380
+ const disabled = !state.writable;
381
+ const shared = { t, disabled };
382
+ return (
383
+ <SettingsCard
384
+ t={t}
385
+ titleKey="card.title"
386
+ descriptionKey="card.description"
387
+ state={state}
388
+ onSave={props.save}
389
+ onDiscard={props.discard}
390
+ >
391
+ <BooleanField
392
+ id="auto-continue-paused"
393
+ label={t('field.paused')}
394
+ hint={t('field.pausedHint')}
395
+ {...shared}
396
+ {...state.paused}
397
+ onEdit={(text) => props.edit('paused', text)}
398
+ onReset={() => props.resetField('paused')}
399
+ />
400
+ <ValueField
401
+ id="auto-continue-continue-text"
402
+ label={t('field.continueText')}
403
+ hint={t('field.continueTextHint')}
404
+ {...shared}
405
+ {...state.continueText}
406
+ onEdit={(text) => props.edit('continueText', text)}
407
+ onReset={() => props.resetField('continueText')}
408
+ />
409
+ <ValueField
410
+ id="auto-continue-continue-text-max-tokens"
411
+ label={t('field.continueTextMaxTokens')}
412
+ hint={t('field.continueTextMaxTokensHint')}
413
+ {...shared}
414
+ {...state.continueTextMaxTokens}
415
+ onEdit={(text) => props.edit('continueTextMaxTokens', text)}
416
+ onReset={() => props.resetField('continueTextMaxTokens')}
417
+ />
418
+ <ValueField
419
+ id="auto-continue-grace-ms"
420
+ label={t('field.graceMs')}
421
+ hint={t('field.graceMsHint')}
422
+ numeric
423
+ {...shared}
424
+ {...state.graceMs}
425
+ onEdit={(text) => props.edit('graceMs', text)}
426
+ onReset={() => props.resetField('graceMs')}
427
+ />
428
+ <ValueField
429
+ id="auto-continue-cooldown-ms"
430
+ label={t('field.cooldownMs')}
431
+ hint={t('field.cooldownMsHint')}
432
+ numeric
433
+ {...shared}
434
+ {...state.cooldownMs}
435
+ onEdit={(text) => props.edit('cooldownMs', text)}
436
+ onReset={() => props.resetField('cooldownMs')}
437
+ />
438
+ <ValueField
439
+ id="auto-continue-max-consecutive"
440
+ label={t('field.maxConsecutive')}
441
+ hint={t('field.maxConsecutiveHint')}
442
+ numeric
443
+ {...shared}
444
+ {...state.maxConsecutive}
445
+ onEdit={(text) => props.edit('maxConsecutive', text)}
446
+ onReset={() => props.resetField('maxConsecutive')}
447
+ />
448
+ <BooleanField
449
+ id="auto-continue-scan-on-boot"
450
+ label={t('field.scanOnBoot')}
451
+ hint={t('field.scanOnBootHint')}
452
+ {...shared}
453
+ {...state.scanOnBoot}
454
+ onEdit={(text) => props.edit('scanOnBoot', text)}
455
+ onReset={() => props.resetField('scanOnBoot')}
456
+ />
457
+ <ValueField
458
+ id="auto-continue-scan-limit"
459
+ label={t('field.scanLimit')}
460
+ hint={t('field.scanLimitHint')}
461
+ numeric
462
+ {...shared}
463
+ {...state.scanLimit}
464
+ onEdit={(text) => props.edit('scanLimit', text)}
465
+ onReset={() => props.resetField('scanLimit')}
466
+ />
467
+ <ValueField
468
+ id="auto-continue-fresh-ms"
469
+ label={t('field.freshMs')}
470
+ hint={t('field.freshMsHint')}
471
+ numeric
472
+ {...shared}
473
+ {...state.freshMs}
474
+ onEdit={(text) => props.edit('freshMs', text)}
475
+ onReset={() => props.resetField('freshMs')}
476
+ />
477
+ <ValueField
478
+ id="auto-continue-reconnect-scan-delay"
479
+ label={t('field.reconnectScanDelayMs')}
480
+ hint={t('field.reconnectScanDelayMsHint')}
481
+ numeric
482
+ {...shared}
483
+ {...state.reconnectScanDelayMs}
484
+ onEdit={(text) => props.edit('reconnectScanDelayMs', text)}
485
+ onReset={() => props.resetField('reconnectScanDelayMs')}
486
+ />
487
+ <ValueField
488
+ id="auto-continue-reconnect-backoff"
489
+ label={t('field.reconnectBackoffMs')}
490
+ hint={t('field.reconnectBackoffMsHint')}
491
+ numeric
492
+ {...shared}
493
+ {...state.reconnectBackoffMs}
494
+ onEdit={(text) => props.edit('reconnectBackoffMs', text)}
495
+ onReset={() => props.resetField('reconnectBackoffMs')}
496
+ />
497
+ <BooleanField
498
+ id="auto-continue-verbose"
499
+ label={t('field.verbose')}
500
+ hint={t('field.verboseHint')}
501
+ {...shared}
502
+ {...state.verbose}
503
+ onEdit={(text) => props.edit('verbose', text)}
504
+ onReset={() => props.resetField('verbose')}
505
+ />
506
+ <BooleanField
507
+ id="auto-continue-classify"
508
+ label={t('field.classify')}
509
+ hint={t('field.classifyHint')}
510
+ {...shared}
511
+ {...state.classify}
512
+ onEdit={(text) => props.edit('classify', text)}
513
+ onReset={() => props.resetField('classify')}
514
+ />
515
+ <ValueField
516
+ id="auto-continue-backoff-factor"
517
+ label={t('field.backoffFactor')}
518
+ hint={t('field.backoffFactorHint')}
519
+ numeric
520
+ {...shared}
521
+ {...state.backoffFactor}
522
+ onEdit={(text) => props.edit('backoffFactor', text)}
523
+ onReset={() => props.resetField('backoffFactor')}
524
+ />
525
+ <ValueField
526
+ id="auto-continue-backoff-max"
527
+ label={t('field.backoffMaxMs')}
528
+ hint={t('field.backoffMaxMsHint')}
529
+ numeric
530
+ {...shared}
531
+ {...state.backoffMaxMs}
532
+ onEdit={(text) => props.edit('backoffMaxMs', text)}
533
+ onReset={() => props.resetField('backoffMaxMs')}
534
+ />
535
+ <BooleanField
536
+ id="auto-continue-notify"
537
+ label={t('field.notify')}
538
+ hint={t('field.notifyHint')}
539
+ {...shared}
540
+ {...state.notify}
541
+ onEdit={(text) => props.edit('notify', text)}
542
+ onReset={() => props.resetField('notify')}
543
+ />
544
+ <LivePanels t={t} />
545
+ </SettingsCard>
546
+ );
547
+ }