dsh-zen-remote 2.3.8 → 2.3.9
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/CHANGELOG.md +29 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +1 -1
- package/docs/remote-access.en.md +1 -1
- package/docs/remote-access.md +1 -1
- package/lib/adapters/dsh-better-sidebar/index.js +21 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client.js +470 -81
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +51 -10
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client-data/new-session.d.ts +26 -0
- package/lib/types/client-data/new-session.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +36 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/check-settings-form.mjs +63 -0
- package/src/adapters/dsh-better-sidebar/index.ts +23 -3
- package/src/client/MobileHome.tsx +44 -7
- package/src/client/MobileSessionHeader.tsx +7 -1
- package/src/client/effects/native-trigger-overlay.ts +50 -11
- package/src/client/effects/permission-sheet.ts +199 -22
- package/src/client/locales.ts +2 -0
- package/src/client/settings/SettingsSection.tsx +43 -1
- package/src/client/styles/composer.css.ts +24 -0
- package/src/client/styles/header.css.ts +58 -46
- package/src/client/styles/layout.css.ts +25 -0
- package/src/client/wizard/GatewayWizard.tsx +23 -1
- package/src/client-data/new-session.ts +30 -0
- package/src/client-data/settings-form.ts +57 -0
|
@@ -128,6 +128,14 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
128
128
|
instead of centered. Pinning the row makes it one 48px band again. */
|
|
129
129
|
[data-phase] header [class$="_titleRow"] {
|
|
130
130
|
position: relative;
|
|
131
|
+
/* UX-2 (2026-11-04): the host gives this row container-type: inline-size,
|
|
132
|
+
which implies layout containment — making the row the containing block
|
|
133
|
+
for every descendant position:fixed. That shifted every viewport
|
|
134
|
+
coordinate effects/native-trigger-overlay.ts writes by the safe-area
|
|
135
|
+
top and the row's horizontal padding. normal hands fixed descendants
|
|
136
|
+
back to the viewport; the phone block owns this layout, so nothing
|
|
137
|
+
scoped container queries against the row here. */
|
|
138
|
+
container-type: normal !important;
|
|
131
139
|
display: grid;
|
|
132
140
|
grid-template-columns: 92px 1fr 92px;
|
|
133
141
|
grid-template-rows: minmax(48px, auto);
|
|
@@ -195,55 +203,30 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
195
203
|
min-width: 0;
|
|
196
204
|
overflow: hidden;
|
|
197
205
|
}
|
|
198
|
-
/* Subagent sessions
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
all — the centered single title is pixel-identical to before. */
|
|
208
|
-
[data-phase] header [class$="_crumbSeg"]:not(:nth-last-child(-n+2)) {
|
|
206
|
+
/* Subagent sessions hide every ancestor crumb (UX-2, 2026-11-04 —
|
|
207
|
+
supersedes the 2026-08-18 "keep ONE ancestor" rule). DSH 0.2.1 turned
|
|
208
|
+
the parent crumb into a session-TREE popup trigger
|
|
209
|
+
(…_switcherTrigger[aria-haspopup="tree"]) instead of a direct
|
|
210
|
+
switch-back tap, and that popup is desktop geometry on the phone; the
|
|
211
|
+
back button and the home stack stay the way back. Main sessions render
|
|
212
|
+
a single span with no segs at all, so :not(:last-child) never matches
|
|
213
|
+
there. */
|
|
214
|
+
[data-phase] header [class$="_crumbSeg"]:not(:last-child) {
|
|
209
215
|
display: none !important;
|
|
210
216
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
max-width: 38%;
|
|
217
|
-
}
|
|
218
|
-
/* Ancestors BEFORE the parent seg are hidden, which would leave the
|
|
219
|
-
parent seg's own leading "/" dangling at the start of the title. */
|
|
220
|
-
[data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumbSep"] {
|
|
217
|
+
/* With the parent seg gone, the current seg's own leading "/" would
|
|
218
|
+
dangle before the title; the dissolved trigger container's stray
|
|
219
|
+
separator is covered by this same rule (its slot rules below hide it
|
|
220
|
+
too). One blanket rule instead of per-segment cases. */
|
|
221
|
+
[data-phase] header [class$="_crumbSep"] {
|
|
221
222
|
display: none !important;
|
|
222
223
|
}
|
|
223
|
-
/* Parent crumb: small tertiary text so the 19px current title stays the
|
|
224
|
-
visual anchor; min-height keeps a finger-sized target inside the 48px
|
|
225
|
-
row. Specificity (0,4,1) outranks the shared 19px _crumb rule below. */
|
|
226
|
-
[data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumb"] {
|
|
227
|
-
font-size: 13px;
|
|
228
|
-
font-weight: 400;
|
|
229
|
-
color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .45));
|
|
230
|
-
min-height: 44px;
|
|
231
|
-
display: inline-flex;
|
|
232
|
-
align-items: center;
|
|
233
|
-
padding: 0 2px 0 6px;
|
|
234
|
-
touch-action: manipulation;
|
|
235
|
-
-webkit-tap-highlight-color: transparent;
|
|
236
|
-
}
|
|
237
224
|
[data-phase] header [class$="_crumbSeg"]:last-child {
|
|
238
225
|
display: flex;
|
|
239
226
|
align-items: center;
|
|
240
227
|
min-width: 0;
|
|
241
228
|
max-width: 100%;
|
|
242
229
|
}
|
|
243
|
-
/* The last seg's own "/" is the divider between parent and current title —
|
|
244
|
-
it only exists when there IS an ancestor (index > 0), so it can simply
|
|
245
|
-
stay visible now that the parent seg shows. (It used to be hidden here:
|
|
246
|
-
with the parent hidden it dangled before the title.) */
|
|
247
230
|
/* BOTH selectors are required: [class$=] matches the whole class
|
|
248
231
|
ATTRIBUTE's suffix, and the current-session title button's attribute is
|
|
249
232
|
"wSkVaW_crumb wSkVaW_crumbCurrent" — it ends in _crumbCurrent, so the _crumb
|
|
@@ -353,12 +336,17 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
353
336
|
pointer-events on the trigger: that is exactly what would take the tap
|
|
354
337
|
target away again.
|
|
355
338
|
|
|
356
|
-
CSS keeps only
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
339
|
+
CSS keeps only the resting dissolve: the root's box (and its stray
|
|
340
|
+
separator, hidden below) stops occupying the breadcrumb until the
|
|
341
|
+
overlay pins it. The measurement chore moved to the overlay (UX-2,
|
|
342
|
+
2026-11-04): the 0.1.1+ popover portals to <body> as position:fixed
|
|
343
|
+
and sizes itself from the OUTER anchor container (the official
|
|
344
|
+
rootRef, the trigger's parent div) — a display:contents root has no
|
|
345
|
+
box, so rootRef.getBoundingClientRect() was all zeros and the panel
|
|
346
|
+
landed at 12,12 inside the status bar. effects/
|
|
347
|
+
native-trigger-overlay.ts now pins container + trigger to the pill's
|
|
348
|
+
rect together (its inline display beats this rule's !important, style
|
|
349
|
+
attribute outranks selectors at author-important). */
|
|
362
350
|
[data-phase] header [data-slot="conversation.session.header.actions"] > *:has(> button[class$="_trigger"]),
|
|
363
351
|
[data-phase] header [data-slot="conversation.session.header.lineage"] > *:has(> button[class$="_trigger"]) {
|
|
364
352
|
display: contents !important;
|
|
@@ -367,6 +355,18 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
367
355
|
[data-phase] header [data-slot="conversation.session.header.lineage"] [class$="_separator"] {
|
|
368
356
|
display: none !important;
|
|
369
357
|
}
|
|
358
|
+
/* The popovers themselves portal to <body> as position:fixed layers
|
|
359
|
+
(class suffix _menu, z-index 100) and place themselves from the pill's
|
|
360
|
+
viewport rect with margin 12 — the overlay above is what makes that
|
|
361
|
+
rect correct. This is only a floor: the panels' 336px width overflows
|
|
362
|
+
320px-class phones, and a tall list must not run under the notch or
|
|
363
|
+
the home indicator. [id$="-menu"] keeps the composer's model menu out
|
|
364
|
+
of it — composer.css.ts owns that surface, and this file sits before
|
|
365
|
+
it in styles/index.ts so its own rules still win the tie. */
|
|
366
|
+
body > [class$="_menu"]:not([id$="-menu"]) {
|
|
367
|
+
max-width: calc(100vw - 24px) !important;
|
|
368
|
+
max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px) !important;
|
|
369
|
+
}
|
|
370
370
|
/* The agent-preset mode badge specifically survives the blanket hide
|
|
371
371
|
above: layout.css.ts's shared <=1023px block targets it directly
|
|
372
372
|
(header [class$="_label"]:has(> svg) { display: block !important }),
|
|
@@ -395,8 +395,12 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
395
395
|
display: inline-flex !important;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
/* Back button: 44pt touch target, leftmost column.
|
|
398
|
+
/* Back button: 44pt touch target, leftmost column.
|
|
399
|
+
order: -1 keeps it to the left of @deepseek-ai/dsh-experimental-client-ui-agent-team's
|
|
400
|
+
TeamAction ([data-team-action], registered into header.actions at order -20 with
|
|
401
|
+
default flex order 0). */
|
|
399
402
|
[data-mobile-nav="header-back"] {
|
|
403
|
+
order: -1;
|
|
400
404
|
display: inline-flex;
|
|
401
405
|
align-items: center;
|
|
402
406
|
justify-content: center;
|
|
@@ -413,6 +417,13 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
413
417
|
[data-mobile-nav="header-back"]:active {
|
|
414
418
|
opacity: .6;
|
|
415
419
|
}
|
|
420
|
+
/* TeamAction's own stylesheet hides .XdyUwa_triggerLabel via
|
|
421
|
+
@container (width<=480px) against _titleRow's container-type: inline-size.
|
|
422
|
+
Shedding container-type on _titleRow above (UX-2) disabled that container
|
|
423
|
+
query, so hide the label explicitly on phone and keep only the icon. */
|
|
424
|
+
[data-phase] header [data-team-action] [class$="_triggerLabel"] {
|
|
425
|
+
display: none !important;
|
|
426
|
+
}
|
|
416
427
|
|
|
417
428
|
/* Info + workbench utility buttons, rightmost column. */
|
|
418
429
|
[data-mobile-nav="header-info"],
|
|
@@ -503,6 +514,7 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
|
|
|
503
514
|
top:100% lands both on the same 28px line. z-index puts it above the
|
|
504
515
|
row it overlaps. */
|
|
505
516
|
[data-mobile-nav="header-activity"] {
|
|
517
|
+
pointer-events: none;
|
|
506
518
|
position: absolute;
|
|
507
519
|
right: 0;
|
|
508
520
|
top: 100%;
|
|
@@ -92,6 +92,31 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
|
|
|
92
92
|
padding-top: var(--mnav-sat) !important;
|
|
93
93
|
padding-bottom: var(--mnav-sab) !important;
|
|
94
94
|
}
|
|
95
|
+
/* Right sidebar start/guide page (dsh-client-ui-sidebar-right GuideBody):
|
|
96
|
+
the host sets .Dv5QdW_tabBody { height: 100%; overflow: hidden } and
|
|
97
|
+
.F6Huca_guide { min-height: 100%; justify-content: center } with no
|
|
98
|
+
overflow-y. When many tab entries are registered (e.g. better-sidebar +
|
|
99
|
+
plugins), the centered flex column overflows equally out the top and
|
|
100
|
+
bottom of the locked 100%-height box — clipping the leading entries
|
|
101
|
+
("文件", "Git") above y=0 with no way to scroll. Make the guide itself
|
|
102
|
+
the scroll container and replace justify-content:center with auto
|
|
103
|
+
margins on the first item and ::after so short lists stay centered
|
|
104
|
+
while long lists start cleanly at the top and scroll end-to-end. */
|
|
105
|
+
[data-sidebar-right-guide] {
|
|
106
|
+
flex: 1 1 0 !important;
|
|
107
|
+
min-height: 0 !important;
|
|
108
|
+
justify-content: flex-start !important;
|
|
109
|
+
overflow-y: auto !important;
|
|
110
|
+
-webkit-overflow-scrolling: touch;
|
|
111
|
+
padding-top: 24px !important;
|
|
112
|
+
padding-bottom: 24px !important;
|
|
113
|
+
}
|
|
114
|
+
[data-sidebar-right-guide] > :first-child {
|
|
115
|
+
margin-top: auto !important;
|
|
116
|
+
}
|
|
117
|
+
[data-sidebar-right-guide]::after {
|
|
118
|
+
margin-bottom: auto !important;
|
|
119
|
+
}
|
|
95
120
|
|
|
96
121
|
/* Expanded state (frame without data-sidebar-collapsed) slides the drawer in.
|
|
97
122
|
The open state must be transform:none — NOT translateX(0): an identity
|
|
@@ -33,11 +33,13 @@ import {
|
|
|
33
33
|
CLIENT_CONFIG_ROUTE,
|
|
34
34
|
CLIENT_RECONNECT_ROUTE,
|
|
35
35
|
CLIENT_STATUS_ROUTE,
|
|
36
|
+
awaitClientStatusReady,
|
|
36
37
|
claimDeviceNameOf,
|
|
37
38
|
clientStatusLineOf,
|
|
38
39
|
deriveClientStatusView,
|
|
39
40
|
deriveSettingsView,
|
|
40
41
|
normalizePairingCode,
|
|
42
|
+
shouldReloadAfterClientActivation,
|
|
41
43
|
} from '../../client-data/settings-form.ts'
|
|
42
44
|
import { qrMatrix } from '../../client-data/qr.ts'
|
|
43
45
|
import {
|
|
@@ -761,6 +763,12 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
|
|
|
761
763
|
const runClientClaim = async (): Promise<void> => {
|
|
762
764
|
setFormError(null)
|
|
763
765
|
setClientClaimFail(null)
|
|
766
|
+
// UX-1: the facts that decide the post-pairing reload — whether the page
|
|
767
|
+
// opened as an already-effective client, and whether the backend's
|
|
768
|
+
// stream interceptor was already active (from the last client/status
|
|
769
|
+
// diagnostics). Captured BEFORE the role switch and the claim below.
|
|
770
|
+
const wasEffectiveClient = effectiveClient
|
|
771
|
+
const streamInterceptAlreadyActive = clientView?.intercept?.installed === true
|
|
764
772
|
const trimmedUrl = clientServerUrl.trim()
|
|
765
773
|
const normalizedCode = normalizePairingCode(clientPairCode)
|
|
766
774
|
if (trimmedUrl === '' || !/^https?:\/\//i.test(trimmedUrl)) {
|
|
@@ -801,7 +809,21 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
|
|
|
801
809
|
if (body?.ok === true && typeof body.token === 'string' && body.token !== '') {
|
|
802
810
|
const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl
|
|
803
811
|
const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName
|
|
804
|
-
await config.writeClientPairing(urlToWrite, body.token, echoed)
|
|
812
|
+
const landed = await config.writeClientPairing(urlToWrite, body.token, echoed)
|
|
813
|
+
if (landed && shouldReloadAfterClientActivation({
|
|
814
|
+
wasEffectiveClient,
|
|
815
|
+
nowClientRole: true,
|
|
816
|
+
savedOrPaired: true,
|
|
817
|
+
streamInterceptAlreadyActive,
|
|
818
|
+
})) {
|
|
819
|
+
// UX-1: this was the FIRST client activation while the page's three
|
|
820
|
+
// resident global streams were opened unwrapped — wait out the
|
|
821
|
+
// backend's row-reload window, then reload so the streams reopen
|
|
822
|
+
// wrapped and the server's shared sessions enter the sidebar.
|
|
823
|
+
await awaitClientStatusReady()
|
|
824
|
+
window.location.reload()
|
|
825
|
+
return
|
|
826
|
+
}
|
|
805
827
|
setClientPairCode('')
|
|
806
828
|
await loadClientStatus()
|
|
807
829
|
setStep('verify')
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UX-3 — what tapping the phone home screen's "+ 新建会话" FAB should do.
|
|
3
|
+
*
|
|
4
|
+
* Entering a blank session with NO workspace leaves the phone dangling in
|
|
5
|
+
* the hero's inert "choose a workspace" state (the host's `marshal-main`
|
|
6
|
+
* belongs to no workspace, so the "all" filter often resolves to
|
|
7
|
+
* `undefined`). Decide BEFORE entering:
|
|
8
|
+
*
|
|
9
|
+
* - a workspace is already selected → start there;
|
|
10
|
+
* - exactly one workspace exists (or none at all) → start with it
|
|
11
|
+
* (`undefined` keeps a no-workspace host usable);
|
|
12
|
+
* - several workspaces and none selected → the FAB opens the same
|
|
13
|
+
* bottom-sheet workspace drawer the title bar uses, and the session only
|
|
14
|
+
* starts once one is picked.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no DOM, no imports — MobileHome calls it inside the FAB's onClick
|
|
17
|
+
* and test/permission-sheet.test.cjs drives it directly.
|
|
18
|
+
*/
|
|
19
|
+
export type NewSessionAction<W extends string> =
|
|
20
|
+
| { kind: 'start'; workspaceId: W | undefined }
|
|
21
|
+
| { kind: 'pick-workspace' }
|
|
22
|
+
|
|
23
|
+
export function decideNewSessionAction<W extends string>(
|
|
24
|
+
selectedWorkspaceId: W | undefined,
|
|
25
|
+
workspaceIds: readonly W[],
|
|
26
|
+
): NewSessionAction<W> {
|
|
27
|
+
if (selectedWorkspaceId !== undefined) return { kind: 'start', workspaceId: selectedWorkspaceId }
|
|
28
|
+
if (workspaceIds.length <= 1) return { kind: 'start', workspaceId: workspaceIds[0] }
|
|
29
|
+
return { kind: 'pick-workspace' }
|
|
30
|
+
}
|
|
@@ -433,6 +433,63 @@ export function shouldFollowServerDeviceName(deviceName: string, rowName: string
|
|
|
433
433
|
return deviceName !== '' && deviceName !== rowName && !hasDraft
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
+
// --- UX-1: reload after the FIRST client activation ---------------------------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Whether one successful client-side activation (a staged-save role switch, a
|
|
440
|
+
* first pairing) needs a page reload: the backend's restart-watcher only
|
|
441
|
+
* installs the stream interceptor when the row reloads into client mode, and
|
|
442
|
+
* the page's three resident global streams were opened under whatever gateway
|
|
443
|
+
* object was current AT LOAD TIME. Without a reload they stay unwrapped and
|
|
444
|
+
* the server's remote sessions never enter the merged sidebar lists.
|
|
445
|
+
*
|
|
446
|
+
* - A write that did not land, or a save that is not heading to `client`,
|
|
447
|
+
* never reloads.
|
|
448
|
+
* - A host→client switch always reloads: the streams predate the interceptor.
|
|
449
|
+
* - An already-client page reloads only when the diagnostics say the
|
|
450
|
+
* interceptor was NOT yet active (`streamInterceptAlreadyActive === false`).
|
|
451
|
+
* `undefined` — an older server that answers no diagnostics — is UNKNOWN and
|
|
452
|
+
* stays on the safe no-reload side (a re-pairing must not needlessly reload).
|
|
453
|
+
*/
|
|
454
|
+
export interface ClientActivationReloadInput {
|
|
455
|
+
/** Whether the page was ALREADY an effective client before this operation. */
|
|
456
|
+
wasEffectiveClient: boolean
|
|
457
|
+
/** Whether the role after the operation is (or is switching to) client. */
|
|
458
|
+
nowClientRole: boolean
|
|
459
|
+
/** Whether this operation actually landed (the save / pairing write). */
|
|
460
|
+
savedOrPaired: boolean
|
|
461
|
+
/** The client/status diagnostics' interceptor flag before the operation
|
|
462
|
+
* (derived by the caller); `undefined` when unknown. */
|
|
463
|
+
streamInterceptAlreadyActive?: boolean
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function shouldReloadAfterClientActivation(input: ClientActivationReloadInput): boolean {
|
|
467
|
+
if (!input.savedOrPaired || !input.nowClientRole) return false
|
|
468
|
+
if (!input.wasEffectiveClient) return true
|
|
469
|
+
if (input.streamInterceptAlreadyActive === false) return true
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Poll {@link CLIENT_STATUS_ROUTE} until the backend answers ok — the bounded
|
|
475
|
+
* wait before the post-activation reload, covering the restart-watcher's row
|
|
476
|
+
* reload window (the route 404s / fails while the row restarts). Resolves
|
|
477
|
+
* after `maxMs` regardless, so the reload always fires. Injectable fetch and
|
|
478
|
+
* timings keep the check script fast; defaults are the page's 3 s / 250 ms.
|
|
479
|
+
*/
|
|
480
|
+
export async function awaitClientStatusReady(fetchFn: typeof fetch = fetch, maxMs = 3000, intervalMs = 250): Promise<void> {
|
|
481
|
+
const deadline = Date.now() + maxMs
|
|
482
|
+
while (true) {
|
|
483
|
+
try {
|
|
484
|
+
if ((await fetchFn(CLIENT_STATUS_ROUTE)).ok) return
|
|
485
|
+
} catch {
|
|
486
|
+
// the row is mid-reload; keep polling
|
|
487
|
+
}
|
|
488
|
+
if (Date.now() >= deadline) return
|
|
489
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
436
493
|
/**
|
|
437
494
|
* Latest-wins sequencing for the status loads: every request takes a ticket,
|
|
438
495
|
* and only the newest ticket may still apply its result. An earlier request
|