@khorsheed/dsh-mobile 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -2603,9 +2603,16 @@ window.__ModuleLoader__.load({
2603
2603
  body: "{}"
2604
2604
  } : {}
2605
2605
  });
2606
- if (!response.ok) throw new Error("unavailable");
2607
2606
  const result = await response.json();
2608
2607
  if (controller.signal.aborted) return;
2608
+ if (!response.ok) {
2609
+ if (result.state === "unreachable") {
2610
+ setInfo(result);
2611
+ return;
2612
+ }
2613
+ throw new Error("unavailable");
2614
+ }
2615
+ if (controller.signal.aborted) return;
2609
2616
  if (generate) {
2610
2617
  const url = new URL(result.loginUrl);
2611
2618
  if (!info?.origin || url.origin !== info.origin || url.protocol !== "https:" || !url.searchParams.get("token")) throw new Error("invalid");
@@ -2632,6 +2639,38 @@ window.__ModuleLoader__.load({
2632
2639
  window.removeEventListener("pagehide", hide);
2633
2640
  };
2634
2641
  }, []);
2642
+ (0, react.useEffect)(() => {
2643
+ if (!login) return;
2644
+ const controller = new AbortController();
2645
+ let checking = false;
2646
+ const poll = setInterval(async () => {
2647
+ if (checking) return;
2648
+ checking = true;
2649
+ try {
2650
+ const response = await fetch(CONNECT_PATH, {
2651
+ credentials: "same-origin",
2652
+ cache: "no-store",
2653
+ signal: controller.signal
2654
+ });
2655
+ if (!response.ok) throw new Error("unavailable");
2656
+ const next = await response.json();
2657
+ if (controller.signal.aborted) return;
2658
+ setInfo(next);
2659
+ if (next.state !== "ready" || next.origin !== new URL(login).origin) hide();
2660
+ } catch {
2661
+ if (!controller.signal.aborted) {
2662
+ hide();
2663
+ setFailed(true);
2664
+ }
2665
+ } finally {
2666
+ checking = false;
2667
+ }
2668
+ }, 15e3);
2669
+ return () => {
2670
+ controller.abort();
2671
+ clearInterval(poll);
2672
+ };
2673
+ }, [login]);
2635
2674
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2636
2675
  "data-mobile-connect": true,
2637
2676
  children: [
@@ -2644,7 +2683,7 @@ window.__ModuleLoader__.load({
2644
2683
  }),
2645
2684
  info && info.state !== "ready" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2646
2685
  role: "status",
2647
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t(info.state === "untrusted-origin" ? "connectUntrusted" : info.state === "unsupported" ? "connectUnsupported" : "connectSetup") })
2686
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t(info.state === "unreachable" ? "connectUnreachable" : info.state === "untrusted-origin" ? "connectUntrusted" : info.state === "unsupported" ? "connectUnsupported" : "connectSetup") })
2648
2687
  }),
2649
2688
  failed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2650
2689
  role: "alert",
@@ -2929,8 +2968,12 @@ html[data-dsh-mobile] [data-slot="conversation.input.right"] { max-width: 100%;
2929
2968
  [data-mobile-dialog] button[aria-pressed="true"] { color: #4d6bfe; background: #4d6bfe12; }
2930
2969
  [data-mobile-dialog] [data-mobile-done] { display: block; width: 100%; margin-top: 24px; background: #4d6bfe; color: white; border-radius: 100px; }
2931
2970
 
2971
+ /* The host trigger menu is translucent; mobile overlays need an opaque face. */
2972
+ html[data-dsh-mobile] [data-trigger-menu] { background:var(--mobile-face,var(--dsw-alias-bg-base))!important; --dsw-specific-menu:var(--mobile-face,var(--dsw-alias-bg-base)); opacity:1; backdrop-filter:none; -webkit-backdrop-filter:none; border:1px solid var(--mobile-edge); padding:8px; border-radius:22px; box-shadow:var(--mobile-shadow); }
2973
+ html[data-dsh-mobile] [data-trigger-menu] [role=option] { min-height:48px; padding:12px; font-size:16px; line-height:1.45; }
2974
+ html[data-dsh-mobile] [data-trigger-menu] [role=listbox] { overscroll-behavior:contain; }
2932
2975
  /* Hide an empty ordinary breadcrumb; populated catalogs keep their Host trigger. */
2933
- html[data-dsh-mobile]:has([data-mobile-session-title]) [data-slot="main.conversation"] header:has([data-conversation-header-corner]) nav:has(> span:only-child > button:disabled + [data-slot="conversation.session.header.lineage"]:empty) { display:none; }
2976
+ html[data-dsh-mobile]:has([data-mobile-session-title]) [data-slot="main.conversation"] header:has([data-conversation-header-corner]) nav:has(> span:only-child > :is(button:disabled,span:not([data-slot])) + [data-slot="conversation.session.header.lineage"]:empty) { display:none; }
2934
2977
  html[data-dsh-mobile] [data-mobile-lineage-count] { flex-shrink:0; white-space:nowrap; }
2935
2978
  [data-mobile-restore] { position:fixed; right:16px; bottom:max(16px,env(safe-area-inset-bottom)); z-index:1200; pointer-events:auto; min-height:44px; padding:10px 18px; border:1px solid #4d6bfe40; border-radius:24px; background:var(--dsw-alias-bg-base,white); color:#4d6bfe; font:inherit; box-shadow:0 3px 18px #15244318; }
2936
2979
 
@@ -3067,17 +3110,17 @@ html[data-dsh-mobile]:has([data-mobile-session-title]) [data-mobile-header-hidde
3067
3110
  [data-mobile-folded-action] { display:none!important; }
3068
3111
  [data-mobile-tools-seat] { display:contents; }
3069
3112
  html[data-dsh-mobile] [data-mobile-tools-open] { display:grid; place-items:center; width:44px; height:44px; min-width:44px; padding:0; border:0; border-radius:50%; corner-shape:round; color:var(--dsw-alias-label-primary); background:var(--mobile-soft); }
3070
- [data-mobile-tools-dialog] { box-sizing:border-box; margin:auto auto 0; width:min(100%,520px); max-width:100%; max-height:75dvh; overflow-y:auto; padding:16px 20px max(20px,env(safe-area-inset-bottom)); border:1px solid var(--mobile-line); border-bottom:0; border-radius:26px 26px 0 0; background:var(--mobile-face); color:var(--dsw-alias-label-primary); box-shadow:0 -8px 40px #0002; }
3113
+ [data-mobile-tools-dialog] { box-sizing:border-box; margin:auto auto 0; width:min(100%,520px); max-width:100%; max-height:75dvh; overscroll-behavior:contain; overflow-y:auto; padding:16px 20px max(20px,env(safe-area-inset-bottom)); border:1px solid var(--mobile-line); border-bottom:0; border-radius:26px 26px 0 0; background:var(--mobile-face); color:var(--dsw-alias-label-primary); box-shadow:0 -8px 40px #0002; }
3071
3114
  [data-mobile-tools-dialog]::backdrop { background:#0005; }
3072
- [data-mobile-tools-dialog] [data-mobile-tools-handle] { width:34px; height:4px; margin:0 auto 12px; border-radius:4px; background:var(--dsw-alias-label-tertiary); opacity:.5; }
3073
- [data-mobile-tools-dialog] header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
3074
- [data-mobile-tools-dialog] strong { font-size:16px; font-weight:500; }
3115
+ [data-mobile-tools-dialog] [data-mobile-tools-handle] { width:34px; height:4px; margin:0 auto 12px; touch-action:pan-x; border-radius:4px; background:var(--dsw-alias-label-tertiary); opacity:.5; }
3116
+ [data-mobile-tools-dialog] header { touch-action:pan-x; display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
3117
+ [data-mobile-tools-dialog] strong { font-size:20px; font-weight:600; }
3075
3118
  [data-mobile-tools-dialog] button { font:inherit; color:inherit; cursor:pointer; border:0; background:transparent; -webkit-tap-highlight-color:transparent; }
3076
3119
  [data-mobile-tools-dialog] button:disabled { opacity:.45; cursor:default; }
3077
3120
  [data-mobile-tools-dialog] header button { width:44px; height:44px; display:grid; place-items:center; border-radius:50%; padding:0; }
3078
3121
  [data-mobile-tools-grid] { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
3079
- [data-mobile-tools-grid] button { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; min-height:92px; padding:12px; border-radius:20px; background:var(--mobile-soft); font-size:14px; }
3080
- [data-mobile-tools-permission] { display:flex; align-items:center; width:100%; gap:10px; margin-top:12px; min-height:64px; padding:12px 0; text-align:left; font-size:14px!important; }
3122
+ [data-mobile-tools-grid] button { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; min-height:96px; padding:16px; border-radius:18px; background:var(--mobile-soft); font-size:14px; }
3123
+ [data-mobile-tools-permission] { display:flex; align-items:center; width:100%; gap:10px; margin-top:12px; border-top:1px solid var(--mobile-line)!important; min-height:64px; padding:12px 0; text-align:left; font-size:14px!important; }
3081
3124
  [data-mobile-tools-permission] > span { flex-shrink:0; }
3082
3125
  [data-mobile-tools-permission] > small { margin-left:auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; color:var(--dsw-alias-label-secondary); }
3083
3126
  [data-mobile-tools-dialog] svg { flex:none; }
@@ -3186,6 +3229,12 @@ html[data-dsh-mobile] [data-mobile-room-form] :is(input,textarea,select) { box-s
3186
3229
  html[data-dsh-mobile] [data-mobile-room-form] textarea { min-height:94px; resize:vertical; }
3187
3230
  html[data-dsh-mobile] [data-mobile-room-form] button { min-height:44px; min-width:44px; font-size:16px; border-radius:14px; }
3188
3231
  html[data-dsh-mobile] [data-mobile-room-form] summary { min-height:44px; padding:10px 0; font-size:16px; color:var(--dsw-alias-label-primary); }
3232
+ /* Harness model controls are expandable summaries, not native selects. Keep the
3233
+ advanced-settings disclosure plain while aligning picker faces with form fields. */
3234
+ html[data-dsh-mobile] [data-mobile-room-form] details > summary[aria-expanded] { display:flex; align-items:center; box-sizing:border-box; width:100%; max-width:none; min-width:0; min-height:48px; gap:12px; padding:11px 12px; border:1px solid var(--mobile-edge); border-radius:14px; background:var(--mobile-soft); font-size:17px; line-height:1.5; color:var(--dsw-alias-label-primary); }
3235
+ html[data-dsh-mobile] [data-mobile-room-form] details > summary[aria-expanded] > span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
3236
+ html[data-dsh-mobile] [data-mobile-room-form] details > summary[aria-expanded]::after { flex-shrink:0; margin-inline-start:auto; margin-inline-end:2px; }
3237
+
3189
3238
  html[data-dsh-mobile] [data-mobile-room-form] > div:last-child { position:sticky; bottom:-16px; background:var(--mobile-face); padding:12px 0 0; margin-top:0; display:grid; grid-template-columns:1fr 1fr; gap:12px; }
3190
3239
  html[data-dsh-mobile] [data-mobile-room-form] [role=alert] { font-size:15px; line-height:1.5; }
3191
3240
  html[data-dsh-mobile] [data-member] { font-size:16px; }
@@ -3246,6 +3295,15 @@ html[data-dsh-mobile] [data-mobile-picker] { position:fixed!important; inset:aut
3246
3295
  html[data-dsh-mobile] [data-mobile-picker] > [role=presentation] { max-height:none; overflow:visible; }
3247
3296
  html[data-dsh-mobile] [data-mobile-picker] [role=menuitem] { min-height:52px; padding:12px; border-radius:14px; font-size:17px; }
3248
3297
  html[data-dsh-mobile] [data-mobile-picker] [role=menuitem][hidden] { display:none; }
3298
+ /* Keep the workspace list inside its flex viewport: visible overflow used to
3299
+ * paint the last folder over the pinned add-action divider on short screens. */
3300
+ html[data-dsh-mobile] [data-mobile-picker=workspace] { overflow:hidden; }
3301
+ html[data-dsh-mobile] [data-mobile-picker=workspace] > [data-mobile-picker-chrome] { flex:none; }
3302
+ html[data-dsh-mobile] [data-mobile-picker=workspace] > [role=presentation]:nth-child(2) { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; }
3303
+ html[data-dsh-mobile] [data-mobile-picker=workspace] > [role=presentation] > * { flex:none; }
3304
+ html[data-dsh-mobile] [data-mobile-picker=workspace] > [role=presentation]:nth-child(3):last-child { flex:none; margin-top:8px; padding-top:8px; border-top:1px solid var(--mobile-edge); }
3305
+ html[data-dsh-mobile] [data-mobile-picker=workspace] [role=menuitem] { box-sizing:border-box; min-height:52px; padding:12px; line-height:28px; }
3306
+
3249
3307
  html[data-dsh-mobile] [data-mobile-picker=preset] [role=menuitem] span > span:last-child { font-size:14px; line-height:1.55; color:var(--dsw-alias-label-secondary); }
3250
3308
 
3251
3309
  html[data-mobile-gesture] [data-mobile-library-pane][hidden] { display:block; }
@@ -3327,13 +3385,13 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
3327
3385
  this.clearMarks();
3328
3386
  const root = frame?.querySelector("[data-slot=\"main.conversation\"] > [data-phase]");
3329
3387
  const row = root?.querySelector("header [data-conversation-header-corner]")?.parentElement;
3330
- const ordinary = row?.querySelector("nav > span:only-child > button:disabled + [data-slot=\"conversation.session.header.lineage\"]:empty");
3388
+ const ordinary = row?.querySelector("nav > span:only-child > :is(button:disabled,span:not([data-slot])) + [data-slot=\"conversation.session.header.lineage\"]:empty");
3331
3389
  let preset = "";
3332
3390
  if (row) {
3333
3391
  for (const button of row.querySelectorAll("[data-slot=\"conversation.session.header.actions\"] button[aria-label]")) if (/^(重命名会话|Rename session)$/.test(button.getAttribute("aria-label") ?? "")) this.mark(button, "data-mobile-original-rename");
3334
3392
  }
3335
3393
  if (root?.dataset.phase === "active" && row) {
3336
- for (const title of row.querySelectorAll("nav > span > button:disabled")) {
3394
+ for (const title of row.querySelectorAll("nav > span > :is(button:disabled,span:not([data-slot]))")) {
3337
3395
  const slot = title.nextElementSibling;
3338
3396
  if (slot?.getAttribute("data-slot") !== "conversation.session.header.lineage") continue;
3339
3397
  const count = slot.querySelector(":scope > div:only-child > button[aria-haspopup=\"tree\"]");
@@ -3343,17 +3401,18 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
3343
3401
  this.mark(separator, "data-mobile-header-hidden");
3344
3402
  this.mark(count, "data-mobile-lineage-count");
3345
3403
  }
3346
- const label = row.querySelector("[data-slot=\"conversation.session.header.actions\"] > span[title]:has(svg mask[id^=\"mask0_agent_preset\"])");
3404
+ const label = row.querySelector("[data-slot=\"conversation.session.header.actions\"] > span[title]:has(svg :is(mask[id^=\"mask0_agent_preset\"],path[d^=\"M6.51867 12.3282\"]))");
3347
3405
  if (label) {
3348
3406
  preset = label.textContent?.trim() ?? "";
3349
3407
  this.mark(label, "data-mobile-header-hidden");
3350
3408
  }
3409
+ for (const target of row.querySelectorAll("[data-slot=\"conversation.session.header.utilities\"] [data-open-target=\"directory\"]")) this.mark(target, "data-mobile-header-hidden");
3351
3410
  for (const img of row.querySelectorAll("img[src^=\"/open-in-app/icon/\"]")) {
3352
3411
  const split = img.closest("button")?.parentElement;
3353
3412
  if (split && split.querySelectorAll("button").length === 2) this.mark(split, "data-mobile-header-hidden");
3354
3413
  }
3355
3414
  for (const button of row.querySelectorAll("[data-sidebar-right-expand], [data-slot=\"conversation.session.header.utilities\"] > span > button[aria-haspopup=\"menu\"][aria-busy]")) if (button.hasAttribute("data-sidebar-right-expand") || /^(更多操作|More actions)$/i.test(button.getAttribute("aria-label") ?? "")) this.mark(button, "data-mobile-header-hidden");
3356
- if (this.doc.querySelector("[data-mobile-members-open], [data-mobile-tools-open]")) {
3415
+ if (this.doc.querySelector("[data-mobile-members-open]:not([disabled])")) {
3357
3416
  for (const button of row.querySelectorAll("[data-slot=\"conversation.session.header.actions\"] button")) if (/^(+|\+)?\s*(邀请 agent|Invite agent)$/i.test(button.textContent?.trim() ?? "")) this.mark(button, "data-mobile-header-hidden");
3358
3417
  }
3359
3418
  const content = row.cloneNode(true);
@@ -4092,6 +4151,19 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4092
4151
  return null;
4093
4152
  }
4094
4153
  //#endregion
4154
+ //#region src/client/commands.ts
4155
+ function openMobileCommands(controller, draft, span) {
4156
+ if (!controller || typeof controller.toggleSource !== "function") return false;
4157
+ controller.toggleSource("command", {
4158
+ trigger: "/",
4159
+ query: "",
4160
+ quoted: false,
4161
+ position: draft.slice(0, span.start).trim() === "" ? "leading" : "inline",
4162
+ span
4163
+ });
4164
+ return true;
4165
+ }
4166
+ //#endregion
4095
4167
  //#region src/client/composerActions.ts
4096
4168
  var ComposerActions = class {
4097
4169
  seat;
@@ -4108,7 +4180,11 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4108
4180
  subtree: true,
4109
4181
  characterData: true,
4110
4182
  attributes: true,
4111
- attributeFilter: ["disabled", "aria-label"]
4183
+ attributeFilter: [
4184
+ "disabled",
4185
+ "aria-label",
4186
+ "contenteditable"
4187
+ ]
4112
4188
  });
4113
4189
  this.sync();
4114
4190
  }
@@ -4120,21 +4196,25 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4120
4196
  };
4121
4197
  };
4122
4198
  sync() {
4123
- const file = this.seat.closest("[data-composer-card]")?.querySelector(":scope > div:last-child > div:first-child > input[type=file][hidden]");
4124
- const attachments = file?.previousElementSibling;
4125
- const commands = attachments?.previousElementSibling;
4199
+ const card = this.seat.closest("[data-composer-card]");
4200
+ const file = card?.querySelector(":scope > div:last-child > div:first-child > input[type=file][hidden]");
4201
+ const previous = file?.previousElementSibling;
4202
+ const unified = previous instanceof HTMLButtonElement && previous.getAttribute("aria-haspopup") === "listbox";
4203
+ const attachments = unified ? file : previous;
4204
+ const commands = unified ? previous : previous?.previousElementSibling;
4126
4205
  const next = {};
4127
- if (commands instanceof HTMLButtonElement && commands.getAttribute("aria-haspopup") === "listbox" && attachments instanceof HTMLButtonElement) {
4206
+ if (commands instanceof HTMLButtonElement && commands.getAttribute("aria-haspopup") === "listbox" && (attachments instanceof HTMLButtonElement || attachments instanceof HTMLInputElement)) {
4128
4207
  next.commands = this.target(commands);
4129
4208
  next.attachments = this.target(attachments);
4130
- const permission = (file?.nextElementSibling)?.querySelector(":scope > span > button[aria-label]");
4209
+ if (unified) next.attachments.disabled ||= commands.disabled || !card?.querySelector("[contenteditable=\"true\"]");
4210
+ const permission = (file?.nextElementSibling)?.querySelector("[data-slot=\"conversation.input.permission\"] button[aria-label], :scope > span > button[aria-label]");
4131
4211
  if (permission?.querySelector(":scope > span:not([aria-hidden])") && permission.querySelector(":scope > span[aria-hidden] svg")) next.permissions = this.target(permission);
4132
4212
  }
4133
4213
  for (const button of this.marked) if (!Object.values(next).some((value) => value.button === button)) {
4134
4214
  button.removeAttribute("data-mobile-folded-action");
4135
4215
  this.marked.delete(button);
4136
4216
  }
4137
- for (const value of Object.values(next)) if (!this.marked.has(value.button)) {
4217
+ for (const value of Object.values(next)) if (value.button instanceof HTMLButtonElement && !this.marked.has(value.button)) {
4138
4218
  value.button.setAttribute("data-mobile-folded-action", "");
4139
4219
  this.marked.add(value.button);
4140
4220
  }
@@ -4154,10 +4234,11 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4154
4234
  disabled: button.disabled
4155
4235
  };
4156
4236
  }
4157
- invoke(action) {
4237
+ invoke(action, openCommands) {
4158
4238
  this.sync();
4159
4239
  const target = this.snapshot[action];
4160
4240
  if (!target || target.disabled || !target.button.isConnected) return false;
4241
+ if (action === "commands" && openCommands?.()) return true;
4161
4242
  if (action === "commands") this.seat.closest("[data-composer-card]")?.querySelector("[contenteditable=\"true\"]")?.focus({ preventScroll: true });
4162
4243
  target.button.click();
4163
4244
  return true;
@@ -4447,8 +4528,9 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4447
4528
  //#endregion
4448
4529
  //#region src/client/MobileTools.tsx
4449
4530
  /** Own only the launcher and grouped navigation; action targets stay host-owned. */
4450
- function MobileTools({ t, useSession, rooms }) {
4531
+ function MobileTools({ t, useSession, useInput, inputActions, rooms, openCommands }) {
4451
4532
  const sessionId = useSession((s) => s.sessionId);
4533
+ const draft = useInput((s) => s.draft);
4452
4534
  const seat = (0, react.useRef)(null);
4453
4535
  const [actions, setActions] = (0, react.useState)();
4454
4536
  (0, react.useLayoutEffect)(() => {
@@ -4463,11 +4545,12 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4463
4545
  children: actions && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tools, {
4464
4546
  actions,
4465
4547
  t,
4548
+ openCommands: openCommands ? () => openCommands(sessionId, draft, inputActions.captureInsertion()) : void 0,
4466
4549
  invite: rooms.available() ? () => openMobileMembers(sessionId, true) : void 0
4467
4550
  })
4468
4551
  });
4469
4552
  }
4470
- function Tools({ actions, t, invite }) {
4553
+ function Tools({ actions, t, invite, openCommands }) {
4471
4554
  const targets = (0, react.useSyncExternalStore)(actions.subscribe, actions.getSnapshot);
4472
4555
  const dialog = (0, react.useRef)(null), launcher = (0, react.useRef)(null);
4473
4556
  const close = () => {
@@ -4480,7 +4563,7 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4480
4563
  if (!targets.commands || !targets.attachments) return null;
4481
4564
  const invoke = (action) => {
4482
4565
  close();
4483
- actions.invoke(action);
4566
+ actions.invoke(action, openCommands);
4484
4567
  };
4485
4568
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4486
4569
  ref: launcher,
@@ -4489,6 +4572,8 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4489
4572
  "aria-haspopup": "dialog",
4490
4573
  "aria-expanded": "false",
4491
4574
  onClick: () => {
4575
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
4576
+ launcher.current?.focus({ preventScroll: true });
4492
4577
  dialog.current?.showModal();
4493
4578
  launcher.current?.setAttribute("aria-expanded", "true");
4494
4579
  },
@@ -4772,6 +4857,42 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4772
4857
  }), document.body);
4773
4858
  }
4774
4859
  //#endregion
4860
+ //#region src/client/catalogReads.ts
4861
+ /** Bound optional catalog enrichment so cold log reads cannot fan out across the library. */
4862
+ var CatalogReads = class {
4863
+ limit;
4864
+ pending = [];
4865
+ running = 0;
4866
+ constructor(limit = 2) {
4867
+ this.limit = limit;
4868
+ }
4869
+ add(work, priority = 1) {
4870
+ const item = {
4871
+ cancelled: false,
4872
+ priority,
4873
+ work
4874
+ };
4875
+ this.pending.push(item);
4876
+ this.pending.sort((a, b) => a.priority - b.priority);
4877
+ this.pump();
4878
+ return () => {
4879
+ item.cancelled = true;
4880
+ this.pending = this.pending.filter((entry) => entry !== item);
4881
+ };
4882
+ }
4883
+ pump() {
4884
+ while (this.running < this.limit && this.pending.length) {
4885
+ const item = this.pending.shift();
4886
+ if (item.cancelled) continue;
4887
+ this.running++;
4888
+ Promise.resolve().then(() => item.cancelled ? void 0 : item.work(() => !item.cancelled)).catch(() => {}).finally(() => {
4889
+ this.running--;
4890
+ this.pump();
4891
+ });
4892
+ }
4893
+ }
4894
+ };
4895
+ //#endregion
4775
4896
  //#region src/client/native.ts
4776
4897
  /** Additive bridge features: older shells and ordinary browsers retain Web controls. */
4777
4898
  function hasNativeAction(action) {
@@ -4793,7 +4914,8 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4793
4914
  //#endregion
4794
4915
  //#region src/client/MobileLibrary.tsx
4795
4916
  /** Browse official metadata; the official workspace service remains the only navigation writer. */
4796
- function MobileLibrary({ rooms, navigation, onOpen, onBeforeOpen, t }) {
4917
+ function MobileLibrary({ rooms, navigation, onOpen, onBeforeOpen, visible = true, t }) {
4918
+ const reads = (0, react.useMemo)(() => new CatalogReads(), [navigation.sessions]);
4797
4919
  const sessionFeed = navigation.sessions.list, workspaceFeed = navigation.workspaces.list;
4798
4920
  const sessions = (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => sessionFeed.subscribe(listener), [sessionFeed]), (0, react.useCallback)(() => sessionFeed.getSnapshot(), [sessionFeed]));
4799
4921
  const workspaces = (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => workspaceFeed.subscribe(listener), [workspaceFeed]), (0, react.useCallback)(() => workspaceFeed.getSnapshot(), [workspaceFeed]));
@@ -4891,8 +5013,10 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4891
5013
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4892
5014
  "data-mobile-session-copy": true,
4893
5015
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: row.title || (row.blank ? t("newSession") : row.displayTitle) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionMetadata, {
4894
- id: row.id,
4895
- cwd: row.cwd ?? "",
5016
+ row,
5017
+ sessions: navigation.sessions,
5018
+ reads,
5019
+ visible,
4896
5020
  ...rooms ? { rooms } : {},
4897
5021
  t
4898
5022
  })]
@@ -4950,29 +5074,56 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
4950
5074
  }
4951
5075
  const emptyRoomSubscribe = () => () => {};
4952
5076
  const emptyRoomSnapshot = () => 0;
4953
- function SessionMetadata({ id, cwd, rooms, t }) {
5077
+ function SessionMetadata({ row, sessions, reads, visible, rooms, t }) {
4954
5078
  const ref = (0, react.useRef)(null);
4955
5079
  (0, react.useSyncExternalStore)(rooms?.subscribe ?? emptyRoomSubscribe, rooms?.getSnapshot ?? emptyRoomSnapshot);
4956
5080
  (0, react.useEffect)(() => {
4957
- if (!rooms?.available() || !ref.current) return;
5081
+ if (!visible || !ref.current) return;
5082
+ let cancel;
5083
+ const enter = () => {
5084
+ if (cancel) return;
5085
+ const cancelTitle = !row.title && !row.blank && typeof sessions.refreshProjections === "function" ? reads.add(async () => {
5086
+ await sessions.refreshProjections(row.id);
5087
+ }, 0) : void 0;
5088
+ const cancelRoom = rooms?.available() ? reads.add(async () => {
5089
+ await rooms.refresh(row.id);
5090
+ }) : void 0;
5091
+ cancel = () => {
5092
+ cancelTitle?.();
5093
+ cancelRoom?.();
5094
+ };
5095
+ };
5096
+ const leave = () => {
5097
+ cancel?.();
5098
+ cancel = void 0;
5099
+ };
4958
5100
  if (typeof IntersectionObserver === "undefined") {
4959
- rooms.refresh(id);
4960
- return;
5101
+ enter();
5102
+ return leave;
4961
5103
  }
4962
5104
  const observer = new IntersectionObserver((entries) => {
4963
- if (entries.some((e) => e.isIntersecting)) {
4964
- rooms.refresh(id);
4965
- observer.disconnect();
4966
- }
4967
- });
5105
+ if (entries.some((entry) => entry.isIntersecting)) enter();
5106
+ else leave();
5107
+ }, { root: ref.current.closest("[data-mobile-recents]") });
4968
5108
  observer.observe(ref.current);
4969
- return () => observer.disconnect();
4970
- }, [rooms, id]);
4971
- const state = rooms?.get(id), count = state ? state.members.length : state === null || !rooms?.available() ? 1 : "…";
5109
+ return () => {
5110
+ observer.disconnect();
5111
+ leave();
5112
+ };
5113
+ }, [
5114
+ rooms,
5115
+ row.id,
5116
+ row.title,
5117
+ row.blank,
5118
+ sessions,
5119
+ reads,
5120
+ visible
5121
+ ]);
5122
+ const state = rooms?.get(row.id), count = state ? state.members.length : state === null || !rooms?.available() ? 1 : "…";
4972
5123
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("small", {
4973
5124
  ref,
4974
5125
  children: [
4975
- cwd.split(/[\\/]/).filter(Boolean).at(-1) || t("unassignedWorkspace"),
5126
+ (row.cwd ?? "").split(/[\\/]/).filter(Boolean).at(-1) || t("unassignedWorkspace"),
4976
5127
  " · ",
4977
5128
  count,
4978
5129
  " ",
@@ -5186,6 +5337,7 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
5186
5337
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MobileLibrary, {
5187
5338
  ...rooms ? { rooms } : {},
5188
5339
  navigation: available,
5340
+ visible: library || wide,
5189
5341
  t,
5190
5342
  onBeforeOpen: presentation.prepareNavigation,
5191
5343
  onOpen: () => setLibrary(false)
@@ -5581,6 +5733,90 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
5581
5733
  }
5582
5734
  };
5583
5735
  //#endregion
5736
+ //#region src/client/sheetGestures.ts
5737
+ /** Mobile dismissal delegates to native dialog.close or the Room owner's close handler. */
5738
+ function installSheetGestures(doc) {
5739
+ let drag;
5740
+ const reset = () => {
5741
+ if (drag) drag.sheet.style.transform = drag.transform;
5742
+ drag = void 0;
5743
+ };
5744
+ const close = (overlay) => {
5745
+ if (overlay.isConnected && doc.documentElement.hasAttribute("data-dsh-mobile")) {
5746
+ if (overlay instanceof HTMLDialogElement) {
5747
+ overlay.close();
5748
+ return;
5749
+ }
5750
+ overlay.dispatchEvent(new MouseEvent("mousedown", {
5751
+ bubbles: true,
5752
+ cancelable: true
5753
+ }));
5754
+ }
5755
+ };
5756
+ const down = (event) => {
5757
+ reset();
5758
+ if (!doc.documentElement.hasAttribute("data-dsh-mobile") || event.touches.length !== 1) return;
5759
+ const target = event.target;
5760
+ const native = target.closest("dialog[data-mobile-tools-dialog][open]");
5761
+ const overlay = native ?? target.closest("[data-mobile-room-overlay]");
5762
+ const sheet = native ?? overlay?.querySelector("[data-mobile-room-form]");
5763
+ if (!sheet || !overlay || !native && doc.querySelector("dialog[open]") || doc.getSelection()?.toString()) return;
5764
+ const point = event.touches[0], bounds = sheet.getBoundingClientRect();
5765
+ const outside = native ? target === native && (point.clientY < bounds.top || point.clientY > bounds.bottom || point.clientX < bounds.left || point.clientX > bounds.right) : target === overlay;
5766
+ if (!outside && (sheet.scrollTop > 0 || point.clientY > sheet.getBoundingClientRect().top + 96 || target.closest("button,input,textarea,select,details,a,[contenteditable],[role=menu]"))) return;
5767
+ drag = {
5768
+ sheet,
5769
+ overlay,
5770
+ x: point.clientX,
5771
+ y: point.clientY,
5772
+ outside,
5773
+ dragging: false,
5774
+ transform: sheet.style.transform
5775
+ };
5776
+ };
5777
+ const move = (event) => {
5778
+ if (!drag) return;
5779
+ if (event.touches.length !== 1 || !drag.sheet.isConnected || !doc.documentElement.hasAttribute("data-dsh-mobile")) {
5780
+ reset();
5781
+ return;
5782
+ }
5783
+ const dx = event.touches[0].clientX - drag.x, dy = event.touches[0].clientY - drag.y;
5784
+ if (drag.outside) {
5785
+ if (Math.hypot(dx, dy) > 10) reset();
5786
+ return;
5787
+ }
5788
+ if (!drag.dragging && (dy < -10 || Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy))) {
5789
+ reset();
5790
+ return;
5791
+ }
5792
+ if (!drag.dragging && (dy < 12 || dy < Math.abs(dx) * 1.5)) return;
5793
+ drag.dragging = true;
5794
+ drag.sheet.style.transform = `translate3d(0,${Math.max(0, dy)}px,0)`;
5795
+ if (event.cancelable) event.preventDefault();
5796
+ };
5797
+ const up = (event) => {
5798
+ const from = drag, point = event.changedTouches[0];
5799
+ reset();
5800
+ if (!from || !point) return;
5801
+ const dx = point.clientX - from.x, dy = point.clientY - from.y;
5802
+ if (from.outside && Math.hypot(dx, dy) <= 10 || from.dragging && dy >= 72 && dy > Math.abs(dx) * 1.5) {
5803
+ if (event.cancelable) event.preventDefault();
5804
+ close(from.overlay);
5805
+ }
5806
+ };
5807
+ doc.addEventListener("touchstart", down, { passive: true });
5808
+ doc.addEventListener("touchmove", move, { passive: false });
5809
+ doc.addEventListener("touchend", up, { passive: false });
5810
+ doc.addEventListener("touchcancel", reset);
5811
+ return () => {
5812
+ reset();
5813
+ doc.removeEventListener("touchstart", down);
5814
+ doc.removeEventListener("touchmove", move);
5815
+ doc.removeEventListener("touchend", up);
5816
+ doc.removeEventListener("touchcancel", reset);
5817
+ };
5818
+ }
5819
+ //#endregion
5584
5820
  //#region src/client/foreground.ts
5585
5821
  /** Resume a stale official follow generation, preserving live reply baselines.
5586
5822
  * Brief interruptions and duplicate native/browser signals leave healthy streams alone. */
@@ -5647,6 +5883,7 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
5647
5883
  connectStepConfirm: "Scan this code, check the host address, then confirm the connection.",
5648
5884
  connectPrivacy: "This QR code grants access to this host. Keep it private. Hiding the code does not revoke the official login link; it remains valid until the host process restarts.",
5649
5885
  connectNetwork: "Keep the computer and its HTTPS entry online. Scanning reconnects the App; it cannot repair a disconnected tunnel.",
5886
+ connectUnreachable: "The public connection is unavailable. Wait for the computer to restore it, then retry to get a new QR code.",
5650
5887
  connectSetup: "The operator must configure a reachable HTTPS origin using mobile.publicOrigin or DSH_MOBILE_PUBLIC_ORIGIN, and add its hostname to the host trusted hosts.",
5651
5888
  connectUntrusted: "The configured address is not trusted by this host. Update --trusted-host and restart the host before generating a code.",
5652
5889
  connectUnsupported: "This host version cannot provide official login links. Update the supported host/plugin combination.",
@@ -5783,6 +6020,7 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
5783
6020
  connectStepConfirm: "扫描二维码,核对主机地址后确认连接。",
5784
6021
  connectPrivacy: "二维码可授予这台主机的访问权限,请勿分享。隐藏二维码不会撤销官方登录链接;链接在宿主进程重启前仍然有效。",
5785
6022
  connectNetwork: "请保持电脑和 HTTPS 入口在线。扫码用于重新连接 App,无法修复已经断开的隧道。",
6023
+ connectUnreachable: "公网入口暂不可用。请等待电脑端恢复后重试,获取新的二维码。",
5786
6024
  connectSetup: "请由部署者通过 mobile.publicOrigin 或 DSH_MOBILE_PUBLIC_ORIGIN 配置手机可达的 HTTPS 地址,并将域名加入宿主信任列表。",
5787
6025
  connectUntrusted: "宿主尚未信任配置的地址。请更新 --trusted-host 并重启宿主后再生成二维码。",
5788
6026
  connectUnsupported: "此宿主版本不能提供官方登录链接,请更新到支持的宿主和插件组合。",
@@ -5969,6 +6207,7 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
5969
6207
  navigation
5970
6208
  })
5971
6209
  }, MobileDirectoryOverlay));
6210
+ ctx.effect(() => installSheetGestures(document), "mobile: sheet dismissal");
5972
6211
  ctx.effect(() => () => {
5973
6212
  presentation.dispose();
5974
6213
  }, "mobile: presentation");
@@ -6067,7 +6306,14 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
6067
6306
  id: "mobile-input-tools",
6068
6307
  order: -100,
6069
6308
  locale: NS,
6070
- inject: () => ({ rooms })
6309
+ inject: () => ({
6310
+ rooms,
6311
+ openCommands: (id, draft, span) => {
6312
+ const actx = ctx.get("sessions")?.scope(id);
6313
+ const service = ctx.get("inputTriggers");
6314
+ return actx !== void 0 && typeof service?.sessionOf === "function" && openMobileCommands(service.sessionOf(actx), draft, span);
6315
+ }
6316
+ })
6071
6317
  }, MobileTools);
6072
6318
  else {
6073
6319
  remove?.();
package/lib/index.js CHANGED
@@ -23,6 +23,25 @@ function mobileHandshake() {
23
23
  }
24
24
  const DIRECTORY_PATH = "/api/mobile/directories";
25
25
  //#endregion
26
+ //#region lib/types/reachability.js
27
+ /** Probe only the operator-configured origin, anonymously and without redirects.
28
+ * Official protected roots answer 401; unprotected roots can answer 2xx.
29
+ * A login token must never be used as a connectivity probe. */
30
+ async function publicOriginReachable(origin, request = fetch) {
31
+ try {
32
+ const response = await request(`${origin}/`, {
33
+ method: "GET",
34
+ redirect: "manual",
35
+ signal: AbortSignal.timeout(6e3),
36
+ headers: { "cache-control": "no-cache" }
37
+ });
38
+ await response.body?.cancel();
39
+ return response.status === 401 || response.ok;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+ //#endregion
26
45
  //#region lib/types/connect.js
27
46
  const headers = {
28
47
  "cache-control": "no-store",
@@ -65,10 +84,14 @@ function connectInfo(connection, configured) {
65
84
  };
66
85
  }
67
86
  /** Called only behind the official authenticated Fetch registry. */
68
- function connectResponse(connection, configured) {
87
+ function connectResponse(connection, configured, reachable = publicOriginReachable) {
69
88
  return async (request) => {
70
89
  const info = connectInfo(connection, configured);
71
- if (request.method === "GET") return Response.json(info, { headers });
90
+ const checked = async () => info.state === "ready" && info.origin && !await reachable(info.origin) ? {
91
+ ...info,
92
+ state: "unreachable"
93
+ } : info;
94
+ if (request.method === "GET") return Response.json(await checked(), { headers });
72
95
  let sameOrigin = false;
73
96
  try {
74
97
  const origin = new URL(request.headers.get("origin") ?? "");
@@ -83,6 +106,11 @@ function connectResponse(connection, configured) {
83
106
  status: 409,
84
107
  headers
85
108
  });
109
+ const live = await checked();
110
+ if (live.state !== "ready") return Response.json(live, {
111
+ status: 503,
112
+ headers
113
+ });
86
114
  try {
87
115
  const login = new URL(connection.authenticatedUrl(info.origin));
88
116
  if (login.origin !== info.origin || login.pathname !== "/" || login.username || login.password || login.hash || !login.searchParams.get("token") || [...login.searchParams].length !== 1) throw new Error("unsupported");