dsh-zen-remote 2.2.4 → 2.2.6

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 (111) hide show
  1. package/AGENTS.md +15 -3
  2. package/README.md +12 -4
  3. package/README.zh-CN.md +12 -4
  4. package/docs/interface.md +18 -1
  5. package/docs/plugin-bridge.md +1 -1
  6. package/docs/remote-access.en.md +10 -3
  7. package/docs/remote-access.md +10 -3
  8. package/lib/adapters/dsh-better-sidebar/index.js +28 -7
  9. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  10. package/lib/adapters/dsh-better-sidebar/methods.js +2 -0
  11. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  12. package/lib/client-routes.js +3 -0
  13. package/lib/client-routes.js.map +1 -1
  14. package/lib/client.js +1164 -193
  15. package/lib/fetch-route-intercept.js +1 -1
  16. package/lib/index.js +73 -16
  17. package/lib/index.js.map +1 -1
  18. package/lib/intercept.js +13 -0
  19. package/lib/intercept.js.map +1 -1
  20. package/lib/invoke-binary.js +8 -2
  21. package/lib/invoke-binary.js.map +1 -1
  22. package/lib/lan-gate-server.cjs +109 -2
  23. package/lib/owner-peer.js +23 -0
  24. package/lib/owner-peer.js.map +1 -1
  25. package/lib/plugin-adapter-core.js +2 -2
  26. package/lib/relay-access.js +24 -2
  27. package/lib/relay-access.js.map +1 -1
  28. package/lib/relay-client.js +2 -2
  29. package/lib/relay-server.js +103 -100
  30. package/lib/relay-server.js.map +1 -1
  31. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  32. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  33. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  34. package/lib/types/client/MobileAttachButton.d.ts +5 -2
  35. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  36. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  37. package/lib/types/client/MobileHome.d.ts.map +1 -1
  38. package/lib/types/client/RemoteFileDownload.d.ts +57 -0
  39. package/lib/types/client/RemoteFileDownload.d.ts.map +1 -0
  40. package/lib/types/client/attach-upload.d.ts +32 -0
  41. package/lib/types/client/attach-upload.d.ts.map +1 -1
  42. package/lib/types/client/effects/remote-drop.d.ts +27 -0
  43. package/lib/types/client/effects/remote-drop.d.ts.map +1 -0
  44. package/lib/types/client/index.d.ts.map +1 -1
  45. package/lib/types/client/locales.d.ts +11 -0
  46. package/lib/types/client/locales.d.ts.map +1 -1
  47. package/lib/types/client/remote-file-save.d.ts +112 -0
  48. package/lib/types/client/remote-file-save.d.ts.map +1 -0
  49. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  50. package/lib/types/client/styles/compat.css.d.ts +1 -1
  51. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  52. package/lib/types/client/styles/header.css.d.ts +1 -1
  53. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  54. package/lib/types/client/styles/home.css.d.ts +1 -1
  55. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  56. package/lib/types/client/styles/layout.css.d.ts +1 -1
  57. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  58. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  59. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  60. package/lib/types/client-data/remote-drop.d.ts +99 -0
  61. package/lib/types/client-data/remote-drop.d.ts.map +1 -0
  62. package/lib/types/client-data/settings-form.d.ts +7 -0
  63. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  64. package/lib/types/client-routes.d.ts +6 -0
  65. package/lib/types/client-routes.d.ts.map +1 -1
  66. package/lib/types/index.d.ts.map +1 -1
  67. package/lib/types/intercept.d.ts.map +1 -1
  68. package/lib/types/invoke-binary.d.ts +4 -1
  69. package/lib/types/invoke-binary.d.ts.map +1 -1
  70. package/lib/types/owner-peer.d.ts +9 -0
  71. package/lib/types/owner-peer.d.ts.map +1 -1
  72. package/lib/types/plugin-adapter-core.d.ts +1 -1
  73. package/lib/types/relay-access.d.ts.map +1 -1
  74. package/lib/types/relay-server.d.ts +10 -7
  75. package/lib/types/relay-server.d.ts.map +1 -1
  76. package/package.json +37 -37
  77. package/pwa/inject.js +20 -9
  78. package/pwa/sw.js +97 -21
  79. package/scripts/check-attach-upload.mjs +37 -0
  80. package/scripts/check-remote-drop.mjs +114 -0
  81. package/scripts/check-settings-form.mjs +3 -2
  82. package/scripts/check-upload-endpoint.mjs +14 -3
  83. package/src/adapters/dsh-better-sidebar/index.ts +27 -7
  84. package/src/adapters/dsh-better-sidebar/methods.ts +2 -0
  85. package/src/client/MobileAttachButton.tsx +14 -9
  86. package/src/client/MobileAttachChips.tsx +4 -0
  87. package/src/client/MobileHome.tsx +79 -12
  88. package/src/client/RemoteFileDownload.tsx +173 -0
  89. package/src/client/attach-upload.ts +47 -0
  90. package/src/client/effects/remote-drop.ts +269 -0
  91. package/src/client/index.tsx +47 -0
  92. package/src/client/locales.ts +32 -0
  93. package/src/client/remote-file-save.ts +232 -0
  94. package/src/client/settings/SettingsSection.tsx +61 -1
  95. package/src/client/styles/compat.css.ts +43 -12
  96. package/src/client/styles/header.css.ts +63 -15
  97. package/src/client/styles/home.css.ts +39 -1
  98. package/src/client/styles/layout.css.ts +24 -7
  99. package/src/client/styles/remote-session.css.ts +38 -0
  100. package/src/client-data/remote-drop.ts +127 -0
  101. package/src/client-data/settings-form.ts +10 -0
  102. package/src/client-routes.ts +8 -0
  103. package/src/fetch-route-intercept.ts +1 -1
  104. package/src/index.ts +71 -18
  105. package/src/intercept.ts +13 -0
  106. package/src/invoke-binary.ts +8 -2
  107. package/src/owner-peer.ts +32 -0
  108. package/src/plugin-adapter-core.ts +2 -2
  109. package/src/relay-access.ts +24 -2
  110. package/src/relay-client.ts +2 -2
  111. package/src/relay-server.ts +114 -90
package/lib/client.js CHANGED
@@ -1500,6 +1500,22 @@ function MobileHome({ useSessions, useWorkspaces, useSessionPendingInteraction,
1500
1500
  window.addEventListener(nav_store_ts_1.GO_HOME_EVENT, onGoHome);
1501
1501
  return () => window.removeEventListener(nav_store_ts_1.GO_HOME_EVENT, onGoHome);
1502
1502
  }, [actions]);
1503
+ // Mirror the current phone view onto the AppFrame element itself so CSS inside
1504
+ // the main column (session header hide + fade strip gate) can key off a direct
1505
+ // ancestor attribute instead of relying solely on cross-subtree body:has(...)
1506
+ // invalidation on iOS WebKit (especially across installViewportHeal's display
1507
+ // toggle on standalone PWA launch).
1508
+ (0, react_1.useLayoutEffect)(() => {
1509
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
1510
+ ?? document.querySelector('[data-shell-overlay]')?.parentElement
1511
+ ?? null;
1512
+ if (!phone || frame === null) {
1513
+ frame?.removeAttribute('data-mobile-view');
1514
+ return;
1515
+ }
1516
+ frame.setAttribute('data-mobile-view', view);
1517
+ return () => frame.removeAttribute('data-mobile-view');
1518
+ }, [phone, view]);
1503
1519
  const enter = (start) => {
1504
1520
  start();
1505
1521
  setSheet(null);
@@ -1507,19 +1523,74 @@ function MobileHome({ useSessions, useWorkspaces, useSessionPendingInteraction,
1507
1523
  (0, history_nav_ts_1.pushLayer)({ id: SESSION_LAYER, close: () => actions.show('home') });
1508
1524
  };
1509
1525
  // Opens the OFFICIAL settings modal (dsh-client-ui-settings-general's
1510
- // SettingsRoot) by clicking its real trigger button — its "open" state is
1526
+ // SettingsRoot) by clicking its real trigger — its "open" state is
1511
1527
  // component-local React state with no public setter, same reasoning as
1512
- // MobileSessionHeader's Chat/Trajectory tab click. The trigger mounts
1513
- // inside the sidebar's `sidebar.settings` footer slot
1514
- // (`[class$="_settingsArea"]`, the sidebar shell's stable class suffix —
1515
- // see dsh-client-ui-sidebar's SidebarRoot), which is display:none on the
1516
- // phone breakpoint (home.css.ts): `.click()` still dispatches (bypasses
1517
- // hit-testing, S2.1 precedent) and flips `open`, but the resulting
1518
- // `aria-modal="true"` dialog would still paint nothing without the portal
1519
- // fix in styles/chips.css.ts (`:has([aria-modal="true"])` un-hides exactly
1520
- // the ancestor chain down to it, not the whole sidebar).
1528
+ // MobileSessionHeader's Chat/Trajectory tab click. The trigger lives in the
1529
+ // sidebar's `sidebar.settings` footer slot (`[class$="_settingsArea"]`, the
1530
+ // sidebar shell's stable class suffix — see dsh-client-ui-sidebar's
1531
+ // SidebarRoot), which is display:none on the phone breakpoint
1532
+ // (home.css.ts): `.click()` still dispatches (bypasses hit-testing, S2.1
1533
+ // precedent) and flips `open`.
1534
+ //
1535
+ // DSH 0.2.1-rc.2 moved the entry: that slot now holds only the ACCOUNT
1536
+ // launcher (aria-haspopup="menu"), and Settings is an item inside its menu
1537
+ // — the `button[aria-haspopup="dialog"]` this used to forward to is gone
1538
+ // (and the resulting dialog is a body portal now, so the chips.css.ts
1539
+ // ancestor un-hide below no longer has anything to reveal). Hence the
1540
+ // menu fallback; both paths keep the label-free, structure-free
1541
+ // "dispatch a click on the official control" approach.
1521
1542
  const openSettings = () => {
1522
- document.querySelector('[class$="_settingsArea"] button[aria-haspopup="dialog"]')?.click();
1543
+ const area = document.querySelector('[class$="_settingsArea"]');
1544
+ // Older DSH: the settings modal's own trigger sits in the settings area.
1545
+ const direct = area?.querySelector('button[aria-haspopup="dialog"]');
1546
+ if (direct) {
1547
+ direct.click();
1548
+ return;
1549
+ }
1550
+ // DSH 0.2.1-rc.2: the sidebar footer's settings area now holds only the
1551
+ // ACCOUNT launcher (aria-haspopup="menu", data-signed-out), and 设置 /
1552
+ // Settings is an item inside its menu — the dialog trigger this used to
1553
+ // forward to no longer exists there, so the bare
1554
+ // querySelector()?.click() silently no-oped (real-phone report
1555
+ // 2026-10-21: "手机端右上角设置点了没反应"). Open the menu, then click the
1556
+ // settings item.
1557
+ //
1558
+ // Two constraints from review: (1) menus are body portals and a sibling
1559
+ // menu (session actions, model picker) may already be mounted, so the
1560
+ // item must come from the menu THIS click opened — snapshot the existing
1561
+ // ones and pick the newly mounted menu; (2) the item is located without
1562
+ // depending on its label, because the account menu's copy is localized:
1563
+ // 设置 / Settings is the FIRST menuitem in both the signed-in and the
1564
+ // signed-out variant (AccountMenu items: settings, contact, signin/out),
1565
+ // so the first item is the locale-independent anchor, with the label as
1566
+ // a refinement if that order ever changes.
1567
+ const launcher = area?.querySelector('button[aria-haspopup="menu"], button');
1568
+ if (!launcher)
1569
+ return;
1570
+ const before = new Set(document.querySelectorAll('[role="menu"]'));
1571
+ launcher.click();
1572
+ const pick = () => {
1573
+ const menu = [...document.querySelectorAll('[role="menu"]')].find((el) => !before.has(el));
1574
+ if (!menu)
1575
+ return false;
1576
+ const items = [...menu.querySelectorAll('button[role="menuitem"]')];
1577
+ const item = items.find((el) => /设置|Settings/i.test(el.textContent || '')) ?? items[0];
1578
+ if (!item)
1579
+ return false;
1580
+ item.click();
1581
+ return true;
1582
+ };
1583
+ // React commits the menu in the click's own task; observe for the mount
1584
+ // (a deferred sibling launcher cannot leave a stale poll behind) and give
1585
+ // up instead of leaking the observer if it never appears.
1586
+ if (pick())
1587
+ return;
1588
+ const observer = new MutationObserver(() => {
1589
+ if (pick())
1590
+ observer.disconnect();
1591
+ });
1592
+ observer.observe(document.body, { childList: true, subtree: true });
1593
+ setTimeout(() => observer.disconnect(), 1000);
1523
1594
  };
1524
1595
  if (!phone)
1525
1596
  return null;
@@ -2151,6 +2222,9 @@ function deriveClientStatusView(body) {
2151
2222
  compat: deriveCompatView(safe.compat),
2152
2223
  pluginBridge: (0, plugin_bridge_ts_1.derivePluginBridgeStatuses)(safe.pluginBridge),
2153
2224
  pluginRouteIntercept: derivePluginRouteInterceptView(safe.pluginRouteIntercept),
2225
+ uploadProbe: safe.serviceProbe !== null && typeof safe.serviceProbe === 'object'
2226
+ ? Object.freeze({ ...safe.serviceProbe })
2227
+ : undefined,
2154
2228
  };
2155
2229
  }
2156
2230
  function clientStatusLineOf(view, now) {
@@ -3219,6 +3293,15 @@ exports.zh = {
3219
3293
  'settings.client.diagRecentFailures': '最近的远程调用失败',
3220
3294
  'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
3221
3295
  'settings.client.diagCompatCalls': '最近的不兼容调用',
3296
+ // --- RD-3 远程会话拖拽守卫的诊断行(浏览器端计数,渲染时现读) ---
3297
+ 'settings.client.diagRemoteDrop': '远程拖拽守卫',
3298
+ 'settings.client.diagRemoteDropOk': '已安装 · 拦截 {intercepted} · 重派发 {redispatched} · 文件夹 {blocked} · 放行 {passthrough} · 重包失败 {failed}',
3299
+ 'settings.client.diagRemoteDropOff': '未安装({reason})',
3300
+ // --- RD-6 上传改道(fetch-route 拦截)的诊断行:读 remote-status 的 serviceProbe ---
3301
+ 'settings.client.diagUploadRedirect': '上传改道',
3302
+ 'settings.client.diagUploadRedirectOk': '已安装 · 上传已接管',
3303
+ 'settings.client.diagUploadRedirectRefused': '拒装:{reason}',
3304
+ 'settings.client.diagUploadRedirectPending': '服务注入未触发 · connection {connection} · fileUploads {fileUploads}',
3222
3305
  // --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
3223
3306
  'settings.bridge.title': '插件桥',
3224
3307
  'settings.bridge.empty': '暂无插件登记插件桥',
@@ -3260,6 +3343,12 @@ exports.zh = {
3260
3343
  'remoteGroupRevoked': '(令牌已吊销)',
3261
3344
  'remoteGroupUnpaired': '(已解除配对)',
3262
3345
  'remoteGroupMismatch': '(版本有差异)',
3346
+ // --- 远程会话拖拽附件(RD-2):文件夹被拦截时的短暂提示 ---
3347
+ 'remoteDropFolderBlocked': '远程会话暂不支持拖入文件夹',
3348
+ // --- 2.2.6: 远程会话产物下载(deliverable 卡片 / 文档预览头) ---
3349
+ 'downloadToLocal': '下载到本机',
3350
+ 'downloadBusy': '下载中…',
3351
+ 'downloadFailed': '下载失败',
3263
3352
  };
3264
3353
  /** English dictionary, key-identical to the Chinese source of truth. */
3265
3354
  exports.en = {
@@ -3508,6 +3597,16 @@ exports.en = {
3508
3597
  'settings.client.diagRecentFailures': 'Recent remote call failures',
3509
3598
  'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
3510
3599
  'settings.client.diagCompatCalls': 'Recent incompatible calls',
3600
+ // --- RD-3: the remote drop guard's diagnostics line (browser-side
3601
+ // counters, read at render time) ---
3602
+ 'settings.client.diagRemoteDrop': 'Remote drop guard',
3603
+ 'settings.client.diagRemoteDropOk': 'Installed · intercepted {intercepted} · redispatched {redispatched} · folders {blocked} · passthrough {passthrough} · rewrap failed {failed}',
3604
+ 'settings.client.diagRemoteDropOff': 'Not installed ({reason})',
3605
+ // --- RD-6: the upload-redirect (fetch-route intercept) diagnostics row ---
3606
+ 'settings.client.diagUploadRedirect': 'Upload redirect',
3607
+ 'settings.client.diagUploadRedirectOk': 'Installed · upload wrapped',
3608
+ 'settings.client.diagUploadRedirectRefused': 'Refused: {reason}',
3609
+ 'settings.client.diagUploadRedirectPending': 'Service inject never fired · connection {connection} · fileUploads {fileUploads}',
3511
3610
  // --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
3512
3611
  'settings.bridge.title': 'Plugin bridge',
3513
3612
  'settings.bridge.empty': 'No plugin has registered a bridge yet',
@@ -3548,6 +3647,13 @@ exports.en = {
3548
3647
  'remoteGroupRevoked': ' (token revoked)',
3549
3648
  'remoteGroupUnpaired': ' (unpaired)',
3550
3649
  'remoteGroupMismatch': ' (version mismatch)',
3650
+ // --- RD-2: the short toast when a folder drop into a remote session is blocked ---
3651
+ 'remoteDropFolderBlocked': 'Folders cannot be dropped into a remote session yet',
3652
+ // 2.2.6: remote-session artifact download (deliverable cards / document
3653
+ // preview header).
3654
+ 'downloadToLocal': 'Download to this computer',
3655
+ 'downloadBusy': 'Downloading…',
3656
+ 'downloadFailed': 'Download failed',
3551
3657
  };
3552
3658
  };
3553
3659
  __modules["client-data/shares.js"] = function (require, module, exports) {
@@ -6970,6 +7076,8 @@ exports.UPLOAD_DIR = exports.UPLOAD_ROUTE = void 0;
6970
7076
  exports.uploadUrl = uploadUrl;
6971
7077
  exports.mentionsIn = mentionsIn;
6972
7078
  exports.draftWithMention = draftWithMention;
7079
+ exports.appendDraftOf = appendDraftOf;
7080
+ exports.writeMention = writeMention;
6973
7081
  exports.draftWithoutMention = draftWithoutMention;
6974
7082
  exports.leafOf = leafOf;
6975
7083
  exports.middleEllipsis = middleEllipsis;
@@ -7023,6 +7131,37 @@ function draftWithMention(draft, relPath) {
7023
7131
  return `${draft}@${relPath} `;
7024
7132
  return `${draft} @${relPath} `;
7025
7133
  }
7134
+ /**
7135
+ * The host's non-destructive append, when the running DSH exposes one.
7136
+ * @param actions - the slot's `inputActions` (or any object carrying them).
7137
+ * @returns a bound `appendDraft`, or undefined when the host has none.
7138
+ */
7139
+ function appendDraftOf(actions) {
7140
+ const append = actions.appendDraft;
7141
+ return typeof append === 'function' ? append.bind(actions) : undefined;
7142
+ }
7143
+ /**
7144
+ * Write one `@path` mention into the composer.
7145
+ *
7146
+ * Prefers `appendDraft` (no whole-draft rewrite, so nothing the user typed or
7147
+ * referenced meanwhile can be lost); falls back to `setDraft` with
7148
+ * {@link draftWithMention} over `base` on a host without it.
7149
+ * @param actions - the draft writers.
7150
+ * @param base - the draft to rebase onto for the `setDraft` fallback.
7151
+ * @param relPath - workspace-relative path returned by the upload route.
7152
+ * @returns the draft text the fallback wrote, or `base` unchanged when the
7153
+ * host appended for us (the next render's `useInput` carries the result).
7154
+ */
7155
+ function writeMention(actions, base, relPath) {
7156
+ const append = appendDraftOf(actions);
7157
+ if (append !== undefined) {
7158
+ append(`@${relPath} `);
7159
+ return base;
7160
+ }
7161
+ const next = draftWithMention(base, relPath);
7162
+ actions.setDraft(next);
7163
+ return next;
7164
+ }
7026
7165
  /**
7027
7166
  * Remove one `@path` mention from the draft — what a chip's × does.
7028
7167
  *
@@ -7196,8 +7335,11 @@ async function uploadFile(sessionId, file) {
7196
7335
  * host without one (DSH ≤ 0.1.2) everything below loads exactly as before.
7197
7336
  *
7198
7337
  * Every picked file takes the SAME path: upload to the node half, then append
7199
- * `@.dsh-uploads/name` to the draft through `inputActions.setDraft` (the
7200
- * official write path — no DOM value poking). S7.1 removed the split that used
7338
+ * `@.dsh-uploads/name` to the draft through the official write path — no DOM
7339
+ * value poking: `inputActions.appendDraft` where the running DSH exposes it
7340
+ * (0.2.1-rc.3+ editor runtime; keeps the user's text and inline references),
7341
+ * else `inputActions.setDraft` with the whole next draft (attach-upload.ts
7342
+ * `writeMention`). S7.1 removed the split that used
7201
7343
  * to send inlineable images straight into the session with `session.prompt`:
7202
7344
  * one behaviour for every attachment, and the user always presses send.
7203
7345
  * MobileAttachChips renders the preview row off those same draft tokens.
@@ -7230,19 +7372,21 @@ function MobileAttachButton({ t, sessionId, useInput, inputActions }) {
7230
7372
  return;
7231
7373
  setBusy(true);
7232
7374
  setError(null);
7233
- // setDraft takes the WHOLE next draft, so each mention is appended to the
7234
- // value we last wrote — unless the ref shows the user has typed since, in
7235
- // which case theirs wins and we append to that instead. (Rebasing on the
7236
- // ref unconditionally would lose a mention whenever React had not yet
7375
+ // appendDraft (DSH 0.2.1-rc.3+) appends after whatever the composer holds
7376
+ // right now, so no rebasing is needed. The setDraft fallback takes the
7377
+ // WHOLE next draft, so each mention is appended to the value we last
7378
+ // wrote — unless the ref shows the user has typed since, in which case
7379
+ // theirs wins and we append to that instead. (Rebasing on the ref
7380
+ // unconditionally would lose a mention whenever React had not yet
7237
7381
  // re-rendered us between two files of the same pick.)
7382
+ const appends = (0, attach_upload_ts_1.appendDraftOf)(inputActions) !== undefined;
7238
7383
  let written = liveDraft.current;
7239
7384
  try {
7240
7385
  for (const file of files) {
7241
7386
  const relPath = await uploadFile(sessionId, file);
7242
7387
  // Local preview for the chip row; a no-op for non-images.
7243
7388
  (0, attach_upload_ts_1.rememberThumbnail)(relPath, file);
7244
- written = (0, attach_upload_ts_1.draftWithMention)(liveDraft.current === written ? written : liveDraft.current, relPath);
7245
- inputActions.setDraft(written);
7389
+ written = (0, attach_upload_ts_1.writeMention)(inputActions, (appends || liveDraft.current === written) ? written : liveDraft.current, relPath);
7246
7390
  }
7247
7391
  }
7248
7392
  catch (cause) {
@@ -7311,7 +7455,12 @@ function MobileAttachChips({ t, useInput, inputActions }) {
7311
7455
  return ((0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "attach-chips", children: attachments.map((relPath) => {
7312
7456
  const name = (0, attach_upload_ts_1.leafOf)(relPath);
7313
7457
  const preview = (0, attach_upload_ts_1.thumbnailFor)(relPath);
7314
- return ((0, jsx_runtime_1.jsxs)("span", { "data-mobile-nav": "attach-chip", "data-kind": preview === undefined ? 'file' : 'image', title: name, children: [(0, jsx_runtime_1.jsxs)("span", { "data-mobile-nav": "attach-chip-art", children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconPaperclipOutlineRegular, { size: 16 }), preview === undefined ? null : (0, jsx_runtime_1.jsx)("img", { src: preview, alt: "" })] }), preview === undefined ? (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "attach-chip-name", children: (0, attach_upload_ts_1.middleEllipsis)(name) }) : null, (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "attach-chip-remove", "aria-label": t('attachRemove', { name }), onClick: () => inputActions.setDraft((0, attach_upload_ts_1.draftWithoutMention)(draft, relPath)), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseFillRegular, { size: 14 }) })] }, relPath));
7458
+ return ((0, jsx_runtime_1.jsxs)("span", { "data-mobile-nav": "attach-chip", "data-kind": preview === undefined ? 'file' : 'image', title: name, children: [(0, jsx_runtime_1.jsxs)("span", { "data-mobile-nav": "attach-chip-art", children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconPaperclipOutlineRegular, { size: 16 }), preview === undefined ? null : (0, jsx_runtime_1.jsx)("img", { src: preview, alt: "" })] }), preview === undefined ? (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "attach-chip-name", children: (0, attach_upload_ts_1.middleEllipsis)(name) }) : null, (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "attach-chip-remove", "aria-label": t('attachRemove', { name }),
7459
+ // Removal stays on setDraft even where the host has appendDraft
7460
+ // (DSH 0.2.1-rc.3+): append can only add text, and the public
7461
+ // inputActions face has no span-replace verb, so lifting one
7462
+ // token out still means writing the whole next draft.
7463
+ onClick: () => inputActions.setDraft((0, attach_upload_ts_1.draftWithoutMention)(draft, relPath)), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseFillRegular, { size: 14 }) })] }, relPath));
7315
7464
  }) }));
7316
7465
  }
7317
7466
  };
@@ -7906,7 +8055,14 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
7906
8055
  display:none rule below, and the user can no longer type a path
7907
8056
  (issue #12, 2026-08-16). The picker family keeps the official layout
7908
8057
  on mobile in every mode. */
7909
- [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])) {
8058
+ /* DSH 0.2.1-rc.2 renders this dialog as a body PORTAL
8059
+ (body > overlay > panel), so the placement below must key off the nav
8060
+ list the panel owns, not off a guessed child depth: that is both the
8061
+ stable "this is the settings sheet" anchor and the reason the safe-area
8062
+ offset below still applies inside the portal (the constants live on
8063
+ :root, so they cross the portal boundary). The bottom inset is
8064
+ subtracted too — a tall panel must not run under the home indicator. */
8065
+ [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])):has([class$="_navList"]) {
7910
8066
  position: absolute !important;
7911
8067
  left: 8px !important;
7912
8068
  /* Fixed top (no translateY): a transform on the panel combined with the
@@ -7917,11 +8073,11 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
7917
8073
  width: calc(100vw - 16px) !important;
7918
8074
  max-width: calc(100vw - 16px) !important;
7919
8075
  /* Height follows the content (no dead space under a short page); it
7920
- caps at 100dvh-24 (less the safe-area top) and the options area
8076
+ caps at 100dvh-24 (less both safe-area insets) and the options area
7921
8077
  scrolls only then. */
7922
8078
  height: auto !important;
7923
- max-height: min(800px, calc(100vh - 24px - var(--mnav-sat))) !important;
7924
- max-height: min(800px, calc(100dvh - 24px - var(--mnav-sat))) !important;
8079
+ max-height: min(800px, calc(100vh - 24px - var(--mnav-sat) - var(--mnav-sab))) !important;
8080
+ max-height: min(800px, calc(100dvh - 24px - var(--mnav-sat) - var(--mnav-sab))) !important;
7925
8081
  flex-direction: column !important;
7926
8082
  border-radius: 14px !important;
7927
8083
  animation: dsh-mobile-nav-sheet-in .22s var(--ds-ease-out, ease-in-out);
@@ -7969,14 +8125,24 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
7969
8125
  (config file + close) is reparented INTO this nav row by a client
7970
8126
  effect (MobileNavOverlay), so the tab list must be anchored by its
7971
8127
  class, NOT by :last-child (the reparented toolbar becomes the nav's
7972
- new last child). */
8128
+ new last child).
8129
+
8130
+ 2026-10-21 (DSH 0.2.1-rc.2): this rule used to declare
8131
+ "flex-wrap: wrap; overflow: visible" on top of the flex/min-width/gap
8132
+ above. Because it is one class-anchored compound selector with a much
8133
+ higher specificity than compat.css.ts's portal-scoped navList rule, it
8134
+ silently won the tie and the tab list wrapped into two rows — with the
8135
+ reparented ✕ vertically centred across both of them. Measured on a
8136
+ phone shell: the list was 86px tall (two rows) instead of one 40px row,
8137
+ and it scrolled nowhere. The wrap was a 2026-08-16 trade-off (show every
8138
+ tab, no hidden scroll cut-off); with the thin scrollbar affordance now
8139
+ in compat.css.ts the single scrolling row is the better trade, so the
8140
+ two declarations live there and this rule no longer contradicts them. */
7973
8141
  [aria-modal="true"]:has(> :first-child > :last-child > button):not(:has([role="navigation"])):not(:has([class*="ZuhsRW"])) > :first-child [class$="_navList"] {
7974
8142
  flex: 1 1 auto !important;
7975
8143
  min-width: 0 !important;
7976
8144
  flex-direction: row !important;
7977
- flex-wrap: wrap !important;
7978
8145
  gap: 6px !important;
7979
- overflow: visible !important;
7980
8146
  }
7981
8147
  /* Content toolbar (Open configuration file + close): grouped flush to
7982
8148
  the right edge, and reparented INTO the nav row on mobile so it shares
@@ -8337,31 +8503,62 @@ exports.COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility ----------
8337
8503
  one row with a thin scrollbar keeps every tab reachable and returns
8338
8504
  that space to the options area (user feedback 2026-08-16). An earlier
8339
8505
  one-row attempt had no scroll affordance and silently cut the last
8340
- tab off; the thin scrollbar IS the affordance. Scoped to the frame
8341
- marker: the desktop dialog keeps its official vertical nav column. */
8342
- [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"] {
8506
+ tab off; the thin scrollbar IS the affordance.
8507
+
8508
+ 2026-10-21 (DSH 0.2.1-rc.2, measured on a real phone shell): the whole
8509
+ block below had silently stopped matching. The settings dialog no longer
8510
+ renders inside the app frame — it is a PORTAL on body
8511
+ (body > [class$="_overlay"] > [class$="_panel"]), so a
8512
+ [data-mobile-nav="frame"] [aria-modal="true"] … prefix can never match
8513
+ it, and the panel depth the old :has(> :first-child > :last-child >
8514
+ button) gate was written against is no longer the shape the sheet has
8515
+ (now panel > nav > (navTitle, navList, header)). Symptom: four nav cells
8516
+ fell back to the host's wrapping grid (measured 209x132 for the list
8517
+ instead of one 40px row) and the "open config file" action came back
8518
+ into the header. Keep the same phone gate from the ancestor side
8519
+ (html:has([data-mobile-nav="frame"]) — the marker only exists outside
8520
+ the desktop gate) and identify the dialog by the nav list it owns, not
8521
+ by a guessed depth. Desktop untouched.
8522
+
8523
+ This rule is the only place that declares the row's wrap/overflow now:
8524
+ layout.css.ts's navList rule has a higher specificity and used to force
8525
+ "flex-wrap: wrap; overflow: visible", so the one-row intent below never
8526
+ actually rendered (see the note in layout.css.ts).
8527
+
8528
+ Every selector below is gated on :has([class$="_navList"]) — the one
8529
+ stable feature of the settings sheet. The ancestor marker alone is not
8530
+ enough: once the dialog is a body portal, [aria-modal="true"] matches
8531
+ EVERY dialog on the phone (session export, directory picker, plugin
8532
+ sheets), which is how the first cut of this block ended up hiding the
8533
+ top-right action button of all of them (review, 2026-10-22). */
8534
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"] {
8343
8535
  display: flex !important;
8344
8536
  flex-wrap: nowrap !important;
8537
+ /* The nav row is [navList][header(close)]: min-width:0 + flex-basis auto
8538
+ lets the list shrink to the width left over by the ✕ and scroll inside
8539
+ itself — without it the nowrap content widens the row and pushes the
8540
+ close button off-screen. */
8541
+ flex: 1 1 auto !important;
8542
+ min-width: 0 !important;
8345
8543
  overflow-x: auto !important;
8346
8544
  overflow-y: hidden !important;
8347
8545
  gap: 6px !important;
8348
- width: 100% !important;
8349
8546
  scrollbar-width: thin !important;
8350
8547
  -webkit-overflow-scrolling: touch !important;
8351
8548
  }
8352
8549
  /* Hairline scrollbar for the tab row: the default WebKit scrollbar reads
8353
8550
  fat on a phone; 2px keeps the scroll affordance without the bulk. */
8354
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar {
8551
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar {
8355
8552
  height: 2px !important;
8356
8553
  }
8357
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar-thumb {
8554
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar-thumb {
8358
8555
  background: var(--dsw-alias-border-l2, rgba(0, 0, 0, .22)) !important;
8359
8556
  border-radius: 1px !important;
8360
8557
  }
8361
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navList"]::-webkit-scrollbar-track {
8558
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navList"]::-webkit-scrollbar-track {
8362
8559
  background: transparent !important;
8363
8560
  }
8364
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navCell"] {
8561
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navCell"] {
8365
8562
  flex: 0 0 auto !important;
8366
8563
  white-space: nowrap !important;
8367
8564
  padding: 6px 8px !important;
@@ -8369,7 +8566,7 @@ exports.COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility ----------
8369
8566
  font-size: 13px !important;
8370
8567
  justify-content: flex-start !important;
8371
8568
  }
8372
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_navCell"] svg {
8569
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_navCell"] svg {
8373
8570
  width: 14px !important;
8374
8571
  height: 14px !important;
8375
8572
  flex: none !important;
@@ -8377,11 +8574,11 @@ exports.COMPAT_CSS = ` /* ---------- dsh-web-ui family compatibility ----------
8377
8574
  /* Content toolbar: the "Open configuration file" button is hidden on
8378
8575
  mobile — it is rarely needed on a phone and steals ~180px from the
8379
8576
  tab row's scroll area (user feedback 2026-08-16). Only the close ✕
8380
- stays, flush right in the nav row. Desktop untouched (frame scoped). */
8381
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_header"] [class$="_actions"] {
8577
+ stays, flush right in the nav row. Desktop untouched (phone gate). */
8578
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_header"] [class$="_actions"] {
8382
8579
  display: none !important;
8383
8580
  }
8384
- [data-mobile-nav="frame"] [aria-modal="true"] [class$="_header"] [class$="_actions"] [class$="_action"] {
8581
+ html:has([data-mobile-nav="frame"]) [aria-modal="true"]:has([class$="_navList"]) [class$="_header"] [class$="_actions"] [class$="_action"] {
8385
8582
  font-size: 13px !important;
8386
8583
  padding: 6px 12px !important;
8387
8584
  min-height: 0 !important;
@@ -9275,7 +9472,17 @@ exports.HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
9275
9472
  [data-mobile-nav="home"] {
9276
9473
  position: absolute;
9277
9474
  inset: 0;
9278
- z-index: 5;
9475
+ /* rc.3 raise (real-device report 2026-10-25 "top strip leaking the
9476
+ conversation through"): this shell layer mounts INSIDE the host's
9477
+ PIRFFq_overlayLayer (z-index:20, new in rc.3 — the conversation
9478
+ content itself moved under it too), so the old z:5 let host session
9479
+ content (STRONG/CODE rows measured at viewport y=4) paint over the
9480
+ shell's top bar on BOTH pages, reading as a faint fixed strip at the
9481
+ window top. z:30 puts the whole shell above everything the host
9482
+ stacks inside that layer; the shell's own transparent middle and
9483
+ pointer-events passthrough keep the conversation visible and tappable
9484
+ (verified live via console injection before landing). */
9485
+ z-index: 30;
9279
9486
  display: flex;
9280
9487
  flex-direction: column;
9281
9488
  pointer-events: auto;
@@ -9320,6 +9527,34 @@ exports.HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
9320
9527
  opacity .3s var(--ds-ease-in-out, ease-in-out),
9321
9528
  visibility 0s .3s;
9322
9529
  }
9530
+ /* The host's SESSION header must not sit on top of this list page.
9531
+ Real-device report (2026-10-22, DSH 0.2.0-rc.2, dsh-web-mobile 3.2.1,
9532
+ 360x792 phone): on the list page the top strip showed the LAST SESSION's
9533
+ header — its title plus the session's tab row — and the workspace switcher
9534
+ underneath was neither visible nor tappable, so "switch workspace" did
9535
+ nothing at all. Measured with elementFromPoint(150,30) on the phone
9536
+ viewport: span.wSkVaW_crumbCurrent, i.e. the host header's crumb, while
9537
+ our own workspace button sat at [46,8,208,44] underneath it.
9538
+
9539
+ Why z-index is not the fix: this page is position:absolute; z-index:5
9540
+ inside the shell overlay layer, and the host header carries its own
9541
+ stacking context, so raising us above its z-index:30 changed nothing
9542
+ (measured: the crumb still won the hit test). The session header has to
9543
+ leave the hit tree while the list page is up — the list page draws its
9544
+ own top bar (home-top, with the workspace switcher).
9545
+
9546
+ The gate is this page's own marker: data-view="session" means a session
9547
+ is open, so the rule lapses exactly when the host header is wanted again
9548
+ (verified: entering a session restores display:grid, the back button in
9549
+ that header stays tappable, and going back hides it once more). Two
9550
+ selectors on purpose — the class suffix survives CSS-module hash renames,
9551
+ the structural one survives a rename of the class itself. */
9552
+ [data-mobile-nav="frame"]:not([data-mobile-view="session"]) header[class*="_header"],
9553
+ [data-mobile-nav="frame"]:not([data-mobile-view="session"]) header:has([class*="_titleRow"]),
9554
+ body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-mobile-nav="frame"] header[class*="_header"],
9555
+ body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-mobile-nav="frame"] header:has([class*="_titleRow"]) {
9556
+ display: none !important;
9557
+ }
9323
9558
  @media (prefers-reduced-motion: reduce) {
9324
9559
  [data-mobile-nav="home"],
9325
9560
  [data-mobile-nav="home"][data-view="session"] {
@@ -9756,6 +9991,13 @@ __modules["client/styles/header.css.js"] = function (require, module, exports) {
9756
9991
  // hidden below, our workbench button routes to
9757
9992
  // its controls instead)
9758
9993
  // div.tabs[role="tablist"] Chat/Trajectory, only when there is >1 view
9994
+ // (DSH <= 0.2.1-rc.2)
9995
+ // div.tabs DSH 0.2.1-rc.3: the row lost its role and
9996
+ // div.viewTabs[role="tablist"][data-conversation-tabs]
9997
+ // div.viewsMoreAnchor "More conversation views" (…) menu launcher,
9998
+ // present while conversation.session.header.
9999
+ // views.actions has registrations (the web app
10000
+ // ships dsh-client-ui-entry-customization)
9759
10001
  Object.defineProperty(exports, "__esModule", { value: true });
9760
10002
  exports.HEADER_CSS = void 0;
9761
10003
  exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) ---------- */
@@ -9816,27 +10058,39 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
9816
10058
  above. */
9817
10059
  z-index: 2;
9818
10060
  padding: 0 8px 28px !important;
10061
+ border-bottom: none !important;
9819
10062
  }
9820
10063
  /* No header bottom line — fade instead (real-device round 2, 2026-08-17).
9821
- The official header draws its border as a \`::after\` 1px bar (the
9822
- \`border-bottom\` on the header itself is transparent, just a layout
9823
- reserve — dsh-client-ui-conversation lib/client.js ".wSkVaW_header{
9824
- border-bottom:1px solid #0000}.wSkVaW_header:after{...height:1px;
9825
- position:absolute;bottom:1px...}", verified live 2026-08-17), so the
9826
- bar is repurposed rather than hidden: restyled into a 20px strip
9827
- hanging just below the header that fades the messages in — the same
9828
- look the scroller's mask-image used to produce, without a mask on a
9829
- scroll container (iOS WebKit renders that as fog over the header, see
9830
- composer.css.ts "no divider above OR below the message list",
9831
- 2026-08-23). color-mix instead of the transparent keyword: the host
9832
- writes its own seat gradient that way, and plain \`transparent\` is
9833
- rgba(0,0,0,0) — WebKit interpolates it through grey. */
9834
- [data-phase] header::after {
9835
- height: 20px !important;
10064
+ The official header in rc.2 drew its border as a \`::after\` 1px bar; in
10065
+ rc.3 (2026-10-25) the host replaced that pseudo-element with a plain 0.5px
10066
+ border-bottom (cleared above) and added a blank 0px header in the hero
10067
+ phase (\`_headerBlank\`). Declaring \`content: ''\` + \`position: absolute;
10068
+ left: 0; right: 0\` explicitly on \`[data-phase="active"] header:not([class$="_headerBlank"])::after\`
10069
+ keeps the 20px fade strip anchored right below the real session header on
10070
+ both rc.2 and rc.3 without touching the scroll container:
10071
+ - When the session header is display:none on the list page, ::after is
10072
+ not generated at all (unlike _scrollBody::before, which stayed in flow
10073
+ at y=0 whenever the header was hidden or blank and showed up at the top
10074
+ of the window on iOS standalone PWA launch before data-view mutated).
10075
+ - :not([class$="_headerBlank"]) excludes the 0-height blank header.
10076
+ - color-mix instead of the transparent keyword: plain \`transparent\` is
10077
+ rgba(0,0,0,0) — WebKit interpolates it through grey. */
10078
+ [data-phase="active"] header:not([class$="_headerBlank"])::after {
10079
+ content: '';
10080
+ position: absolute;
10081
+ left: 0;
10082
+ right: 0;
9836
10083
  top: 100% !important;
9837
10084
  bottom: auto !important;
10085
+ height: 20px !important;
10086
+ pointer-events: none;
9838
10087
  background: linear-gradient(to bottom, var(--dsw-alias-bg-base), color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent)) !important;
9839
10088
  }
10089
+ [data-mobile-nav="frame"]:not([data-mobile-view="session"]) [data-phase] header::after,
10090
+ body:has([data-mobile-nav="home"]:not([data-view="session"])) [data-phase] header::after {
10091
+ content: none !important;
10092
+ display: none !important;
10093
+ }
9840
10094
  /* grid-row: 1 on every item is load-bearing, not decoration (S2.1 fix for
9841
10095
  the "标题被挤下去" report). The three items are placed with explicit
9842
10096
  grid-column but arrive in DOM order crumbs(2) → headerActions(1) →
@@ -10178,6 +10432,35 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10178
10432
  [data-phase] header [class$="_tabs"][role="tablist"] {
10179
10433
  display: none;
10180
10434
  }
10435
+ /* DSH 0.2.1-rc.3 split the row: div._tabs (no role) now wraps the
10436
+ tablist (div._viewTabs[role=tablist][data-conversation-tabs]) and the
10437
+ new "More conversation views" launcher, so the rule above matches
10438
+ nothing there and the official row — plus a stray (…) More button —
10439
+ came back under our view-switch band. It cannot simply be display:none
10440
+ either: while the More menu has registrations the header folds every
10441
+ View past floor((row.clientWidth - 48) / 120) into More, and a
10442
+ display:none row measures 0, which would leave only Chat as a
10443
+ [role=tab] for readViewTabs() (MobileSessionHeader.tsx) to mirror.
10444
+ So the row keeps a wide layout box that paints nothing: absolute (out
10445
+ of the header's flow, no band), 2048px wide (room for 16 tabs before
10446
+ anything folds), scale(0) so it adds no scrollable overflow (clientWidth
10447
+ ignores transforms), visibility:hidden + pointer-events:none so it is
10448
+ never seen or hit. Its tabs still take the synthetic .click() the view
10449
+ switcher sends — the same reasoning as the display:none rule above. */
10450
+ [data-phase] header [class$="_tabs"]:has(> [data-conversation-tabs]) {
10451
+ position: absolute !important;
10452
+ top: 0;
10453
+ left: 0;
10454
+ width: 2048px !important;
10455
+ max-width: none !important;
10456
+ height: 0 !important;
10457
+ margin: 0 !important;
10458
+ overflow: hidden !important;
10459
+ transform: scale(0);
10460
+ transform-origin: 0 0;
10461
+ visibility: hidden !important;
10462
+ pointer-events: none !important;
10463
+ }
10181
10464
 
10182
10465
  /* View-switch row: "current view name + dots", replacing the (still
10183
10466
  present, still clickable) official tablist visually. It renders inside
@@ -15243,6 +15526,445 @@ const EXPORT_STYLE = {
15243
15526
  whiteSpace: 'nowrap',
15244
15527
  };
15245
15528
  };
15529
+ __modules["virtual-id.js"] = function (require, module, exports) {
15530
+ "use strict";
15531
+ /**
15532
+ * Reversible virtual ids for sessions and workspaces the sub-client surfaces
15533
+ * locally while their real records live on a relay server (T23b-1). The UI
15534
+ * only ever sees `zr~<serverId>~<original id>`; the interceptor maps those
15535
+ * back to the original ids before a call travels, and maps server-returned
15536
+ * ids back to virtual form on the way out.
15537
+ *
15538
+ * The mapping is deliberately a PURE function of (serverId, id), not a
15539
+ * session-table lookup: every shared session of one server gets the same
15540
+ * virtual id whether or not this process ever opened it, so result rewriting
15541
+ * needs no state. Parsing validates the fixed skeleton (`zr~` + the 8 hex
15542
+ * chars `loadServerId` mints, relay-server.ts) and keeps the remainder
15543
+ * VERBATIM — the original id may contain any characters, including `~`, so
15544
+ * nothing after the second separator is interpreted.
15545
+ *
15546
+ * A local DSH id can never collide with the prefix: real session ids are
15547
+ * `session-<uuid>` and workspace ids are bare uuids.
15548
+ */
15549
+ Object.defineProperty(exports, "__esModule", { value: true });
15550
+ exports.VIRTUAL_GROUP_NAME_MARK = exports.VIRTUAL_ID_PREFIX = void 0;
15551
+ exports.toVirtual = toVirtual;
15552
+ exports.fromVirtual = fromVirtual;
15553
+ exports.isVirtual = isVirtual;
15554
+ /** The fixed prefix every virtual id starts with. */
15555
+ exports.VIRTUAL_ID_PREFIX = 'zr~';
15556
+ /**
15557
+ * The invisible WORD JOINER (U+2060) every virtual GROUP NAME starts with
15558
+ * (T74). DSH 0.2.0 renders the model menu's provider groups with the
15559
+ * primitives' MenuGroup, whose `aria-labelledby` carries only a useId — the
15560
+ * group id is gone from the markup — so the browser half can no longer tell
15561
+ * a virtual group from a local one by attribute. The merged catalog's group
15562
+ * NAME (merge-streams.ts mergeModelCatalogs) is the one mark that survives
15563
+ * in both 0.1.7 and 0.2.0, and effects/model-group-side.ts classifies each
15564
+ * group container by which headings start with it. Zero-width: headings
15565
+ * read exactly as before. Sidebar workspace TITLES deliberately do NOT
15566
+ * carry it — those groups are told apart by their `zr~` row ids, not by
15567
+ * text.
15568
+ */
15569
+ exports.VIRTUAL_GROUP_NAME_MARK = '\u2060';
15570
+ /** The server id half: exactly the 8 lowercase hex chars relay-server.ts
15571
+ * mints (`randomBytes(4).toString('hex')`). */
15572
+ const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/;
15573
+ /** Build the virtual form of one server-side session or workspace id. */
15574
+ function toVirtual(serverId, id) {
15575
+ return `${exports.VIRTUAL_ID_PREFIX}${serverId}~${id}`;
15576
+ }
15577
+ /**
15578
+ * Parse one virtual id back into its halves, or `undefined` for anything
15579
+ * that does not match the skeleton. Strict on the parts that carry meaning:
15580
+ * the literal prefix, exactly 8 lowercase hex server id chars, and the
15581
+ * separating `~` after them. The remainder is not validated — any string,
15582
+ * including the empty one, is a legal original id.
15583
+ */
15584
+ function fromVirtual(id) {
15585
+ if (typeof id !== 'string' || !id.startsWith(exports.VIRTUAL_ID_PREFIX))
15586
+ return undefined;
15587
+ const rest = id.slice(exports.VIRTUAL_ID_PREFIX.length);
15588
+ // The shortest legal remainder is `<8 hex>~` — 9 chars with the separator
15589
+ // at index 8. Anything shorter cannot carry a server id.
15590
+ if (rest.length < 9 || rest[8] !== '~')
15591
+ return undefined;
15592
+ const serverId = rest.slice(0, 8);
15593
+ if (!SERVER_ID_PATTERN.test(serverId))
15594
+ return undefined;
15595
+ return { serverId, id: rest.slice(9) };
15596
+ }
15597
+ /** True when {@link fromVirtual} would parse this value. */
15598
+ function isVirtual(id) {
15599
+ return fromVirtual(id) !== undefined;
15600
+ }
15601
+ };
15602
+ __modules["client-data/remote-drop.js"] = function (require, module, exports) {
15603
+ "use strict";
15604
+ /**
15605
+ * Remote-session drag-and-drop decision core (RD-1): the DOM-free, readable
15606
+ * half of the document-level drop guard (src/client/effects/remote-drop.ts).
15607
+ *
15608
+ * Why this exists: the desktop app's attachment pipeline has a same-machine
15609
+ * zero-copy fast path — the Electron preload exposes `__DSH_HOST_PATHS__`,
15610
+ * and the host's `addFiles` turns a File that still carries a real disk path
15611
+ * into an `@/absolute/path` mention chip WITHOUT uploading any bytes. In a
15612
+ * remote session (`zr~<serverId>~<id>`) that path names a file on the
15613
+ * SUB-CLIENT's machine; the server-side session can never read it, so the
15614
+ * mention lands broken. The fix re-shapes the drop before the host sees it:
15615
+ * strip the internal path by re-wrapping each File (`new File([f], ...)`) and
15616
+ * redispatch a synthetic, untrusted DragEvent carrying them in a fresh
15617
+ * DataTransfer. The host's own drop handler then treats it as an ordinary
15618
+ * browser drag — the byte-upload branch — which the T51 fetch-route
15619
+ * interception already relays to the server for staging.
15620
+ *
15621
+ * This module only DECIDES; it never touches the DOM:
15622
+ * - `isRemoteSessionId` — the current-session gate, judged by the same
15623
+ * `zr~` skeleton (virtual-id.ts) every other remote gate in this plugin
15624
+ * keys on, so the two ends cannot drift;
15625
+ * - `decideRemoteDrop` — the three trigger gates plus the directory rule,
15626
+ * as a pure function over injectable data so the whole truth table is
15627
+ * assertable in Node (scripts/check-remote-drop.mjs);
15628
+ * - `rewrapFiles` — the File re-wrap itself (Node >= 20 has File).
15629
+ *
15630
+ * Misjudgement direction is safe by construction: every gate failing open
15631
+ * lands on `passthrough` = today's behavior. The only wrong action a stale
15632
+ * current-session signal can produce is re-shaping a LOCAL session's drop
15633
+ * into an upload — bytes travel that did not have to, but nothing is lost.
15634
+ * The reverse (a remote drop reaching the host untouched) is what loses the
15635
+ * attachment, which is why all three gates must affirmatively pass.
15636
+ */
15637
+ Object.defineProperty(exports, "__esModule", { value: true });
15638
+ exports.isRemoteSessionId = isRemoteSessionId;
15639
+ exports.decideRemoteDrop = decideRemoteDrop;
15640
+ exports.rewrapFiles = rewrapFiles;
15641
+ const virtual_id_ts_1 = require("./virtual-id.js");
15642
+ /** True when the id is one of this plugin's virtual (relay) session ids —
15643
+ * the `zr~<serverId>~<id>` form. `undefined` (no readable current-session
15644
+ * signal) is deliberately NOT remote: the conservative gate reads a missing
15645
+ * signal as a local session and never intervenes. */
15646
+ function isRemoteSessionId(id) {
15647
+ return typeof id === 'string' && (0, virtual_id_ts_1.isVirtual)(id);
15648
+ }
15649
+ /**
15650
+ * The three trigger gates, then the directory rule. Every gate must
15651
+ * affirmatively pass before anything is intervened on:
15652
+ * 1. `trusted` — synthetic (our own) events pass straight through, which is
15653
+ * what makes the redispatch loop-free;
15654
+ * 2. `hasHostPaths` — without the Electron bridge the host is already on
15655
+ * the byte-upload branch and there is nothing to fix;
15656
+ * 3. remote session — a missing/failed current-session read counts as local
15657
+ * (conservative: passthrough keeps today's behavior);
15658
+ * 4. no entry API — redispatch (no directory knowledge, same default as the
15659
+ * host);
15660
+ * 5. any directory — `block-directories`, which takes precedence over
15661
+ * redispatch: a folder cannot be fixed by single-file upload, so the
15662
+ * whole drop is stopped with a toast rather than silently reduced;
15663
+ * 6. no file items at all — passthrough (the host's own `Files`-in-types
15664
+ * gate would ignore the drop; there is nothing to re-shape);
15665
+ * 7. otherwise — `redispatch`.
15666
+ */
15667
+ function decideRemoteDrop(input) {
15668
+ if (!input.trusted)
15669
+ return { action: 'passthrough', reason: 'untrusted-event' };
15670
+ if (!input.hasHostPaths)
15671
+ return { action: 'passthrough', reason: 'no-host-paths' };
15672
+ if (!isRemoteSessionId(input.currentSessionId))
15673
+ return { action: 'passthrough', reason: 'not-remote-session' };
15674
+ if (input.items === undefined)
15675
+ return { action: 'redispatch', reason: 'no-entry-api' };
15676
+ if (input.items.some((item) => item.isDirectory))
15677
+ return { action: 'block-directories', reason: 'contains-directory' };
15678
+ if (!input.items.some((item) => item.kind === 'file'))
15679
+ return { action: 'passthrough', reason: 'no-files' };
15680
+ return { action: 'redispatch', reason: 'remote-files' };
15681
+ }
15682
+ /**
15683
+ * Re-wrap files so they stop carrying their disk paths. A File the browser
15684
+ * constructed from an OS drop keeps a hidden reference the Electron
15685
+ * `getPathForFile` can read; `new File([f], ...)` builds a JS-constructed
15686
+ * File with no such backing — `getPathForFile` returns "" for it, which
15687
+ * sends the host's `addFiles` down the byte-upload branch. name / type /
15688
+ * lastModified are preserved so the staged upload keeps its filename.
15689
+ */
15690
+ function rewrapFiles(files) {
15691
+ return Array.from(files, (file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified }));
15692
+ }
15693
+ };
15694
+ __modules["client-data/current-session.js"] = function (require, module, exports) {
15695
+ "use strict";
15696
+ /**
15697
+ * T62: the browser half's current-session parsing, as pure functions — the
15698
+ * readable core of the reporter (src/client/current-session-report.ts) and
15699
+ * the one place its rules are unit-tested without a DOM.
15700
+ *
15701
+ * The host DSH persists its selection (the session its UI has open) to
15702
+ * localStorage under `dsh.sessions.current` as a JSON object carrying
15703
+ * `sessionId` (dsh-client-ui-workspace's snapshot store, `replaceMain` /
15704
+ * `clearMain`). The reporter re-reads that key and POSTs it to the backend
15705
+ * whenever it CHANGES; the backend's intercept layer judges close frames
15706
+ * against it — a closed session the user has open keeps its page and its
15707
+ * 「远程已关闭」 banner, anything else hides. The vocabulary mirrors the
15708
+ * intercept side's `CurrentSessionRead`:
15709
+ * - `unavailable`: the key is absent or the value unreadable — NEVER
15710
+ * report; the backend keeps its conservative tombstone behavior;
15711
+ * - `none`: readable, no session open — report `{sessionId: null}`;
15712
+ * - `open`: a session id — report `{sessionId}`.
15713
+ */
15714
+ Object.defineProperty(exports, "__esModule", { value: true });
15715
+ exports.CURRENT_SESSION_STORAGE_KEY = void 0;
15716
+ exports.parseCurrentSessionStorage = parseCurrentSessionStorage;
15717
+ exports.currentSessionReportEquals = currentSessionReportEquals;
15718
+ /** Mirror of src/intercept.ts's key (the client build cannot import the
15719
+ * host file). The host's selection store persists under this exact name. */
15720
+ exports.CURRENT_SESSION_STORAGE_KEY = 'dsh.sessions.current';
15721
+ /** Parse one raw localStorage read. `null` / `undefined` (the key is absent
15722
+ * — the host store only starts persisting with its first write) and any
15723
+ * value that does not parse into an object with a usable shape are
15724
+ * `unavailable`: no signal, never a guess. An object WITHOUT a `sessionId`
15725
+ * (the host's `clearMain` writes `{}`) is a real, reportable "nothing is
15726
+ * open". */
15727
+ function parseCurrentSessionStorage(raw) {
15728
+ if (raw === null || raw === undefined)
15729
+ return { kind: 'unavailable' };
15730
+ let parsed;
15731
+ try {
15732
+ parsed = JSON.parse(raw);
15733
+ }
15734
+ catch {
15735
+ return { kind: 'unavailable' };
15736
+ }
15737
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
15738
+ return { kind: 'unavailable' };
15739
+ const sessionId = parsed.sessionId;
15740
+ if (typeof sessionId === 'string' && sessionId !== '')
15741
+ return { kind: 'open', sessionId };
15742
+ if (sessionId === undefined || sessionId === null || sessionId === '')
15743
+ return { kind: 'none' };
15744
+ return { kind: 'unavailable' };
15745
+ }
15746
+ /** Whether one report differs from another — the reporter posts only on a
15747
+ * change, so this compares content, never object identity: kind first, then
15748
+ * the session id. */
15749
+ function currentSessionReportEquals(a, b) {
15750
+ if (a.kind !== b.kind)
15751
+ return false;
15752
+ if (a.kind === 'open' && b.kind === 'open')
15753
+ return a.sessionId === b.sessionId;
15754
+ return true;
15755
+ }
15756
+ };
15757
+ __modules["client/effects/remote-drop.js"] = function (require, module, exports) {
15758
+ "use strict";
15759
+ Object.defineProperty(exports, "__esModule", { value: true });
15760
+ exports.remoteDropDiagnostics = remoteDropDiagnostics;
15761
+ exports.mountRemoteDropGuard = mountRemoteDropGuard;
15762
+ const remote_drop_ts_1 = require("./client-data/remote-drop.js");
15763
+ const current_session_ts_1 = require("./client-data/current-session.js");
15764
+ const locales_ts_1 = require("./client/locales.js");
15765
+ /** Module-level singleton: the bundle has one document, so one guard state.
15766
+ * Re-mounting (a plugin row reload) resets it. */
15767
+ let diagnostics = freshDiagnostics();
15768
+ function freshDiagnostics() {
15769
+ return { installed: false, notInstalledReason: undefined, intercepted: 0, redispatched: 0, blockedDirectories: 0, passthrough: 0, rewrapFailed: 0 };
15770
+ }
15771
+ /** A snapshot for the diagnostics carriers (settings page). Copying keeps
15772
+ * callers from holding a live reference into a later mount's state. */
15773
+ function remoteDropDiagnostics() {
15774
+ return { ...diagnostics };
15775
+ }
15776
+ /** The contextBridge global's existence only — never touched otherwise
15777
+ * (it is deep-frozen and must stay unwrapped). */
15778
+ function hostPathsOf() {
15779
+ return globalThis.__DSH_HOST_PATHS__;
15780
+ }
15781
+ /** The current-session signal, read AT EVENT TIME (the selection changes
15782
+ * while the page is open; a mount-time snapshot would go stale). Any
15783
+ * storage failure reads as "no signal" = local session = passthrough. */
15784
+ function readCurrentSessionId() {
15785
+ let raw;
15786
+ try {
15787
+ const storage = globalThis.localStorage;
15788
+ raw = storage === null || storage === undefined ? null : storage.getItem(current_session_ts_1.CURRENT_SESSION_STORAGE_KEY);
15789
+ }
15790
+ catch {
15791
+ return undefined;
15792
+ }
15793
+ const report = (0, current_session_ts_1.parseCurrentSessionStorage)(raw);
15794
+ return report.kind === 'open' ? report.sessionId : undefined;
15795
+ }
15796
+ /** Reduce the drop's items to RD-1's descriptors. `undefined` when the
15797
+ * entry API is missing entirely — directories cannot be detected, and the
15798
+ * host's own semantics treat that as "no directories". Directory entries
15799
+ * surface as file-kind items (their bytes are unreadable), which is what
15800
+ * the block-directories branch keys on. */
15801
+ function collectItems(transfer) {
15802
+ const out = [];
15803
+ for (const item of Array.from(transfer.items)) {
15804
+ if (item.kind !== 'file')
15805
+ continue;
15806
+ if (typeof item.webkitGetAsEntry !== 'function')
15807
+ return undefined;
15808
+ const entry = item.webkitGetAsEntry();
15809
+ out.push({ kind: 'file', isDirectory: entry?.isDirectory === true });
15810
+ }
15811
+ return out;
15812
+ }
15813
+ /** The toast copy in the page's language (the plugin's namespace carries
15814
+ * both dictionaries; the framework `t` seat is not reachable from a
15815
+ * non-React effect — same pick as the bridge note). */
15816
+ function toastText(language) {
15817
+ const key = 'remoteDropFolderBlocked';
15818
+ return typeof language === 'string' && language.startsWith('zh') ? locales_ts_1.zh[key] : locales_ts_1.en[key];
15819
+ }
15820
+ /** Take the host's「拖拽文件到此处添加」drop overlay back down after this
15821
+ * guard has stopped a drop outright. The overlay's own state machine
15822
+ * (`dsh-client-ui-attachment` drop-events.ts) closes on the reset() inside
15823
+ * its DOCUMENT-level bubble `drop` handler — which this guard's capture
15824
+ * stopPropagation deliberately prevents from running — or on `dragend` at
15825
+ * `window` level, a channel it reads without isTrust-checking. An OS-file
15826
+ * drag never delivers a native dragend (the drag source is the OS, not a
15827
+ * page element), so a branch that eats the drop must fire a synthetic one
15828
+ * itself or the overlay stays up forever. Only the folder-block branch
15829
+ * calls this: the redispatch branch's synthetic drop reaches the host's
15830
+ * bubble handler, whose own reset brings the overlay down. */
15831
+ function dismissHostDropOverlay() {
15832
+ try {
15833
+ window.dispatchEvent(new Event('dragend'));
15834
+ }
15835
+ catch {
15836
+ // No window / Event in this host: the overlay is the only casualty.
15837
+ }
15838
+ }
15839
+ function mountRemoteDropGuard(ctx) {
15840
+ ctx.effect(() => {
15841
+ // Fresh state per mount: a plugin reload re-runs apply() and must not
15842
+ // inherit the previous mount's counters or installed flag.
15843
+ diagnostics = freshDiagnostics();
15844
+ // Capability self-check — anything missing means "not installed",
15845
+ // back to today's behavior. Counted, not thrown: the diagnostics
15846
+ // block explains why the guard is absent.
15847
+ if (typeof DragEvent !== 'function') {
15848
+ diagnostics.notInstalledReason = 'drag-event-unavailable';
15849
+ return () => { };
15850
+ }
15851
+ try {
15852
+ new DataTransfer();
15853
+ }
15854
+ catch {
15855
+ diagnostics.notInstalledReason = 'datatransfer-unavailable';
15856
+ return () => { };
15857
+ }
15858
+ if (hostPathsOf() === undefined) {
15859
+ // Phone / plain-browser hosts: the bridge is absent and the host is
15860
+ // already on the byte-upload branch — nothing to fix here.
15861
+ diagnostics.notInstalledReason = 'host-paths-unavailable';
15862
+ return () => { };
15863
+ }
15864
+ diagnostics.installed = true;
15865
+ // --- toast (folder-blocked notice), inline styles per the CSS split ---
15866
+ let toast;
15867
+ let toastTimer;
15868
+ const removeToast = () => {
15869
+ if (toastTimer !== undefined)
15870
+ clearTimeout(toastTimer);
15871
+ toastTimer = undefined;
15872
+ toast?.remove();
15873
+ toast = undefined;
15874
+ };
15875
+ const showToast = () => {
15876
+ // Restart the window on rapid re-drops rather than stacking toasts.
15877
+ removeToast();
15878
+ const el = document.createElement('div');
15879
+ el.dataset.zenRemote = 'remote-drop-toast';
15880
+ el.textContent = toastText(navigator.language);
15881
+ el.style.cssText = [
15882
+ 'position:fixed', 'left:50%', 'transform:translateX(-50%)',
15883
+ // Safe-area habit shared with the overlay effects: keep clear of the
15884
+ // home indicator, degrade to a plain 32px offset where env() is 0.
15885
+ 'bottom:calc(env(safe-area-inset-bottom, 0px) + 32px)',
15886
+ 'z-index:2147483000',
15887
+ 'background:rgba(0,0,0,.82)', 'color:#fff',
15888
+ 'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
15889
+ 'padding:10px 16px', 'border-radius:10px', 'max-width:90vw', 'text-align:center',
15890
+ // Purely informational: never intercepts the taps under it.
15891
+ 'pointer-events:none',
15892
+ ].join(';');
15893
+ document.body.appendChild(el);
15894
+ toast = el;
15895
+ toastTimer = setTimeout(removeToast, 3000);
15896
+ };
15897
+ const onDrop = (event) => {
15898
+ // Self-reentry guard first: the redispatched event is untrusted, and
15899
+ // this capture listener would otherwise see it again.
15900
+ if (!event.isTrusted)
15901
+ return;
15902
+ const transfer = event.dataTransfer;
15903
+ if (transfer === null || !transfer.types.includes('Files'))
15904
+ return;
15905
+ // Not a file drag: the host's own Files-in-types gate ignores the
15906
+ // drop anyway — not a candidate, not counted.
15907
+ const decision = (0, remote_drop_ts_1.decideRemoteDrop)({
15908
+ hasHostPaths: hostPathsOf() !== undefined,
15909
+ currentSessionId: readCurrentSessionId(),
15910
+ trusted: event.isTrusted,
15911
+ items: collectItems(transfer),
15912
+ });
15913
+ if (decision.action === 'passthrough') {
15914
+ diagnostics.passthrough += 1;
15915
+ return;
15916
+ }
15917
+ if (decision.action === 'block-directories') {
15918
+ // A folder cannot be fixed by single-file upload: stop the whole
15919
+ // drop, say so, nothing is dispatched.
15920
+ event.preventDefault();
15921
+ event.stopPropagation();
15922
+ dismissHostDropOverlay();
15923
+ diagnostics.intercepted += 1;
15924
+ diagnostics.blockedDirectories += 1;
15925
+ showToast();
15926
+ return;
15927
+ }
15928
+ // redispatch: build the ENTIRE payload before stopping the original
15929
+ // event — any failure below releases the original untouched, so the
15930
+ // host still receives today's (path-chip) behavior instead of
15931
+ // nothing at all.
15932
+ try {
15933
+ const rewrapped = (0, remote_drop_ts_1.rewrapFiles)(Array.from(transfer.files));
15934
+ if (rewrapped.length === 0) {
15935
+ diagnostics.passthrough += 1;
15936
+ return;
15937
+ }
15938
+ const payload = new DataTransfer();
15939
+ for (const file of rewrapped)
15940
+ payload.items.add(file);
15941
+ event.preventDefault();
15942
+ event.stopPropagation();
15943
+ diagnostics.intercepted += 1;
15944
+ // The host's drop handler sits on document, bubble phase (F4): a
15945
+ // bubbling synthetic drop on document reaches it. Its own handler
15946
+ // does the preventDefault / mask reset / addFiles work; our capture
15947
+ // listener skips this event via the isTrusted gate above.
15948
+ document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }));
15949
+ diagnostics.redispatched += 1;
15950
+ }
15951
+ catch {
15952
+ // A re-wrap or a single items.add threw (0-byte / exotic File):
15953
+ // count it and let the original event flow — 宁坏勿丢.
15954
+ diagnostics.rewrapFailed += 1;
15955
+ }
15956
+ };
15957
+ document.addEventListener('drop', onDrop, true);
15958
+ return () => {
15959
+ document.removeEventListener('drop', onDrop, true);
15960
+ removeToast();
15961
+ // Keep the counters readable after a dispose (the settings page may
15962
+ // still be open mid-reload); `installed` is the armed flag only.
15963
+ diagnostics.installed = false;
15964
+ };
15965
+ }, 'dsh-zen-remote: remote drop guard');
15966
+ }
15967
+ };
15246
15968
  __modules["client/settings/SettingsSection.js"] = function (require, module, exports) {
15247
15969
  "use strict";
15248
15970
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -15285,6 +16007,7 @@ const react_1 = require("react");
15285
16007
  const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
15286
16008
  const settings_form_ts_1 = require("./client-data/settings-form.js");
15287
16009
  const plugin_bridge_ts_1 = require("./client-data/plugin-bridge.js");
16010
+ const remote_drop_ts_1 = require("./client/effects/remote-drop.js");
15288
16011
  const shares_ts_1 = require("./client-data/shares.js");
15289
16012
  /** Device roles the pairing picker offers, in display order. */
15290
16013
  const PAIR_ROLES = ['web', 'desktop-client'];
@@ -15476,6 +16199,11 @@ function SettingsSectionPage({ config, shares, t }) {
15476
16199
  // The client group's rendered view, hoisted for the countdown ticker and
15477
16200
  // the retry-follow refresh below (T43).
15478
16201
  const clientView = clientLoad.state === 'ready' || clientLoad.state === 'stale' ? clientLoad.view : undefined;
16202
+ // RD-3: the remote drop guard's browser-side counters, read at render
16203
+ // time — no snapshot state of its own: the initial mount, the 15s
16204
+ // connected-status poll and the countdown ticker all re-render this page,
16205
+ // and each render takes the values as they are right then.
16206
+ const dropDiag = (0, remote_drop_ts_1.remoteDropDiagnostics)();
15479
16207
  const clientRetryAt = clientView?.nextRetryAt;
15480
16208
  const clientCountingDown = clientRetryAt !== undefined;
15481
16209
  // Offline with a pending retry: refresh the status shortly after the
@@ -15936,9 +16664,31 @@ function SettingsSectionPage({ config, shares, t }) {
15936
16664
  // hyphens stripped) on every keystroke, and only the
15937
16665
  // NORMALIZED length gates the pair button — a code pasted with
15938
16666
  // separators must survive pasting intact.
15939
- value: pairCode, onChange: (e) => { setPairCode((0, settings_form_ts_1.normalizePairingCode)(e.currentTarget.value)); } }), (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.client.pairingCodeHint') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-row", style: { paddingBottom: 12 }, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: !claimAllowed, onClick: () => { void runClaim(); }, children: claimBusy ? t('settings.client.pairing') : t('settings.client.pair') }), form.serverName.invalid && (0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.client.fixDeviceName') }), claimFail !== null && (0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", "data-invalid": "true", children: pairFailText(claimFail, t) }), claimWriteFailed && (0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.client.failWrite') })] }), (0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.client.statusTitle') }), (0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-row", style: { paddingBottom: 4 }, children: [(0, jsx_runtime_1.jsx)("p", { className: "zr-settings-status-line", style: { flex: 1 }, "data-down": clientView !== undefined && (clientView.state === 'unreachable' || clientView.state === 'unexpected' || clientView.state === 'revoked' || clientView.state === 'invalid-url' || clientView.state === 'incompatible') ? 'true' : undefined, children: clientView === undefined ? '' : clientStatusText(clientView, now, t) }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: clientView === undefined || clientView.nextRetryAt === undefined || reconnectBusy, onClick: () => { void runReconnect(); }, children: reconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: clientLoad.state === 'loading', onClick: loadClientStatus, children: clientLoad.state === 'loading' ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh') })] }), form.deviceToken.configured && ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-row", style: { paddingBottom: 12 }, children: [(0, jsx_runtime_1.jsx)("span", { className: "zr-settings-badge", children: t('settings.client.tokenSet') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "ghost", size: "sm", disabled: !form.available || !form.writable || unpairBusy, onClick: () => { void runUnpair(); }, children: unpairBusy ? t('settings.client.unpairBusy') : t('settings.client.unpair') })] })), unpairDone && (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", style: { paddingBottom: 12 }, children: t('settings.client.unpairDone') }), clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined) && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.client.diagTitle') }), clientView.lastError !== '' && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": clientView.state === 'unreachable' ? 'true' : undefined, children: t('settings.client.diagLastError', { code: clientView.lastError }) })), clientView.intercept !== undefined && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: clientView.intercept.installed
16667
+ value: pairCode, onChange: (e) => { setPairCode((0, settings_form_ts_1.normalizePairingCode)(e.currentTarget.value)); } }), (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.client.pairingCodeHint') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-row", style: { paddingBottom: 12 }, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: !claimAllowed, onClick: () => { void runClaim(); }, children: claimBusy ? t('settings.client.pairing') : t('settings.client.pair') }), form.serverName.invalid && (0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.client.fixDeviceName') }), claimFail !== null && (0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", "data-invalid": "true", children: pairFailText(claimFail, t) }), claimWriteFailed && (0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.client.failWrite') })] }), (0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.client.statusTitle') }), (0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-row", style: { paddingBottom: 4 }, children: [(0, jsx_runtime_1.jsx)("p", { className: "zr-settings-status-line", style: { flex: 1 }, "data-down": clientView !== undefined && (clientView.state === 'unreachable' || clientView.state === 'unexpected' || clientView.state === 'revoked' || clientView.state === 'invalid-url' || clientView.state === 'incompatible') ? 'true' : undefined, children: clientView === undefined ? '' : clientStatusText(clientView, now, t) }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: clientView === undefined || clientView.nextRetryAt === undefined || reconnectBusy, onClick: () => { void runReconnect(); }, children: reconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", size: "sm", disabled: clientLoad.state === 'loading', onClick: loadClientStatus, children: clientLoad.state === 'loading' ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh') })] }), form.deviceToken.configured && ((0, jsx_runtime_1.jsxs)("div", { className: "zr-settings-row", style: { paddingBottom: 12 }, children: [(0, jsx_runtime_1.jsx)("span", { className: "zr-settings-badge", children: t('settings.client.tokenSet') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "ghost", size: "sm", disabled: !form.available || !form.writable || unpairBusy, onClick: () => { void runUnpair(); }, children: unpairBusy ? t('settings.client.unpairBusy') : t('settings.client.unpair') })] })), unpairDone && (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", style: { paddingBottom: 12 }, children: t('settings.client.unpairDone') }), clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined || dropDiag.installed || dropDiag.notInstalledReason !== undefined) && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.client.diagTitle') }), clientView.lastError !== '' && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": clientView.state === 'unreachable' ? 'true' : undefined, children: t('settings.client.diagLastError', { code: clientView.lastError }) })), clientView.intercept !== undefined && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: clientView.intercept.installed
15940
16668
  ? t('settings.client.diagInterceptInstalled')
15941
- : t('settings.client.diagInterceptMissing') }), clientView.intercept.reasons.length > 0 && ((0, jsx_runtime_1.jsxs)("p", { className: "zr-settings-hint", "data-invalid": "true", children: [t('settings.client.diagInterceptDisabled'), clientView.intercept.reasons.map((reason, index) => ((0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", children: reason }, index)))] })), clientView.intercept.recentFailures.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.diagRecentFailures') }) }), clientView.intercept.recentFailures.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.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'
16669
+ : t('settings.client.diagInterceptMissing') }), clientView.intercept.reasons.length > 0 && ((0, jsx_runtime_1.jsxs)("p", { className: "zr-settings-hint", "data-invalid": "true", children: [t('settings.client.diagInterceptDisabled'), clientView.intercept.reasons.map((reason, index) => ((0, jsx_runtime_1.jsx)("span", { className: "zr-settings-hint", children: reason }, index)))] })), clientView.intercept.recentFailures.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.diagRecentFailures') }) }), clientView.intercept.recentFailures.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)))] }))] })), (dropDiag.installed || dropDiag.notInstalledReason !== 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.client.diagRemoteDrop') }) }), (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: dropDiag.installed
16670
+ ? t('settings.client.diagRemoteDropOk', {
16671
+ intercepted: dropDiag.intercepted,
16672
+ redispatched: dropDiag.redispatched,
16673
+ blocked: dropDiag.blockedDirectories,
16674
+ passthrough: dropDiag.passthrough,
16675
+ failed: dropDiag.rewrapFailed,
16676
+ })
16677
+ : t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' }) })] })), clientView?.uploadProbe !== undefined && (() => {
16678
+ const probe = clientView.uploadProbe;
16679
+ const seen = probe.uploadInjectSeen === true;
16680
+ const refused = seen && probe.fetchRoute?.shape?.reasons instanceof Array && probe.fetchRoute.shape.reasons.length > 0;
16681
+ const wrapped = probe.fetchRoute?.uploadWrapped === true;
16682
+ const text = wrapped
16683
+ ? t('settings.client.diagUploadRedirectOk')
16684
+ : refused
16685
+ ? t('settings.client.diagUploadRedirectRefused', { reason: (probe.fetchRoute?.shape?.reasons).join('; ') })
16686
+ : t('settings.client.diagUploadRedirectPending', {
16687
+ connection: typeof probe.connectionService === 'string' ? probe.connectionService : '—',
16688
+ fileUploads: typeof probe.fileUploadsService === 'string' ? probe.fileUploadsService : '—',
16689
+ });
16690
+ 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 })] }));
16691
+ })(), 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'
15942
16692
  ? t(row.stateKey, { version: row.serverVersion ?? '?' })
15943
16693
  : 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('; ') || '—' }) }))] }))] }))] }));
15944
16694
  }
@@ -16652,79 +17402,6 @@ function registerRemoteShareUi(ctx, headerIcon, menuItem) {
16652
17402
  });
16653
17403
  }
16654
17404
  };
16655
- __modules["virtual-id.js"] = function (require, module, exports) {
16656
- "use strict";
16657
- /**
16658
- * Reversible virtual ids for sessions and workspaces the sub-client surfaces
16659
- * locally while their real records live on a relay server (T23b-1). The UI
16660
- * only ever sees `zr~<serverId>~<original id>`; the interceptor maps those
16661
- * back to the original ids before a call travels, and maps server-returned
16662
- * ids back to virtual form on the way out.
16663
- *
16664
- * The mapping is deliberately a PURE function of (serverId, id), not a
16665
- * session-table lookup: every shared session of one server gets the same
16666
- * virtual id whether or not this process ever opened it, so result rewriting
16667
- * needs no state. Parsing validates the fixed skeleton (`zr~` + the 8 hex
16668
- * chars `loadServerId` mints, relay-server.ts) and keeps the remainder
16669
- * VERBATIM — the original id may contain any characters, including `~`, so
16670
- * nothing after the second separator is interpreted.
16671
- *
16672
- * A local DSH id can never collide with the prefix: real session ids are
16673
- * `session-<uuid>` and workspace ids are bare uuids.
16674
- */
16675
- Object.defineProperty(exports, "__esModule", { value: true });
16676
- exports.VIRTUAL_GROUP_NAME_MARK = exports.VIRTUAL_ID_PREFIX = void 0;
16677
- exports.toVirtual = toVirtual;
16678
- exports.fromVirtual = fromVirtual;
16679
- exports.isVirtual = isVirtual;
16680
- /** The fixed prefix every virtual id starts with. */
16681
- exports.VIRTUAL_ID_PREFIX = 'zr~';
16682
- /**
16683
- * The invisible WORD JOINER (U+2060) every virtual GROUP NAME starts with
16684
- * (T74). DSH 0.2.0 renders the model menu's provider groups with the
16685
- * primitives' MenuGroup, whose `aria-labelledby` carries only a useId — the
16686
- * group id is gone from the markup — so the browser half can no longer tell
16687
- * a virtual group from a local one by attribute. The merged catalog's group
16688
- * NAME (merge-streams.ts mergeModelCatalogs) is the one mark that survives
16689
- * in both 0.1.7 and 0.2.0, and effects/model-group-side.ts classifies each
16690
- * group container by which headings start with it. Zero-width: headings
16691
- * read exactly as before. Sidebar workspace TITLES deliberately do NOT
16692
- * carry it — those groups are told apart by their `zr~` row ids, not by
16693
- * text.
16694
- */
16695
- exports.VIRTUAL_GROUP_NAME_MARK = '\u2060';
16696
- /** The server id half: exactly the 8 lowercase hex chars relay-server.ts
16697
- * mints (`randomBytes(4).toString('hex')`). */
16698
- const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/;
16699
- /** Build the virtual form of one server-side session or workspace id. */
16700
- function toVirtual(serverId, id) {
16701
- return `${exports.VIRTUAL_ID_PREFIX}${serverId}~${id}`;
16702
- }
16703
- /**
16704
- * Parse one virtual id back into its halves, or `undefined` for anything
16705
- * that does not match the skeleton. Strict on the parts that carry meaning:
16706
- * the literal prefix, exactly 8 lowercase hex server id chars, and the
16707
- * separating `~` after them. The remainder is not validated — any string,
16708
- * including the empty one, is a legal original id.
16709
- */
16710
- function fromVirtual(id) {
16711
- if (typeof id !== 'string' || !id.startsWith(exports.VIRTUAL_ID_PREFIX))
16712
- return undefined;
16713
- const rest = id.slice(exports.VIRTUAL_ID_PREFIX.length);
16714
- // The shortest legal remainder is `<8 hex>~` — 9 chars with the separator
16715
- // at index 8. Anything shorter cannot carry a server id.
16716
- if (rest.length < 9 || rest[8] !== '~')
16717
- return undefined;
16718
- const serverId = rest.slice(0, 8);
16719
- if (!SERVER_ID_PATTERN.test(serverId))
16720
- return undefined;
16721
- return { serverId, id: rest.slice(9) };
16722
- }
16723
- /** True when {@link fromVirtual} would parse this value. */
16724
- function isVirtual(id) {
16725
- return fromVirtual(id) !== undefined;
16726
- }
16727
- };
16728
17405
  __modules["client-data/remote-status.js"] = function (require, module, exports) {
16729
17406
  "use strict";
16730
17407
  /**
@@ -17290,69 +17967,6 @@ function registerRemoteStatusUi(ctx, icon, banner) {
17290
17967
  }, banner));
17291
17968
  }
17292
17969
  };
17293
- __modules["client-data/current-session.js"] = function (require, module, exports) {
17294
- "use strict";
17295
- /**
17296
- * T62: the browser half's current-session parsing, as pure functions — the
17297
- * readable core of the reporter (src/client/current-session-report.ts) and
17298
- * the one place its rules are unit-tested without a DOM.
17299
- *
17300
- * The host DSH persists its selection (the session its UI has open) to
17301
- * localStorage under `dsh.sessions.current` as a JSON object carrying
17302
- * `sessionId` (dsh-client-ui-workspace's snapshot store, `replaceMain` /
17303
- * `clearMain`). The reporter re-reads that key and POSTs it to the backend
17304
- * whenever it CHANGES; the backend's intercept layer judges close frames
17305
- * against it — a closed session the user has open keeps its page and its
17306
- * 「远程已关闭」 banner, anything else hides. The vocabulary mirrors the
17307
- * intercept side's `CurrentSessionRead`:
17308
- * - `unavailable`: the key is absent or the value unreadable — NEVER
17309
- * report; the backend keeps its conservative tombstone behavior;
17310
- * - `none`: readable, no session open — report `{sessionId: null}`;
17311
- * - `open`: a session id — report `{sessionId}`.
17312
- */
17313
- Object.defineProperty(exports, "__esModule", { value: true });
17314
- exports.CURRENT_SESSION_STORAGE_KEY = void 0;
17315
- exports.parseCurrentSessionStorage = parseCurrentSessionStorage;
17316
- exports.currentSessionReportEquals = currentSessionReportEquals;
17317
- /** Mirror of src/intercept.ts's key (the client build cannot import the
17318
- * host file). The host's selection store persists under this exact name. */
17319
- exports.CURRENT_SESSION_STORAGE_KEY = 'dsh.sessions.current';
17320
- /** Parse one raw localStorage read. `null` / `undefined` (the key is absent
17321
- * — the host store only starts persisting with its first write) and any
17322
- * value that does not parse into an object with a usable shape are
17323
- * `unavailable`: no signal, never a guess. An object WITHOUT a `sessionId`
17324
- * (the host's `clearMain` writes `{}`) is a real, reportable "nothing is
17325
- * open". */
17326
- function parseCurrentSessionStorage(raw) {
17327
- if (raw === null || raw === undefined)
17328
- return { kind: 'unavailable' };
17329
- let parsed;
17330
- try {
17331
- parsed = JSON.parse(raw);
17332
- }
17333
- catch {
17334
- return { kind: 'unavailable' };
17335
- }
17336
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
17337
- return { kind: 'unavailable' };
17338
- const sessionId = parsed.sessionId;
17339
- if (typeof sessionId === 'string' && sessionId !== '')
17340
- return { kind: 'open', sessionId };
17341
- if (sessionId === undefined || sessionId === null || sessionId === '')
17342
- return { kind: 'none' };
17343
- return { kind: 'unavailable' };
17344
- }
17345
- /** Whether one report differs from another — the reporter posts only on a
17346
- * change, so this compares content, never object identity: kind first, then
17347
- * the session id. */
17348
- function currentSessionReportEquals(a, b) {
17349
- if (a.kind !== b.kind)
17350
- return false;
17351
- if (a.kind === 'open' && b.kind === 'open')
17352
- return a.sessionId === b.sessionId;
17353
- return true;
17354
- }
17355
- };
17356
17970
  __modules["client/current-session-report.js"] = function (require, module, exports) {
17357
17971
  "use strict";
17358
17972
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -18225,6 +18839,44 @@ option[value^="zr~"] {
18225
18839
  html[data-zr-remote-session="1"] [data-slot="conversation.session.header.utilities"] [class$="_moreButton"] ~ [role="menu"] > [role="presentation"] > :first-child {
18226
18840
  display: none !important;
18227
18841
  }
18842
+
18843
+ /* ---------- remote session: the "save to this machine" action (2.2.6) ----------
18844
+ One class, owned by this plugin (no host module class, no hash): the
18845
+ deliverable-card entry renders it in the card's own actions cell, the
18846
+ document-preview entry in the preview header where open-in-app's control
18847
+ sits (that one is hidden remotely by the [data-open-target] rule above,
18848
+ so this is the only action left there). Both entries only MOUNT for a
18849
+ virtual session, so the html attribute gate is belt-and-braces, not the
18850
+ decision.
18851
+ The palette variables are the host's own (label-secondary / border-l1 /
18852
+ interactive-bg-hover / state-error), which keeps light/dark identical to
18853
+ the cards around it without copying a theme. */
18854
+ html[data-zr-remote-session="1"] .zr-download-action {
18855
+ box-sizing: border-box;
18856
+ flex: none;
18857
+ font: inherit;
18858
+ font-size: 12px;
18859
+ line-height: 18px;
18860
+ color: var(--dsw-alias-label-secondary, #6b7280);
18861
+ background: transparent;
18862
+ border: 0.5px solid var(--dsw-alias-border-l1, #d9dde3);
18863
+ border-radius: var(--dsw-radius-sm, 4px);
18864
+ padding: 1px 8px;
18865
+ cursor: pointer;
18866
+ white-space: nowrap;
18867
+ }
18868
+ html[data-zr-remote-session="1"] .zr-download-action:hover {
18869
+ color: var(--dsw-alias-label-primary, #111827);
18870
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
18871
+ }
18872
+ html[data-zr-remote-session="1"] .zr-download-action[data-phase="busy"] {
18873
+ cursor: progress;
18874
+ opacity: 0.7;
18875
+ }
18876
+ html[data-zr-remote-session="1"] .zr-download-action[data-phase="error"] {
18877
+ color: var(--dsw-alias-state-error-primary, #dc2626);
18878
+ border-color: var(--dsw-alias-state-error-primary, #dc2626);
18879
+ }
18228
18880
  `;
18229
18881
  };
18230
18882
  __modules["client/effects/remote-session.js"] = function (require, module, exports) {
@@ -18562,6 +19214,289 @@ function installModelGroupSide(ctx) {
18562
19214
  }, 'dsh-zen-remote: model group side');
18563
19215
  }
18564
19216
  };
19217
+ __modules["client/remote-file-save.js"] = function (require, module, exports) {
19218
+ "use strict";
19219
+ Object.defineProperty(exports, "__esModule", { value: true });
19220
+ exports.DOWNLOAD_CHUNK_BYTES = void 0;
19221
+ exports.downloadFileNameOf = downloadFileNameOf;
19222
+ exports.downloadPathFromCard = downloadPathFromCard;
19223
+ exports.collectRemoteFile = collectRemoteFile;
19224
+ exports.createSaveRemoteFile = createSaveRemoteFile;
19225
+ /** The byte window one relayed read asks for. Matches the host default
19226
+ * (`Config.maxBytes` 2 MiB) exactly: one round trip per window, and a host
19227
+ * configured smaller self-corrects through the too-large branch below. */
19228
+ exports.DOWNLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
19229
+ /** A hard ceiling on windows per file (4 GiB at the default size) — the
19230
+ * guard against a reader that never advances (a host ignoring `range` and
19231
+ * answering the same bytes forever) hanging the button in `busy`. */
19232
+ const MAX_DOWNLOAD_CHUNKS = 2048;
19233
+ /**
19234
+ * The last path segment, as the browser download name. Slash- and
19235
+ * backslash-separated paths both occur (the host may be Windows); an empty
19236
+ * tail falls back to a fixed name so `download` is never blank.
19237
+ * @param path - the file's absolute path on the host.
19238
+ * @returns the name to save under.
19239
+ */
19240
+ function downloadFileNameOf(path) {
19241
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
19242
+ const name = (cut === -1 ? path : path.slice(cut + 1)).trim();
19243
+ return name === '' ? 'download' : name;
19244
+ }
19245
+ /**
19246
+ * The file path a produced-file card carries, read off the host's own
19247
+ * rendered contract: the card root is `[data-presented-file]`, and its
19248
+ * preview button's `title` is the resolved workspace path (RT
19249
+ * dsh-client-ui-deliverables PresentedFileCard: `title:
19250
+ * resolveWorkspacePath(cwd, file.path)`). The action button mounts inside
19251
+ * that card, so `closest` picks THIS card, never a neighbouring one.
19252
+ * @param button - the download action's own DOM node.
19253
+ * @returns the path, or undefined when the card shape is not there (a future
19254
+ * host): the caller then reports failure instead of guessing a path.
19255
+ */
19256
+ function downloadPathFromCard(button) {
19257
+ const card = button?.closest('[data-presented-file]');
19258
+ if (card === null || card === undefined)
19259
+ return undefined;
19260
+ // Prefer the DIRECT child: the card's preview overlay is the first child
19261
+ // button, while this action (and open-in-app's control) live deeper in the
19262
+ // actions cell — and this action carries a `title` of its own, so a broad
19263
+ // `querySelector('button[title]')` could match ITSELF on a host whose card
19264
+ // markup differs. `:scope` narrows the match to the card's own children.
19265
+ let preview = null;
19266
+ try {
19267
+ preview = card.querySelector(':scope > button[title]');
19268
+ }
19269
+ catch {
19270
+ preview = null;
19271
+ }
19272
+ preview ??= card.querySelector('button[title]');
19273
+ const title = preview?.getAttribute('title')?.trim();
19274
+ return title === undefined || title === '' ? undefined : title;
19275
+ }
19276
+ /**
19277
+ * Read one file window by window and return its complete bytes.
19278
+ *
19279
+ * The window starts at `chunkBytes` and adapts DOWN when the host refuses a
19280
+ * window as too large (`workspace-file/too-large` + `details.limit`), so a
19281
+ * host configured below the default still completes. The loop ends at the
19282
+ * host's own `eof` flag, or on an empty window (never spin on a server that
19283
+ * reports neither).
19284
+ * @param read - one ranged read, already bound to the session and path.
19285
+ * @param chunkBytes - the first window size; tests inject a small one.
19286
+ * @returns the complete file bytes, in one buffer.
19287
+ * @throws when a read is refused (the code rides the message) or the reader
19288
+ * keeps advancing past {@link MAX_DOWNLOAD_CHUNKS} windows.
19289
+ */
19290
+ async function collectRemoteFile(read, chunkBytes = exports.DOWNLOAD_CHUNK_BYTES) {
19291
+ const chunks = [];
19292
+ let offset = 0;
19293
+ let size = Math.max(1, Math.floor(chunkBytes));
19294
+ for (;;) {
19295
+ if (chunks.length >= MAX_DOWNLOAD_CHUNKS)
19296
+ throw new Error('workspace-file/too-many-chunks');
19297
+ const result = await read({ offset, length: size });
19298
+ if (!result.ok) {
19299
+ const limit = result.error.details?.limit;
19300
+ if (result.error.code === 'workspace-file/too-large' &&
19301
+ typeof limit === 'number' &&
19302
+ Number.isSafeInteger(limit) &&
19303
+ limit > 0 &&
19304
+ limit < size) {
19305
+ // The host's own window cap is below ours: take its number and retry
19306
+ // the SAME offset — nothing was read.
19307
+ size = limit;
19308
+ continue;
19309
+ }
19310
+ throw new Error(result.error.code === undefined ? 'workspace-file/read-failed' : result.error.code);
19311
+ }
19312
+ const data = result.value.data;
19313
+ chunks.push(data);
19314
+ offset += data.byteLength;
19315
+ if (result.value.eof || data.byteLength === 0)
19316
+ break;
19317
+ }
19318
+ let total = 0;
19319
+ for (const chunk of chunks)
19320
+ total += chunk.byteLength;
19321
+ const out = new Uint8Array(total);
19322
+ let at = 0;
19323
+ for (const chunk of chunks) {
19324
+ out.set(chunk, at);
19325
+ at += chunk.byteLength;
19326
+ }
19327
+ return out;
19328
+ }
19329
+ /** Hand a finished byte array to the browser's download machinery. */
19330
+ function saveBytes(data, fileName) {
19331
+ // `new Uint8Array(length)` always owns a plain ArrayBuffer; the cast only
19332
+ // satisfies the BlobPart union in DOM typings that predate generic views.
19333
+ const blob = new Blob([data], { type: 'application/octet-stream' });
19334
+ const url = URL.createObjectURL(blob);
19335
+ const anchor = document.createElement('a');
19336
+ anchor.href = url;
19337
+ anchor.download = downloadFileNameOf(fileName);
19338
+ anchor.rel = 'noopener';
19339
+ anchor.style.display = 'none';
19340
+ document.body.append(anchor);
19341
+ anchor.click();
19342
+ anchor.remove();
19343
+ setTimeout(() => {
19344
+ URL.revokeObjectURL(url);
19345
+ }, 60_000);
19346
+ }
19347
+ /**
19348
+ * Bind the save capability the download buttons share to the client context.
19349
+ *
19350
+ * The `remote.workspaceFiles` service is looked up at CLICK time, not at
19351
+ * registration time: a host without the service (an older DSH) must not
19352
+ * break the plugin's apply, and the buttons only ever mount for a virtual
19353
+ * session — where the service exists by construction.
19354
+ * @param ctx - client root context.
19355
+ * @returns the save function the injected `saveFile` prop carries.
19356
+ */
19357
+ function createSaveRemoteFile(ctx) {
19358
+ return async (sessionId, path) => {
19359
+ // The namespace is BOTH a service of its own (`remote.workspaceFiles`) and
19360
+ // reachable through the `remote` service's namespace proxy; ask for the
19361
+ // exact service first and fall back to the proxy, so a composition that
19362
+ // only provides one of the two still works.
19363
+ const direct = ctx.get('remote.workspaceFiles');
19364
+ const remote = ctx.get('remote');
19365
+ const files = direct ?? remote?.workspaceFiles;
19366
+ if (files === undefined)
19367
+ throw new Error('remote-unavailable');
19368
+ let data;
19369
+ try {
19370
+ data = await collectRemoteFile((range) => files.readBytes(sessionId, path, { range }, undefined));
19371
+ }
19372
+ catch (error) {
19373
+ throw new Error(`read: ${describeError(error)}`, { cause: error });
19374
+ }
19375
+ try {
19376
+ saveBytes(data, path);
19377
+ }
19378
+ catch (error) {
19379
+ throw new Error(`save: ${describeError(error)}`, { cause: error });
19380
+ }
19381
+ };
19382
+ }
19383
+ /** A short, user-visible reason: an error's message (the relay's failures
19384
+ * carry their code as the message), else the thrown value's text. Kept short
19385
+ * because it is rendered INSIDE the card's button. */
19386
+ function describeError(error) {
19387
+ const text = error instanceof Error ? error.message : String(error);
19388
+ const trimmed = text.trim();
19389
+ return trimmed.length > 40 ? `${trimmed.slice(0, 40)}…` : trimmed === '' ? 'unknown' : trimmed;
19390
+ }
19391
+ };
19392
+ __modules["client/RemoteFileDownload.js"] = function (require, module, exports) {
19393
+ "use strict";
19394
+ Object.defineProperty(exports, "__esModule", { value: true });
19395
+ exports.DeliverableDownload = DeliverableDownload;
19396
+ exports.DocumentDownload = DocumentDownload;
19397
+ const jsx_runtime_1 = require("react/jsx-runtime");
19398
+ /**
19399
+ * The "save this file to THIS machine" action for a REMOTE session (2.2.6).
19400
+ *
19401
+ * DSH's produced-file surfaces only ever OPEN a file: the deliverable card's
19402
+ * overlay previews it in the document panel, and open-in-app's split button
19403
+ * hands the path to the machine the DSH process runs on. On a paired desktop
19404
+ * sub-client both of those are the HOST's machine, which is why the
19405
+ * remote-session stylesheet hides the open controls — leaving a produced
19406
+ * Word/PDF/zip with no way to reach the client's disk at all. This action
19407
+ * fills exactly that gap: it reads the file through the relayed
19408
+ * `workspaceFiles/readBytes` channel (src/client/remote-file-save.ts) and
19409
+ * hands the bytes to the browser's download machinery, so the file lands on
19410
+ * the machine whose screen the user is looking at.
19411
+ *
19412
+ * Two surfaces, one button:
19413
+ *
19414
+ * - `deliverables.file.actions` — the file card of a closing turn. The slot
19415
+ * deliberately hands its entries only the open-route coordinates
19416
+ * (`actionUrl`), NOT the path; the card's own preview button carries the
19417
+ * resolved absolute path in its `title` attribute (RT
19418
+ * dsh-client-ui-deliverables PresentedFileCard: `title:
19419
+ * resolveWorkspacePath(cwd, file.path)`), and the entry mounts INSIDE that
19420
+ * card (`[data-presented-file]`), so the button reads the same host-rendered
19421
+ * contract the plugin's stylesheets already anchor on. A card without the
19422
+ * attribute (a future host) simply renders no path and the button reports
19423
+ * the failure instead of guessing.
19424
+ * - `sidebar.right.tab.document.actions` — the document preview header, where
19425
+ * the owner hands `absolutePath` directly (RT
19426
+ * dsh-client-ui-sidebar-documentpreview TextPreview `fileOwner`), so the
19427
+ * same file can be saved without hunting for the card again.
19428
+ *
19429
+ * Both registrations are unconditional; both COMPONENTS return null for a
19430
+ * local session (`isVirtual`), so a host-role deployment — and every local
19431
+ * session on a client deployment — renders exactly nothing.
19432
+ */
19433
+ const react_1 = require("react");
19434
+ const virtual_id_js_1 = require("./virtual-id.js");
19435
+ const remote_file_save_ts_1 = require("./client/remote-file-save.js");
19436
+ /**
19437
+ * The save control. One download at a time; the label carries the three
19438
+ * states (idle / saving / failed) so a failure is visible on the card instead
19439
+ * of a silent no-op.
19440
+ * @param props - session, copy, save capability and (header only) the path.
19441
+ * @returns the button.
19442
+ */
19443
+ function DownloadButton({ sessionId, t, saveFile, absolutePath }) {
19444
+ const [phase, setPhase] = (0, react_1.useState)('idle');
19445
+ const [failure, setFailure] = (0, react_1.useState)(undefined);
19446
+ const buttonRef = (0, react_1.useRef)(null);
19447
+ const onClick = async () => {
19448
+ if (phase === 'busy')
19449
+ return;
19450
+ const path = absolutePath ?? (0, remote_file_save_ts_1.downloadPathFromCard)(buttonRef.current);
19451
+ setFailure(undefined);
19452
+ if (path === undefined) {
19453
+ setFailure('no-path');
19454
+ setPhase('error');
19455
+ return;
19456
+ }
19457
+ setPhase('busy');
19458
+ try {
19459
+ await saveFile(sessionId, path);
19460
+ setPhase('idle');
19461
+ }
19462
+ catch (error) {
19463
+ // The reason is rendered INSIDE the button (short) and logged for the
19464
+ // console: without it a failed download on a client we cannot debug is
19465
+ // a dead end.
19466
+ const reason = (error instanceof Error ? error.message : String(error)).trim();
19467
+ console.error('[dsh-zen-remote] remote download failed:', path, error);
19468
+ setFailure(reason === '' ? 'unknown' : reason.slice(0, 22));
19469
+ setPhase('error');
19470
+ }
19471
+ };
19472
+ const label = phase === 'busy' ? t('downloadBusy') : phase === 'error' ? (failure === undefined ? t('downloadFailed') : `${t('downloadFailed')} (${failure})`) : t('downloadToLocal');
19473
+ return ((0, jsx_runtime_1.jsx)("button", { ref: buttonRef, type: "button", className: "zr-download-action", "data-phase": phase, "aria-label": t('downloadToLocal'), title: failure === undefined ? t('downloadToLocal') : `${t('downloadToLocal')} — ${failure}`, onClick: () => {
19474
+ void onClick();
19475
+ }, children: label }));
19476
+ }
19477
+ /**
19478
+ * The deliverable card entry. Renders nothing on a local session.
19479
+ * @param props - the card slot's owner props plus the injected save
19480
+ * capability and copy.
19481
+ * @returns the save button, or null.
19482
+ */
19483
+ function DeliverableDownload({ sessionId, t, saveFile }) {
19484
+ if (!(0, virtual_id_js_1.isVirtual)(sessionId))
19485
+ return null;
19486
+ return (0, jsx_runtime_1.jsx)(DownloadButton, { sessionId: sessionId, t: t, saveFile: saveFile });
19487
+ }
19488
+ /**
19489
+ * The document preview header entry. Renders nothing on a local session.
19490
+ * @param props - the document slot's owner props plus the injected save
19491
+ * capability and copy.
19492
+ * @returns the save button, or null.
19493
+ */
19494
+ function DocumentDownload({ sessionId, t, saveFile, absolutePath }) {
19495
+ if (!(0, virtual_id_js_1.isVirtual)(sessionId))
19496
+ return null;
19497
+ return (0, jsx_runtime_1.jsx)(DownloadButton, { sessionId: sessionId, t: t, saveFile: saveFile, absolutePath: absolutePath });
19498
+ }
19499
+ };
18565
19500
  __modules["client/index.js"] = function (require, module, exports) {
18566
19501
  "use strict";
18567
19502
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -18604,10 +19539,13 @@ const RemoteStatusIcon_tsx_1 = require("./client/RemoteStatusIcon.js");
18604
19539
  const RemoteComposerBanner_tsx_1 = require("./client/RemoteComposerBanner.js");
18605
19540
  const remote_status_register_ts_1 = require("./client/remote-status-register.js");
18606
19541
  const current_session_report_ts_1 = require("./client/current-session-report.js");
19542
+ const remote_drop_ts_1 = require("./client/effects/remote-drop.js");
18607
19543
  const remote_group_expansion_ts_1 = require("./client/remote-group-expansion.js");
18608
19544
  const remote_fetch_ts_1 = require("./client/remote-fetch.js");
18609
19545
  const remote_session_ts_1 = require("./client/effects/remote-session.js");
18610
19546
  const model_group_side_ts_1 = require("./client/effects/model-group-side.js");
19547
+ const RemoteFileDownload_tsx_1 = require("./client/RemoteFileDownload.js");
19548
+ const remote_file_save_ts_1 = require("./client/remote-file-save.js");
18611
19549
  /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
18612
19550
  exports.inject = ['slots', 'layout', 'locale', 'sessionLogDownload', 'sessions', 'workspaces'];
18613
19551
  /**
@@ -18675,6 +19613,15 @@ function apply(ctx) {
18675
19613
  // 的桌面端 App 正是远程会话所在;只在探测出的子客户端角色下运行,主
18676
19614
  // 服务端的页面探测一次后就静默。
18677
19615
  (0, current_session_report_ts_1.mountCurrentSessionReporter)(ctx);
19616
+ // 远程会话拖拽附件(RD-2):document 级 capture 阶段的 drop 拦截。桌面端
19617
+ // 的同机零拷贝会把拖入的文件变成 `@本机绝对路径` chip,远程会话里服务端
19618
+ // 读不到这个路径——守卫把远程会话的文件拖拽改写成无路径 File 的合成
19619
+ // drop(宿主原生管道按浏览器拖拽走字节上传,再由 T51 的 fetch-route
19620
+ // 拦截经中继转服务端)。判定在 client-data/remote-drop.ts(纯函数),
19621
+ // 这里只做监听与重派发。同样必须在桌面门之前挂载:远程会话恰在桌面端
19622
+ // 全宽使用;三门全不中(本地会话 / 手机端无 bridge / 非受信事件)时
19623
+ // 零干预,主服务端的页面不受影响。
19624
+ (0, remote_drop_ts_1.mountRemoteDropGuard)(ctx);
18678
19625
  // T64 服务端分组展开状态的记忆与恢复:宿主在工作区列表 ready 时会把不在
18679
19626
  // 列表里的分组展开记录清掉(retainAccountKeys),而本地工作区先 ready、
18680
19627
  // 服务端分组后并入,zr~ 键每次加载都被清掉、分组回到折叠。与上报器共用
@@ -18695,6 +19642,30 @@ function apply(ctx) {
18695
19642
  // client-data/model-group-side.ts),CSS 按标隐藏。同样必须在桌面门之前挂载:
18696
19643
  // 桌面端子客户端的模型选单一样要分侧,桌面无手机外壳不影响它。
18697
19644
  (0, model_group_side_ts_1.installModelGroupSide)(ctx);
19645
+ // 2.2.6 远程会话产物下载:子客户端最常见的诉求是「把服务端工作区里刚产出的
19646
+ // 文件拿到本机」。DSH 官方的产物卡片只有预览,打开类动作(present.open /
19647
+ // open-in-app)作用于跑 DSH 的那台机器,远程会话里被样式表整组隐藏——
19648
+ // 也就是说此前客户端根本没有把文件落到本机磁盘的入口。两个入口共用同一个
19649
+ // 「读中继 + 浏览器下载」实现(RemoteFileDownload.tsx + remote-file-save.ts):
19650
+ // deliverable 卡片上的按钮从卡片自己的预览按钮 title 取绝对路径,
19651
+ // 文档预览头的按钮直接拿 owner 给的 absolutePath;两者都只在虚拟会话渲染,
19652
+ // 主服务端与本地会话逐像素 no-op。同样在桌面门之前注册——client 角色的
19653
+ // 桌面端 App 正是远程会话所在。
19654
+ const saveRemoteFileToLocal = (0, remote_file_save_ts_1.createSaveRemoteFile)(ctx);
19655
+ ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({
19656
+ name: 'deliverables.file.actions',
19657
+ id: 'zr-download',
19658
+ order: 40,
19659
+ locale: locales_ts_1.NS,
19660
+ inject: () => ({ saveFile: saveRemoteFileToLocal }),
19661
+ }, RemoteFileDownload_tsx_1.DeliverableDownload));
19662
+ ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({
19663
+ name: 'sidebar.right.tab.document.actions',
19664
+ id: 'zr-download',
19665
+ order: 40,
19666
+ locale: locales_ts_1.NS,
19667
+ inject: () => ({ saveFile: saveRemoteFileToLocal }),
19668
+ }, RemoteFileDownload_tsx_1.DocumentDownload));
18698
19669
  // Desktop gate (DSH 0.1.7): the official Electron shell can be dragged
18699
19670
  // down to ~520px wide, where every width-based gate would flip the phone
18700
19671
  // shell on inside the desktop app. Inside that shell this plugin is a