dsh-zen-remote 2.3.5 → 2.3.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 (100) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +12 -6
  6. package/docs/plugin-bridge.md +2 -2
  7. package/docs/remote-access.en.md +9 -3
  8. package/docs/remote-access.md +9 -3
  9. package/lan-gate.mjs +7 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +31 -2
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  12. package/lib/client-pairing.js +10 -0
  13. package/lib/client-pairing.js.map +1 -1
  14. package/lib/client-routes.js +183 -94
  15. package/lib/client-routes.js.map +1 -1
  16. package/lib/client.js +589 -97
  17. package/lib/debug-redact.js +253 -0
  18. package/lib/debug-redact.js.map +1 -0
  19. package/lib/debug-report.js +60 -0
  20. package/lib/debug-report.js.map +1 -0
  21. package/lib/fetch-route-intercept.js +23 -0
  22. package/lib/fetch-route-intercept.js.map +1 -1
  23. package/lib/index.js +306 -45
  24. package/lib/index.js.map +1 -1
  25. package/lib/intercept.js +43 -1
  26. package/lib/intercept.js.map +1 -1
  27. package/lib/log-ring.js +76 -0
  28. package/lib/log-ring.js.map +1 -0
  29. package/lib/plugin-adapter-core.js +30 -1
  30. package/lib/plugin-adapter-core.js.map +1 -1
  31. package/lib/plugin-route-intercept.js +506 -11
  32. package/lib/plugin-route-intercept.js.map +1 -1
  33. package/lib/plugin-tunnel.js +210 -18
  34. package/lib/plugin-tunnel.js.map +1 -1
  35. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
  36. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  38. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  39. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  40. package/lib/types/client/locales.d.ts +10 -1
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  43. package/lib/types/client/styles/content.css.d.ts +1 -1
  44. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/layout.css.d.ts +1 -1
  46. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  47. package/lib/types/client/styles/misc.css.d.ts +1 -1
  48. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  49. package/lib/types/client-data/remote-drop.d.ts +64 -0
  50. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  51. package/lib/types/client-data/settings-form.d.ts +3 -0
  52. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  53. package/lib/types/client-pairing.d.ts +8 -0
  54. package/lib/types/client-pairing.d.ts.map +1 -1
  55. package/lib/types/client-routes.d.ts +23 -0
  56. package/lib/types/client-routes.d.ts.map +1 -1
  57. package/lib/types/debug-redact.d.ts +68 -0
  58. package/lib/types/debug-redact.d.ts.map +1 -0
  59. package/lib/types/debug-report.d.ts +58 -0
  60. package/lib/types/debug-report.d.ts.map +1 -0
  61. package/lib/types/fetch-route-intercept.d.ts +26 -0
  62. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  63. package/lib/types/index.d.ts +7 -1
  64. package/lib/types/index.d.ts.map +1 -1
  65. package/lib/types/intercept.d.ts +6 -0
  66. package/lib/types/intercept.d.ts.map +1 -1
  67. package/lib/types/log-ring.d.ts +57 -0
  68. package/lib/types/log-ring.d.ts.map +1 -0
  69. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  70. package/lib/types/plugin-route-intercept.d.ts +8 -0
  71. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  72. package/lib/types/plugin-tunnel.d.ts +37 -2
  73. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  74. package/package.json +3 -2
  75. package/pwa/app.css +13 -2
  76. package/scripts/check-debug-report.mjs +277 -0
  77. package/scripts/check-info-sheet-stacking.mjs +85 -0
  78. package/scripts/check-remote-drop.mjs +130 -0
  79. package/scripts/check-tablet-safe-area.mjs +49 -0
  80. package/src/adapters/dsh-better-sidebar/index.ts +31 -2
  81. package/src/client/MobileSessionInfo.tsx +29 -2
  82. package/src/client/effects/remote-drop.ts +271 -24
  83. package/src/client/locales.ts +24 -4
  84. package/src/client/settings/SettingsSection.tsx +120 -3
  85. package/src/client/styles/content.css.ts +44 -14
  86. package/src/client/styles/layout.css.ts +9 -3
  87. package/src/client/styles/misc.css.ts +38 -0
  88. package/src/client-data/remote-drop.ts +103 -0
  89. package/src/client-data/settings-form.ts +3 -0
  90. package/src/client-pairing.ts +11 -0
  91. package/src/client-routes.ts +196 -90
  92. package/src/debug-redact.ts +290 -0
  93. package/src/debug-report.ts +93 -0
  94. package/src/fetch-route-intercept.ts +29 -0
  95. package/src/index.ts +295 -43
  96. package/src/intercept.ts +46 -0
  97. package/src/log-ring.ts +101 -0
  98. package/src/plugin-adapter-core.ts +29 -2
  99. package/src/plugin-route-intercept.ts +516 -12
  100. package/src/plugin-tunnel.ts +230 -20
package/lib/client.js CHANGED
@@ -2067,7 +2067,7 @@ __modules["client-data/settings-form.js"] = function (require, module, exports)
2067
2067
  * (devices / pairing) plus the T14 envelope (ok / gateway / config / viaGateway).
2068
2068
  */
2069
2069
  Object.defineProperty(exports, "__esModule", { value: true });
2070
- exports.ZenRemoteSettingsForm = exports.SETTINGS_FIELDS = exports.IDLE_HOURS_MAX = exports.SERVER_NAME_MAX = exports.RESTART_FIELDS = exports.PAIRING_CODE_LENGTH = exports.PAIRING_CODE_ALPHABET = exports.DEVICE_TOKEN_FIELD = exports.CLIENT_CONFIG_ROUTE = exports.CLIENT_RECONNECT_ROUTE = exports.CLIENT_STATUS_ROUTE = exports.CLIENT_CLAIM_ROUTE = exports.ADMIN_PLUGIN_BRIDGE_ROUTE = exports.ADMIN_PUSH_TEST_ROUTE = exports.ADMIN_ACTION_ROUTE = exports.ADMIN_PAIR_ROUTE = exports.ADMIN_STATUS_ROUTE = void 0;
2070
+ exports.ZenRemoteSettingsForm = exports.SETTINGS_FIELDS = exports.IDLE_HOURS_MAX = exports.SERVER_NAME_MAX = exports.RESTART_FIELDS = exports.PAIRING_CODE_LENGTH = exports.PAIRING_CODE_ALPHABET = exports.DEVICE_TOKEN_FIELD = exports.CLIENT_CONFIG_ROUTE = exports.CLIENT_DEBUG_REPORT_ROUTE = exports.CLIENT_RECONNECT_ROUTE = exports.CLIENT_STATUS_ROUTE = exports.CLIENT_CLAIM_ROUTE = exports.ADMIN_PLUGIN_BRIDGE_ROUTE = exports.ADMIN_PUSH_TEST_ROUTE = exports.ADMIN_ACTION_ROUTE = exports.ADMIN_PAIR_ROUTE = exports.ADMIN_STATUS_ROUTE = void 0;
2071
2071
  exports.deriveClientStatusView = deriveClientStatusView;
2072
2072
  exports.clientStatusLineOf = clientStatusLineOf;
2073
2073
  exports.normalizePairingCode = normalizePairingCode;
@@ -2100,6 +2100,9 @@ exports.CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim';
2100
2100
  exports.CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status';
2101
2101
  /** T43: one immediate reconnect — answered 409 unless the client is offline. */
2102
2102
  exports.CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect';
2103
+ /** CU-4: the redacted diagnostic report (GET, both roles, machine-local only
2104
+ * — a gateway-forwarded fetch is refused server-side). */
2105
+ exports.CLIENT_DEBUG_REPORT_ROUTE = '/_dsh/zen-remote/client/debug-report';
2103
2106
  /** The lightweight client-facing config route (host half, both roles): the
2104
2107
  * settings page's FALLBACK role probe (T17) — it registers wherever a
2105
2108
  * webServer exists, and since T17 its body carries the effective `role`
@@ -3083,6 +3086,8 @@ exports.zh = {
3083
3086
  'activityOpen': '查看子代理与后台任务',
3084
3087
  'infoStatTurns': '轮次',
3085
3088
  'infoStatSteps': '步骤',
3089
+ 'infoStatTurnsSteps': '轮次 / 步数',
3090
+ 'infoStatSpeed': '输出速度',
3086
3091
  'infoStatTtft': '首字延迟',
3087
3092
  'infoStatLlm': '模型耗时',
3088
3093
  'infoStatTool': '工具耗时',
@@ -3301,15 +3306,23 @@ exports.zh = {
3301
3306
  'settings.client.diagRecentFailures': '最近的远程调用失败',
3302
3307
  'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
3303
3308
  'settings.client.diagCompatCalls': '最近的不兼容调用',
3304
- // --- RD-3 远程会话拖拽守卫的诊断行(浏览器端计数,渲染时现读) ---
3305
- 'settings.client.diagRemoteDrop': '远程拖拽守卫',
3309
+ // --- RD-3/AU-3 附件入口守卫的诊断行(浏览器端计数,渲染时现读) ---
3310
+ 'settings.client.diagRemoteDrop': '附件入口守卫',
3306
3311
  'settings.client.diagRemoteDropOk': '已安装 · 拦截 {intercepted} · 重派发 {redispatched} · 文件夹 {blocked} · 放行 {passthrough} · 重包失败 {failed}',
3312
+ 'settings.client.diagRemoteDropEntries': '分入口 · 拦截 拖拽 {dropI} / 回形针 {pickerI} / 粘贴 {pasteI} · 放行 拖拽 {dropP} / 回形针 {pickerP} / 粘贴 {pasteP}',
3307
3313
  'settings.client.diagRemoteDropOff': '未安装({reason})',
3308
3314
  // --- RD-6 上传改道(fetch-route 拦截)的诊断行:读 remote-status 的 serviceProbe ---
3309
3315
  'settings.client.diagUploadRedirect': '上传改道',
3310
3316
  'settings.client.diagUploadRedirectOk': '已安装 · 上传已接管',
3311
3317
  'settings.client.diagUploadRedirectRefused': '拒装:{reason}',
3312
3318
  'settings.client.diagUploadRedirectPending': '服务注入未触发 · connection {connection} · fileUploads {fileUploads}',
3319
+ // --- CU-4 诊断信息导出(设置页最底部,两种角色都有) ---
3320
+ 'settings.debug.title': '诊断信息',
3321
+ 'settings.debug.export': '导出诊断信息',
3322
+ 'settings.debug.exporting': '正在生成…',
3323
+ 'settings.debug.hint': '下载一份脱敏后的诊断文件(版本、配置形态、连接诊断、插件日志)。令牌与密钥、对话内容、附件和完整文件路径一律不包含,服务器地址只保留类型。建议从机与主服务器各导一份,可直接附到 GitHub issue。',
3324
+ 'settings.debug.done': '诊断文件已下载',
3325
+ 'settings.debug.fail': '导出失败:无法生成诊断报告',
3313
3326
  // --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
3314
3327
  'settings.bridge.title': '插件桥',
3315
3328
  'settings.bridge.empty': '暂无插件登记插件桥',
@@ -3596,6 +3609,8 @@ exports.en = {
3596
3609
  'activityOpen': 'View subagents and background tasks',
3597
3610
  'infoStatTurns': 'Turns',
3598
3611
  'infoStatSteps': 'Steps',
3612
+ 'infoStatTurnsSteps': 'Turns / Steps',
3613
+ 'infoStatSpeed': 'Output speed',
3599
3614
  'infoStatTtft': 'TTFT',
3600
3615
  'infoStatLlm': 'LLM time',
3601
3616
  'infoStatTool': 'Tool time',
@@ -3809,16 +3824,24 @@ exports.en = {
3809
3824
  'settings.client.diagRecentFailures': 'Recent remote call failures',
3810
3825
  'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
3811
3826
  'settings.client.diagCompatCalls': 'Recent incompatible calls',
3812
- // --- RD-3: the remote drop guard's diagnostics line (browser-side
3827
+ // --- RD-3/AU-3: the attachment-entry guard's diagnostics line (browser-side
3813
3828
  // counters, read at render time) ---
3814
- 'settings.client.diagRemoteDrop': 'Remote drop guard',
3829
+ 'settings.client.diagRemoteDrop': 'Attachment entry guard',
3815
3830
  'settings.client.diagRemoteDropOk': 'Installed · intercepted {intercepted} · redispatched {redispatched} · folders {blocked} · passthrough {passthrough} · rewrap failed {failed}',
3831
+ 'settings.client.diagRemoteDropEntries': 'By entry · intercepted drop {dropI} / picker {pickerI} / paste {pasteI} · passthrough drop {dropP} / picker {pickerP} / paste {pasteP}',
3816
3832
  'settings.client.diagRemoteDropOff': 'Not installed ({reason})',
3817
3833
  // --- RD-6: the upload-redirect (fetch-route intercept) diagnostics row ---
3818
3834
  'settings.client.diagUploadRedirect': 'Upload redirect',
3819
3835
  'settings.client.diagUploadRedirectOk': 'Installed · upload wrapped',
3820
3836
  'settings.client.diagUploadRedirectRefused': 'Refused: {reason}',
3821
3837
  'settings.client.diagUploadRedirectPending': 'Service inject never fired · connection {connection} · fileUploads {fileUploads}',
3838
+ // --- CU-4: the diagnostics export (bottom of the settings page, both roles) ---
3839
+ 'settings.debug.title': 'Diagnostics',
3840
+ 'settings.debug.export': 'Export diagnostics',
3841
+ 'settings.debug.exporting': 'Generating…',
3842
+ 'settings.debug.hint': 'Download a redacted diagnostics file (versions, config shape, connection diagnostics, plugin log). Tokens and secrets, conversation content, attachments and full file paths are never included; server addresses keep only their type. Export one on the client and one on the server — both can be attached to a GitHub issue as-is.',
3843
+ 'settings.debug.done': 'Diagnostics file downloaded',
3844
+ 'settings.debug.fail': 'Export failed: the diagnostics report could not be generated',
3822
3845
  // --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
3823
3846
  'settings.bridge.title': 'Plugin bridge',
3824
3847
  'settings.bridge.empty': 'No plugin has registered a bridge yet',
@@ -7100,6 +7123,16 @@ function formatDuration(ms) {
7100
7123
  const whole = Math.round(s);
7101
7124
  return `${Math.floor(whole / 60)}m${whole % 60}s`;
7102
7125
  }
7126
+ /**
7127
+ * Decode-throughput figure: whole tokens from ten up, one decimal below.
7128
+ * Ported digit-for-digit from dsh-client-ui-chat lib/client.js
7129
+ * formatTokensPerSecond (DSH 0.2.x TurnUsagePanel; verified 2026-10-30),
7130
+ * which also owns the "{tps} tok/s" unit the cell appends.
7131
+ */
7132
+ function formatTokensPerSecond(tps) {
7133
+ const clamped = Math.max(0, tps);
7134
+ return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
7135
+ }
7103
7136
  /** Sum of the three disjoint prompt-side billing buckets. */
7104
7137
  function billedInputTokens(usage) {
7105
7138
  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
@@ -7426,8 +7459,23 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
7426
7459
  });
7427
7460
  };
7428
7461
  const cells = [
7429
- { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
7430
- { label: t('infoStatSteps'), value: stats === undefined ? NA : String(stats.steps), sub: undefined },
7462
+ /* Turns and steps share one cell ("3 / 12") so the grid stays 2×3 with
7463
+ output speed taking the freed slot (UI-1, 2026-10-30). */
7464
+ {
7465
+ label: t('infoStatTurnsSteps'),
7466
+ value: stats === undefined ? NA : `${stats.turns} / ${stats.steps}`,
7467
+ sub: undefined,
7468
+ },
7469
+ /* Same guard and formula as the official TurnUsagePanel: decodeMs > 0
7470
+ is its "we have a throughput reading" test, so a session with no
7471
+ usage-reporting step shows the placeholder, never "0 tok/s". */
7472
+ {
7473
+ label: t('infoStatSpeed'),
7474
+ value: stats === undefined || stats.decodeMs <= 0
7475
+ ? NA
7476
+ : `${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`,
7477
+ sub: undefined,
7478
+ },
7431
7479
  {
7432
7480
  label: t('infoStatTtft'),
7433
7481
  value: stats === undefined || stats.ttftSteps === 0 ? NA : formatDuration(stats.ttftMs / stats.ttftSteps),
@@ -8162,12 +8210,18 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
8162
8210
  [data-phase] [class$="_actions"] {
8163
8211
  overflow: hidden !important;
8164
8212
  }
8213
+ /* The 24h clock ("18:06" / "MM-DD 18:06") is a fixed-size item: it must
8214
+ never be the thing that shrinks. DSH 0.2.x wraps it with the usage
8215
+ button in an inline-flex _endInfo (min-width: 0), so a shrinkable
8216
+ time (the old flex: 0 1 auto + min-width: 0) got squeezed inside that
8217
+ non-wrapping box and "18:06" broke into two lines. flex: none pins its
8218
+ width; the turn-tail rules in content.css.ts let _endInfo wrap as a
8219
+ whole instead. */
8165
8220
  [data-phase] [class$="_actions"] [class$="_timeEnd"] {
8166
- flex: 0 1 auto !important;
8167
- min-width: 0 !important;
8221
+ flex: none !important;
8222
+ white-space: nowrap !important;
8168
8223
  overflow: hidden !important;
8169
8224
  text-overflow: ellipsis !important;
8170
- white-space: nowrap !important;
8171
8225
  }
8172
8226
 
8173
8227
  [data-phase] [class$="_scroll"]:has(p) {
@@ -9736,6 +9790,44 @@ exports.MISC_CSS = ` /* ---------- hero composer on mobile ----------
9736
9790
  display: none !important;
9737
9791
  }
9738
9792
  }
9793
+
9794
+ /* ---------- tablet standalone PWA: pay the safe area above 1023px ---------- */
9795
+ /* The gateway serves viewport-fit=cover to every non-desktop device (see
9796
+ coverViewport in lan-gate-server), phone-chrome only manages the meta at
9797
+ <=1023px, and every compensation rule in layout.css lives behind the same
9798
+ 1023px breakpoint and the data-mobile-nav frame marker MobileNavOverlay
9799
+ only sets there. A wide tablet's home-screen PWA — iPad landscape, the
9800
+ 12.9"/13" iPad portrait at exactly 1024, Android tablets — therefore
9801
+ painted under the status bar with nothing paying the inset: the top row
9802
+ of controls sat in the system area and was hard to tap.
9803
+
9804
+ Anchor: the official AppFrame (ui-layout AppFrame.tsx) is the direct
9805
+ parent of the [data-shell-overlay] layer — the same element
9806
+ MobileNavOverlay's findFrame() resolves — and the frame already pads
9807
+ ITSELF for the Windows titlebar (padding-top: --dsh-windows-titlebar-height
9808
+ in AppFrame.module.css), so padding the frame is the host's own pattern
9809
+ for system bars.
9810
+
9811
+ Structural no-op everywhere else, by construction:
9812
+ - display-mode: standalone matches only an installed PWA; desktop
9813
+ browsers report browser.
9814
+ - The Electron app never receives this stylesheet at all (the
9815
+ isDesktopShell gate in client/index.tsx) and never gets cover.
9816
+ - env() insets are hard 0 in every desktop browser, so even a browser
9817
+ that somehow matched the media query computes 0px padding; on iPads
9818
+ the bottom inset is 0 too (no home indicator) — the bottom line only
9819
+ ever pays out on Android tablets with a gesture/nav bar.
9820
+ box-sizing is load-bearing (the S2.1 lesson, see the frame rule in
9821
+ layout.css): the official frame is height:100% content-box, and padding
9822
+ without border-box would grow it past the viewport instead of reserving
9823
+ the strip. Pinned by scripts/check-tablet-safe-area.mjs. */
9824
+ @media (min-width: 1024px) and (display-mode: standalone) {
9825
+ :has(> [data-shell-overlay]) {
9826
+ box-sizing: border-box !important;
9827
+ padding-top: var(--mnav-sat) !important;
9828
+ padding-bottom: var(--mnav-sab) !important;
9829
+ }
9830
+ }
9739
9831
  `;
9740
9832
  };
9741
9833
  __modules["client/styles/home.css.js"] = function (require, module, exports) {
@@ -13412,15 +13504,25 @@ exports.CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ----------
13412
13504
  max-width: none !important;
13413
13505
  }
13414
13506
 
13415
- /* Turn-tail metadata (2026-08-18): the timing stats ("18:06 · 用时 29秒 ·
13416
- 首 token 0.9秒 · 163 tok/s") are desktop-hover-revealed (official
13417
- [data-time-hover-root] gate holds them at opacity 0 until :hover /
13418
- :focus-within) — a phone never hovers, so they were simply invisible;
13419
- and even ignoring that, the row is nowrap + overflow:hidden (plus this
13420
- plugin's own legacy ellipsis clamp in layout.css.ts), which clipped the
13421
- text. On phones: always visible, own full-width line under the action
13422
- icons, wrapping freely, one type size down. !important + the extra
13423
- [class$="_actions"] hop out-specifies both upstream and layout.css.ts. */
13507
+ /* Turn-tail metadata (2026-08-18; reshaped 2026-10-30 for DSH 0.2.x): the
13508
+ tail's 24h clock is desktop-hover-revealed (official
13509
+ [data-time-hover-root] gate holds it at opacity 0 until :hover /
13510
+ :focus-within) — a phone never hovers, so it was simply invisible; and
13511
+ the row is a fixed-height nowrap + overflow:hidden flex line (plus this
13512
+ plugin's own clamp in layout.css.ts), which clipped it.
13513
+
13514
+ DSH 0.2.x (MessageIconActions, dsh-client-ui-chat) groups the usage
13515
+ button (TurnUsagePanel) and the clock (_timeEnd) inside ONE inline-flex
13516
+ _endInfo (min-width: 0; align-items: center; gap: 8px; margin-left: 8px)
13517
+ that never wraps internally. The 0.1.x-era rule here gave _timeEnd
13518
+ flex-basis: 100% + white-space: normal — inside _endInfo that no longer
13519
+ reads as "own full-width line", it reads as "shrink to nothing and
13520
+ break", and with the transcript-wide overflow-wrap: anywhere above,
13521
+ "18:06" rendered as two lines. On phones now: always visible; the row
13522
+ may grow in height and wrap; _endInfo drops to the next line as one
13523
+ unit when icons + usage + clock do not fit; the clock itself never
13524
+ shrinks or breaks. !important + the extra [class$="_actions"] hop
13525
+ out-specifies both upstream and layout.css.ts. */
13424
13526
  /* Pin the tail row's width chain before allowing wrap: with wrap enabled
13425
13527
  the row's intrinsic (shrink-to-fit) width collapses to its widest
13426
13528
  single icon, taking the whole tail down to ~16px. */
@@ -13428,9 +13530,26 @@ exports.CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ----------
13428
13530
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
13429
13531
  width: 100%;
13430
13532
  }
13533
+ /* height: auto releases the upstream fixed height: calc(28px + …);
13534
+ overflow: visible beats layout.css.ts's overflow: hidden (same
13535
+ specificity — this file is concatenated last, so it wins the tie). A
13536
+ wrapped second line must not be cut off vertically; min-height keeps
13537
+ the one-line case at the upstream height so nothing shifts when it
13538
+ fits. */
13431
13539
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] {
13432
13540
  flex-wrap: wrap;
13433
13541
  row-gap: 2px;
13542
+ height: auto !important;
13543
+ min-height: 28px;
13544
+ overflow: visible !important;
13545
+ }
13546
+ /* _endInfo moves as a whole: flex: none so the usage + clock group is
13547
+ never asked to shrink — when the row is too narrow the entire group
13548
+ wraps to the next line instead of compressing the clock inside it.
13549
+ max-width keeps an unusually long usage label inside the column. */
13550
+ [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_endInfo"] {
13551
+ flex: none;
13552
+ max-width: 100%;
13434
13553
  }
13435
13554
  /* Wide companion to the rule below: mirrors the upstream hover-gate
13436
13555
  selector shape directly, with no assumptions about the tail's DOM
@@ -13441,13 +13560,16 @@ exports.CONTENT_CSS = `/* ---------- chat markdown content (< 768px) ----------
13441
13560
  opacity: 1 !important;
13442
13561
  transition: none !important;
13443
13562
  }
13563
+ /* overflow-wrap / word-break: normal undo the [data-chat-flow]
13564
+ overflow-wrap: anywhere inherited from the top of this block — that is
13565
+ the rule that let "18:06" break between digits once its box was
13566
+ squeezed. */
13444
13567
  [data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"] {
13445
13568
  opacity: 1 !important;
13446
- flex-basis: 100% !important;
13447
- white-space: normal !important;
13448
- overflow: visible !important;
13449
- text-overflow: clip !important;
13450
- padding-left: 0 !important;
13569
+ flex: none !important;
13570
+ white-space: nowrap !important;
13571
+ overflow-wrap: normal !important;
13572
+ word-break: normal !important;
13451
13573
  font-size: 12px;
13452
13574
  line-height: 18px;
13453
13575
  }
@@ -16158,6 +16280,69 @@ const EXPORT_STYLE = {
16158
16280
  whiteSpace: 'nowrap',
16159
16281
  };
16160
16282
  };
16283
+ __modules["client-data/current-session.js"] = function (require, module, exports) {
16284
+ "use strict";
16285
+ /**
16286
+ * T62: the browser half's current-session parsing, as pure functions — the
16287
+ * readable core of the reporter (src/client/current-session-report.ts) and
16288
+ * the one place its rules are unit-tested without a DOM.
16289
+ *
16290
+ * The host DSH persists its selection (the session its UI has open) to
16291
+ * localStorage under `dsh.sessions.current` as a JSON object carrying
16292
+ * `sessionId` (dsh-client-ui-workspace's snapshot store, `replaceMain` /
16293
+ * `clearMain`). The reporter re-reads that key and POSTs it to the backend
16294
+ * whenever it CHANGES; the backend's intercept layer judges close frames
16295
+ * against it — a closed session the user has open keeps its page and its
16296
+ * 「远程已关闭」 banner, anything else hides. The vocabulary mirrors the
16297
+ * intercept side's `CurrentSessionRead`:
16298
+ * - `unavailable`: the key is absent or the value unreadable — NEVER
16299
+ * report; the backend keeps its conservative tombstone behavior;
16300
+ * - `none`: readable, no session open — report `{sessionId: null}`;
16301
+ * - `open`: a session id — report `{sessionId}`.
16302
+ */
16303
+ Object.defineProperty(exports, "__esModule", { value: true });
16304
+ exports.CURRENT_SESSION_STORAGE_KEY = void 0;
16305
+ exports.parseCurrentSessionStorage = parseCurrentSessionStorage;
16306
+ exports.currentSessionReportEquals = currentSessionReportEquals;
16307
+ /** Mirror of src/intercept.ts's key (the client build cannot import the
16308
+ * host file). The host's selection store persists under this exact name. */
16309
+ exports.CURRENT_SESSION_STORAGE_KEY = 'dsh.sessions.current';
16310
+ /** Parse one raw localStorage read. `null` / `undefined` (the key is absent
16311
+ * — the host store only starts persisting with its first write) and any
16312
+ * value that does not parse into an object with a usable shape are
16313
+ * `unavailable`: no signal, never a guess. An object WITHOUT a `sessionId`
16314
+ * (the host's `clearMain` writes `{}`) is a real, reportable "nothing is
16315
+ * open". */
16316
+ function parseCurrentSessionStorage(raw) {
16317
+ if (raw === null || raw === undefined)
16318
+ return { kind: 'unavailable' };
16319
+ let parsed;
16320
+ try {
16321
+ parsed = JSON.parse(raw);
16322
+ }
16323
+ catch {
16324
+ return { kind: 'unavailable' };
16325
+ }
16326
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
16327
+ return { kind: 'unavailable' };
16328
+ const sessionId = parsed.sessionId;
16329
+ if (typeof sessionId === 'string' && sessionId !== '')
16330
+ return { kind: 'open', sessionId };
16331
+ if (sessionId === undefined || sessionId === null || sessionId === '')
16332
+ return { kind: 'none' };
16333
+ return { kind: 'unavailable' };
16334
+ }
16335
+ /** Whether one report differs from another — the reporter posts only on a
16336
+ * change, so this compares content, never object identity: kind first, then
16337
+ * the session id. */
16338
+ function currentSessionReportEquals(a, b) {
16339
+ if (a.kind !== b.kind)
16340
+ return false;
16341
+ if (a.kind === 'open' && b.kind === 'open')
16342
+ return a.sessionId === b.sessionId;
16343
+ return true;
16344
+ }
16345
+ };
16161
16346
  __modules["virtual-id.js"] = function (require, module, exports) {
16162
16347
  "use strict";
16163
16348
  /**
@@ -16270,6 +16455,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16270
16455
  exports.isRemoteSessionId = isRemoteSessionId;
16271
16456
  exports.decideRemoteDrop = decideRemoteDrop;
16272
16457
  exports.rewrapFiles = rewrapFiles;
16458
+ exports.decideRemoteIntake = decideRemoteIntake;
16273
16459
  const virtual_id_ts_1 = require("./virtual-id.js");
16274
16460
  /** True when the id is one of this plugin's virtual (relay) session ids —
16275
16461
  * the `zr~<serverId>~<id>` form. `undefined` (no readable current-session
@@ -16322,68 +16508,48 @@ function decideRemoteDrop(input) {
16322
16508
  function rewrapFiles(files) {
16323
16509
  return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }));
16324
16510
  }
16325
- };
16326
- __modules["client-data/current-session.js"] = function (require, module, exports) {
16327
- "use strict";
16328
16511
  /**
16329
- * T62: the browser half's current-session parsing, as pure functions — the
16330
- * readable core of the reporter (src/client/current-session-report.ts) and
16331
- * the one place its rules are unit-tested without a DOM.
16512
+ * The three gates, then the intervention rule. The gates are decideRemoteDrop's
16513
+ * verbatim (all must affirmatively pass — a missing signal reads as local and
16514
+ * never intervenes); the rule after them:
16515
+ * 1. `items === undefined` — no inventory, pass through (`no-entry-api`;
16516
+ * deciding blind could eat a working flow);
16517
+ * 2. any directory — `block-directories`, ahead of every redirect: a folder
16518
+ * cannot be fixed by single-file upload, the whole intake is stopped with
16519
+ * a toast (same semantics as the drop branch);
16520
+ * 3. no items at all — passthrough (`no-files`; the plain-text paste shape);
16521
+ * 4. otherwise — `redirect` when ANY item is a `drop` item (the frozen
16522
+ * "any file intervenes" semantics) or carries a path while not being an
16523
+ * image (the zero-copy bad branch — exactly what issue #49 broke on);
16524
+ * `no-path-or-image-only` passthrough when none does.
16332
16525
  *
16333
- * The host DSH persists its selection (the session its UI has open) to
16334
- * localStorage under `dsh.sessions.current` as a JSON object carrying
16335
- * `sessionId` (dsh-client-ui-workspace's snapshot store, `replaceMain` /
16336
- * `clearMain`). The reporter re-reads that key and POSTs it to the backend
16337
- * whenever it CHANGES; the backend's intercept layer judges close frames
16338
- * against it — a closed session the user has open keeps its page and its
16339
- * 「远程已关闭」 banner, anything else hides. The vocabulary mirrors the
16340
- * intercept side's `CurrentSessionRead`:
16341
- * - `unavailable`: the key is absent or the value unreadable — NEVER
16342
- * report; the backend keeps its conservative tombstone behavior;
16343
- * - `none`: readable, no session open — report `{sessionId: null}`;
16344
- * - `open`: a session id — report `{sessionId}`.
16345
- */
16346
- Object.defineProperty(exports, "__esModule", { value: true });
16347
- exports.CURRENT_SESSION_STORAGE_KEY = void 0;
16348
- exports.parseCurrentSessionStorage = parseCurrentSessionStorage;
16349
- exports.currentSessionReportEquals = currentSessionReportEquals;
16350
- /** Mirror of src/intercept.ts's key (the client build cannot import the
16351
- * host file). The host's selection store persists under this exact name. */
16352
- exports.CURRENT_SESSION_STORAGE_KEY = 'dsh.sessions.current';
16353
- /** Parse one raw localStorage read. `null` / `undefined` (the key is absent
16354
- * — the host store only starts persisting with its first write) and any
16355
- * value that does not parse into an object with a usable shape are
16356
- * `unavailable`: no signal, never a guess. An object WITHOUT a `sessionId`
16357
- * (the host's `clearMain` writes `{}`) is a real, reportable "nothing is
16358
- * open". */
16359
- function parseCurrentSessionStorage(raw) {
16360
- if (raw === null || raw === undefined)
16361
- return { kind: 'unavailable' };
16362
- let parsed;
16363
- try {
16364
- parsed = JSON.parse(raw);
16365
- }
16366
- catch {
16367
- return { kind: 'unavailable' };
16368
- }
16369
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
16370
- return { kind: 'unavailable' };
16371
- const sessionId = parsed.sessionId;
16372
- if (typeof sessionId === 'string' && sessionId !== '')
16373
- return { kind: 'open', sessionId };
16374
- if (sessionId === undefined || sessionId === null || sessionId === '')
16375
- return { kind: 'none' };
16376
- return { kind: 'unavailable' };
16377
- }
16378
- /** Whether one report differs from another — the reporter posts only on a
16379
- * change, so this compares content, never object identity: kind first, then
16380
- * the session id. */
16381
- function currentSessionReportEquals(a, b) {
16382
- if (a.kind !== b.kind)
16383
- return false;
16384
- if (a.kind === 'open' && b.kind === 'open')
16385
- return a.sessionId === b.sessionId;
16386
- return true;
16526
+ * Drop-entry parity pin: a `drop` item's intervention semantics here are
16527
+ * kept EQUIVALENT to `decideRemoteDrop` — same gates, directory rule and
16528
+ * "any file intervenes" — with exactly ONE deliberate divergence: when
16529
+ * `items === undefined`, `decideRemoteDrop` redispatches (a drop always has
16530
+ * a Files payload worth re-shaping) while this function passes through
16531
+ * (`no-entry-api` — an intake caller that cannot even enumerate its items
16532
+ * must not be intercepted blind). If either function's gate sequence or
16533
+ * drop-entry rule changes, update the other in the same commit; the truth
16534
+ * tables in scripts/check-remote-drop.mjs pin both sides.
16535
+ */
16536
+ function decideRemoteIntake(input) {
16537
+ if (!input.trusted)
16538
+ return { action: 'passthrough', reason: 'untrusted-event' };
16539
+ if (!input.hasHostPaths)
16540
+ return { action: 'passthrough', reason: 'no-host-paths' };
16541
+ if (!isRemoteSessionId(input.currentSessionId))
16542
+ return { action: 'passthrough', reason: 'not-remote-session' };
16543
+ if (input.items === undefined)
16544
+ return { action: 'passthrough', reason: 'no-entry-api' };
16545
+ if (input.items.some((item) => item.isDirectory))
16546
+ return { action: 'block-directories', reason: 'contains-directory' };
16547
+ if (input.items.length === 0)
16548
+ return { action: 'passthrough', reason: 'no-files' };
16549
+ const intervenes = input.items.some((item) => item.entry === 'drop' || (item.hasPath && !item.isImage));
16550
+ return intervenes
16551
+ ? { action: 'redirect', reason: 'remote-path-files' }
16552
+ : { action: 'passthrough', reason: 'no-path-or-image-only' };
16387
16553
  }
16388
16554
  };
16389
16555
  __modules["client/effects/remote-drop.js"] = function (require, module, exports) {
@@ -16398,18 +16564,60 @@ const locales_ts_1 = require("./client/locales.js");
16398
16564
  * Re-mounting (a plugin row reload) resets it. */
16399
16565
  let diagnostics = freshDiagnostics();
16400
16566
  function freshDiagnostics() {
16401
- return { installed: false, notInstalledReason: undefined, intercepted: 0, redispatched: 0, blockedDirectories: 0, passthrough: 0, rewrapFailed: 0 };
16567
+ return {
16568
+ installed: false,
16569
+ notInstalledReason: undefined,
16570
+ intercepted: 0,
16571
+ redispatched: 0,
16572
+ blockedDirectories: 0,
16573
+ passthrough: 0,
16574
+ rewrapFailed: 0,
16575
+ interceptedBy: { drop: 0, picker: 0, paste: 0 },
16576
+ redirectedBy: { drop: 0, picker: 0, paste: 0 },
16577
+ blockedBy: { drop: 0, picker: 0, paste: 0 },
16578
+ passthroughBy: { drop: 0, picker: 0, paste: 0 },
16579
+ };
16402
16580
  }
16403
16581
  /** A snapshot for the diagnostics carriers (settings page). Copying keeps
16404
- * callers from holding a live reference into a later mount's state. */
16582
+ * callers from holding a live reference into a later mount's state — the
16583
+ * per-entry objects are copied too, or a caller would keep counting. */
16405
16584
  function remoteDropDiagnostics() {
16406
- return { ...diagnostics };
16585
+ return {
16586
+ ...diagnostics,
16587
+ interceptedBy: { ...diagnostics.interceptedBy },
16588
+ redirectedBy: { ...diagnostics.redirectedBy },
16589
+ blockedBy: { ...diagnostics.blockedBy },
16590
+ passthroughBy: { ...diagnostics.passthroughBy },
16591
+ };
16592
+ }
16593
+ function bump(counts, entry) {
16594
+ counts[entry] += 1;
16407
16595
  }
16408
16596
  /** The contextBridge global's existence only — never touched otherwise
16409
16597
  * (it is deep-frozen and must stay unwrapped). */
16410
16598
  function hostPathsOf() {
16411
16599
  return globalThis.__DSH_HOST_PATHS__;
16412
16600
  }
16601
+ /** The bridge's `pathFor` — the preload's `webUtils.getPathForFile`. A
16602
+ * non-empty string means the File still carries its disk path, the exact
16603
+ * fact the host's zero-copy branch keys on. Anything else (bridge shape,
16604
+ * non-string answer, a throw) reads as "no path" — the conservative
16605
+ * direction: the file is treated as already byte-uploading and stays
16606
+ * untouched. */
16607
+ function bridgePathFor(bridge, file) {
16608
+ if (bridge === null || typeof bridge !== 'object')
16609
+ return '';
16610
+ const pathFor = bridge.pathFor;
16611
+ if (typeof pathFor !== 'function')
16612
+ return '';
16613
+ try {
16614
+ const result = pathFor.call(bridge, file);
16615
+ return typeof result === 'string' ? result : '';
16616
+ }
16617
+ catch {
16618
+ return '';
16619
+ }
16620
+ }
16413
16621
  /** The current-session signal, read AT EVENT TIME (the selection changes
16414
16622
  * while the page is open; a mount-time snapshot would go stale). Any
16415
16623
  * storage failure reads as "no signal" = local session = passthrough. */
@@ -16468,6 +16676,28 @@ function dismissHostDropOverlay() {
16468
16676
  // No window / Event in this host: the overlay is the only casualty.
16469
16677
  }
16470
16678
  }
16679
+ /** Build the redispatch payload WITHOUT touching any event: re-wrap the files
16680
+ * (path-stripped) and pack them into a fresh DataTransfer. `undefined` means
16681
+ * the file list was empty (nothing to dispatch — the caller passes through);
16682
+ * a throw means a re-wrap or an items.add failed (0-byte / exotic File) and
16683
+ * the caller releases the original event untouched (宁坏勿丢). Shared by all
16684
+ * three entries — the payload shape is the redispatch contract, and the
16685
+ * drop entry's behavior is frozen by its existing tests. */
16686
+ function buildDropPayload(files) {
16687
+ const rewrapped = (0, remote_drop_ts_1.rewrapFiles)(files);
16688
+ if (rewrapped.length === 0)
16689
+ return undefined;
16690
+ const payload = new DataTransfer();
16691
+ for (const file of rewrapped)
16692
+ payload.items.add(file);
16693
+ return payload;
16694
+ }
16695
+ /** Dispatch the host-shaped synthetic drop on document, bubble phase — the
16696
+ * host's own drop handler (mask reset, busy rejection, addFiles) digests it;
16697
+ * our capture listener skips it via the isTrusted gate. */
16698
+ function dispatchSyntheticDrop(payload) {
16699
+ document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }));
16700
+ }
16471
16701
  function mountRemoteDropGuard(ctx) {
16472
16702
  ctx.effect(() => {
16473
16703
  // Fresh state per mount: a plugin reload re-runs apply() and must not
@@ -16544,6 +16774,7 @@ function mountRemoteDropGuard(ctx) {
16544
16774
  });
16545
16775
  if (decision.action === 'passthrough') {
16546
16776
  diagnostics.passthrough += 1;
16777
+ bump(diagnostics.passthroughBy, 'drop');
16547
16778
  return;
16548
16779
  }
16549
16780
  if (decision.action === 'block-directories') {
@@ -16553,7 +16784,9 @@ function mountRemoteDropGuard(ctx) {
16553
16784
  event.stopPropagation();
16554
16785
  dismissHostDropOverlay();
16555
16786
  diagnostics.intercepted += 1;
16787
+ bump(diagnostics.interceptedBy, 'drop');
16556
16788
  diagnostics.blockedDirectories += 1;
16789
+ bump(diagnostics.blockedBy, 'drop');
16557
16790
  showToast();
16558
16791
  return;
16559
16792
  }
@@ -16562,23 +16795,23 @@ function mountRemoteDropGuard(ctx) {
16562
16795
  // host still receives today's (path-chip) behavior instead of
16563
16796
  // nothing at all.
16564
16797
  try {
16565
- const rewrapped = (0, remote_drop_ts_1.rewrapFiles)(Array.from(transfer.files));
16566
- if (rewrapped.length === 0) {
16798
+ const payload = buildDropPayload(Array.from(transfer.files));
16799
+ if (payload === undefined) {
16567
16800
  diagnostics.passthrough += 1;
16801
+ bump(diagnostics.passthroughBy, 'drop');
16568
16802
  return;
16569
16803
  }
16570
- const payload = new DataTransfer();
16571
- for (const file of rewrapped)
16572
- payload.items.add(file);
16573
16804
  event.preventDefault();
16574
16805
  event.stopPropagation();
16575
16806
  diagnostics.intercepted += 1;
16807
+ bump(diagnostics.interceptedBy, 'drop');
16576
16808
  // The host's drop handler sits on document, bubble phase (F4): a
16577
16809
  // bubbling synthetic drop on document reaches it. Its own handler
16578
16810
  // does the preventDefault / mask reset / addFiles work; our capture
16579
16811
  // listener skips this event via the isTrusted gate above.
16580
- document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }));
16812
+ dispatchSyntheticDrop(payload);
16581
16813
  diagnostics.redispatched += 1;
16814
+ bump(diagnostics.redirectedBy, 'drop');
16582
16815
  }
16583
16816
  catch {
16584
16817
  // A re-wrap or a single items.add threw (0-byte / exotic File):
@@ -16587,8 +16820,173 @@ function mountRemoteDropGuard(ctx) {
16587
16820
  }
16588
16821
  };
16589
16822
  document.addEventListener('drop', onDrop, true);
16823
+ // --- the picker entry (AU-2): the composer's hidden file selector -------
16824
+ // F1: the host composer's picker is an `input[type=file][multiple][hidden]`
16825
+ // with NO accept attribute, and its React onChange feeds intakeFiles →
16826
+ // addFiles — the same zero-copy branch the drop entry guards. Judged by
16827
+ // STRUCTURE, never by class names (hash classes cannot be anchored): an
16828
+ // input with an accept attribute (import / restore pickers), a visible
16829
+ // one, or a `webkitdirectory` folder picker (a third-party composition
16830
+ // could combine all four) is somebody else's and passes through. The
16831
+ // host composer has none of those, so the hit is unchanged.
16832
+ const onChange = (event) => {
16833
+ if (!event.isTrusted)
16834
+ return;
16835
+ const input = event.target;
16836
+ if (!(input instanceof HTMLInputElement))
16837
+ return;
16838
+ if (input.type !== 'file' || !input.multiple || !input.hidden || input.hasAttribute('accept') || input.webkitdirectory)
16839
+ return;
16840
+ const files = Array.from(input.files ?? []);
16841
+ if (files.length === 0)
16842
+ return;
16843
+ // The remote gate first, BEFORE counting: a local session's everyday
16844
+ // picker use must not show up in the diagnostics at all (the counters
16845
+ // are a record of what the guard saw in remote sessions).
16846
+ if (!(0, remote_drop_ts_1.isRemoteSessionId)(readCurrentSessionId()))
16847
+ return;
16848
+ const bridge = hostPathsOf();
16849
+ const items = files.map((file) => ({
16850
+ entry: 'picker',
16851
+ // A picker yields plain files; directories are structurally
16852
+ // impossible, so the block-directories branch is unreachable here
16853
+ // (defensive passthrough below).
16854
+ isDirectory: false,
16855
+ hasPath: bridgePathFor(bridge, file) !== '',
16856
+ isImage: file.type.startsWith('image/'),
16857
+ }));
16858
+ const decision = (0, remote_drop_ts_1.decideRemoteIntake)({
16859
+ hasHostPaths: bridge !== undefined,
16860
+ currentSessionId: readCurrentSessionId(),
16861
+ trusted: event.isTrusted,
16862
+ items,
16863
+ });
16864
+ if (decision.action !== 'redirect') {
16865
+ // passthrough, or the defensive block branch that cannot happen for
16866
+ // a picker — either way zero intervention.
16867
+ diagnostics.passthrough += 1;
16868
+ bump(diagnostics.passthroughBy, 'picker');
16869
+ return;
16870
+ }
16871
+ // Build the payload BEFORE stopping the event (宁坏勿丢): a re-wrap
16872
+ // failure releases the change untouched — the host keeps today's
16873
+ // (path-chip) behavior instead of losing the pick entirely.
16874
+ try {
16875
+ const payload = buildDropPayload(files);
16876
+ if (payload === undefined) {
16877
+ diagnostics.passthrough += 1;
16878
+ bump(diagnostics.passthroughBy, 'picker');
16879
+ return;
16880
+ }
16881
+ // stopPropagation keeps the event from reaching React's delegated
16882
+ // onChange at the root container (F5); clearing the value lets the
16883
+ // SAME file trigger a later change again (the host's own onPickFiles
16884
+ // habit, applied here since the host's handler no longer runs).
16885
+ event.stopPropagation();
16886
+ input.value = '';
16887
+ diagnostics.intercepted += 1;
16888
+ bump(diagnostics.interceptedBy, 'picker');
16889
+ dispatchSyntheticDrop(payload);
16890
+ diagnostics.redispatched += 1;
16891
+ bump(diagnostics.redirectedBy, 'picker');
16892
+ }
16893
+ catch {
16894
+ diagnostics.rewrapFailed += 1;
16895
+ }
16896
+ };
16897
+ document.addEventListener('change', onChange, true);
16898
+ // --- the paste entry (AU-2): the editor's clipboard files ---------------
16899
+ // F2: the composer editor (a contenteditable root) reads file items off
16900
+ // clipboardData; a path-carrying non-image lands on the same zero-copy
16901
+ // branch. Pasting into any other target (settings fields, search) is not
16902
+ // ours. clipboardData is read-only, so a redirect can only re-dispatch
16903
+ // — preventDefault keeps the editor from inserting anything.
16904
+ const onPaste = (event) => {
16905
+ if (!event.isTrusted)
16906
+ return;
16907
+ const transfer = event.clipboardData;
16908
+ if (transfer === null)
16909
+ return;
16910
+ const firstElement = event.composedPath().find((node) => node instanceof Element);
16911
+ if (firstElement === undefined || firstElement.closest('[contenteditable="true"]') === null)
16912
+ return;
16913
+ // The remote gate BEFORE any per-item work (same rationale as picker):
16914
+ // a local session's paste — including pasted FILES — returns here, so
16915
+ // it never runs bridgePathFor per file and never reaches the counters.
16916
+ // Everything after this point still runs inside this one synchronous
16917
+ // handler, within the dispatch lifetime clipboardData requires.
16918
+ if (!(0, remote_drop_ts_1.isRemoteSessionId)(readCurrentSessionId()))
16919
+ return;
16920
+ const bridge = hostPathsOf();
16921
+ const files = [];
16922
+ const items = [];
16923
+ for (const item of Array.from(transfer.items)) {
16924
+ if (item.kind !== 'file')
16925
+ continue;
16926
+ const file = item.getAsFile();
16927
+ if (file === null)
16928
+ continue;
16929
+ // Directory detection with the host's own default: no entry API
16930
+ // reads as "not a directory". Items without a File behind them never
16931
+ // reach the descriptors, so a plain-text paste arrives as an empty
16932
+ // list — the pure no-files passthrough.
16933
+ const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
16934
+ files.push(file);
16935
+ items.push({
16936
+ entry: 'paste',
16937
+ isDirectory: entry?.isDirectory === true,
16938
+ hasPath: bridgePathFor(bridge, file) !== '',
16939
+ isImage: file.type.startsWith('image/'),
16940
+ });
16941
+ }
16942
+ const decision = (0, remote_drop_ts_1.decideRemoteIntake)({
16943
+ hasHostPaths: bridge !== undefined,
16944
+ currentSessionId: readCurrentSessionId(),
16945
+ trusted: event.isTrusted,
16946
+ items,
16947
+ });
16948
+ if (decision.action === 'block-directories') {
16949
+ // A folder paste cannot be fixed by single-file upload: same toast
16950
+ // semantics as the drop branch (no overlay to dismiss — paste never
16951
+ // opened one).
16952
+ event.preventDefault();
16953
+ event.stopPropagation();
16954
+ diagnostics.intercepted += 1;
16955
+ bump(diagnostics.interceptedBy, 'paste');
16956
+ diagnostics.blockedDirectories += 1;
16957
+ bump(diagnostics.blockedBy, 'paste');
16958
+ showToast();
16959
+ return;
16960
+ }
16961
+ if (decision.action !== 'redirect') {
16962
+ diagnostics.passthrough += 1;
16963
+ bump(diagnostics.passthroughBy, 'paste');
16964
+ return;
16965
+ }
16966
+ try {
16967
+ const payload = buildDropPayload(files);
16968
+ if (payload === undefined) {
16969
+ diagnostics.passthrough += 1;
16970
+ bump(diagnostics.passthroughBy, 'paste');
16971
+ return;
16972
+ }
16973
+ event.preventDefault();
16974
+ event.stopPropagation();
16975
+ diagnostics.intercepted += 1;
16976
+ bump(diagnostics.interceptedBy, 'paste');
16977
+ dispatchSyntheticDrop(payload);
16978
+ diagnostics.redispatched += 1;
16979
+ bump(diagnostics.redirectedBy, 'paste');
16980
+ }
16981
+ catch {
16982
+ diagnostics.rewrapFailed += 1;
16983
+ }
16984
+ };
16985
+ document.addEventListener('paste', onPaste, true);
16590
16986
  return () => {
16591
16987
  document.removeEventListener('drop', onDrop, true);
16988
+ document.removeEventListener('change', onChange, true);
16989
+ document.removeEventListener('paste', onPaste, true);
16592
16990
  removeToast();
16593
16991
  // Keep the counters readable after a dispose (the settings page may
16594
16992
  // still be open mid-reload); `installed` is the armed flag only.
@@ -16639,7 +17037,9 @@ const react_1 = require("react");
16639
17037
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
16640
17038
  const settings_form_ts_1 = require("./client-data/settings-form.js");
16641
17039
  const plugin_bridge_ts_1 = require("./client-data/plugin-bridge.js");
16642
- const remote_drop_ts_1 = require("./client/effects/remote-drop.js");
17040
+ const current_session_ts_1 = require("./client-data/current-session.js");
17041
+ const remote_drop_ts_1 = require("./client-data/remote-drop.js");
17042
+ const remote_drop_ts_2 = require("./client/effects/remote-drop.js");
16643
17043
  const shares_ts_1 = require("./client-data/shares.js");
16644
17044
  /** Device roles the pairing picker offers, in display order. */
16645
17045
  const PAIR_ROLES = ['web', 'desktop-client'];
@@ -16665,6 +17065,45 @@ async function postJson(url, payload) {
16665
17065
  return { ok: false, sent: 0, failed: 0 };
16666
17066
  }
16667
17067
  }
17068
+ /** CU-4: the export's download name, `zen-remote-debug-<role>-<yyyymmdd-hhmm>.json`,
17069
+ * stamped in the browser's local time (the file is for the user's own
17070
+ * folder; the report's `_meta.generatedAt` carries the exact UTC instant). */
17071
+ function debugExportFileName(role, now) {
17072
+ const pad = (n) => String(n).padStart(2, '0');
17073
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
17074
+ return `zen-remote-debug-${role}-${stamp}.json`;
17075
+ }
17076
+ /** CU-4: one transient notice for the export's outcome — the same inline-
17077
+ * styled overlay habit the remote-drop guard's folder toast uses (styles
17078
+ * stay inline: nothing of this goes into pwa/app.css or the style sheets),
17079
+ * one at a time, gone after 3 s, never intercepting taps. */
17080
+ let debugToast;
17081
+ let debugToastTimer;
17082
+ function showDebugToast(text) {
17083
+ if (debugToastTimer !== undefined)
17084
+ window.clearTimeout(debugToastTimer);
17085
+ debugToast?.remove();
17086
+ const el = document.createElement('div');
17087
+ el.dataset.zenRemote = 'debug-export-toast';
17088
+ el.textContent = text;
17089
+ el.style.cssText = [
17090
+ 'position:fixed', 'left:50%', 'transform:translateX(-50%)',
17091
+ 'bottom:calc(env(safe-area-inset-bottom, 0px) + 32px)',
17092
+ 'z-index:2147483000',
17093
+ 'background:rgba(0,0,0,.82)', 'color:#fff',
17094
+ 'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
17095
+ 'padding:10px 16px', 'border-radius:10px', 'max-width:90vw', 'text-align:center',
17096
+ 'pointer-events:none',
17097
+ ].join(';');
17098
+ document.body.appendChild(el);
17099
+ debugToast = el;
17100
+ debugToastTimer = window.setTimeout(() => {
17101
+ el.remove();
17102
+ if (debugToast === el)
17103
+ debugToast = undefined;
17104
+ debugToastTimer = undefined;
17105
+ }, 3000);
17106
+ }
16668
17107
  function SettingsSection(props) {
16669
17108
  // The summary view is a one-liner only — it must not even fetch
16670
17109
  // admin/status, so every hook (and the fetch) lives in the page component.
@@ -16738,6 +17177,8 @@ function SettingsSectionPage({ config, shares, t }) {
16738
17177
  // JS dialog methods, so renames edit inline).
16739
17178
  const [renaming, setRenaming] = (0, react_1.useState)(null);
16740
17179
  const [pushTest, setPushTest] = (0, react_1.useState)({ state: 'idle' });
17180
+ // CU-4: the debug export's in-flight marker.
17181
+ const [debugBusy, setDebugBusy] = (0, react_1.useState)(false);
16741
17182
  // The client group's pairing form (T16). The address prefills from the row
16742
17183
  // (once the shared form's document is served) until the user types into it;
16743
17184
  // the code normalizes as typed. The registered name is the shared 设备名称
@@ -16835,7 +17276,7 @@ function SettingsSectionPage({ config, shares, t }) {
16835
17276
  // time — no snapshot state of its own: the initial mount, the 15s
16836
17277
  // connected-status poll and the countdown ticker all re-render this page,
16837
17278
  // and each render takes the values as they are right then.
16838
- const dropDiag = (0, remote_drop_ts_1.remoteDropDiagnostics)();
17279
+ const dropDiag = (0, remote_drop_ts_2.remoteDropDiagnostics)();
16839
17280
  const clientRetryAt = clientView?.nextRetryAt;
16840
17281
  const clientCountingDown = clientRetryAt !== undefined;
16841
17282
  // Offline with a pending retry: refresh the status shortly after the
@@ -17089,6 +17530,50 @@ function SettingsSectionPage({ config, shares, t }) {
17089
17530
  const result = await postJson(settings_form_ts_1.ADMIN_PUSH_TEST_ROUTE, {});
17090
17531
  setPushTest(result.ok ? { state: 'ok', sent: result.sent, failed: result.failed } : { state: 'fail' });
17091
17532
  };
17533
+ // CU-4 导出诊断信息: the backend's redacted report (both roles answer
17534
+ // their own), merged with the two facts only THIS page can read — the
17535
+ // attachment-entry guard's browser-side counters and whether the session
17536
+ // open right now is a remote one — then downloaded as one JSON file. The
17537
+ // browser plate carries counters and a boolean, nothing else: no session
17538
+ // id, no storage dump.
17539
+ const runDebugExport = async () => {
17540
+ setDebugBusy(true);
17541
+ try {
17542
+ const res = await fetch(settings_form_ts_1.CLIENT_DEBUG_REPORT_ROUTE);
17543
+ if (!res.ok)
17544
+ throw new StatusError(res.status);
17545
+ const report = await res.json();
17546
+ let remote = false;
17547
+ try {
17548
+ const current = (0, current_session_ts_1.parseCurrentSessionStorage)(window.localStorage.getItem(current_session_ts_1.CURRENT_SESSION_STORAGE_KEY));
17549
+ remote = current.kind === 'open' && (0, remote_drop_ts_1.isRemoteSessionId)(current.sessionId);
17550
+ }
17551
+ catch { /* storage unreadable: report not-remote */ }
17552
+ const merged = {
17553
+ ...report,
17554
+ browser: {
17555
+ remoteDrop: (0, remote_drop_ts_2.remoteDropDiagnostics)(),
17556
+ currentSession: { remote },
17557
+ },
17558
+ };
17559
+ const blob = new Blob([`${JSON.stringify(merged, null, 2)}\n`], { type: 'application/json' });
17560
+ const url = URL.createObjectURL(blob);
17561
+ const anchor = document.createElement('a');
17562
+ anchor.href = url;
17563
+ anchor.download = debugExportFileName(clientRole ? 'client' : 'host', new Date());
17564
+ document.body.appendChild(anchor);
17565
+ anchor.click();
17566
+ anchor.remove();
17567
+ // Revoked on the next tick: some engines start the download only
17568
+ // after the click handler returns.
17569
+ window.setTimeout(() => { URL.revokeObjectURL(url); }, 0);
17570
+ showDebugToast(t('settings.debug.done'));
17571
+ }
17572
+ catch {
17573
+ showDebugToast(t('settings.debug.fail'));
17574
+ }
17575
+ setDebugBusy(false);
17576
+ };
17092
17577
  // --- client group (T16) ----------------------------------------------------
17093
17578
  // The address prefill reads the row's stored serverUrl while the user has
17094
17579
  // not typed anything of their own.
@@ -17306,7 +17791,14 @@ function SettingsSectionPage({ config, shares, t }) {
17306
17791
  passthrough: dropDiag.passthrough,
17307
17792
  failed: dropDiag.rewrapFailed,
17308
17793
  })
17309
- : t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' }) })] })), clientView?.uploadProbe !== undefined && (() => {
17794
+ : t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' }) }), dropDiag.installed && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.client.diagRemoteDropEntries', {
17795
+ dropI: dropDiag.interceptedBy.drop,
17796
+ pickerI: dropDiag.interceptedBy.picker,
17797
+ pasteI: dropDiag.interceptedBy.paste,
17798
+ dropP: dropDiag.passthroughBy.drop,
17799
+ pickerP: dropDiag.passthroughBy.picker,
17800
+ pasteP: dropDiag.passthroughBy.paste,
17801
+ }) }))] })), clientView?.uploadProbe !== undefined && (() => {
17310
17802
  const probe = clientView.uploadProbe;
17311
17803
  const seen = probe.uploadInjectSeen === true;
17312
17804
  const refused = seen && probe.fetchRoute?.shape?.reasons instanceof Array && probe.fetchRoute.shape.reasons.length > 0;
@@ -17322,7 +17814,7 @@ function SettingsSectionPage({ config, shares, t }) {
17322
17814
  return ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsx)("div", { className: "zr-settings-head", children: (0, jsx_runtime_1.jsx)("label", { children: t('settings.client.diagUploadRedirect') }) }), (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: text })] }));
17323
17815
  })(), clientView.compat !== undefined && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [clientView.compat.mismatchedGroups.length > 0 && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.client.diagCompatGroups', { groups: clientView.compat.mismatchedGroups.join(', ') }) })), clientView.compat.recentCalls.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsx)("div", { className: "zr-settings-head", children: (0, jsx_runtime_1.jsx)("label", { children: t('settings.client.diagCompatCalls') }) }), clientView.compat.recentCalls.map((failure, index) => ((0, jsx_runtime_1.jsxs)("p", { className: "zr-settings-hint", children: [new Date(failure.time).toLocaleString(), " \u00B7 ", failure.method !== '' ? failure.method : '—', " \u00B7 ", failure.code] }, index)))] }))] })), (clientView.pluginBridge?.length ?? 0) > 0 && clientView.pluginBridge !== undefined && ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsx)("div", { className: "zr-settings-head", children: (0, jsx_runtime_1.jsx)("label", { children: t('settings.bridge.title') }) }), clientView.pluginBridge.map((row) => ((0, jsx_runtime_1.jsxs)("p", { className: "zr-settings-hint", children: [row.plugin, " \u00B7 ", row.localVersion !== '' ? `v${row.localVersion}` : '—', " \u00B7", ' ', row.stateKey === 'settings.bridge.stateVersionMismatch'
17324
17816
  ? t(row.stateKey, { version: row.serverVersion ?? '?' })
17325
- : t(row.stateKey)] }, row.plugin)))] })), clientView.pluginRouteIntercept !== undefined && !clientView.pluginRouteIntercept.installed && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' }) }))] }))] }))] }));
17817
+ : t(row.stateKey)] }, row.plugin)))] })), clientView.pluginRouteIntercept !== undefined && !clientView.pluginRouteIntercept.installed && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' }) }))] }))] })), (0, jsx_runtime_1.jsx)("div", { className: "zr-settings-card", children: (0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-field", children: [(0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-head", children: [(0, jsx_runtime_1.jsx)("span", { className: "zr-settings-head-title", children: t('settings.debug.title') }), (0, jsx_runtime_1.jsx)("span", { style: { flex: 1 } }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: debugBusy, onClick: () => { void runDebugExport(); }, children: debugBusy ? t('settings.debug.exporting') : t('settings.debug.export') })] }), (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.debug.hint') })] }) })] }));
17326
17818
  }
17327
17819
  /** Display name of a value's source layer for the invalid-saved-value note. */
17328
17820
  function sourceName(source, t) {