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.
Files changed (45) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/docs/interface.md +1 -1
  5. package/docs/remote-access.en.md +1 -1
  6. package/docs/remote-access.md +1 -1
  7. package/lib/adapters/dsh-better-sidebar/index.js +21 -3
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/client.js +470 -81
  10. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  11. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  12. package/lib/types/client/MobileHome.d.ts.map +1 -1
  13. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  14. package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
  15. package/lib/types/client/effects/permission-sheet.d.ts +51 -10
  16. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -1
  17. package/lib/types/client/locales.d.ts +1 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  20. package/lib/types/client/styles/composer.css.d.ts +1 -1
  21. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  22. package/lib/types/client/styles/header.css.d.ts +1 -1
  23. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  24. package/lib/types/client/styles/layout.css.d.ts +1 -1
  25. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  26. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  27. package/lib/types/client-data/new-session.d.ts +26 -0
  28. package/lib/types/client-data/new-session.d.ts.map +1 -0
  29. package/lib/types/client-data/settings-form.d.ts +36 -0
  30. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/scripts/check-settings-form.mjs +63 -0
  33. package/src/adapters/dsh-better-sidebar/index.ts +23 -3
  34. package/src/client/MobileHome.tsx +44 -7
  35. package/src/client/MobileSessionHeader.tsx +7 -1
  36. package/src/client/effects/native-trigger-overlay.ts +50 -11
  37. package/src/client/effects/permission-sheet.ts +199 -22
  38. package/src/client/locales.ts +2 -0
  39. package/src/client/settings/SettingsSection.tsx +43 -1
  40. package/src/client/styles/composer.css.ts +24 -0
  41. package/src/client/styles/header.css.ts +58 -46
  42. package/src/client/styles/layout.css.ts +25 -0
  43. package/src/client/wizard/GatewayWizard.tsx +23 -1
  44. package/src/client-data/new-session.ts +30 -0
  45. 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 keep ONE ancestor crumb (2026-08-18): the PC header
199
- lets you tap the parent session's title to switch back — that affordance
200
- was lost on phone when the whole parent chain was hidden as "多余项".
201
- Show only the immediate parent (for agent-teams member sessions that IS
202
- the captain/main session; deeper ancestors stay hidden — a 3-level chain
203
- cannot fit the ~206px center column at 390px). The parent crumb is the
204
- official breadcrumb <button> (dsh-client-ui-conversation renders
205
- open(parentId) as its onClick), so tapping needs no JS from this plugin,
206
- and main sessions (chain length 1) have no :nth-last-child(2) seg at
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
- [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) {
212
- display: inline-flex;
213
- align-items: center;
214
- flex: none;
215
- min-width: 0;
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 two chores. Dissolve the root so its box (and its
357
- stray separator, hidden below) stops occupying the breadcrumb, and let
358
- the trigger participate from wherever the overlay pins it. And no menu
359
- re-anchoring anymore: the 0.1.1 popover portals to <body> as
360
- position:fixed and positions itself from the trigger's viewport rect
361
- (measured 336px wide, fully on screen at 411px). */
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