dsh-zen-remote 2.3.8 → 2.3.10

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 (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/docs/interface.md +1 -1
  5. package/docs/remote-access.en.md +1 -1
  6. package/docs/remote-access.md +1 -1
  7. package/lib/adapters/dsh-better-sidebar/index.js +21 -3
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/client.js +522 -89
  10. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  11. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  12. package/lib/types/client/MobileHome.d.ts.map +1 -1
  13. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -1
  14. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  15. package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
  16. package/lib/types/client/effects/permission-sheet.d.ts +51 -10
  17. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -1
  18. package/lib/types/client/locales.d.ts +1 -0
  19. package/lib/types/client/locales.d.ts.map +1 -1
  20. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  21. package/lib/types/client/styles/chips.css.d.ts +1 -1
  22. package/lib/types/client/styles/chips.css.d.ts.map +1 -1
  23. package/lib/types/client/styles/composer.css.d.ts +1 -1
  24. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  25. package/lib/types/client/styles/header.css.d.ts +1 -1
  26. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  27. package/lib/types/client/styles/home.css.d.ts +1 -1
  28. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  29. package/lib/types/client/styles/layout.css.d.ts +1 -1
  30. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  31. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  32. package/lib/types/client-data/new-session.d.ts +26 -0
  33. package/lib/types/client-data/new-session.d.ts.map +1 -0
  34. package/lib/types/client-data/settings-form.d.ts +36 -0
  35. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/scripts/check-settings-form.mjs +63 -0
  38. package/src/adapters/dsh-better-sidebar/index.ts +23 -3
  39. package/src/client/MobileHome.tsx +63 -8
  40. package/src/client/MobileNavOverlay.tsx +3 -1
  41. package/src/client/MobileSessionHeader.tsx +7 -1
  42. package/src/client/effects/native-trigger-overlay.ts +50 -11
  43. package/src/client/effects/permission-sheet.ts +199 -22
  44. package/src/client/locales.ts +2 -0
  45. package/src/client/settings/SettingsSection.tsx +43 -1
  46. package/src/client/styles/chips.css.ts +7 -2
  47. package/src/client/styles/composer.css.ts +24 -0
  48. package/src/client/styles/header.css.ts +58 -46
  49. package/src/client/styles/home.css.ts +6 -2
  50. package/src/client/styles/layout.css.ts +43 -2
  51. package/src/client/wizard/GatewayWizard.tsx +23 -1
  52. package/src/client-data/new-session.ts +30 -0
  53. package/src/client-data/settings-form.ts +57 -0
package/lib/client.js CHANGED
@@ -181,7 +181,7 @@ function MobileNavOverlay({ toggleSidebar, t }) {
181
181
  const target = event.target;
182
182
  if (target === null)
183
183
  return;
184
- const drawer = document.querySelector('[data-mobile-nav="frame"] > :first-child');
184
+ const drawer = document.querySelector('[data-mobile-nav="frame"] > [class$="_sidebarCol"], [data-mobile-nav="frame"] > :first-child:not([data-sidebar-mask])');
185
185
  if (drawer === null || !drawer.contains(target))
186
186
  return;
187
187
  // A session row's own action buttons — the "Session actions" kebab
@@ -754,6 +754,18 @@ function dotState(row, pending) {
754
754
  return undefined;
755
755
  }
756
756
  };
757
+ __modules["client-data/new-session.js"] = function (require, module, exports) {
758
+ "use strict";
759
+ Object.defineProperty(exports, "__esModule", { value: true });
760
+ exports.decideNewSessionAction = decideNewSessionAction;
761
+ function decideNewSessionAction(selectedWorkspaceId, workspaceIds) {
762
+ if (selectedWorkspaceId !== undefined)
763
+ return { kind: 'start', workspaceId: selectedWorkspaceId };
764
+ if (workspaceIds.length <= 1)
765
+ return { kind: 'start', workspaceId: workspaceIds[0] };
766
+ return { kind: 'pick-workspace' };
767
+ }
768
+ };
757
769
  __modules["client/chips-store.js"] = function (require, module, exports) {
758
770
  "use strict";
759
771
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -1220,6 +1232,7 @@ const history_nav_ts_1 = require("./client/history-nav.js");
1220
1232
  /** The phone shell's one page-stack step: session list -> session. */
1221
1233
  const SESSION_LAYER = 'session';
1222
1234
  const session_dot_ts_1 = require("./client/session-dot.js");
1235
+ const new_session_ts_1 = require("./client-data/new-session.js");
1223
1236
  const MobileHomeChips_tsx_1 = require("./client/MobileHomeChips.js");
1224
1237
  /** Phone breakpoint: below the tablet range, where the app-shell layout applies. */
1225
1238
  const PHONE_QUERY = '(max-width: 767px)';
@@ -1514,7 +1527,26 @@ function MobileHome({ useSessions, useWorkspaces, useSessionPendingInteraction,
1514
1527
  return;
1515
1528
  }
1516
1529
  frame.setAttribute('data-mobile-view', view);
1517
- return () => frame.removeAttribute('data-mobile-view');
1530
+ // DSH 0.2.2-rc.1+ puts `inert=""` on `[class$="_sidebarCol"]` while
1531
+ // `drawer && sidebarCollapsed` (< 768px). Because home.css.ts already
1532
+ // hides sidebarCol with `display: none !important`, `inert` is redundant
1533
+ // for accessibility/tab order, but per HTML spec an `inert` ancestor
1534
+ // suppresses synthetic `.click()` (breaking openSettings and harvested
1535
+ // sidebar-footer chips) and blocks pointer events inside non-portal
1536
+ // sidebar dialogs. Strip it and keep it stripped while the phone shell
1537
+ // owns the viewport.
1538
+ const sidebarCol = frame.querySelector(':scope > [class$="_sidebarCol"]');
1539
+ const clearInert = () => {
1540
+ if (sidebarCol?.hasAttribute('inert'))
1541
+ sidebarCol.removeAttribute('inert');
1542
+ };
1543
+ clearInert();
1544
+ const observer = sidebarCol ? new MutationObserver(clearInert) : null;
1545
+ observer?.observe(sidebarCol, { attributes: true, attributeFilter: ['inert'] });
1546
+ return () => {
1547
+ observer?.disconnect();
1548
+ frame.removeAttribute('data-mobile-view');
1549
+ };
1518
1550
  }, [phone, view]);
1519
1551
  const enter = (start) => {
1520
1552
  start();
@@ -1600,12 +1632,29 @@ function MobileHome({ useSessions, useWorkspaces, useSessionPendingInteraction,
1600
1632
  const status = statusLine(row, dot, t);
1601
1633
  const initial = row.displayTitle.trim().charAt(0).toUpperCase();
1602
1634
  return ((0, jsx_runtime_1.jsx)(SwipeRow, { open: swipeOpen === row.id, onOpenChange: (open) => setSwipeOpen(open ? row.id : null), onArchive: () => onArchiveRow(row.id), archiveLabel: t('infoArchive'), children: (guardClick) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "home-row", "data-current": row.id === current ? '' : undefined, onClick: () => guardClick(() => enter(() => openSession(row.id))), children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "home-row-avatar", "aria-hidden": "true", children: dot !== undefined ? (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.StateDot, { state: dot, size: 10 }) : initial }), (0, jsx_runtime_1.jsxs)("span", { "data-mobile-nav": "home-row-body", children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "home-row-title", children: row.displayTitle }), status !== undefined && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "home-row-status", children: status }))] }), (0, jsx_runtime_1.jsx)("time", { "data-mobile-nav": "home-row-time", dateTime: new Date(row.updatedAt).toISOString(), children: relativeTime(row.updatedAt) })] })) }, row.id));
1603
- }), rows.length === 0 && (0, jsx_runtime_1.jsx)("li", { "data-mobile-nav": "home-empty", children: t('noSessions') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "home-fab", "aria-label": t('newSession'), title: t('newSession'), onClick: () => enter(() => startSession(selectedWorkspace?.workspaceId)), children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconPlusOutlineRegular, { size: 18 }), (0, jsx_runtime_1.jsx)("span", { children: t('newSession') })] }), sheet !== null && ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "home-sheet-layer", children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "home-sheet-mask", role: "button", tabIndex: -1, "aria-label": t('close'), onClick: () => setSheet(null) }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "home-sheet", role: sheet === 'filter' ? 'menu' : undefined, children: [sheet === 'filter' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "home-sheet-title", children: t('switchWorkspace') }), (0, jsx_runtime_1.jsx)("button", { type: "button", role: "menuitem", "data-mobile-nav": "home-sheet-item", "data-selected": selected === 'all' ? '' : undefined, onClick: () => {
1635
+ }), rows.length === 0 && (0, jsx_runtime_1.jsx)("li", { "data-mobile-nav": "home-empty", children: t('noSessions') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "home-fab", "aria-label": t('newSession'), title: t('newSession'), onClick: () => {
1636
+ // UX-3: never enter a dangling no-workspace session. With "all"
1637
+ // selected (the common case — the host's own sessions belong to no
1638
+ // workspace) the decision either auto-starts the only workspace or
1639
+ // opens the workspace drawer first; the session then starts with a
1640
+ // workspace attached from the first frame.
1641
+ const action = (0, new_session_ts_1.decideNewSessionAction)(selectedWorkspace?.workspaceId, workspaces.items.map((item) => item.workspaceId));
1642
+ if (action.kind === 'start')
1643
+ enter(() => startSession(action.workspaceId));
1644
+ else
1645
+ setSheet('new-session');
1646
+ }, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconPlusOutlineRegular, { size: 18 }), (0, jsx_runtime_1.jsx)("span", { children: t('newSession') })] }), sheet !== null && ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "home-sheet-layer", children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "home-sheet-mask", role: "button", tabIndex: -1, "aria-label": t('close'), onClick: () => setSheet(null) }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "home-sheet", role: sheet === 'chips' ? undefined : 'menu', children: [sheet === 'filter' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "home-sheet-title", children: t('switchWorkspace') }), (0, jsx_runtime_1.jsx)("button", { type: "button", role: "menuitem", "data-mobile-nav": "home-sheet-item", "data-selected": selected === 'all' ? '' : undefined, onClick: () => {
1604
1647
  actions.filter('all');
1605
1648
  setSheet(null);
1606
1649
  }, children: t('allWorkspaces') }), workspaces.items.map((item) => ((0, jsx_runtime_1.jsx)("button", { type: "button", role: "menuitem", "data-mobile-nav": "home-sheet-item", "data-selected": selected === item.workspaceId ? '' : undefined, onClick: () => {
1607
1650
  actions.filter(item.workspaceId);
1608
1651
  setSheet(null);
1652
+ }, children: item.title }, item.workspaceId)))] })), sheet === 'new-session' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "home-sheet-title", children: t('pickWorkspaceNew') }), workspaces.items.map((item) => ((0, jsx_runtime_1.jsx)("button", { type: "button", role: "menuitem", "data-mobile-nav": "home-sheet-item", onClick: () => {
1653
+ // Pin the list to the picked workspace (so backing out
1654
+ // of the blank session lands on ITS list) and start the
1655
+ // session with that workspace from the first frame.
1656
+ actions.filter(item.workspaceId);
1657
+ enter(() => startSession(item.workspaceId));
1609
1658
  }, children: item.title }, item.workspaceId)))] })), sheet === 'chips' && (0, jsx_runtime_1.jsx)(MobileHomeChips_tsx_1.MobileHomeChipsSheetBody, { t: t })] })] }))] })] }));
1610
1659
  }
1611
1660
  };
@@ -1665,7 +1714,13 @@ function IconTaskGlyph({ size = 14 }) {
1665
1714
  * the root through `:has()` instead of naming it.
1666
1715
  */
1667
1716
  const HEADER = '[data-phase] header';
1668
- const SUBAGENT_TRIGGER = `${HEADER} button[aria-haspopup="tree"]`;
1717
+ /* UX-2 (2026-11-04): narrowed by the action-button class tail. The bare
1718
+ `button[aria-haspopup="tree"]` matched the breadcrumb's parent-session
1719
+ switcher first (`…_switcherTrigger` — also a tree popup), so both the
1720
+ overlay and this fallback check resolved to the wrong button. The suffix
1721
+ matcher is case-sensitive and `_switcherTrigger` does not end in
1722
+ `_trigger`. Must stay in lockstep with effects/native-trigger-overlay.ts. */
1723
+ const SUBAGENT_TRIGGER = `${HEADER} button[class$="_trigger"][aria-haspopup="tree"]`;
1669
1724
  const JOBS_TRIGGER = `${HEADER} button[class$="_trigger"]:not([aria-haspopup])`;
1670
1725
  /** One glanceable group: icon + count + state dot; opens the official popover. */
1671
1726
  function ActivityPill({ kind, count, state, label, trigger, onFallback, Icon }) {
@@ -2072,6 +2127,8 @@ exports.deriveClientStatusView = deriveClientStatusView;
2072
2127
  exports.clientStatusLineOf = clientStatusLineOf;
2073
2128
  exports.normalizePairingCode = normalizePairingCode;
2074
2129
  exports.shouldFollowServerDeviceName = shouldFollowServerDeviceName;
2130
+ exports.shouldReloadAfterClientActivation = shouldReloadAfterClientActivation;
2131
+ exports.awaitClientStatusReady = awaitClientStatusReady;
2075
2132
  exports.createLatestGate = createLatestGate;
2076
2133
  exports.savedRowRole = savedRowRole;
2077
2134
  exports.settingsRoleOf = settingsRoleOf;
@@ -2273,6 +2330,37 @@ function normalizePairingCode(input) {
2273
2330
  function shouldFollowServerDeviceName(deviceName, rowName, hasDraft) {
2274
2331
  return deviceName !== '' && deviceName !== rowName && !hasDraft;
2275
2332
  }
2333
+ function shouldReloadAfterClientActivation(input) {
2334
+ if (!input.savedOrPaired || !input.nowClientRole)
2335
+ return false;
2336
+ if (!input.wasEffectiveClient)
2337
+ return true;
2338
+ if (input.streamInterceptAlreadyActive === false)
2339
+ return true;
2340
+ return false;
2341
+ }
2342
+ /**
2343
+ * Poll {@link CLIENT_STATUS_ROUTE} until the backend answers ok — the bounded
2344
+ * wait before the post-activation reload, covering the restart-watcher's row
2345
+ * reload window (the route 404s / fails while the row restarts). Resolves
2346
+ * after `maxMs` regardless, so the reload always fires. Injectable fetch and
2347
+ * timings keep the check script fast; defaults are the page's 3 s / 250 ms.
2348
+ */
2349
+ async function awaitClientStatusReady(fetchFn = fetch, maxMs = 3000, intervalMs = 250) {
2350
+ const deadline = Date.now() + maxMs;
2351
+ while (true) {
2352
+ try {
2353
+ if ((await fetchFn(exports.CLIENT_STATUS_ROUTE)).ok)
2354
+ return;
2355
+ }
2356
+ catch {
2357
+ // the row is mid-reload; keep polling
2358
+ }
2359
+ if (Date.now() >= deadline)
2360
+ return;
2361
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2362
+ }
2363
+ }
2276
2364
  function createLatestGate() {
2277
2365
  let current = 0;
2278
2366
  return {
@@ -3063,6 +3151,7 @@ exports.zh = {
3063
3151
  'previewExitFullscreen': '退出全屏',
3064
3152
  'allWorkspaces': '全部',
3065
3153
  'switchWorkspace': '切换工作区',
3154
+ 'pickWorkspaceNew': '选择工作区开始',
3066
3155
  'newSession': '新建会话',
3067
3156
  'noSessions': '暂无会话,点击右下角加号新建',
3068
3157
  'homeStatusOngoing': '运行中',
@@ -3586,6 +3675,7 @@ exports.en = {
3586
3675
  'previewExitFullscreen': 'Exit fullscreen',
3587
3676
  'allWorkspaces': 'All',
3588
3677
  'switchWorkspace': 'Switch workspace',
3678
+ 'pickWorkspaceNew': 'Pick a workspace to start',
3589
3679
  'newSession': 'New session',
3590
3680
  'noSessions': 'No sessions yet — tap + to start one',
3591
3681
  'homeStatusOngoing': 'Running',
@@ -8113,6 +8203,20 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
8113
8203
  padding-top: var(--mnav-sat) !important;
8114
8204
  }
8115
8205
 
8206
+ /* DSH 0.2.2-rc.1+: AppFrame pins centerCol to grid-area: 1/2 (column 2,
8207
+ which is 0px wide in our 1-column mobile grid) and inserts a
8208
+ [data-sidebar-mask] div before sidebarCol when viewport < 768. Pin
8209
+ centerCol to column 1 so it fills the 1fr track, hide the host mask
8210
+ (MobileNavOverlay renders its own backdrop on tablet, and MobileHome
8211
+ replaces the drawer on phone), and match sidebarCol by its stable class
8212
+ suffix alongside :first-child. */
8213
+ [data-mobile-nav="frame"] > [class$="_centerCol"] {
8214
+ grid-area: 1 / 1 !important;
8215
+ }
8216
+ [data-mobile-nav="frame"] > [data-sidebar-mask] {
8217
+ display: none !important;
8218
+ }
8219
+
8116
8220
  /* The sidebar column (first grid child) becomes a left drawer. The drawer
8117
8221
  hugs the sidebar content exactly (the wide sidebar carries an inline
8118
8222
  width, ~280px): a fixed 92vw box would leave a white strip where the
@@ -8123,7 +8227,8 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
8123
8227
  before) left 14px of the drawer plus a long 32px-blur shadow gradient
8124
8228
  visible along the left edge of the main UI. No box-shadow at all: the
8125
8229
  dimmed backdrop already separates drawer from content. */
8126
- [data-mobile-nav="frame"] > :first-child {
8230
+ [data-mobile-nav="frame"] > :first-child,
8231
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"] {
8127
8232
  position: absolute !important;
8128
8233
  inset: 0 auto 0 0 !important;
8129
8234
  width: max-content !important;
@@ -8163,6 +8268,31 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
8163
8268
  padding-top: var(--mnav-sat) !important;
8164
8269
  padding-bottom: var(--mnav-sab) !important;
8165
8270
  }
8271
+ /* Right sidebar start/guide page (dsh-client-ui-sidebar-right GuideBody):
8272
+ the host sets .Dv5QdW_tabBody { height: 100%; overflow: hidden } and
8273
+ .F6Huca_guide { min-height: 100%; justify-content: center } with no
8274
+ overflow-y. When many tab entries are registered (e.g. better-sidebar +
8275
+ plugins), the centered flex column overflows equally out the top and
8276
+ bottom of the locked 100%-height box — clipping the leading entries
8277
+ ("文件", "Git") above y=0 with no way to scroll. Make the guide itself
8278
+ the scroll container and replace justify-content:center with auto
8279
+ margins on the first item and ::after so short lists stay centered
8280
+ while long lists start cleanly at the top and scroll end-to-end. */
8281
+ [data-sidebar-right-guide] {
8282
+ flex: 1 1 0 !important;
8283
+ min-height: 0 !important;
8284
+ justify-content: flex-start !important;
8285
+ overflow-y: auto !important;
8286
+ -webkit-overflow-scrolling: touch;
8287
+ padding-top: 24px !important;
8288
+ padding-bottom: 24px !important;
8289
+ }
8290
+ [data-sidebar-right-guide] > :first-child {
8291
+ margin-top: auto !important;
8292
+ }
8293
+ [data-sidebar-right-guide]::after {
8294
+ margin-bottom: auto !important;
8295
+ }
8166
8296
 
8167
8297
  /* Expanded state (frame without data-sidebar-collapsed) slides the drawer in.
8168
8298
  The open state must be transform:none — NOT translateX(0): an identity
@@ -8173,7 +8303,8 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
8173
8303
  overflow:hidden drawer to scrollLeft=102, and every static child (plus the
8174
8304
  fixed overlay) shifts 102px off-screen. With transform:none the overlay is
8175
8305
  viewport-anchored: it dims the full screen and the sheet sits at left:8. */
8176
- [data-mobile-nav="frame"]:not([data-sidebar-collapsed]) > :first-child {
8306
+ [data-mobile-nav="frame"]:not([data-sidebar-collapsed]) > :first-child,
8307
+ [data-mobile-nav="frame"]:not([data-sidebar-collapsed]) > [class$="_sidebarCol"] {
8177
8308
  transform: none !important;
8178
8309
  }
8179
8310
 
@@ -9907,8 +10038,12 @@ exports.HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
9907
10038
 
9908
10039
  /* The official sidebar is no longer a drawer on a phone — the home screen
9909
10040
  replaced it. Hidden outright (not translated off-screen) so it cannot
9910
- capture taps or hold layout. */
9911
- [data-mobile-nav="frame"] > :first-child {
10041
+ capture taps or hold layout. DSH 0.2.2-rc.1+ inserts [data-sidebar-mask]
10042
+ before [class$="_sidebarCol"] and mounts [data-shell-leading] (the host's
10043
+ own floating drawer opener) on < 768px; hide all three. */
10044
+ [data-mobile-nav="frame"] > :first-child,
10045
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"],
10046
+ [data-mobile-nav="frame"] > [data-shell-leading] {
9912
10047
  display: none !important;
9913
10048
  }
9914
10049
  /* The header's drawer toggle opened that sidebar; with it gone the button
@@ -10570,6 +10705,14 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10570
10705
  instead of centered. Pinning the row makes it one 48px band again. */
10571
10706
  [data-phase] header [class$="_titleRow"] {
10572
10707
  position: relative;
10708
+ /* UX-2 (2026-11-04): the host gives this row container-type: inline-size,
10709
+ which implies layout containment — making the row the containing block
10710
+ for every descendant position:fixed. That shifted every viewport
10711
+ coordinate effects/native-trigger-overlay.ts writes by the safe-area
10712
+ top and the row's horizontal padding. normal hands fixed descendants
10713
+ back to the viewport; the phone block owns this layout, so nothing
10714
+ scoped container queries against the row here. */
10715
+ container-type: normal !important;
10573
10716
  display: grid;
10574
10717
  grid-template-columns: 92px 1fr 92px;
10575
10718
  grid-template-rows: minmax(48px, auto);
@@ -10637,55 +10780,30 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10637
10780
  min-width: 0;
10638
10781
  overflow: hidden;
10639
10782
  }
10640
- /* Subagent sessions keep ONE ancestor crumb (2026-08-18): the PC header
10641
- lets you tap the parent session's title to switch back — that affordance
10642
- was lost on phone when the whole parent chain was hidden as "多余项".
10643
- Show only the immediate parent (for agent-teams member sessions that IS
10644
- the captain/main session; deeper ancestors stay hidden — a 3-level chain
10645
- cannot fit the ~206px center column at 390px). The parent crumb is the
10646
- official breadcrumb <button> (dsh-client-ui-conversation renders
10647
- open(parentId) as its onClick), so tapping needs no JS from this plugin,
10648
- and main sessions (chain length 1) have no :nth-last-child(2) seg at
10649
- all — the centered single title is pixel-identical to before. */
10650
- [data-phase] header [class$="_crumbSeg"]:not(:nth-last-child(-n+2)) {
10783
+ /* Subagent sessions hide every ancestor crumb (UX-2, 2026-11-04 —
10784
+ supersedes the 2026-08-18 "keep ONE ancestor" rule). DSH 0.2.1 turned
10785
+ the parent crumb into a session-TREE popup trigger
10786
+ (…_switcherTrigger[aria-haspopup="tree"]) instead of a direct
10787
+ switch-back tap, and that popup is desktop geometry on the phone; the
10788
+ back button and the home stack stay the way back. Main sessions render
10789
+ a single span with no segs at all, so :not(:last-child) never matches
10790
+ there. */
10791
+ [data-phase] header [class$="_crumbSeg"]:not(:last-child) {
10651
10792
  display: none !important;
10652
10793
  }
10653
- [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) {
10654
- display: inline-flex;
10655
- align-items: center;
10656
- flex: none;
10657
- min-width: 0;
10658
- max-width: 38%;
10659
- }
10660
- /* Ancestors BEFORE the parent seg are hidden, which would leave the
10661
- parent seg's own leading "/" dangling at the start of the title. */
10662
- [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumbSep"] {
10794
+ /* With the parent seg gone, the current seg's own leading "/" would
10795
+ dangle before the title; the dissolved trigger container's stray
10796
+ separator is covered by this same rule (its slot rules below hide it
10797
+ too). One blanket rule instead of per-segment cases. */
10798
+ [data-phase] header [class$="_crumbSep"] {
10663
10799
  display: none !important;
10664
10800
  }
10665
- /* Parent crumb: small tertiary text so the 19px current title stays the
10666
- visual anchor; min-height keeps a finger-sized target inside the 48px
10667
- row. Specificity (0,4,1) outranks the shared 19px _crumb rule below. */
10668
- [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumb"] {
10669
- font-size: 13px;
10670
- font-weight: 400;
10671
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .45));
10672
- min-height: 44px;
10673
- display: inline-flex;
10674
- align-items: center;
10675
- padding: 0 2px 0 6px;
10676
- touch-action: manipulation;
10677
- -webkit-tap-highlight-color: transparent;
10678
- }
10679
10801
  [data-phase] header [class$="_crumbSeg"]:last-child {
10680
10802
  display: flex;
10681
10803
  align-items: center;
10682
10804
  min-width: 0;
10683
10805
  max-width: 100%;
10684
10806
  }
10685
- /* The last seg's own "/" is the divider between parent and current title —
10686
- it only exists when there IS an ancestor (index > 0), so it can simply
10687
- stay visible now that the parent seg shows. (It used to be hidden here:
10688
- with the parent hidden it dangled before the title.) */
10689
10807
  /* BOTH selectors are required: [class$=] matches the whole class
10690
10808
  ATTRIBUTE's suffix, and the current-session title button's attribute is
10691
10809
  "wSkVaW_crumb wSkVaW_crumbCurrent" — it ends in _crumbCurrent, so the _crumb
@@ -10795,12 +10913,17 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10795
10913
  pointer-events on the trigger: that is exactly what would take the tap
10796
10914
  target away again.
10797
10915
 
10798
- CSS keeps only two chores. Dissolve the root so its box (and its
10799
- stray separator, hidden below) stops occupying the breadcrumb, and let
10800
- the trigger participate from wherever the overlay pins it. And no menu
10801
- re-anchoring anymore: the 0.1.1 popover portals to <body> as
10802
- position:fixed and positions itself from the trigger's viewport rect
10803
- (measured 336px wide, fully on screen at 411px). */
10916
+ CSS keeps only the resting dissolve: the root's box (and its stray
10917
+ separator, hidden below) stops occupying the breadcrumb until the
10918
+ overlay pins it. The measurement chore moved to the overlay (UX-2,
10919
+ 2026-11-04): the 0.1.1+ popover portals to <body> as position:fixed
10920
+ and sizes itself from the OUTER anchor container (the official
10921
+ rootRef, the trigger's parent div) — a display:contents root has no
10922
+ box, so rootRef.getBoundingClientRect() was all zeros and the panel
10923
+ landed at 12,12 inside the status bar. effects/
10924
+ native-trigger-overlay.ts now pins container + trigger to the pill's
10925
+ rect together (its inline display beats this rule's !important, style
10926
+ attribute outranks selectors at author-important). */
10804
10927
  [data-phase] header [data-slot="conversation.session.header.actions"] > *:has(> button[class$="_trigger"]),
10805
10928
  [data-phase] header [data-slot="conversation.session.header.lineage"] > *:has(> button[class$="_trigger"]) {
10806
10929
  display: contents !important;
@@ -10809,6 +10932,18 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10809
10932
  [data-phase] header [data-slot="conversation.session.header.lineage"] [class$="_separator"] {
10810
10933
  display: none !important;
10811
10934
  }
10935
+ /* The popovers themselves portal to <body> as position:fixed layers
10936
+ (class suffix _menu, z-index 100) and place themselves from the pill's
10937
+ viewport rect with margin 12 — the overlay above is what makes that
10938
+ rect correct. This is only a floor: the panels' 336px width overflows
10939
+ 320px-class phones, and a tall list must not run under the notch or
10940
+ the home indicator. [id$="-menu"] keeps the composer's model menu out
10941
+ of it — composer.css.ts owns that surface, and this file sits before
10942
+ it in styles/index.ts so its own rules still win the tie. */
10943
+ body > [class$="_menu"]:not([id$="-menu"]) {
10944
+ max-width: calc(100vw - 24px) !important;
10945
+ max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px) !important;
10946
+ }
10812
10947
  /* The agent-preset mode badge specifically survives the blanket hide
10813
10948
  above: layout.css.ts's shared <=1023px block targets it directly
10814
10949
  (header [class$="_label"]:has(> svg) { display: block !important }),
@@ -10837,8 +10972,12 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10837
10972
  display: inline-flex !important;
10838
10973
  }
10839
10974
 
10840
- /* Back button: 44pt touch target, leftmost column. */
10975
+ /* Back button: 44pt touch target, leftmost column.
10976
+ order: -1 keeps it to the left of @deepseek-ai/dsh-experimental-client-ui-agent-team's
10977
+ TeamAction ([data-team-action], registered into header.actions at order -20 with
10978
+ default flex order 0). */
10841
10979
  [data-mobile-nav="header-back"] {
10980
+ order: -1;
10842
10981
  display: inline-flex;
10843
10982
  align-items: center;
10844
10983
  justify-content: center;
@@ -10855,6 +10994,13 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10855
10994
  [data-mobile-nav="header-back"]:active {
10856
10995
  opacity: .6;
10857
10996
  }
10997
+ /* TeamAction's own stylesheet hides .XdyUwa_triggerLabel via
10998
+ @container (width<=480px) against _titleRow's container-type: inline-size.
10999
+ Shedding container-type on _titleRow above (UX-2) disabled that container
11000
+ query, so hide the label explicitly on phone and keep only the icon. */
11001
+ [data-phase] header [data-team-action] [class$="_triggerLabel"] {
11002
+ display: none !important;
11003
+ }
10858
11004
 
10859
11005
  /* Info + workbench utility buttons, rightmost column. */
10860
11006
  [data-mobile-nav="header-info"],
@@ -10945,6 +11091,7 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10945
11091
  top:100% lands both on the same 28px line. z-index puts it above the
10946
11092
  row it overlaps. */
10947
11093
  [data-mobile-nav="header-activity"] {
11094
+ pointer-events: none;
10948
11095
  position: absolute;
10949
11096
  right: 0;
10950
11097
  top: 100%;
@@ -11406,6 +11553,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11406
11553
  …, open-in-app, deliverables, …), and none opens through this trigger
11407
11554
  while no marked menu is up. */
11408
11555
  body > div[role="menu"][data-zen-sheet="perm"],
11556
+ /* UX-3: the hero page's workspace menu, marked by the same effect through
11557
+ its own two triggers (the WorkspaceChip and the placeholder card) with
11558
+ the workspace value — a distinct marker so the pick that starts the
11559
+ workspace switch (effects/permission-sheet.ts) can key on THIS sheet
11560
+ without ever touching permission options. */
11561
+ body > div[role="menu"][data-zen-sheet="workspace"],
11409
11562
  ${MODEL_MENU} {
11410
11563
  position: fixed !important;
11411
11564
  left: 0 !important;
@@ -11438,6 +11591,7 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11438
11591
  older hosts. */
11439
11592
  ${PERM} [role="menu"] [role="menuitem"],
11440
11593
  body > div[role="menu"][data-zen-sheet="perm"] [role="menuitem"],
11594
+ body > div[role="menu"][data-zen-sheet="workspace"] [role="menuitem"],
11441
11595
  ${MODEL_MENU} [role="menuitem"],
11442
11596
  ${MODEL_MENU} [role="menuitemradio"],
11443
11597
  ${MODEL_MENU} [class$="_option"],
@@ -11586,11 +11740,28 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11586
11740
  body:has(> [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"])) [data-chat-flow] + div,
11587
11741
  /* T70: the portaled permission menu (marked by effects/permission-sheet.ts). */
11588
11742
  body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div,
11743
+ /* UX-3: the portaled hero workspace menu, same treatment. */
11744
+ body:has(> div[role="menu"][data-zen-sheet="workspace"]) [data-chat-flow] + div,
11589
11745
  /* T-issue30: an inline menu a parked chip opened (escaped to a fixed
11590
11746
  sheet by 4a). */
11591
11747
  body:has([data-zen-sheet-extras] [role="menu"]) [data-chat-flow] + div {
11592
11748
  display: none !important;
11593
11749
  }
11750
+ /* --- 4c. UX-3: the workspace switch lock ---
11751
+ Between picking a workspace in the hero sheet and the new blank session
11752
+ landing (selectWorkspace → POST /api/session/create → replaceMain
11753
+ remounts the conversation), the composer still belongs to the OLD blank
11754
+ session: a message sent in that window lands there and is then wiped by
11755
+ the remount, bouncing the UI back to the "choose a workspace" state.
11756
+ effects/permission-sheet.ts stamps data-zen-ws-switching on <html> for
11757
+ exactly that window — the lock releases when the conversation root's
11758
+ data-conversation-session has moved and the placeholder card is gone,
11759
+ with a 1.5 s cap — and the dim (not just pointer-events) makes the
11760
+ freeze visible instead of reading as a dead input. */
11761
+ html[data-zen-ws-switching] [data-slot="conversation.composer.bar"] {
11762
+ pointer-events: none !important;
11763
+ opacity: .6 !important;
11764
+ }
11594
11765
  /* --- 5. input box: two lines minimum, five lines maximum ---
11595
11766
  The official autosizer drives the box off the hidden mirror's height, so
11596
11767
  a min-height on the mirror IS the min-height of the field; the scroll cap
@@ -13244,12 +13415,17 @@ exports.CHIPS_CSS = `/* ---------- chips row + settings entry (S5) ---------- */
13244
13415
  logoRow/newSession/regionArea at \`display: flex\`, which is only
13245
13416
  hidden from view by being fully underneath the usage-stats panel's
13246
13417
  opaque body, not by this rule. */
13247
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) {
13418
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay),
13419
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) {
13248
13420
  display: contents !important;
13421
+ visibility: visible !important;
13249
13422
  }
13250
13423
  [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_logoRow"],
13251
13424
  [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_newSession"],
13252
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"] {
13425
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"],
13426
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_logoRow"],
13427
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_newSession"],
13428
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"] {
13253
13429
  display: none !important;
13254
13430
  }
13255
13431
 
@@ -15112,7 +15288,10 @@ __modules["client/effects/permission-sheet.js"] = function (require, module, exp
15112
15288
  "use strict";
15113
15289
  Object.defineProperty(exports, "__esModule", { value: true });
15114
15290
  exports.isUnmarkedPortalMenu = isUnmarkedPortalMenu;
15115
- exports.clickArmsPermissionSheet = clickArmsPermissionSheet;
15291
+ exports.sheetKindForTrigger = sheetKindForTrigger;
15292
+ exports.pointerdownOnWorkspaceTrigger = pointerdownOnWorkspaceTrigger;
15293
+ exports.menuitemClickStartsWorkspaceSwitch = menuitemClickStartsWorkspaceSwitch;
15294
+ exports.workspaceSwitchSettled = workspaceSwitchSettled;
15116
15295
  exports.installPermissionSheet = installPermissionSheet;
15117
15296
  /** Phone breakpoint — the same query every phone-only effect in this plugin uses. */
15118
15297
  const PHONE_QUERY = '(max-width: 767px)';
@@ -15123,7 +15302,18 @@ const PHONE_QUERY = '(max-width: 767px)';
15123
15302
  * NO aria-controls on 0.2.0 (the Menu primitive portals its list to body
15124
15303
  * and links nothing back), so the click is the only open signal there is.
15125
15304
  */
15126
- const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]';
15305
+ const PERM_TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]';
15306
+ /**
15307
+ * UX-3: the HERO page's two workspace triggers (WorkspaceSheet slot impl,
15308
+ * verified in the dsh-client-ui-conversation bundle 2026-11-05):
15309
+ * - `WorkspaceChip` — a bare `button` whose hashed class ends in
15310
+ * `_workspace`, carrying `aria-haspopup="menu"` + `aria-expanded`;
15311
+ * - the placeholder card — when a blank hero session has no workspace yet
15312
+ * the whole `div[data-composer-card]` carries `cardWorkspaceTrigger` and
15313
+ * its click opens the same menu (`onRequestWorkspace`).
15314
+ * Both open a `body > div[role="menu"]` of `button[role="menuitem"]` items.
15315
+ */
15316
+ const WORKSPACE_TRIGGER_SELECTOR = '[class$="_workspace"][aria-haspopup="menu"], [data-composer-card][class*="_cardWorkspaceTrigger"]';
15127
15317
  /**
15128
15318
  * What the Menu primitive portals on 0.2.0: a `role=menu` div DIRECTLY under
15129
15319
  * body, carrying `data-menu-material` and a randomized `--dsh-menu-anchor`
@@ -15132,15 +15322,36 @@ const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[cla
15132
15322
  * host has MANY `portal: true` Menus (agent-preset, chat preference rows,
15133
15323
  * the Enter-behavior row, the workspace sidebar's 「…」, open-in-app,
15134
15324
  * deliverables, the copy button's confirm, …), and none of them may become
15135
- * a bottom sheet. That is why this effect exists: a click on the permission
15136
- * trigger arms a short window and the next body-level menu to land is OURS
15137
- * to mark — while a marked menu still on the page (the close click) keeps
15138
- * the window shut.
15325
+ * a bottom sheet. That is why this effect exists: a click on a sheet trigger
15326
+ * arms a short window and the next body-level menu to land is OURS to mark —
15327
+ * while a marked menu still on the page (the close click) keeps the window
15328
+ * shut.
15139
15329
  */
15140
15330
  const PORTAL_MENU_SELECTOR = 'body > div[role="menu"]';
15141
15331
  /** The marker composer.css.ts section 4 styles into the bottom sheet. */
15142
15332
  const SHEET_MARKER = 'data-zen-sheet';
15143
- const SHEET_VALUE = 'perm';
15333
+ const SHEET_VALUE_PERM = 'perm';
15334
+ const SHEET_VALUE_WORKSPACE = 'workspace';
15335
+ /**
15336
+ * UX-3: the switch lock. Picking a workspace in the hero sheet runs
15337
+ * `selectWorkspace` → `POST /api/session/create` → `replaceMain` — the
15338
+ * conversation unmounts and remounts (`useMaybeIncarnation` epoch+1) and the
15339
+ * composer briefly still belongs to the OLD blank session. A message sent in
15340
+ * that window lands on the old session and is then wiped by the remount,
15341
+ * bouncing the UI back to the "choose a workspace" placeholder. While the
15342
+ * `data-zen-ws-switching` attribute sits on `<html>`, composer.css dead-zones
15343
+ * and dims the composer bar; the lock releases the moment the conversation
15344
+ * root's `data-conversation-session` has MOVED and the placeholder card is
15345
+ * gone — with a hard cap so a failed/no-op switch cannot freeze the input.
15346
+ */
15347
+ const SWITCH_MARKER = 'data-zen-ws-switching';
15348
+ const SWITCH_LOCK_MS = 1_500;
15349
+ const SESSION_ATTR = 'data-conversation-session';
15350
+ const SESSION_SELECTOR = `[${SESSION_ATTR}]`;
15351
+ /** The hero's inert "choose a workspace" state: the whole input card IS the trigger. */
15352
+ const PLACEHOLDER_CARD_SELECTOR = '[data-composer-card][class*="_cardWorkspaceTrigger"]';
15353
+ /** Spelled literally (not interpolated) — the test pins this exact text as the perm/workspace discriminator. */
15354
+ const WS_MENUITEM_SELECTOR = 'body > div[role="menu"][data-zen-sheet="workspace"] button[role="menuitem"]';
15144
15355
  /**
15145
15356
  * Whether ONE body-level node is a portal menu that does not yet carry the
15146
15357
  * sheet marker. Pure over the node's own methods: `matches` answers the
@@ -15149,17 +15360,52 @@ const SHEET_VALUE = 'perm';
15149
15360
  function isUnmarkedPortalMenu(node) {
15150
15361
  return node.matches(PORTAL_MENU_SELECTOR) && node.getAttribute(SHEET_MARKER) === null;
15151
15362
  }
15363
+ const inSeat = (seat) => seat !== undefined && seat !== null;
15152
15364
  /**
15153
- * Whether one click event's target arms the marking window: the phone shell
15154
- * only, and only a click that started inside the permission seat's trigger.
15155
- * Any other button — the sidebar's 「…」, a copy button — never arms, so
15156
- * the menus they open are never marked.
15365
+ * Which sheet ONE click event's target arms: the phone shell only. The
15366
+ * permission seat's trigger arms `perm` (T70); the hero page's two
15367
+ * workspace triggers arm `workspace` (UX-3). Any other button — the
15368
+ * sidebar's 「…」, a copy button — arms nothing, so the menus they open are
15369
+ * never marked.
15157
15370
  */
15158
- function clickArmsPermissionSheet(target, phoneMatches) {
15371
+ function sheetKindForTrigger(target, phoneMatches) {
15159
15372
  if (!phoneMatches)
15373
+ return null;
15374
+ if (inSeat(target?.closest(PERM_TRIGGER_SELECTOR)))
15375
+ return 'perm';
15376
+ if (inSeat(target?.closest(WORKSPACE_TRIGGER_SELECTOR)))
15377
+ return 'workspace';
15378
+ return null;
15379
+ }
15380
+ /**
15381
+ * UX-3: whether a pointerdown landed inside a WORKSPACE trigger — the only
15382
+ * triggers whose open/close suffers the outside-pointerdown + toggle-click
15383
+ * race (the Menu closes the portaled sheet on the outside pointerdown, and
15384
+ * the trigger's own click would flip it straight back open). The permission
15385
+ * trigger toggles by click alone and is never suppressed.
15386
+ */
15387
+ function pointerdownOnWorkspaceTrigger(target) {
15388
+ return inSeat(target?.closest(WORKSPACE_TRIGGER_SELECTOR));
15389
+ }
15390
+ /**
15391
+ * UX-3: whether a click started on a workspace item inside the MARKED
15392
+ * workspace sheet — the pick that starts the switch lock. Keyed on the
15393
+ * `workspace` marker value, so a permission option can never lock the
15394
+ * composer.
15395
+ */
15396
+ function menuitemClickStartsWorkspaceSwitch(target) {
15397
+ return inSeat(target?.closest(WS_MENUITEM_SELECTOR));
15398
+ }
15399
+ /**
15400
+ * Whether the workspace switch has settled: the conversation root now names
15401
+ * a DIFFERENT session than the one the lock was taken on, and the no-workspace
15402
+ * placeholder card is gone (the optimistic `pendingWorkspaceId` display alone
15403
+ * is not enough — the remount resets it).
15404
+ */
15405
+ function workspaceSwitchSettled(probe, lockedSessionValue) {
15406
+ if (probe.currentSessionValue() === lockedSessionValue)
15160
15407
  return false;
15161
- const seat = target?.closest(TRIGGER_SELECTOR);
15162
- return seat !== undefined && seat !== null;
15408
+ return !probe.placeholderOpen();
15163
15409
  }
15164
15410
  /**
15165
15411
  * How long an OPEN click stays armed. The portal lands in the same React
@@ -15178,18 +15424,36 @@ const ARM_MS = 1_500;
15178
15424
  * selectors stopped matching, and the phone got a tiny floating popup. The
15179
15425
  * host offers no stable attribute to key the portal off — the anchor is a
15180
15426
  * per-instance useId, there is no id and no aria-controls — so this effect
15181
- * marks it: the trigger's click arms a window, the observer stamps the next
15182
- * body-level menu with `data-zen-sheet="perm"`, and the stylesheet (which
15427
+ * marks it: the trigger's click arms a short window, the observer stamps the
15428
+ * next body-level menu with `data-zen-sheet`, and the stylesheet (which
15183
15429
  * only the phone shell loads) turns THAT into the sheet. The portal menu
15184
15430
  * unmounts on close, so the marker's lifetime is exactly the menu's; the
15185
15431
  * 0.1.7 inline structure keeps working through the untouched descendant
15186
15432
  * selectors, and the desktop shell never loads this stylesheet at all.
15433
+ *
15434
+ * UX-3 extends the same machinery to the hero page's workspace menu:
15435
+ * - the two workspace triggers arm a `data-zen-sheet="workspace"` sheet;
15436
+ * - a pointerdown on a workspace trigger while a marked sheet is up
15437
+ * suppresses the trigger's own click, so one tap reliably CLOSES (the
15438
+ * host's outside-pointerdown close stands instead of being undone by the
15439
+ * toggle click);
15440
+ * - a pick inside the workspace sheet locks the composer for the length of
15441
+ * the `selectWorkspace` remount (see SWITCH_MARKER above).
15187
15442
  */
15188
15443
  function installPermissionSheet(ctx) {
15189
15444
  ctx.effect(() => {
15190
15445
  const phone = window.matchMedia(PHONE_QUERY);
15191
15446
  let armed = false;
15447
+ let armedKind = SHEET_VALUE_PERM;
15192
15448
  let armTimer;
15449
+ // UX-3 toggle debounce: recomputed on EVERY pointerdown, consumed by the
15450
+ // next click — a pointerdown whose click never comes (drag cancel) cannot
15451
+ // leave a stale swallow behind.
15452
+ let suppressClick = false;
15453
+ // Set when a swallowed click found the marked menu STILL on the page
15454
+ // (the pointerdown close did not land): the next tap passes through to
15455
+ // the host's own toggle instead of being swallowed forever.
15456
+ let allowToggle = false;
15193
15457
  const disarm = () => {
15194
15458
  armed = false;
15195
15459
  if (armTimer !== undefined) {
@@ -15197,9 +15461,75 @@ function installPermissionSheet(ctx) {
15197
15461
  armTimer = undefined;
15198
15462
  }
15199
15463
  };
15464
+ const markedSheet = () => document.querySelector(`${PORTAL_MENU_SELECTOR}[${SHEET_MARKER}]`);
15465
+ const readSessionValue = () => document.querySelector(SESSION_SELECTOR)?.getAttribute(SESSION_ATTR) ?? null;
15466
+ const placeholderOpen = () => document.querySelector(PLACEHOLDER_CARD_SELECTOR) !== null;
15467
+ // -- UX-3 switch lock -------------------------------------------------
15468
+ let lockedSessionValue = null;
15469
+ let lockTimer;
15470
+ let lockObserver;
15471
+ const releaseLock = () => {
15472
+ if (lockTimer !== undefined) {
15473
+ clearTimeout(lockTimer);
15474
+ lockTimer = undefined;
15475
+ }
15476
+ lockObserver?.disconnect();
15477
+ lockObserver = undefined;
15478
+ lockedSessionValue = null;
15479
+ document.documentElement.removeAttribute(SWITCH_MARKER);
15480
+ };
15481
+ const checkLock = () => {
15482
+ if (lockedSessionValue === null)
15483
+ return;
15484
+ if (workspaceSwitchSettled({ currentSessionValue: readSessionValue, placeholderOpen }, lockedSessionValue)) {
15485
+ releaseLock();
15486
+ }
15487
+ };
15488
+ const startSwitchLock = () => {
15489
+ if (lockedSessionValue === null) {
15490
+ lockedSessionValue = readSessionValue();
15491
+ document.documentElement.setAttribute(SWITCH_MARKER, '1');
15492
+ lockObserver = new MutationObserver(checkLock);
15493
+ // The session id moves on the conversation root (attribute) and the
15494
+ // placeholder card mounts/unmounts (childList) — plus class flips on
15495
+ // the card are attribute mutations too. One bounded subtree watch
15496
+ // covers all of it.
15497
+ lockObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
15498
+ }
15499
+ if (lockTimer !== undefined)
15500
+ clearTimeout(lockTimer);
15501
+ lockTimer = setTimeout(releaseLock, SWITCH_LOCK_MS);
15502
+ };
15503
+ // ---------------------------------------------------------------------
15504
+ const onCapturePointerDown = (event) => {
15505
+ const target = event.target instanceof Element ? event.target : null;
15506
+ const onTrigger = pointerdownOnWorkspaceTrigger(target);
15507
+ suppressClick = onTrigger && markedSheet() !== null && !allowToggle;
15508
+ if (!suppressClick)
15509
+ allowToggle = false;
15510
+ };
15200
15511
  const onCaptureClick = (event) => {
15512
+ // UX-3: let the outside-pointerdown close stand — stopPropagation
15513
+ // keeps the click from React's root listeners, so the trigger cannot
15514
+ // toggle the sheet straight back open.
15515
+ if (suppressClick) {
15516
+ suppressClick = false;
15517
+ if (markedSheet() !== null)
15518
+ allowToggle = true;
15519
+ event.preventDefault();
15520
+ event.stopPropagation();
15521
+ return;
15522
+ }
15201
15523
  const target = event.target instanceof Element ? event.target : null;
15202
- if (!clickArmsPermissionSheet(target, phone.matches))
15524
+ // UX-3: a pick inside the workspace sheet starts the switch lock — the
15525
+ // composer must not accept input on the old blank session while
15526
+ // selectWorkspace remounts the conversation underneath it.
15527
+ if (phone.matches && menuitemClickStartsWorkspaceSwitch(target)) {
15528
+ startSwitchLock();
15529
+ return;
15530
+ }
15531
+ const kind = sheetKindForTrigger(target, phone.matches);
15532
+ if (kind === null)
15203
15533
  return;
15204
15534
  // T70-fix: the trigger TOGGLES. A click that CLOSES the menu lands
15205
15535
  // while the marked menu is still in the DOM (capture runs before the
@@ -15208,9 +15538,10 @@ function installPermissionSheet(ctx) {
15208
15538
  // body-level menu opening inside that window would be mis-marked as
15209
15539
  // ours. A marked menu present at click time means this click can only
15210
15540
  // be the close (or a no-op re-open), never an opening we must catch.
15211
- if (document.querySelector(`${PORTAL_MENU_SELECTOR}[${SHEET_MARKER}="${SHEET_VALUE}"]`) !== null)
15541
+ if (markedSheet() !== null)
15212
15542
  return;
15213
15543
  armed = true;
15544
+ armedKind = kind === 'workspace' ? SHEET_VALUE_WORKSPACE : SHEET_VALUE_PERM;
15214
15545
  if (armTimer !== undefined)
15215
15546
  clearTimeout(armTimer);
15216
15547
  armTimer = setTimeout(disarm, ARM_MS);
@@ -15220,11 +15551,12 @@ function installPermissionSheet(ctx) {
15220
15551
  if (!armed)
15221
15552
  return;
15222
15553
  disarm();
15223
- root.setAttribute(SHEET_MARKER, SHEET_VALUE);
15554
+ root.setAttribute(SHEET_MARKER, armedKind);
15224
15555
  };
15225
15556
  // Capture phase: the host's own handlers may stop the click's bubble
15226
15557
  // (the trigger toggles `open` itself), so listen on the document in
15227
15558
  // capture and never depend on propagation reaching us.
15559
+ document.addEventListener('pointerdown', onCapturePointerDown, true);
15228
15560
  document.addEventListener('click', onCaptureClick, true);
15229
15561
  const observer = new MutationObserver((records) => {
15230
15562
  if (!armed)
@@ -15238,9 +15570,11 @@ function installPermissionSheet(ctx) {
15238
15570
  });
15239
15571
  observer.observe(document.body, { childList: true });
15240
15572
  return () => {
15573
+ document.removeEventListener('pointerdown', onCapturePointerDown, true);
15241
15574
  document.removeEventListener('click', onCaptureClick, true);
15242
15575
  observer.disconnect();
15243
15576
  disarm();
15577
+ releaseLock();
15244
15578
  // A still-open marked menu outliving this effect keeps its marker only
15245
15579
  // until the user closes it (the element unmounts); scrubbing here
15246
15580
  // would fight a React commit mid-flight for no visual gain.
@@ -15276,36 +15610,75 @@ Object.defineProperty(exports, "__esModule", { value: true });
15276
15610
  exports.installNativeTriggerOverlay = installNativeTriggerOverlay;
15277
15611
  const PHONE = '(max-width: 767px)';
15278
15612
  const HEADER = '[data-phase] header';
15279
- /** Pill → the official trigger it fronts. Same ARIA split the pills use. */
15613
+ /**
15614
+ * Pill → the official trigger it fronts. Same ARIA split the pills use,
15615
+ * narrowed by the action-button class tail (UX-2, 2026-11-04): the bare
15616
+ * `button[aria-haspopup="tree"]` matched the breadcrumb's parent-session
15617
+ * switcher first (`…_switcherTrigger` — also a tree popup, earlier in
15618
+ * document order), so the pill got the wrong button laid over it. The
15619
+ * suffix matcher is case-sensitive and `_switcherTrigger` does not end in
15620
+ * `_trigger`, so the catalog trigger is the only match left.
15621
+ */
15280
15622
  const PAIRS = [
15281
- { pill: '[data-activity-kind="subagent"]', trigger: `${HEADER} button[aria-haspopup="tree"]` },
15623
+ { pill: '[data-activity-kind="subagent"]', trigger: `${HEADER} button[class$="_trigger"][aria-haspopup="tree"]` },
15282
15624
  { pill: '[data-activity-kind="job"]', trigger: `${HEADER} button[class$="_trigger"]:not([aria-haspopup])` },
15283
15625
  ];
15284
- /** Marks a trigger we have taken over, so cleanup can find it again. */
15626
+ /** Marks a trigger (and its anchor container) we have taken over, so cleanup can find them again. */
15285
15627
  const TAKEN = 'data-zen-overlay';
15628
+ /** The shared fixed-viewport box: exactly what the portalled popover measures against. */
15629
+ function pin(el, r, display) {
15630
+ el.style.setProperty('position', 'fixed', 'important');
15631
+ el.style.setProperty('left', `${r.left}px`, 'important');
15632
+ el.style.setProperty('top', `${r.top}px`, 'important');
15633
+ el.style.setProperty('width', `${r.width}px`, 'important');
15634
+ el.style.setProperty('height', `${r.height}px`, 'important');
15635
+ el.style.setProperty('margin', '0', 'important');
15636
+ el.style.setProperty('padding', '0', 'important');
15637
+ el.style.setProperty('border', '0', 'important');
15638
+ if (display !== undefined)
15639
+ el.style.setProperty('display', display, 'important');
15640
+ }
15286
15641
  function place(trigger, pill) {
15287
15642
  const r = pill.getBoundingClientRect();
15288
15643
  if (r.width === 0 || r.height === 0)
15289
15644
  return;
15290
15645
  trigger.setAttribute(TAKEN, '');
15646
+ pin(trigger, r);
15291
15647
  // Fixed, not absolute: the header's containing block is not the pill's, and
15292
15648
  // fixed rects are exactly what the portalled popover measures against.
15293
- trigger.style.setProperty('position', 'fixed', 'important');
15294
- trigger.style.setProperty('left', `${r.left}px`, 'important');
15295
- trigger.style.setProperty('top', `${r.top}px`, 'important');
15296
- trigger.style.setProperty('width', `${r.width}px`, 'important');
15297
- trigger.style.setProperty('height', `${r.height}px`, 'important');
15298
- trigger.style.setProperty('margin', '0', 'important');
15299
- trigger.style.setProperty('padding', '0', 'important');
15300
- trigger.style.setProperty('border', '0', 'important');
15301
15649
  trigger.style.setProperty('opacity', '0', 'important');
15302
15650
  trigger.style.setProperty('overflow', 'hidden', 'important');
15303
15651
  trigger.style.setProperty('pointer-events', 'auto', 'important');
15304
15652
  trigger.style.setProperty('z-index', '3', 'important');
15653
+ // The official popover (useAnchoredPosition) sizes itself from the OUTER
15654
+ // anchor container — the trigger's parent div, the official rootRef — not
15655
+ // the button. header.css.ts dissolves that container with
15656
+ // display:contents, so its rect was all zeros and the panel landed at
15657
+ // 12,12 inside the status bar (UX-2). Give the anchor the pill's exact
15658
+ // box: the inline display beats the stylesheet's display:contents
15659
+ // !important (the style attribute outranks selectors at author-important),
15660
+ // so rootRef.getBoundingClientRect() finally sees the pill. pointer-events
15661
+ // none keeps the invisible box from ever stealing a tap — the trigger
15662
+ // child above it is the tappable one. z-index 3 is required on the anchor
15663
+ // too: position:fixed unconditionally creates a stacking context (even at
15664
+ // z-index:auto, stack level 0), which otherwise traps the trigger's own
15665
+ // z-index:3 underneath [data-mobile-nav="header-activity"] (z-index:1).
15666
+ const anchor = trigger.parentElement;
15667
+ if (anchor !== null && anchor.closest(HEADER) !== null) {
15668
+ anchor.setAttribute(TAKEN, '');
15669
+ pin(anchor, r, 'block');
15670
+ anchor.style.setProperty('pointer-events', 'none', 'important');
15671
+ anchor.style.setProperty('z-index', '3', 'important');
15672
+ }
15305
15673
  }
15306
15674
  function release(trigger) {
15307
15675
  trigger.removeAttribute(TAKEN);
15308
15676
  trigger.style.cssText = '';
15677
+ const anchor = trigger.parentElement;
15678
+ if (anchor !== null && anchor.hasAttribute(TAKEN)) {
15679
+ anchor.removeAttribute(TAKEN);
15680
+ anchor.style.cssText = '';
15681
+ }
15309
15682
  }
15310
15683
  function installNativeTriggerOverlay(ctx) {
15311
15684
  ctx.effect(() => {
@@ -17593,6 +17966,11 @@ function SettingsSectionPage({ config, shares, t }) {
17593
17966
  setClaimBusy(true);
17594
17967
  setClaimFail(null);
17595
17968
  setClaimWriteFailed(false);
17969
+ // UX-1: captured BEFORE the pairing — whether the page ran as client, and
17970
+ // whether the backend's stream interceptor was already active per the
17971
+ // last client/status diagnostics.
17972
+ const wasEffectiveClient = clientRole;
17973
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true;
17596
17974
  // T60: one name for both halves of the round-trip — trimmed and legal
17597
17975
  // ({@link claimDeviceNameOf}), sent to the server AND written into the
17598
17976
  // row when the claim lands.
@@ -17625,6 +18003,20 @@ function SettingsSectionPage({ config, shares, t }) {
17625
18003
  // could not store falls back to the name we actually sent.
17626
18004
  const echoed = typeof body.deviceName === 'string' && body.deviceName !== '' ? (0, settings_form_ts_1.claimDeviceNameOf)(body.deviceName, claimName) : claimName;
17627
18005
  const landed = await config.writeClientPairing(urlToWrite, body.token, echoed);
18006
+ if (landed && (0, settings_form_ts_1.shouldReloadAfterClientActivation)({
18007
+ wasEffectiveClient,
18008
+ nowClientRole: true,
18009
+ savedOrPaired: true,
18010
+ streamInterceptAlreadyActive,
18011
+ })) {
18012
+ // UX-1: FIRST pairing on a page whose resident global streams were
18013
+ // opened unwrapped (or with the interceptor inactive) — wait out the
18014
+ // backend's row-reload window, then reload so the streams reopen
18015
+ // wrapped and the server's sessions enter the sidebar.
18016
+ setClaimBusy(false);
18017
+ void (0, settings_form_ts_1.awaitClientStatusReady)().then(() => window.location.reload());
18018
+ return;
18019
+ }
17628
18020
  if (landed) {
17629
18021
  // The draft did its job — it now equals the displayed value, and
17630
18022
  // keeping it would be a phantom edit (T60).
@@ -17753,8 +18145,29 @@ function SettingsSectionPage({ config, shares, t }) {
17753
18145
  ? t('settings.statusUnknown')
17754
18146
  : t('settings.statusUnavailable', { code: load.status }) })), !clientRole && load.state === 'stale' && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.refreshFailed') })), clientRole && clientLoad.state === 'error' && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.statusUnknown') })), clientRole && clientLoad.state === 'stale' && ((0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.refreshFailed') })), data?.viaGateway === true && (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-notice", children: t('settings.remoteNotice') }), (0, jsx_runtime_1.jsx)("div", { className: "zr-settings-card", children: (0, jsx_runtime_1.jsxs)(dsh_client_ui_primitives_1.SettingsForm, { labels: labels, state: form, onSave: () => {
17755
18147
  const reloadsRow = saveReloadsRow();
17756
- void config.save().then((landed) => { if (landed)
17757
- saveLanded(reloadsRow); });
18148
+ // UX-1: captured BEFORE the save — whether the page ran as client,
18149
+ // and whether the backend's stream interceptor was already active
18150
+ // per the last client/status diagnostics.
18151
+ const wasEffectiveClient = clientRole;
18152
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true;
18153
+ void config.save().then((landed) => {
18154
+ if (!landed)
18155
+ return;
18156
+ if ((0, settings_form_ts_1.shouldReloadAfterClientActivation)({
18157
+ wasEffectiveClient,
18158
+ nowClientRole: config.savedRoleIsClient(),
18159
+ savedOrPaired: true,
18160
+ streamInterceptAlreadyActive,
18161
+ })) {
18162
+ // UX-1: the save first-activated the client interceptor while
18163
+ // this page's resident global streams were opened unwrapped —
18164
+ // wait out the row-reload window, then reload so the streams
18165
+ // reopen wrapped and the server's sessions enter the sidebar.
18166
+ void (0, settings_form_ts_1.awaitClientStatusReady)().then(() => window.location.reload());
18167
+ return;
18168
+ }
18169
+ saveLanded(reloadsRow);
18170
+ });
17758
18171
  }, onDiscard: () => { config.discard(); }, children: [(0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.roleTitle') }), select(form.role, 'role', t('settings.role'), t('settings.roleHint'), ['host', 'client'], (option) => (option === 'host' ? t('settings.roleHost') : t('settings.roleClient'))), text(form.serverName, 'serverName', t('settings.fieldServerName'), t(clientRole ? 'settings.fieldServerNameHintClient' : 'settings.fieldServerNameHint'), t('settings.invalidName')), reloadPending && (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-status-line", children: t('settings.reloadNote') }), !clientRole && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.gatewayTitle') }), text(form.host, 'host', t('settings.fieldHost'), t('settings.fieldHostHint'), '', false, t('settings.fieldHostNote')), text(form.port, 'port', t('settings.fieldPort'), t('settings.fieldPortHint'), t('settings.invalidPort'), true), text(form.trustedProxies, 'trustedProxies', t('settings.fieldTrustedProxies'), t('settings.fieldTrustedProxiesHint'), ''), text(form.rateLimit, 'rateLimit', t('settings.fieldRateLimit'), t('settings.fieldRateLimitHint'), t('settings.invalidPositiveInt'), true), text(form.vapidSubject, 'vapidSubject', t('settings.fieldVapidSubject'), t('settings.fieldVapidSubjectHint'), ''), (0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.pushTitle') }), bool(form.pushSummary, 'pushSummary', t('settings.fieldPushSummary'), t('settings.fieldPushSummaryHint')), bool(form.pushTurnEnd, 'pushTurnEnd', t('settings.fieldPushTurnEnd'), t('settings.fieldPushTurnEndHint')), bool(form.pushTool, 'pushTool', t('settings.fieldPushTool'), t('settings.fieldPushToolHint')), text(form.pushDebounceMs, 'pushDebounceMs', t('settings.fieldPushDebounceMs'), t('settings.fieldPushDebounceMsHint'), t('settings.invalidNonNegativeInt'), true), select(form.lang, 'lang', t('settings.fieldLang'), t('settings.fieldLangHint'), ['auto', 'zh', 'en'], (option) => (option === 'auto' ? t('settings.langAuto') : option === 'zh' ? t('settings.langZh') : t('settings.langEn'))), (0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.shareTitle') }), text(form.idleHours, 'idleHours', t('settings.fieldIdleHours'), t('settings.fieldIdleHoursHint'), t('settings.invalidHours'), true), bool(form.autoShareNewSessions, 'autoShareNewSessions', t('settings.fieldAutoShare'), t('settings.fieldAutoShareHint')), (0, jsx_runtime_1.jsx)("h3", { className: "zr-settings-card-title", children: t('settings.bridge.title') }), bridgeState === 'loading' && (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.bridge.loading') }), bridgeState === 'error' && (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", "data-invalid": "true", children: t('settings.bridge.loadFailed') }), bridgeState === 'empty' && (0, jsx_runtime_1.jsx)("p", { className: "zr-settings-hint", children: t('settings.bridge.empty') }), bridgeLoad.rows.map((row) => {
17759
18172
  let current = {};
17760
18173
  try {
@@ -19620,6 +20033,12 @@ function GatewayWizard(props) {
19620
20033
  const runClientClaim = async () => {
19621
20034
  setFormError(null);
19622
20035
  setClientClaimFail(null);
20036
+ // UX-1: the facts that decide the post-pairing reload — whether the page
20037
+ // opened as an already-effective client, and whether the backend's
20038
+ // stream interceptor was already active (from the last client/status
20039
+ // diagnostics). Captured BEFORE the role switch and the claim below.
20040
+ const wasEffectiveClient = effectiveClient;
20041
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true;
19623
20042
  const trimmedUrl = clientServerUrl.trim();
19624
20043
  const normalizedCode = (0, settings_form_ts_1.normalizePairingCode)(clientPairCode);
19625
20044
  if (trimmedUrl === '' || !/^https?:\/\//i.test(trimmedUrl)) {
@@ -19661,7 +20080,21 @@ function GatewayWizard(props) {
19661
20080
  if (body?.ok === true && typeof body.token === 'string' && body.token !== '') {
19662
20081
  const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl;
19663
20082
  const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName;
19664
- await config.writeClientPairing(urlToWrite, body.token, echoed);
20083
+ const landed = await config.writeClientPairing(urlToWrite, body.token, echoed);
20084
+ if (landed && (0, settings_form_ts_1.shouldReloadAfterClientActivation)({
20085
+ wasEffectiveClient,
20086
+ nowClientRole: true,
20087
+ savedOrPaired: true,
20088
+ streamInterceptAlreadyActive,
20089
+ })) {
20090
+ // UX-1: this was the FIRST client activation while the page's three
20091
+ // resident global streams were opened unwrapped — wait out the
20092
+ // backend's row-reload window, then reload so the streams reopen
20093
+ // wrapped and the server's shared sessions enter the sidebar.
20094
+ await (0, settings_form_ts_1.awaitClientStatusReady)();
20095
+ window.location.reload();
20096
+ return;
20097
+ }
19665
20098
  setClientPairCode('');
19666
20099
  await loadClientStatus();
19667
20100
  setStep('verify');