dsh-zen-remote 2.2.5 → 2.2.7

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 (103) hide show
  1. package/AGENTS.md +15 -3
  2. package/README.md +10 -2
  3. package/README.zh-CN.md +10 -2
  4. package/docs/interface.md +17 -0
  5. package/docs/remote-access.en.md +9 -2
  6. package/docs/remote-access.md +9 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js +1 -1
  8. package/lib/client-routes.js +3 -0
  9. package/lib/client-routes.js.map +1 -1
  10. package/lib/client.js +1189 -203
  11. package/lib/fetch-route-intercept.js +1 -1
  12. package/lib/index.js +73 -16
  13. package/lib/index.js.map +1 -1
  14. package/lib/intercept.js +13 -0
  15. package/lib/intercept.js.map +1 -1
  16. package/lib/invoke-binary.js +8 -2
  17. package/lib/invoke-binary.js.map +1 -1
  18. package/lib/lan-gate-server.cjs +109 -2
  19. package/lib/owner-peer.js +23 -0
  20. package/lib/owner-peer.js.map +1 -1
  21. package/lib/plugin-adapter-core.js +2 -2
  22. package/lib/relay-access.js +24 -2
  23. package/lib/relay-access.js.map +1 -1
  24. package/lib/relay-client.js +2 -2
  25. package/lib/relay-server.js +103 -100
  26. package/lib/relay-server.js.map +1 -1
  27. package/lib/types/client/MobileAttachButton.d.ts +5 -2
  28. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  29. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  30. package/lib/types/client/MobileHome.d.ts.map +1 -1
  31. package/lib/types/client/RemoteFileDownload.d.ts +57 -0
  32. package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
  33. package/lib/types/client/attach-upload.d.ts +32 -0
  34. package/lib/types/client/attach-upload.d.ts.map +1 -1
  35. package/lib/types/client/effects/remote-drop.d.ts +27 -0
  36. package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/client/locales.d.ts +11 -0
  39. package/lib/types/client/locales.d.ts.map +1 -1
  40. package/lib/types/client/remote-file-save.d.ts +112 -0
  41. package/lib/types/client/remote-file-save.d.ts.map +1 -0
  42. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  43. package/lib/types/client/styles/compat.css.d.ts +1 -1
  44. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/header.css.d.ts +1 -1
  46. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  47. package/lib/types/client/styles/home.css.d.ts +1 -1
  48. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  49. package/lib/types/client/styles/layout.css.d.ts +1 -1
  50. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  51. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  52. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  53. package/lib/types/client-data/remote-drop.d.ts +99 -0
  54. package/lib/types/client-data/remote-drop.d.ts.map +1 -0
  55. package/lib/types/client-data/settings-form.d.ts +7 -0
  56. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  57. package/lib/types/client-routes.d.ts +6 -0
  58. package/lib/types/client-routes.d.ts.map +1 -1
  59. package/lib/types/index.d.ts.map +1 -1
  60. package/lib/types/intercept.d.ts.map +1 -1
  61. package/lib/types/invoke-binary.d.ts +4 -1
  62. package/lib/types/invoke-binary.d.ts.map +1 -1
  63. package/lib/types/owner-peer.d.ts +9 -0
  64. package/lib/types/owner-peer.d.ts.map +1 -1
  65. package/lib/types/plugin-adapter-core.d.ts +1 -1
  66. package/lib/types/relay-access.d.ts.map +1 -1
  67. package/lib/types/relay-server.d.ts +10 -7
  68. package/lib/types/relay-server.d.ts.map +1 -1
  69. package/package.json +36 -36
  70. package/pwa/inject.js +20 -9
  71. package/pwa/sw.js +97 -21
  72. package/scripts/check-attach-upload.mjs +37 -0
  73. package/scripts/check-remote-drop.mjs +114 -0
  74. package/scripts/check-settings-form.mjs +3 -2
  75. package/scripts/check-upload-endpoint.mjs +14 -3
  76. package/src/adapters/dsh-better-sidebar/index.ts +1 -1
  77. package/src/client/MobileAttachButton.tsx +14 -9
  78. package/src/client/MobileAttachChips.tsx +4 -0
  79. package/src/client/MobileHome.tsx +79 -12
  80. package/src/client/RemoteFileDownload.tsx +173 -0
  81. package/src/client/attach-upload.ts +47 -0
  82. package/src/client/effects/remote-drop.ts +269 -0
  83. package/src/client/index.tsx +47 -0
  84. package/src/client/locales.ts +32 -0
  85. package/src/client/remote-file-save.ts +232 -0
  86. package/src/client/settings/SettingsSection.tsx +61 -1
  87. package/src/client/styles/compat.css.ts +43 -12
  88. package/src/client/styles/header.css.ts +63 -15
  89. package/src/client/styles/home.css.ts +64 -11
  90. package/src/client/styles/layout.css.ts +24 -7
  91. package/src/client/styles/remote-session.css.ts +38 -0
  92. package/src/client-data/remote-drop.ts +127 -0
  93. package/src/client-data/settings-form.ts +10 -0
  94. package/src/client-routes.ts +8 -0
  95. package/src/fetch-route-intercept.ts +1 -1
  96. package/src/index.ts +71 -18
  97. package/src/intercept.ts +13 -0
  98. package/src/invoke-binary.ts +8 -2
  99. package/src/owner-peer.ts +32 -0
  100. package/src/plugin-adapter-core.ts +2 -2
  101. package/src/relay-access.ts +24 -2
  102. package/src/relay-client.ts +2 -2
  103. package/src/relay-server.ts +114 -90
@@ -71,6 +71,7 @@ import type {
71
71
  ZenRemoteSettingsForm,
72
72
  } from '../../client-data/settings-form.ts'
73
73
  import { NS } from '../locales.ts'
74
+ import { remoteDropDiagnostics } from '../effects/remote-drop.ts'
74
75
  import { describeShare } from '../../client-data/shares.ts'
75
76
  import type { ShareEntryView, SharesStore } from '../../client-data/shares.ts'
76
77
 
@@ -315,6 +316,11 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
315
316
  // The client group's rendered view, hoisted for the countdown ticker and
316
317
  // the retry-follow refresh below (T43).
317
318
  const clientView = clientLoad.state === 'ready' || clientLoad.state === 'stale' ? clientLoad.view : undefined
319
+ // RD-3: the remote drop guard's browser-side counters, read at render
320
+ // time — no snapshot state of its own: the initial mount, the 15s
321
+ // connected-status poll and the countdown ticker all re-render this page,
322
+ // and each render takes the values as they are right then.
323
+ const dropDiag = remoteDropDiagnostics()
318
324
  const clientRetryAt = clientView?.nextRetryAt
319
325
  const clientCountingDown = clientRetryAt !== undefined
320
326
 
@@ -1158,7 +1164,7 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1158
1164
  earlier servers answer neither, and the section itself only
1159
1165
  exists when there is at least one line to show. PB-5 adds the
1160
1166
  bridge rows and the rerouting note on the same presence gate. */}
1161
- {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined) && (
1167
+ {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined || dropDiag.installed || dropDiag.notInstalledReason !== undefined) && (
1162
1168
  <>
1163
1169
  <h3 className="zr-settings-card-title">{t('settings.client.diagTitle')}</h3>
1164
1170
  {clientView.lastError !== '' && (
@@ -1195,6 +1201,60 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1195
1201
  )}
1196
1202
  </>
1197
1203
  )}
1204
+ {/* RD-3: the remote drop guard's own line — installed state
1205
+ plus the five counters. Browser-side values, so unlike the
1206
+ rows above it never rides the client/status body. */}
1207
+ {(dropDiag.installed || dropDiag.notInstalledReason !== undefined) && (
1208
+ <div className="zr-settings-field">
1209
+ <div className="zr-settings-head">
1210
+ <label>{t('settings.client.diagRemoteDrop')}</label>
1211
+ </div>
1212
+ <p className="zr-settings-hint">
1213
+ {dropDiag.installed
1214
+ ? t('settings.client.diagRemoteDropOk', {
1215
+ intercepted: dropDiag.intercepted,
1216
+ redispatched: dropDiag.redispatched,
1217
+ blocked: dropDiag.blockedDirectories,
1218
+ passthrough: dropDiag.passthrough,
1219
+ failed: dropDiag.rewrapFailed,
1220
+ })
1221
+ : t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' })}
1222
+ </p>
1223
+ </div>
1224
+ )}
1225
+ {/* RD-6: the upload-redirect (fetch-route intercept) line — rides
1226
+ the client/status body's serviceProbe. One row answers the
1227
+ question this diagnosis was built for: whether the redirect
1228
+ is installed, refused (with the shape reason), or its service
1229
+ inject never fired — and which of the two services is
1230
+ missing when so. */}
1231
+ {clientView?.uploadProbe !== undefined && (() => {
1232
+ const probe = clientView.uploadProbe as {
1233
+ uploadInjectSeen?: unknown
1234
+ connectionService?: unknown
1235
+ fileUploadsService?: unknown
1236
+ fetchRoute?: { installed?: unknown; uploadWrapped?: unknown; shape?: { reasons?: unknown } }
1237
+ }
1238
+ const seen = probe.uploadInjectSeen === true
1239
+ const refused = seen && probe.fetchRoute?.shape?.reasons instanceof Array && (probe.fetchRoute.shape.reasons as string[]).length > 0
1240
+ const wrapped = probe.fetchRoute?.uploadWrapped === true
1241
+ const text = wrapped
1242
+ ? t('settings.client.diagUploadRedirectOk')
1243
+ : refused
1244
+ ? t('settings.client.diagUploadRedirectRefused', { reason: (probe.fetchRoute?.shape?.reasons as string[]).join('; ') })
1245
+ : t('settings.client.diagUploadRedirectPending', {
1246
+ connection: typeof probe.connectionService === 'string' ? probe.connectionService : '—',
1247
+ fileUploads: typeof probe.fileUploadsService === 'string' ? probe.fileUploadsService : '—',
1248
+ })
1249
+ return (
1250
+ <div className="zr-settings-field">
1251
+ <div className="zr-settings-head">
1252
+ <label>{t('settings.client.diagUploadRedirect')}</label>
1253
+ </div>
1254
+ <p className="zr-settings-hint">{text}</p>
1255
+ </div>
1256
+ )
1257
+ })()}
1198
1258
  {clientView.compat !== undefined && (
1199
1259
  <>
1200
1260
  {clientView.compat.mismatchedGroups.length > 0 && (
@@ -299,31 +299,62 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
299
299
  one row with a thin scrollbar keeps every tab reachable and returns
300
300
  that space to the options area (user feedback 2026-08-16). An earlier
301
301
  one-row attempt had no scroll affordance and silently cut the last
302
- tab off; the thin scrollbar IS the affordance. Scoped to the frame
303
- marker: the desktop dialog keeps its official vertical nav column. */
304
- [data-mobile-nav="frame"] [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])) > :first-child [class$="_navList"] {
302
+ tab off; the thin scrollbar IS the affordance.
303
+
304
+ 2026-10-21 (DSH 0.2.1-rc.2, measured on a real phone shell): the whole
305
+ block below had silently stopped matching. The settings dialog no longer
306
+ renders inside the app frame — it is a PORTAL on body
307
+ (body > [class$="_overlay"] > [class$="_panel"]), so a
308
+ [data-mobile-nav="frame"] [aria-modal="true"] … prefix can never match
309
+ it, and the panel depth the old :has(> :first-child > :last-child >
310
+ button) gate was written against is no longer the shape the sheet has
311
+ (now panel > nav > (navTitle, navList, header)). Symptom: four nav cells
312
+ fell back to the host's wrapping grid (measured 209x132 for the list
313
+ instead of one 40px row) and the "open config file" action came back
314
+ into the header. Keep the same phone gate from the ancestor side
315
+ (html:has([data-mobile-nav="frame"]) — the marker only exists outside
316
+ the desktop gate) and identify the dialog by the nav list it owns, not
317
+ by a guessed depth. Desktop untouched.
318
+
319
+ This rule is the only place that declares the row's wrap/overflow now:
320
+ layout.css.ts's navList rule has a higher specificity and used to force
321
+ "flex-wrap: wrap; overflow: visible", so the one-row intent below never
322
+ actually rendered (see the note in layout.css.ts).
323
+
324
+ Every selector below is gated on :has([class$="_navList"]) — the one
325
+ stable feature of the settings sheet. The ancestor marker alone is not
326
+ enough: once the dialog is a body portal, [aria-modal="true"] matches
327
+ EVERY dialog on the phone (session export, directory picker, plugin
328
+ sheets), which is how the first cut of this block ended up hiding the
329
+ top-right action button of all of them (review, 2026-10-22). */
330
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"] {
305
331
  display: flex !important;
306
332
  flex-wrap: nowrap !important;
333
+ /* The nav row is [navList][header(close)]: min-width:0 + flex-basis auto
334
+ lets the list shrink to the width left over by the ✕ and scroll inside
335
+ itself — without it the nowrap content widens the row and pushes the
336
+ close button off-screen. */
337
+ flex: 1 1 auto !important;
338
+ min-width: 0 !important;
307
339
  overflow-x: auto !important;
308
340
  overflow-y: hidden !important;
309
341
  gap: 6px !important;
310
- width: 100% !important;
311
342
  scrollbar-width: thin !important;
312
343
  -webkit-overflow-scrolling: touch !important;
313
344
  }
314
345
  /* Hairline scrollbar for the tab row: the default WebKit scrollbar reads
315
346
  fat on a phone; 2px keeps the scroll affordance without the bulk. */
316
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar {
347
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar {
317
348
  height: 2px !important;
318
349
  }
319
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar-thumb {
350
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar-thumb {
320
351
  background: var(--dsw-alias-border-l2, rgba(0, 0, 0, .22)) !important;
321
352
  border-radius: 1px !important;
322
353
  }
323
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar-track {
354
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar-track {
324
355
  background: transparent !important;
325
356
  }
326
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navCell"] {
357
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navCell"] {
327
358
  flex: 0 0 auto !important;
328
359
  white-space: nowrap !important;
329
360
  padding: 6px 8px !important;
@@ -331,7 +362,7 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
331
362
  font-size: 13px !important;
332
363
  justify-content: flex-start !important;
333
364
  }
334
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navCell"] svg {
365
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navCell"] svg {
335
366
  width: 14px !important;
336
367
  height: 14px !important;
337
368
  flex: none !important;
@@ -339,11 +370,11 @@ export const COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility -----
339
370
  /* Content toolbar: the "Open configuration file" button is hidden on
340
371
  mobile — it is rarely needed on a phone and steals ~180px from the
341
372
  tab row's scroll area (user feedback 2026-08-16). Only the close ✕
342
- stays, flush right in the nav row. Desktop untouched (frame scoped). */
343
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_header"] [class$="_actions"] {
373
+ stays, flush right in the nav row. Desktop untouched (phone gate). */
374
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_header"] [class$="_actions"] {
344
375
  display: none !important;
345
376
  }
346
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_header"] [class$="_actions"] [class$="_action"] {
377
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_header"] [class$="_actions"] [class$="_action"] {
347
378
  font-size: 13px !important;
348
379
  padding: 6px 12px !important;
349
380
  min-height: 0 !important;
@@ -19,6 +19,13 @@
19
19
  // hidden below, our workbench button routes to
20
20
  // its controls instead)
21
21
  // div.tabs[role="tablist"] Chat/Trajectory, only when there is >1 view
22
+ // (DSH <= 0.2.1-rc.2)
23
+ // div.tabs DSH 0.2.1-rc.3: the row lost its role and
24
+ // div.viewTabs[role="tablist"][data-conversation-tabs]
25
+ // div.viewsMoreAnchor "More conversation views" (…) menu launcher,
26
+ // present while conversation.session.header.
27
+ // views.actions has registrations (the web app
28
+ // ships dsh-client-ui-entry-customization)
22
29
 
23
30
  export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) ---------- */
24
31
 
@@ -78,27 +85,39 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
78
85
  above. */
79
86
  z-index: 2;
80
87
  padding: 0 8px 28px !important;
88
+ border-bottom: none !important;
81
89
  }
82
90
  /* No header bottom line — fade instead (real-device round 2, 2026-08-17).
83
- The official header draws its border as a \`::after\` 1px bar (the
84
- \`border-bottom\` on the header itself is transparent, just a layout
85
- reserve — dsh-client-ui-conversation lib/client.js ".wSkVaW_header{
86
- border-bottom:1px solid #0000}.wSkVaW_header:after{...height:1px;
87
- position:absolute;bottom:1px...}", verified live 2026-08-17), so the
88
- bar is repurposed rather than hidden: restyled into a 20px strip
89
- hanging just below the header that fades the messages in — the same
90
- look the scroller's mask-image used to produce, without a mask on a
91
- scroll container (iOS WebKit renders that as fog over the header, see
92
- composer.css.ts "no divider above OR below the message list",
93
- 2026-08-23). color-mix instead of the transparent keyword: the host
94
- writes its own seat gradient that way, and plain \`transparent\` is
95
- rgba(0,0,0,0) — WebKit interpolates it through grey. */
96
- [data-phase] header::after {
97
- height: 20px !important;
91
+ The official header in rc.2 drew its border as a \`::after\` 1px bar; in
92
+ rc.3 (2026-10-25) the host replaced that pseudo-element with a plain 0.5px
93
+ border-bottom (cleared above) and added a blank 0px header in the hero
94
+ phase (\`_headerBlank\`). Declaring \`content: ''\` + \`position: absolute;
95
+ left: 0; right: 0\` explicitly on \`[data-phase="active"] header:not([class$="_headerBlank"])::after\`
96
+ keeps the 20px fade strip anchored right below the real session header on
97
+ both rc.2 and rc.3 without touching the scroll container:
98
+ - When the session header is display:none on the list page, ::after is
99
+ not generated at all (unlike _scrollBody::before, which stayed in flow
100
+ at y=0 whenever the header was hidden or blank and showed up at the top
101
+ of the window on iOS standalone PWA launch before data-view mutated).
102
+ - :not([class$="_headerBlank"]) excludes the 0-height blank header.
103
+ - color-mix instead of the transparent keyword: plain \`transparent\` is
104
+ rgba(0,0,0,0) — WebKit interpolates it through grey. */
105
+ [data-phase="active"] header:not([class$="_headerBlank"])::after {
106
+ content: '';
107
+ position: absolute;
108
+ left: 0;
109
+ right: 0;
98
110
  top: 100% !important;
99
111
  bottom: auto !important;
112
+ height: 20px !important;
113
+ pointer-events: none;
100
114
  background: linear-gradient(to bottom, var(--dsw-alias-bg-base), color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent)) !important;
101
115
  }
116
+ [data-mobile-nav="frame"]:not([data-mobile-view="session"]) [data-phase] header::after,
117
+ body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-phase] header::after {
118
+ content: none !important;
119
+ display: none !important;
120
+ }
102
121
  /* grid-row: 1 on every item is load-bearing, not decoration (S2.1 fix for
103
122
  the "标题被挤下去" report). The three items are placed with explicit
104
123
  grid-column but arrive in DOM order crumbs(2) → headerActions(1) →
@@ -440,6 +459,35 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
440
459
  [data-phase] header [class$="_tabs"][role="tablist"] {
441
460
  display: none;
442
461
  }
462
+ /* DSH 0.2.1-rc.3 split the row: div._tabs (no role) now wraps the
463
+ tablist (div._viewTabs[role=tablist][data-conversation-tabs]) and the
464
+ new "More conversation views" launcher, so the rule above matches
465
+ nothing there and the official row — plus a stray (…) More button —
466
+ came back under our view-switch band. It cannot simply be display:none
467
+ either: while the More menu has registrations the header folds every
468
+ View past floor((row.clientWidth - 48) / 120) into More, and a
469
+ display:none row measures 0, which would leave only Chat as a
470
+ [role=tab] for readViewTabs() (MobileSessionHeader.tsx) to mirror.
471
+ So the row keeps a wide layout box that paints nothing: absolute (out
472
+ of the header's flow, no band), 2048px wide (room for 16 tabs before
473
+ anything folds), scale(0) so it adds no scrollable overflow (clientWidth
474
+ ignores transforms), visibility:hidden + pointer-events:none so it is
475
+ never seen or hit. Its tabs still take the synthetic .click() the view
476
+ switcher sends — the same reasoning as the display:none rule above. */
477
+ [data-phase] header [class$="_tabs"]:has(> [data-conversation-tabs]) {
478
+ position: absolute !important;
479
+ top: 0;
480
+ left: 0;
481
+ width: 2048px !important;
482
+ max-width: none !important;
483
+ height: 0 !important;
484
+ margin: 0 !important;
485
+ overflow: hidden !important;
486
+ transform: scale(0);
487
+ transform-origin: 0 0;
488
+ visibility: hidden !important;
489
+ pointer-events: none !important;
490
+ }
443
491
 
444
492
  /* View-switch row: "current view name + dots", replacing the (still
445
493
  present, still clickable) official tablist visually. It renders inside
@@ -91,7 +91,17 @@ export const HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
91
91
  [data-mobile-nav="home"] {
92
92
  position: absolute;
93
93
  inset: 0;
94
- z-index: 5;
94
+ /* rc.3 raise (real-device report 2026-10-25 "top strip leaking the
95
+ conversation through"): this shell layer mounts INSIDE the host's
96
+ PIRFFq_overlayLayer (z-index:20, new in rc.3 — the conversation
97
+ content itself moved under it too), so the old z:5 let host session
98
+ content (STRONG/CODE rows measured at viewport y=4) paint over the
99
+ shell's top bar on BOTH pages, reading as a faint fixed strip at the
100
+ window top. z:30 puts the whole shell above everything the host
101
+ stacks inside that layer; the shell's own transparent middle and
102
+ pointer-events passthrough keep the conversation visible and tappable
103
+ (verified live via console injection before landing). */
104
+ z-index: 30;
95
105
  display: flex;
96
106
  flex-direction: column;
97
107
  pointer-events: auto;
@@ -136,6 +146,34 @@ export const HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
136
146
  opacity .3s var(--ds-ease-in-out, ease-in-out),
137
147
  visibility 0s .3s;
138
148
  }
149
+ /* The host's SESSION header must not sit on top of this list page.
150
+ Real-device report (2026-10-22, DSH 0.2.0-rc.2, dsh-web-mobile 3.2.1,
151
+ 360x792 phone): on the list page the top strip showed the LAST SESSION's
152
+ header — its title plus the session's tab row — and the workspace switcher
153
+ underneath was neither visible nor tappable, so "switch workspace" did
154
+ nothing at all. Measured with elementFromPoint(150,30) on the phone
155
+ viewport: span.wSkVaW_crumbCurrent, i.e. the host header's crumb, while
156
+ our own workspace button sat at [46,8,208,44] underneath it.
157
+
158
+ Why z-index is not the fix: this page is position:absolute; z-index:5
159
+ inside the shell overlay layer, and the host header carries its own
160
+ stacking context, so raising us above its z-index:30 changed nothing
161
+ (measured: the crumb still won the hit test). The session header has to
162
+ leave the hit tree while the list page is up — the list page draws its
163
+ own top bar (home-top, with the workspace switcher).
164
+
165
+ The gate is this page's own marker: data-view="session" means a session
166
+ is open, so the rule lapses exactly when the host header is wanted again
167
+ (verified: entering a session restores display:grid, the back button in
168
+ that header stays tappable, and going back hides it once more). Two
169
+ selectors on purpose — the class suffix survives CSS-module hash renames,
170
+ the structural one survives a rename of the class itself. */
171
+ [data-mobile-nav="frame"]:not([data-mobile-view="session"]) header[class*="_header"],
172
+ [data-mobile-nav="frame"]:not([data-mobile-view="session"]) header:has([class*="_titleRow"]),
173
+ body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-mobile-nav="frame"] header[class*="_header"],
174
+ body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-mobile-nav="frame"] header:has([class*="_titleRow"]) {
175
+ display: none !important;
176
+ }
139
177
  @media (prefers-reduced-motion: reduce) {
140
178
  [data-mobile-nav="home"],
141
179
  [data-mobile-nav="home"][data-view="session"] {
@@ -162,18 +200,33 @@ export const HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
162
200
  flex: none;
163
201
  border-radius: 6px;
164
202
  object-fit: contain;
165
- }
166
- /* Dark theme (real-device report, 2026-08-17): the runtime favicon is a
167
- dark-on-transparent whale mark, illegible on the dark page. Force a
168
- white silhouette regardless of the source image's own colors —
169
- brightness(0) collapses every opaque pixel to black first, invert(1)
170
- then flips that to white; transparent pixels stay transparent either
171
- way. Same body[data-ds-dark-theme] marker as the home-row hairline
172
- above (AGENTS.md: prefers-color-scheme never fires, the app switches
173
- themes via this attribute with color-scheme hardcoded to light). */
174
- body[data-ds-dark-theme] [data-mobile-nav="home-logo"] {
203
+ /* rc.3 fix (2026-10-25, Safari real-device report "logo 不显示"): the
204
+ host now ships TWO media-scoped favicons and the first in DOM order
205
+ is the DARK one — a WHITE whale (fill #fff). useSiteIconHref picks
206
+ the first link unconditionally, so on a light page the logo arrived
207
+ white-on-light and vanished. brightness(0) collapses ANY source (the
208
+ old single black favicon or the new white one) to a black silhouette,
209
+ making light mode correct for both hosts; the dark-theme rules below
210
+ re-invert it to white. */
211
+ filter: brightness(0);
212
+ }
213
+ /* Dark theme (real-device report, 2026-08-17): force a white silhouette
214
+ regardless of the source image's own colors — brightness(0) collapses
215
+ every opaque pixel to black first, invert(1) then flips that to white;
216
+ transparent pixels stay transparent either way. Three dark signals,
217
+ deliberately redundant: the pre-rc.3 body[data-ds-dark-theme] marker,
218
+ and rc.3's resolved <html data-ds-theme-source> ("dark" pinned, or
219
+ "system" under a dark OS — rc.3 stamps the resolved scheme on
220
+ html[style] with no body attribute until the first theme flip). */
221
+ body[data-ds-dark-theme] [data-mobile-nav="home-logo"],
222
+ html[data-ds-theme-source="dark"] [data-mobile-nav="home-logo"] {
175
223
  filter: brightness(0) invert(1);
176
224
  }
225
+ @media (prefers-color-scheme: dark) {
226
+ html[data-ds-theme-source="system"] [data-mobile-nav="home-logo"] {
227
+ filter: brightness(0) invert(1);
228
+ }
229
+ }
177
230
  [data-mobile-nav="ws-switch"] {
178
231
  display: inline-flex;
179
232
  align-items: center;
@@ -394,7 +394,14 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
394
394
  display:none rule below, and the user can no longer type a path
395
395
  (issue #12, 2026-08-16). The picker family keeps the official layout
396
396
  on mobile in every mode. */
397
- [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])) {
397
+ /* DSH 0.2.1-rc.2 renders this dialog as a body PORTAL
398
+ (body > overlay > panel), so the placement below must key off the nav
399
+ list the panel owns, not off a guessed child depth: that is both the
400
+ stable "this is the settings sheet" anchor and the reason the safe-area
401
+ offset below still applies inside the portal (the constants live on
402
+ :root, so they cross the portal boundary). The bottom inset is
403
+ subtracted too — a tall panel must not run under the home indicator. */
404
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])):has([class$="_navList"]) {
398
405
  position: absolute !important;
399
406
  left: 8px !important;
400
407
  /* Fixed top (no translateY): a transform on the panel combined with the
@@ -405,11 +412,11 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
405
412
  width: calc(100vw - 16px) !important;
406
413
  max-width: calc(100vw - 16px) !important;
407
414
  /* Height follows the content (no dead space under a short page); it
408
- caps at 100dvh-24 (less the safe-area top) and the options area
415
+ caps at 100dvh-24 (less both safe-area insets) and the options area
409
416
  scrolls only then. */
410
417
  height: auto !important;
411
- max-height: min(800px, calc(100vh - 24px - var(--mnav-sat))) !important;
412
- max-height: min(800px, calc(100dvh - 24px - var(--mnav-sat))) !important;
418
+ max-height: min(800px, calc(100vh - 24px - var(--mnav-sat) - var(--mnav-sab))) !important;
419
+ max-height: min(800px, calc(100dvh - 24px - var(--mnav-sat) - var(--mnav-sab))) !important;
413
420
  flex-direction: column !important;
414
421
  border-radius: 14px !important;
415
422
  animation: dsh-mobile-nav-sheet-in .22s var(--ds-ease-out, ease-in-out);
@@ -457,14 +464,24 @@ export const LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
457
464
  (config file + close) is reparented INTO this nav row by a client
458
465
  effect (MobileNavOverlay), so the tab list must be anchored by its
459
466
  class, NOT by :last-child (the reparented toolbar becomes the nav's
460
- new last child). */
467
+ new last child).
468
+
469
+ 2026-10-21 (DSH 0.2.1-rc.2): this rule used to declare
470
+ "flex-wrap: wrap; overflow: visible" on top of the flex/min-width/gap
471
+ above. Because it is one class-anchored compound selector with a much
472
+ higher specificity than compat.css.ts's portal-scoped navList rule, it
473
+ silently won the tie and the tab list wrapped into two rows — with the
474
+ reparented ✕ vertically centred across both of them. Measured on a
475
+ phone shell: the list was 86px tall (two rows) instead of one 40px row,
476
+ and it scrolled nowhere. The wrap was a 2026-08-16 trade-off (show every
477
+ tab, no hidden scroll cut-off); with the thin scrollbar affordance now
478
+ in compat.css.ts the single scrolling row is the better trade, so the
479
+ two declarations live there and this rule no longer contradicts them. */
461
480
  [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])) > :first-child [class$="_navList"] {
462
481
  flex: 1 1 auto !important;
463
482
  min-width: 0 !important;
464
483
  flex-direction: row !important;
465
- flex-wrap: wrap !important;
466
484
  gap: 6px !important;
467
- overflow: visible !important;
468
485
  }
469
486
  /* Content toolbar (Open configuration file + close): grouped flush to
470
487
  the right edge, and reparented INTO the nav row on mobile so it shares
@@ -310,4 +310,42 @@ option[value^="zr~"] {
310
310
  html[data-zr-remote-session="1"] [data-slot="conversation.session.header.utilities"] [class$="_moreButton"] ~ [role="menu"] > [role="presentation"] > :first-child {
311
311
  display: none !important;
312
312
  }
313
+
314
+ /* ---------- remote session: the "save to this machine" action (2.2.6) ----------
315
+ One class, owned by this plugin (no host module class, no hash): the
316
+ deliverable-card entry renders it in the card's own actions cell, the
317
+ document-preview entry in the preview header where open-in-app's control
318
+ sits (that one is hidden remotely by the [data-open-target] rule above,
319
+ so this is the only action left there). Both entries only MOUNT for a
320
+ virtual session, so the html attribute gate is belt-and-braces, not the
321
+ decision.
322
+ The palette variables are the host's own (label-secondary / border-l1 /
323
+ interactive-bg-hover / state-error), which keeps light/dark identical to
324
+ the cards around it without copying a theme. */
325
+ html[data-zr-remote-session="1"] .zr-download-action {
326
+ box-sizing: border-box;
327
+ flex: none;
328
+ font: inherit;
329
+ font-size: 12px;
330
+ line-height: 18px;
331
+ color: var(--dsw-alias-label-secondary, #6b7280);
332
+ background: transparent;
333
+ border: 0.5px solid var(--dsw-alias-border-l1, #d9dde3);
334
+ border-radius: var(--dsw-radius-sm, 4px);
335
+ padding: 1px 8px;
336
+ cursor: pointer;
337
+ white-space: nowrap;
338
+ }
339
+ html[data-zr-remote-session="1"] .zr-download-action:hover {
340
+ color: var(--dsw-alias-label-primary, #111827);
341
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
342
+ }
343
+ html[data-zr-remote-session="1"] .zr-download-action[data-phase="busy"] {
344
+ cursor: progress;
345
+ opacity: 0.7;
346
+ }
347
+ html[data-zr-remote-session="1"] .zr-download-action[data-phase="error"] {
348
+ color: var(--dsw-alias-state-error-primary, #dc2626);
349
+ border-color: var(--dsw-alias-state-error-primary, #dc2626);
350
+ }
313
351
  `
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Remote-session drag-and-drop decision core (RD-1): the DOM-free, readable
3
+ * half of the document-level drop guard (src/client/effects/remote-drop.ts).
4
+ *
5
+ * Why this exists: the desktop app's attachment pipeline has a same-machine
6
+ * zero-copy fast path — the Electron preload exposes `__DSH_HOST_PATHS__`,
7
+ * and the host's `addFiles` turns a File that still carries a real disk path
8
+ * into an `@/absolute/path` mention chip WITHOUT uploading any bytes. In a
9
+ * remote session (`zr~<serverId>~<id>`) that path names a file on the
10
+ * SUB-CLIENT's machine; the server-side session can never read it, so the
11
+ * mention lands broken. The fix re-shapes the drop before the host sees it:
12
+ * strip the internal path by re-wrapping each File (`new File([f], ...)`) and
13
+ * redispatch a synthetic, untrusted DragEvent carrying them in a fresh
14
+ * DataTransfer. The host's own drop handler then treats it as an ordinary
15
+ * browser drag — the byte-upload branch — which the T51 fetch-route
16
+ * interception already relays to the server for staging.
17
+ *
18
+ * This module only DECIDES; it never touches the DOM:
19
+ * - `isRemoteSessionId` — the current-session gate, judged by the same
20
+ * `zr~` skeleton (virtual-id.ts) every other remote gate in this plugin
21
+ * keys on, so the two ends cannot drift;
22
+ * - `decideRemoteDrop` — the three trigger gates plus the directory rule,
23
+ * as a pure function over injectable data so the whole truth table is
24
+ * assertable in Node (scripts/check-remote-drop.mjs);
25
+ * - `rewrapFiles` — the File re-wrap itself (Node >= 20 has File).
26
+ *
27
+ * Misjudgement direction is safe by construction: every gate failing open
28
+ * lands on `passthrough` = today's behavior. The only wrong action a stale
29
+ * current-session signal can produce is re-shaping a LOCAL session's drop
30
+ * into an upload — bytes travel that did not have to, but nothing is lost.
31
+ * The reverse (a remote drop reaching the host untouched) is what loses the
32
+ * attachment, which is why all three gates must affirmatively pass.
33
+ */
34
+
35
+ import { isVirtual } from '../virtual-id.ts'
36
+
37
+ /** True when the id is one of this plugin's virtual (relay) session ids —
38
+ * the `zr~<serverId>~<id>` form. `undefined` (no readable current-session
39
+ * signal) is deliberately NOT remote: the conservative gate reads a missing
40
+ * signal as a local session and never intervenes. */
41
+ export function isRemoteSessionId(id: string | undefined): boolean {
42
+ return typeof id === 'string' && isVirtual(id)
43
+ }
44
+
45
+ /** One drag item, reduced to what the decision needs. `kind` mirrors the
46
+ * DataTransferItem kind — only `file` items can carry attachment bytes.
47
+ * `isDirectory` comes from `webkitGetAsEntry().isDirectory`; a file item
48
+ * whose entry cannot be read counts as NOT a directory (the host applies
49
+ * the same default). */
50
+ export interface RemoteDropItemDescriptor {
51
+ kind: 'file' | 'other'
52
+ isDirectory: boolean
53
+ }
54
+
55
+ /** Everything the decision may look at, injectable so no DOM is needed. */
56
+ export interface RemoteDropInput {
57
+ /** `__DSH_HOST_PATHS__` exists — the Electron preload's zero-copy bridge.
58
+ * False on phone / plain-browser hosts, which already upload bytes. */
59
+ hasHostPaths: boolean
60
+ /** The currently open session id, or `undefined` when no signal. */
61
+ currentSessionId: string | undefined
62
+ /** `event.isTrusted` — our own redispatched events are untrusted; this
63
+ * gate is the re-entry guard. */
64
+ trusted: boolean
65
+ /** Descriptor per file-kind drag item. `undefined` means the entry API
66
+ * (`webkitGetAsEntry`) is unavailable, so directories cannot be detected —
67
+ * the host's own semantics treat that as "no directories". */
68
+ items: readonly RemoteDropItemDescriptor[] | undefined
69
+ }
70
+
71
+ export type RemoteDropAction = 'passthrough' | 'redispatch' | 'block-directories'
72
+
73
+ /** Stable machine-readable why, one per action branch. Ordered by the gate
74
+ * sequence below. */
75
+ export type RemoteDropReason =
76
+ | 'untrusted-event'
77
+ | 'no-host-paths'
78
+ | 'not-remote-session'
79
+ | 'no-entry-api'
80
+ | 'contains-directory'
81
+ | 'no-files'
82
+ | 'remote-files'
83
+
84
+ export interface RemoteDropDecision {
85
+ action: RemoteDropAction
86
+ reason: RemoteDropReason
87
+ }
88
+
89
+ /**
90
+ * The three trigger gates, then the directory rule. Every gate must
91
+ * affirmatively pass before anything is intervened on:
92
+ * 1. `trusted` — synthetic (our own) events pass straight through, which is
93
+ * what makes the redispatch loop-free;
94
+ * 2. `hasHostPaths` — without the Electron bridge the host is already on
95
+ * the byte-upload branch and there is nothing to fix;
96
+ * 3. remote session — a missing/failed current-session read counts as local
97
+ * (conservative: passthrough keeps today's behavior);
98
+ * 4. no entry API — redispatch (no directory knowledge, same default as the
99
+ * host);
100
+ * 5. any directory — `block-directories`, which takes precedence over
101
+ * redispatch: a folder cannot be fixed by single-file upload, so the
102
+ * whole drop is stopped with a toast rather than silently reduced;
103
+ * 6. no file items at all — passthrough (the host's own `Files`-in-types
104
+ * gate would ignore the drop; there is nothing to re-shape);
105
+ * 7. otherwise — `redispatch`.
106
+ */
107
+ export function decideRemoteDrop(input: RemoteDropInput): RemoteDropDecision {
108
+ if (!input.trusted) return { action: 'passthrough', reason: 'untrusted-event' }
109
+ if (!input.hasHostPaths) return { action: 'passthrough', reason: 'no-host-paths' }
110
+ if (!isRemoteSessionId(input.currentSessionId)) return { action: 'passthrough', reason: 'not-remote-session' }
111
+ if (input.items === undefined) return { action: 'redispatch', reason: 'no-entry-api' }
112
+ if (input.items.some((item) => item.isDirectory)) return { action: 'block-directories', reason: 'contains-directory' }
113
+ if (!input.items.some((item) => item.kind === 'file')) return { action: 'passthrough', reason: 'no-files' }
114
+ return { action: 'redispatch', reason: 'remote-files' }
115
+ }
116
+
117
+ /**
118
+ * Re-wrap files so they stop carrying their disk paths. A File the browser
119
+ * constructed from an OS drop keeps a hidden reference the Electron
120
+ * `getPathForFile` can read; `new File([f], ...)` builds a JS-constructed
121
+ * File with no such backing — `getPathForFile` returns "" for it, which
122
+ * sends the host's `addFiles` down the byte-upload branch. name / type /
123
+ * lastModified are preserved so the staged upload keeps its filename.
124
+ */
125
+ export function rewrapFiles(files: Iterable<File>): File[] {
126
+ return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }))
127
+ }