dsh-zen-remote 2.3.8 → 2.3.9

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 (45) hide show
  1. package/CHANGELOG.md +29 -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 +470 -81
  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/MobileSessionHeader.d.ts.map +1 -1
  14. package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
  15. package/lib/types/client/effects/permission-sheet.d.ts +51 -10
  16. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -1
  17. package/lib/types/client/locales.d.ts +1 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  20. package/lib/types/client/styles/composer.css.d.ts +1 -1
  21. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  22. package/lib/types/client/styles/header.css.d.ts +1 -1
  23. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  24. package/lib/types/client/styles/layout.css.d.ts +1 -1
  25. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  26. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  27. package/lib/types/client-data/new-session.d.ts +26 -0
  28. package/lib/types/client-data/new-session.d.ts.map +1 -0
  29. package/lib/types/client-data/settings-form.d.ts +36 -0
  30. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/scripts/check-settings-form.mjs +63 -0
  33. package/src/adapters/dsh-better-sidebar/index.ts +23 -3
  34. package/src/client/MobileHome.tsx +44 -7
  35. package/src/client/MobileSessionHeader.tsx +7 -1
  36. package/src/client/effects/native-trigger-overlay.ts +50 -11
  37. package/src/client/effects/permission-sheet.ts +199 -22
  38. package/src/client/locales.ts +2 -0
  39. package/src/client/settings/SettingsSection.tsx +43 -1
  40. package/src/client/styles/composer.css.ts +24 -0
  41. package/src/client/styles/header.css.ts +58 -46
  42. package/src/client/styles/layout.css.ts +25 -0
  43. package/src/client/wizard/GatewayWizard.tsx +23 -1
  44. package/src/client-data/new-session.ts +30 -0
  45. package/src/client-data/settings-form.ts +57 -0
package/lib/client.js CHANGED
@@ -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)';
@@ -1600,12 +1613,29 @@ function MobileHome({ useSessions, useWorkspaces, useSessionPendingInteraction,
1600
1613
  const status = statusLine(row, dot, t);
1601
1614
  const initial = row.displayTitle.trim().charAt(0).toUpperCase();
1602
1615
  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: () => {
1616
+ }), 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: () => {
1617
+ // UX-3: never enter a dangling no-workspace session. With "all"
1618
+ // selected (the common case — the host's own sessions belong to no
1619
+ // workspace) the decision either auto-starts the only workspace or
1620
+ // opens the workspace drawer first; the session then starts with a
1621
+ // workspace attached from the first frame.
1622
+ const action = (0, new_session_ts_1.decideNewSessionAction)(selectedWorkspace?.workspaceId, workspaces.items.map((item) => item.workspaceId));
1623
+ if (action.kind === 'start')
1624
+ enter(() => startSession(action.workspaceId));
1625
+ else
1626
+ setSheet('new-session');
1627
+ }, 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
1628
  actions.filter('all');
1605
1629
  setSheet(null);
1606
1630
  }, 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
1631
  actions.filter(item.workspaceId);
1608
1632
  setSheet(null);
1633
+ }, 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: () => {
1634
+ // Pin the list to the picked workspace (so backing out
1635
+ // of the blank session lands on ITS list) and start the
1636
+ // session with that workspace from the first frame.
1637
+ actions.filter(item.workspaceId);
1638
+ enter(() => startSession(item.workspaceId));
1609
1639
  }, children: item.title }, item.workspaceId)))] })), sheet === 'chips' && (0, jsx_runtime_1.jsx)(MobileHomeChips_tsx_1.MobileHomeChipsSheetBody, { t: t })] })] }))] })] }));
1610
1640
  }
1611
1641
  };
@@ -1665,7 +1695,13 @@ function IconTaskGlyph({ size = 14 }) {
1665
1695
  * the root through `:has()` instead of naming it.
1666
1696
  */
1667
1697
  const HEADER = '[data-phase] header';
1668
- const SUBAGENT_TRIGGER = `${HEADER} button[aria-haspopup="tree"]`;
1698
+ /* UX-2 (2026-11-04): narrowed by the action-button class tail. The bare
1699
+ `button[aria-haspopup="tree"]` matched the breadcrumb's parent-session
1700
+ switcher first (`…_switcherTrigger` — also a tree popup), so both the
1701
+ overlay and this fallback check resolved to the wrong button. The suffix
1702
+ matcher is case-sensitive and `_switcherTrigger` does not end in
1703
+ `_trigger`. Must stay in lockstep with effects/native-trigger-overlay.ts. */
1704
+ const SUBAGENT_TRIGGER = `${HEADER} button[class$="_trigger"][aria-haspopup="tree"]`;
1669
1705
  const JOBS_TRIGGER = `${HEADER} button[class$="_trigger"]:not([aria-haspopup])`;
1670
1706
  /** One glanceable group: icon + count + state dot; opens the official popover. */
1671
1707
  function ActivityPill({ kind, count, state, label, trigger, onFallback, Icon }) {
@@ -2072,6 +2108,8 @@ exports.deriveClientStatusView = deriveClientStatusView;
2072
2108
  exports.clientStatusLineOf = clientStatusLineOf;
2073
2109
  exports.normalizePairingCode = normalizePairingCode;
2074
2110
  exports.shouldFollowServerDeviceName = shouldFollowServerDeviceName;
2111
+ exports.shouldReloadAfterClientActivation = shouldReloadAfterClientActivation;
2112
+ exports.awaitClientStatusReady = awaitClientStatusReady;
2075
2113
  exports.createLatestGate = createLatestGate;
2076
2114
  exports.savedRowRole = savedRowRole;
2077
2115
  exports.settingsRoleOf = settingsRoleOf;
@@ -2273,6 +2311,37 @@ function normalizePairingCode(input) {
2273
2311
  function shouldFollowServerDeviceName(deviceName, rowName, hasDraft) {
2274
2312
  return deviceName !== '' && deviceName !== rowName && !hasDraft;
2275
2313
  }
2314
+ function shouldReloadAfterClientActivation(input) {
2315
+ if (!input.savedOrPaired || !input.nowClientRole)
2316
+ return false;
2317
+ if (!input.wasEffectiveClient)
2318
+ return true;
2319
+ if (input.streamInterceptAlreadyActive === false)
2320
+ return true;
2321
+ return false;
2322
+ }
2323
+ /**
2324
+ * Poll {@link CLIENT_STATUS_ROUTE} until the backend answers ok — the bounded
2325
+ * wait before the post-activation reload, covering the restart-watcher's row
2326
+ * reload window (the route 404s / fails while the row restarts). Resolves
2327
+ * after `maxMs` regardless, so the reload always fires. Injectable fetch and
2328
+ * timings keep the check script fast; defaults are the page's 3 s / 250 ms.
2329
+ */
2330
+ async function awaitClientStatusReady(fetchFn = fetch, maxMs = 3000, intervalMs = 250) {
2331
+ const deadline = Date.now() + maxMs;
2332
+ while (true) {
2333
+ try {
2334
+ if ((await fetchFn(exports.CLIENT_STATUS_ROUTE)).ok)
2335
+ return;
2336
+ }
2337
+ catch {
2338
+ // the row is mid-reload; keep polling
2339
+ }
2340
+ if (Date.now() >= deadline)
2341
+ return;
2342
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2343
+ }
2344
+ }
2276
2345
  function createLatestGate() {
2277
2346
  let current = 0;
2278
2347
  return {
@@ -3063,6 +3132,7 @@ exports.zh = {
3063
3132
  'previewExitFullscreen': '退出全屏',
3064
3133
  'allWorkspaces': '全部',
3065
3134
  'switchWorkspace': '切换工作区',
3135
+ 'pickWorkspaceNew': '选择工作区开始',
3066
3136
  'newSession': '新建会话',
3067
3137
  'noSessions': '暂无会话,点击右下角加号新建',
3068
3138
  'homeStatusOngoing': '运行中',
@@ -3586,6 +3656,7 @@ exports.en = {
3586
3656
  'previewExitFullscreen': 'Exit fullscreen',
3587
3657
  'allWorkspaces': 'All',
3588
3658
  'switchWorkspace': 'Switch workspace',
3659
+ 'pickWorkspaceNew': 'Pick a workspace to start',
3589
3660
  'newSession': 'New session',
3590
3661
  'noSessions': 'No sessions yet — tap + to start one',
3591
3662
  'homeStatusOngoing': 'Running',
@@ -8163,6 +8234,31 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
8163
8234
  padding-top: var(--mnav-sat) !important;
8164
8235
  padding-bottom: var(--mnav-sab) !important;
8165
8236
  }
8237
+ /* Right sidebar start/guide page (dsh-client-ui-sidebar-right GuideBody):
8238
+ the host sets .Dv5QdW_tabBody { height: 100%; overflow: hidden } and
8239
+ .F6Huca_guide { min-height: 100%; justify-content: center } with no
8240
+ overflow-y. When many tab entries are registered (e.g. better-sidebar +
8241
+ plugins), the centered flex column overflows equally out the top and
8242
+ bottom of the locked 100%-height box — clipping the leading entries
8243
+ ("文件", "Git") above y=0 with no way to scroll. Make the guide itself
8244
+ the scroll container and replace justify-content:center with auto
8245
+ margins on the first item and ::after so short lists stay centered
8246
+ while long lists start cleanly at the top and scroll end-to-end. */
8247
+ [data-sidebar-right-guide] {
8248
+ flex: 1 1 0 !important;
8249
+ min-height: 0 !important;
8250
+ justify-content: flex-start !important;
8251
+ overflow-y: auto !important;
8252
+ -webkit-overflow-scrolling: touch;
8253
+ padding-top: 24px !important;
8254
+ padding-bottom: 24px !important;
8255
+ }
8256
+ [data-sidebar-right-guide] > :first-child {
8257
+ margin-top: auto !important;
8258
+ }
8259
+ [data-sidebar-right-guide]::after {
8260
+ margin-bottom: auto !important;
8261
+ }
8166
8262
 
8167
8263
  /* Expanded state (frame without data-sidebar-collapsed) slides the drawer in.
8168
8264
  The open state must be transform:none — NOT translateX(0): an identity
@@ -10570,6 +10666,14 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10570
10666
  instead of centered. Pinning the row makes it one 48px band again. */
10571
10667
  [data-phase] header [class$="_titleRow"] {
10572
10668
  position: relative;
10669
+ /* UX-2 (2026-11-04): the host gives this row container-type: inline-size,
10670
+ which implies layout containment — making the row the containing block
10671
+ for every descendant position:fixed. That shifted every viewport
10672
+ coordinate effects/native-trigger-overlay.ts writes by the safe-area
10673
+ top and the row's horizontal padding. normal hands fixed descendants
10674
+ back to the viewport; the phone block owns this layout, so nothing
10675
+ scoped container queries against the row here. */
10676
+ container-type: normal !important;
10573
10677
  display: grid;
10574
10678
  grid-template-columns: 92px 1fr 92px;
10575
10679
  grid-template-rows: minmax(48px, auto);
@@ -10637,55 +10741,30 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10637
10741
  min-width: 0;
10638
10742
  overflow: hidden;
10639
10743
  }
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)) {
10744
+ /* Subagent sessions hide every ancestor crumb (UX-2, 2026-11-04 —
10745
+ supersedes the 2026-08-18 "keep ONE ancestor" rule). DSH 0.2.1 turned
10746
+ the parent crumb into a session-TREE popup trigger
10747
+ (…_switcherTrigger[aria-haspopup="tree"]) instead of a direct
10748
+ switch-back tap, and that popup is desktop geometry on the phone; the
10749
+ back button and the home stack stay the way back. Main sessions render
10750
+ a single span with no segs at all, so :not(:last-child) never matches
10751
+ there. */
10752
+ [data-phase] header [class$="_crumbSeg"]:not(:last-child) {
10651
10753
  display: none !important;
10652
10754
  }
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"] {
10755
+ /* With the parent seg gone, the current seg's own leading "/" would
10756
+ dangle before the title; the dissolved trigger container's stray
10757
+ separator is covered by this same rule (its slot rules below hide it
10758
+ too). One blanket rule instead of per-segment cases. */
10759
+ [data-phase] header [class$="_crumbSep"] {
10663
10760
  display: none !important;
10664
10761
  }
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
10762
  [data-phase] header [class$="_crumbSeg"]:last-child {
10680
10763
  display: flex;
10681
10764
  align-items: center;
10682
10765
  min-width: 0;
10683
10766
  max-width: 100%;
10684
10767
  }
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
10768
  /* BOTH selectors are required: [class$=] matches the whole class
10690
10769
  ATTRIBUTE's suffix, and the current-session title button's attribute is
10691
10770
  "wSkVaW_crumb wSkVaW_crumbCurrent" — it ends in _crumbCurrent, so the _crumb
@@ -10795,12 +10874,17 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10795
10874
  pointer-events on the trigger: that is exactly what would take the tap
10796
10875
  target away again.
10797
10876
 
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). */
10877
+ CSS keeps only the resting dissolve: the root's box (and its stray
10878
+ separator, hidden below) stops occupying the breadcrumb until the
10879
+ overlay pins it. The measurement chore moved to the overlay (UX-2,
10880
+ 2026-11-04): the 0.1.1+ popover portals to <body> as position:fixed
10881
+ and sizes itself from the OUTER anchor container (the official
10882
+ rootRef, the trigger's parent div) — a display:contents root has no
10883
+ box, so rootRef.getBoundingClientRect() was all zeros and the panel
10884
+ landed at 12,12 inside the status bar. effects/
10885
+ native-trigger-overlay.ts now pins container + trigger to the pill's
10886
+ rect together (its inline display beats this rule's !important, style
10887
+ attribute outranks selectors at author-important). */
10804
10888
  [data-phase] header [data-slot="conversation.session.header.actions"] > *:has(> button[class$="_trigger"]),
10805
10889
  [data-phase] header [data-slot="conversation.session.header.lineage"] > *:has(> button[class$="_trigger"]) {
10806
10890
  display: contents !important;
@@ -10809,6 +10893,18 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10809
10893
  [data-phase] header [data-slot="conversation.session.header.lineage"] [class$="_separator"] {
10810
10894
  display: none !important;
10811
10895
  }
10896
+ /* The popovers themselves portal to <body> as position:fixed layers
10897
+ (class suffix _menu, z-index 100) and place themselves from the pill's
10898
+ viewport rect with margin 12 — the overlay above is what makes that
10899
+ rect correct. This is only a floor: the panels' 336px width overflows
10900
+ 320px-class phones, and a tall list must not run under the notch or
10901
+ the home indicator. [id$="-menu"] keeps the composer's model menu out
10902
+ of it — composer.css.ts owns that surface, and this file sits before
10903
+ it in styles/index.ts so its own rules still win the tie. */
10904
+ body > [class$="_menu"]:not([id$="-menu"]) {
10905
+ max-width: calc(100vw - 24px) !important;
10906
+ max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px) !important;
10907
+ }
10812
10908
  /* The agent-preset mode badge specifically survives the blanket hide
10813
10909
  above: layout.css.ts's shared <=1023px block targets it directly
10814
10910
  (header [class$="_label"]:has(> svg) { display: block !important }),
@@ -10837,8 +10933,12 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10837
10933
  display: inline-flex !important;
10838
10934
  }
10839
10935
 
10840
- /* Back button: 44pt touch target, leftmost column. */
10936
+ /* Back button: 44pt touch target, leftmost column.
10937
+ order: -1 keeps it to the left of @deepseek-ai/dsh-experimental-client-ui-agent-team's
10938
+ TeamAction ([data-team-action], registered into header.actions at order -20 with
10939
+ default flex order 0). */
10841
10940
  [data-mobile-nav="header-back"] {
10941
+ order: -1;
10842
10942
  display: inline-flex;
10843
10943
  align-items: center;
10844
10944
  justify-content: center;
@@ -10855,6 +10955,13 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10855
10955
  [data-mobile-nav="header-back"]:active {
10856
10956
  opacity: .6;
10857
10957
  }
10958
+ /* TeamAction's own stylesheet hides .XdyUwa_triggerLabel via
10959
+ @container (width<=480px) against _titleRow's container-type: inline-size.
10960
+ Shedding container-type on _titleRow above (UX-2) disabled that container
10961
+ query, so hide the label explicitly on phone and keep only the icon. */
10962
+ [data-phase] header [data-team-action] [class$="_triggerLabel"] {
10963
+ display: none !important;
10964
+ }
10858
10965
 
10859
10966
  /* Info + workbench utility buttons, rightmost column. */
10860
10967
  [data-mobile-nav="header-info"],
@@ -10945,6 +11052,7 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
10945
11052
  top:100% lands both on the same 28px line. z-index puts it above the
10946
11053
  row it overlaps. */
10947
11054
  [data-mobile-nav="header-activity"] {
11055
+ pointer-events: none;
10948
11056
  position: absolute;
10949
11057
  right: 0;
10950
11058
  top: 100%;
@@ -11406,6 +11514,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11406
11514
  …, open-in-app, deliverables, …), and none opens through this trigger
11407
11515
  while no marked menu is up. */
11408
11516
  body > div[role="menu"][data-zen-sheet="perm"],
11517
+ /* UX-3: the hero page's workspace menu, marked by the same effect through
11518
+ its own two triggers (the WorkspaceChip and the placeholder card) with
11519
+ the workspace value — a distinct marker so the pick that starts the
11520
+ workspace switch (effects/permission-sheet.ts) can key on THIS sheet
11521
+ without ever touching permission options. */
11522
+ body > div[role="menu"][data-zen-sheet="workspace"],
11409
11523
  ${MODEL_MENU} {
11410
11524
  position: fixed !important;
11411
11525
  left: 0 !important;
@@ -11438,6 +11552,7 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11438
11552
  older hosts. */
11439
11553
  ${PERM} [role="menu"] [role="menuitem"],
11440
11554
  body > div[role="menu"][data-zen-sheet="perm"] [role="menuitem"],
11555
+ body > div[role="menu"][data-zen-sheet="workspace"] [role="menuitem"],
11441
11556
  ${MODEL_MENU} [role="menuitem"],
11442
11557
  ${MODEL_MENU} [role="menuitemradio"],
11443
11558
  ${MODEL_MENU} [class$="_option"],
@@ -11586,11 +11701,28 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
11586
11701
  body:has(> [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"])) [data-chat-flow] + div,
11587
11702
  /* T70: the portaled permission menu (marked by effects/permission-sheet.ts). */
11588
11703
  body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div,
11704
+ /* UX-3: the portaled hero workspace menu, same treatment. */
11705
+ body:has(> div[role="menu"][data-zen-sheet="workspace"]) [data-chat-flow] + div,
11589
11706
  /* T-issue30: an inline menu a parked chip opened (escaped to a fixed
11590
11707
  sheet by 4a). */
11591
11708
  body:has([data-zen-sheet-extras] [role="menu"]) [data-chat-flow] + div {
11592
11709
  display: none !important;
11593
11710
  }
11711
+ /* --- 4c. UX-3: the workspace switch lock ---
11712
+ Between picking a workspace in the hero sheet and the new blank session
11713
+ landing (selectWorkspace → POST /api/session/create → replaceMain
11714
+ remounts the conversation), the composer still belongs to the OLD blank
11715
+ session: a message sent in that window lands there and is then wiped by
11716
+ the remount, bouncing the UI back to the "choose a workspace" state.
11717
+ effects/permission-sheet.ts stamps data-zen-ws-switching on <html> for
11718
+ exactly that window — the lock releases when the conversation root's
11719
+ data-conversation-session has moved and the placeholder card is gone,
11720
+ with a 1.5 s cap — and the dim (not just pointer-events) makes the
11721
+ freeze visible instead of reading as a dead input. */
11722
+ html[data-zen-ws-switching] [data-slot="conversation.composer.bar"] {
11723
+ pointer-events: none !important;
11724
+ opacity: .6 !important;
11725
+ }
11594
11726
  /* --- 5. input box: two lines minimum, five lines maximum ---
11595
11727
  The official autosizer drives the box off the hidden mirror's height, so
11596
11728
  a min-height on the mirror IS the min-height of the field; the scroll cap
@@ -15112,7 +15244,10 @@ __modules["client/effects/permission-sheet.js"] = function (require, module, exp
15112
15244
  "use strict";
15113
15245
  Object.defineProperty(exports, "__esModule", { value: true });
15114
15246
  exports.isUnmarkedPortalMenu = isUnmarkedPortalMenu;
15115
- exports.clickArmsPermissionSheet = clickArmsPermissionSheet;
15247
+ exports.sheetKindForTrigger = sheetKindForTrigger;
15248
+ exports.pointerdownOnWorkspaceTrigger = pointerdownOnWorkspaceTrigger;
15249
+ exports.menuitemClickStartsWorkspaceSwitch = menuitemClickStartsWorkspaceSwitch;
15250
+ exports.workspaceSwitchSettled = workspaceSwitchSettled;
15116
15251
  exports.installPermissionSheet = installPermissionSheet;
15117
15252
  /** Phone breakpoint — the same query every phone-only effect in this plugin uses. */
15118
15253
  const PHONE_QUERY = '(max-width: 767px)';
@@ -15123,7 +15258,18 @@ const PHONE_QUERY = '(max-width: 767px)';
15123
15258
  * NO aria-controls on 0.2.0 (the Menu primitive portals its list to body
15124
15259
  * and links nothing back), so the click is the only open signal there is.
15125
15260
  */
15126
- const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]';
15261
+ const PERM_TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]';
15262
+ /**
15263
+ * UX-3: the HERO page's two workspace triggers (WorkspaceSheet slot impl,
15264
+ * verified in the dsh-client-ui-conversation bundle 2026-11-05):
15265
+ * - `WorkspaceChip` — a bare `button` whose hashed class ends in
15266
+ * `_workspace`, carrying `aria-haspopup="menu"` + `aria-expanded`;
15267
+ * - the placeholder card — when a blank hero session has no workspace yet
15268
+ * the whole `div[data-composer-card]` carries `cardWorkspaceTrigger` and
15269
+ * its click opens the same menu (`onRequestWorkspace`).
15270
+ * Both open a `body > div[role="menu"]` of `button[role="menuitem"]` items.
15271
+ */
15272
+ const WORKSPACE_TRIGGER_SELECTOR = '[class$="_workspace"][aria-haspopup="menu"], [data-composer-card][class*="_cardWorkspaceTrigger"]';
15127
15273
  /**
15128
15274
  * What the Menu primitive portals on 0.2.0: a `role=menu` div DIRECTLY under
15129
15275
  * body, carrying `data-menu-material` and a randomized `--dsh-menu-anchor`
@@ -15132,15 +15278,36 @@ const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[cla
15132
15278
  * host has MANY `portal: true` Menus (agent-preset, chat preference rows,
15133
15279
  * the Enter-behavior row, the workspace sidebar's 「…」, open-in-app,
15134
15280
  * 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.
15281
+ * a bottom sheet. That is why this effect exists: a click on a sheet trigger
15282
+ * arms a short window and the next body-level menu to land is OURS to mark —
15283
+ * while a marked menu still on the page (the close click) keeps the window
15284
+ * shut.
15139
15285
  */
15140
15286
  const PORTAL_MENU_SELECTOR = 'body > div[role="menu"]';
15141
15287
  /** The marker composer.css.ts section 4 styles into the bottom sheet. */
15142
15288
  const SHEET_MARKER = 'data-zen-sheet';
15143
- const SHEET_VALUE = 'perm';
15289
+ const SHEET_VALUE_PERM = 'perm';
15290
+ const SHEET_VALUE_WORKSPACE = 'workspace';
15291
+ /**
15292
+ * UX-3: the switch lock. Picking a workspace in the hero sheet runs
15293
+ * `selectWorkspace` → `POST /api/session/create` → `replaceMain` — the
15294
+ * conversation unmounts and remounts (`useMaybeIncarnation` epoch+1) and the
15295
+ * composer briefly still belongs to the OLD blank session. A message sent in
15296
+ * that window lands on the old session and is then wiped by the remount,
15297
+ * bouncing the UI back to the "choose a workspace" placeholder. While the
15298
+ * `data-zen-ws-switching` attribute sits on `<html>`, composer.css dead-zones
15299
+ * and dims the composer bar; the lock releases the moment the conversation
15300
+ * root's `data-conversation-session` has MOVED and the placeholder card is
15301
+ * gone — with a hard cap so a failed/no-op switch cannot freeze the input.
15302
+ */
15303
+ const SWITCH_MARKER = 'data-zen-ws-switching';
15304
+ const SWITCH_LOCK_MS = 1_500;
15305
+ const SESSION_ATTR = 'data-conversation-session';
15306
+ const SESSION_SELECTOR = `[${SESSION_ATTR}]`;
15307
+ /** The hero's inert "choose a workspace" state: the whole input card IS the trigger. */
15308
+ const PLACEHOLDER_CARD_SELECTOR = '[data-composer-card][class*="_cardWorkspaceTrigger"]';
15309
+ /** Spelled literally (not interpolated) — the test pins this exact text as the perm/workspace discriminator. */
15310
+ const WS_MENUITEM_SELECTOR = 'body > div[role="menu"][data-zen-sheet="workspace"] button[role="menuitem"]';
15144
15311
  /**
15145
15312
  * Whether ONE body-level node is a portal menu that does not yet carry the
15146
15313
  * sheet marker. Pure over the node's own methods: `matches` answers the
@@ -15149,17 +15316,52 @@ const SHEET_VALUE = 'perm';
15149
15316
  function isUnmarkedPortalMenu(node) {
15150
15317
  return node.matches(PORTAL_MENU_SELECTOR) && node.getAttribute(SHEET_MARKER) === null;
15151
15318
  }
15319
+ const inSeat = (seat) => seat !== undefined && seat !== null;
15152
15320
  /**
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.
15321
+ * Which sheet ONE click event's target arms: the phone shell only. The
15322
+ * permission seat's trigger arms `perm` (T70); the hero page's two
15323
+ * workspace triggers arm `workspace` (UX-3). Any other button — the
15324
+ * sidebar's 「…」, a copy button — arms nothing, so the menus they open are
15325
+ * never marked.
15157
15326
  */
15158
- function clickArmsPermissionSheet(target, phoneMatches) {
15327
+ function sheetKindForTrigger(target, phoneMatches) {
15159
15328
  if (!phoneMatches)
15329
+ return null;
15330
+ if (inSeat(target?.closest(PERM_TRIGGER_SELECTOR)))
15331
+ return 'perm';
15332
+ if (inSeat(target?.closest(WORKSPACE_TRIGGER_SELECTOR)))
15333
+ return 'workspace';
15334
+ return null;
15335
+ }
15336
+ /**
15337
+ * UX-3: whether a pointerdown landed inside a WORKSPACE trigger — the only
15338
+ * triggers whose open/close suffers the outside-pointerdown + toggle-click
15339
+ * race (the Menu closes the portaled sheet on the outside pointerdown, and
15340
+ * the trigger's own click would flip it straight back open). The permission
15341
+ * trigger toggles by click alone and is never suppressed.
15342
+ */
15343
+ function pointerdownOnWorkspaceTrigger(target) {
15344
+ return inSeat(target?.closest(WORKSPACE_TRIGGER_SELECTOR));
15345
+ }
15346
+ /**
15347
+ * UX-3: whether a click started on a workspace item inside the MARKED
15348
+ * workspace sheet — the pick that starts the switch lock. Keyed on the
15349
+ * `workspace` marker value, so a permission option can never lock the
15350
+ * composer.
15351
+ */
15352
+ function menuitemClickStartsWorkspaceSwitch(target) {
15353
+ return inSeat(target?.closest(WS_MENUITEM_SELECTOR));
15354
+ }
15355
+ /**
15356
+ * Whether the workspace switch has settled: the conversation root now names
15357
+ * a DIFFERENT session than the one the lock was taken on, and the no-workspace
15358
+ * placeholder card is gone (the optimistic `pendingWorkspaceId` display alone
15359
+ * is not enough — the remount resets it).
15360
+ */
15361
+ function workspaceSwitchSettled(probe, lockedSessionValue) {
15362
+ if (probe.currentSessionValue() === lockedSessionValue)
15160
15363
  return false;
15161
- const seat = target?.closest(TRIGGER_SELECTOR);
15162
- return seat !== undefined && seat !== null;
15364
+ return !probe.placeholderOpen();
15163
15365
  }
15164
15366
  /**
15165
15367
  * How long an OPEN click stays armed. The portal lands in the same React
@@ -15178,18 +15380,36 @@ const ARM_MS = 1_500;
15178
15380
  * selectors stopped matching, and the phone got a tiny floating popup. The
15179
15381
  * host offers no stable attribute to key the portal off — the anchor is a
15180
15382
  * 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
15383
+ * marks it: the trigger's click arms a short window, the observer stamps the
15384
+ * next body-level menu with `data-zen-sheet`, and the stylesheet (which
15183
15385
  * only the phone shell loads) turns THAT into the sheet. The portal menu
15184
15386
  * unmounts on close, so the marker's lifetime is exactly the menu's; the
15185
15387
  * 0.1.7 inline structure keeps working through the untouched descendant
15186
15388
  * selectors, and the desktop shell never loads this stylesheet at all.
15389
+ *
15390
+ * UX-3 extends the same machinery to the hero page's workspace menu:
15391
+ * - the two workspace triggers arm a `data-zen-sheet="workspace"` sheet;
15392
+ * - a pointerdown on a workspace trigger while a marked sheet is up
15393
+ * suppresses the trigger's own click, so one tap reliably CLOSES (the
15394
+ * host's outside-pointerdown close stands instead of being undone by the
15395
+ * toggle click);
15396
+ * - a pick inside the workspace sheet locks the composer for the length of
15397
+ * the `selectWorkspace` remount (see SWITCH_MARKER above).
15187
15398
  */
15188
15399
  function installPermissionSheet(ctx) {
15189
15400
  ctx.effect(() => {
15190
15401
  const phone = window.matchMedia(PHONE_QUERY);
15191
15402
  let armed = false;
15403
+ let armedKind = SHEET_VALUE_PERM;
15192
15404
  let armTimer;
15405
+ // UX-3 toggle debounce: recomputed on EVERY pointerdown, consumed by the
15406
+ // next click — a pointerdown whose click never comes (drag cancel) cannot
15407
+ // leave a stale swallow behind.
15408
+ let suppressClick = false;
15409
+ // Set when a swallowed click found the marked menu STILL on the page
15410
+ // (the pointerdown close did not land): the next tap passes through to
15411
+ // the host's own toggle instead of being swallowed forever.
15412
+ let allowToggle = false;
15193
15413
  const disarm = () => {
15194
15414
  armed = false;
15195
15415
  if (armTimer !== undefined) {
@@ -15197,9 +15417,75 @@ function installPermissionSheet(ctx) {
15197
15417
  armTimer = undefined;
15198
15418
  }
15199
15419
  };
15420
+ const markedSheet = () => document.querySelector(`${PORTAL_MENU_SELECTOR}[${SHEET_MARKER}]`);
15421
+ const readSessionValue = () => document.querySelector(SESSION_SELECTOR)?.getAttribute(SESSION_ATTR) ?? null;
15422
+ const placeholderOpen = () => document.querySelector(PLACEHOLDER_CARD_SELECTOR) !== null;
15423
+ // -- UX-3 switch lock -------------------------------------------------
15424
+ let lockedSessionValue = null;
15425
+ let lockTimer;
15426
+ let lockObserver;
15427
+ const releaseLock = () => {
15428
+ if (lockTimer !== undefined) {
15429
+ clearTimeout(lockTimer);
15430
+ lockTimer = undefined;
15431
+ }
15432
+ lockObserver?.disconnect();
15433
+ lockObserver = undefined;
15434
+ lockedSessionValue = null;
15435
+ document.documentElement.removeAttribute(SWITCH_MARKER);
15436
+ };
15437
+ const checkLock = () => {
15438
+ if (lockedSessionValue === null)
15439
+ return;
15440
+ if (workspaceSwitchSettled({ currentSessionValue: readSessionValue, placeholderOpen }, lockedSessionValue)) {
15441
+ releaseLock();
15442
+ }
15443
+ };
15444
+ const startSwitchLock = () => {
15445
+ if (lockedSessionValue === null) {
15446
+ lockedSessionValue = readSessionValue();
15447
+ document.documentElement.setAttribute(SWITCH_MARKER, '1');
15448
+ lockObserver = new MutationObserver(checkLock);
15449
+ // The session id moves on the conversation root (attribute) and the
15450
+ // placeholder card mounts/unmounts (childList) — plus class flips on
15451
+ // the card are attribute mutations too. One bounded subtree watch
15452
+ // covers all of it.
15453
+ lockObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
15454
+ }
15455
+ if (lockTimer !== undefined)
15456
+ clearTimeout(lockTimer);
15457
+ lockTimer = setTimeout(releaseLock, SWITCH_LOCK_MS);
15458
+ };
15459
+ // ---------------------------------------------------------------------
15460
+ const onCapturePointerDown = (event) => {
15461
+ const target = event.target instanceof Element ? event.target : null;
15462
+ const onTrigger = pointerdownOnWorkspaceTrigger(target);
15463
+ suppressClick = onTrigger && markedSheet() !== null && !allowToggle;
15464
+ if (!suppressClick)
15465
+ allowToggle = false;
15466
+ };
15200
15467
  const onCaptureClick = (event) => {
15468
+ // UX-3: let the outside-pointerdown close stand — stopPropagation
15469
+ // keeps the click from React's root listeners, so the trigger cannot
15470
+ // toggle the sheet straight back open.
15471
+ if (suppressClick) {
15472
+ suppressClick = false;
15473
+ if (markedSheet() !== null)
15474
+ allowToggle = true;
15475
+ event.preventDefault();
15476
+ event.stopPropagation();
15477
+ return;
15478
+ }
15201
15479
  const target = event.target instanceof Element ? event.target : null;
15202
- if (!clickArmsPermissionSheet(target, phone.matches))
15480
+ // UX-3: a pick inside the workspace sheet starts the switch lock — the
15481
+ // composer must not accept input on the old blank session while
15482
+ // selectWorkspace remounts the conversation underneath it.
15483
+ if (phone.matches && menuitemClickStartsWorkspaceSwitch(target)) {
15484
+ startSwitchLock();
15485
+ return;
15486
+ }
15487
+ const kind = sheetKindForTrigger(target, phone.matches);
15488
+ if (kind === null)
15203
15489
  return;
15204
15490
  // T70-fix: the trigger TOGGLES. A click that CLOSES the menu lands
15205
15491
  // while the marked menu is still in the DOM (capture runs before the
@@ -15208,9 +15494,10 @@ function installPermissionSheet(ctx) {
15208
15494
  // body-level menu opening inside that window would be mis-marked as
15209
15495
  // ours. A marked menu present at click time means this click can only
15210
15496
  // 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)
15497
+ if (markedSheet() !== null)
15212
15498
  return;
15213
15499
  armed = true;
15500
+ armedKind = kind === 'workspace' ? SHEET_VALUE_WORKSPACE : SHEET_VALUE_PERM;
15214
15501
  if (armTimer !== undefined)
15215
15502
  clearTimeout(armTimer);
15216
15503
  armTimer = setTimeout(disarm, ARM_MS);
@@ -15220,11 +15507,12 @@ function installPermissionSheet(ctx) {
15220
15507
  if (!armed)
15221
15508
  return;
15222
15509
  disarm();
15223
- root.setAttribute(SHEET_MARKER, SHEET_VALUE);
15510
+ root.setAttribute(SHEET_MARKER, armedKind);
15224
15511
  };
15225
15512
  // Capture phase: the host's own handlers may stop the click's bubble
15226
15513
  // (the trigger toggles `open` itself), so listen on the document in
15227
15514
  // capture and never depend on propagation reaching us.
15515
+ document.addEventListener('pointerdown', onCapturePointerDown, true);
15228
15516
  document.addEventListener('click', onCaptureClick, true);
15229
15517
  const observer = new MutationObserver((records) => {
15230
15518
  if (!armed)
@@ -15238,9 +15526,11 @@ function installPermissionSheet(ctx) {
15238
15526
  });
15239
15527
  observer.observe(document.body, { childList: true });
15240
15528
  return () => {
15529
+ document.removeEventListener('pointerdown', onCapturePointerDown, true);
15241
15530
  document.removeEventListener('click', onCaptureClick, true);
15242
15531
  observer.disconnect();
15243
15532
  disarm();
15533
+ releaseLock();
15244
15534
  // A still-open marked menu outliving this effect keeps its marker only
15245
15535
  // until the user closes it (the element unmounts); scrubbing here
15246
15536
  // would fight a React commit mid-flight for no visual gain.
@@ -15276,36 +15566,75 @@ Object.defineProperty(exports, "__esModule", { value: true });
15276
15566
  exports.installNativeTriggerOverlay = installNativeTriggerOverlay;
15277
15567
  const PHONE = '(max-width: 767px)';
15278
15568
  const HEADER = '[data-phase] header';
15279
- /** Pill → the official trigger it fronts. Same ARIA split the pills use. */
15569
+ /**
15570
+ * Pill → the official trigger it fronts. Same ARIA split the pills use,
15571
+ * narrowed by the action-button class tail (UX-2, 2026-11-04): the bare
15572
+ * `button[aria-haspopup="tree"]` matched the breadcrumb's parent-session
15573
+ * switcher first (`…_switcherTrigger` — also a tree popup, earlier in
15574
+ * document order), so the pill got the wrong button laid over it. The
15575
+ * suffix matcher is case-sensitive and `_switcherTrigger` does not end in
15576
+ * `_trigger`, so the catalog trigger is the only match left.
15577
+ */
15280
15578
  const PAIRS = [
15281
- { pill: '[data-activity-kind="subagent"]', trigger: `${HEADER} button[aria-haspopup="tree"]` },
15579
+ { pill: '[data-activity-kind="subagent"]', trigger: `${HEADER} button[class$="_trigger"][aria-haspopup="tree"]` },
15282
15580
  { pill: '[data-activity-kind="job"]', trigger: `${HEADER} button[class$="_trigger"]:not([aria-haspopup])` },
15283
15581
  ];
15284
- /** Marks a trigger we have taken over, so cleanup can find it again. */
15582
+ /** Marks a trigger (and its anchor container) we have taken over, so cleanup can find them again. */
15285
15583
  const TAKEN = 'data-zen-overlay';
15584
+ /** The shared fixed-viewport box: exactly what the portalled popover measures against. */
15585
+ function pin(el, r, display) {
15586
+ el.style.setProperty('position', 'fixed', 'important');
15587
+ el.style.setProperty('left', `${r.left}px`, 'important');
15588
+ el.style.setProperty('top', `${r.top}px`, 'important');
15589
+ el.style.setProperty('width', `${r.width}px`, 'important');
15590
+ el.style.setProperty('height', `${r.height}px`, 'important');
15591
+ el.style.setProperty('margin', '0', 'important');
15592
+ el.style.setProperty('padding', '0', 'important');
15593
+ el.style.setProperty('border', '0', 'important');
15594
+ if (display !== undefined)
15595
+ el.style.setProperty('display', display, 'important');
15596
+ }
15286
15597
  function place(trigger, pill) {
15287
15598
  const r = pill.getBoundingClientRect();
15288
15599
  if (r.width === 0 || r.height === 0)
15289
15600
  return;
15290
15601
  trigger.setAttribute(TAKEN, '');
15602
+ pin(trigger, r);
15291
15603
  // Fixed, not absolute: the header's containing block is not the pill's, and
15292
15604
  // 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
15605
  trigger.style.setProperty('opacity', '0', 'important');
15302
15606
  trigger.style.setProperty('overflow', 'hidden', 'important');
15303
15607
  trigger.style.setProperty('pointer-events', 'auto', 'important');
15304
15608
  trigger.style.setProperty('z-index', '3', 'important');
15609
+ // The official popover (useAnchoredPosition) sizes itself from the OUTER
15610
+ // anchor container — the trigger's parent div, the official rootRef — not
15611
+ // the button. header.css.ts dissolves that container with
15612
+ // display:contents, so its rect was all zeros and the panel landed at
15613
+ // 12,12 inside the status bar (UX-2). Give the anchor the pill's exact
15614
+ // box: the inline display beats the stylesheet's display:contents
15615
+ // !important (the style attribute outranks selectors at author-important),
15616
+ // so rootRef.getBoundingClientRect() finally sees the pill. pointer-events
15617
+ // none keeps the invisible box from ever stealing a tap — the trigger
15618
+ // child above it is the tappable one. z-index 3 is required on the anchor
15619
+ // too: position:fixed unconditionally creates a stacking context (even at
15620
+ // z-index:auto, stack level 0), which otherwise traps the trigger's own
15621
+ // z-index:3 underneath [data-mobile-nav="header-activity"] (z-index:1).
15622
+ const anchor = trigger.parentElement;
15623
+ if (anchor !== null && anchor.closest(HEADER) !== null) {
15624
+ anchor.setAttribute(TAKEN, '');
15625
+ pin(anchor, r, 'block');
15626
+ anchor.style.setProperty('pointer-events', 'none', 'important');
15627
+ anchor.style.setProperty('z-index', '3', 'important');
15628
+ }
15305
15629
  }
15306
15630
  function release(trigger) {
15307
15631
  trigger.removeAttribute(TAKEN);
15308
15632
  trigger.style.cssText = '';
15633
+ const anchor = trigger.parentElement;
15634
+ if (anchor !== null && anchor.hasAttribute(TAKEN)) {
15635
+ anchor.removeAttribute(TAKEN);
15636
+ anchor.style.cssText = '';
15637
+ }
15309
15638
  }
15310
15639
  function installNativeTriggerOverlay(ctx) {
15311
15640
  ctx.effect(() => {
@@ -17593,6 +17922,11 @@ function SettingsSectionPage({ config, shares, t }) {
17593
17922
  setClaimBusy(true);
17594
17923
  setClaimFail(null);
17595
17924
  setClaimWriteFailed(false);
17925
+ // UX-1: captured BEFORE the pairing — whether the page ran as client, and
17926
+ // whether the backend's stream interceptor was already active per the
17927
+ // last client/status diagnostics.
17928
+ const wasEffectiveClient = clientRole;
17929
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true;
17596
17930
  // T60: one name for both halves of the round-trip — trimmed and legal
17597
17931
  // ({@link claimDeviceNameOf}), sent to the server AND written into the
17598
17932
  // row when the claim lands.
@@ -17625,6 +17959,20 @@ function SettingsSectionPage({ config, shares, t }) {
17625
17959
  // could not store falls back to the name we actually sent.
17626
17960
  const echoed = typeof body.deviceName === 'string' && body.deviceName !== '' ? (0, settings_form_ts_1.claimDeviceNameOf)(body.deviceName, claimName) : claimName;
17627
17961
  const landed = await config.writeClientPairing(urlToWrite, body.token, echoed);
17962
+ if (landed && (0, settings_form_ts_1.shouldReloadAfterClientActivation)({
17963
+ wasEffectiveClient,
17964
+ nowClientRole: true,
17965
+ savedOrPaired: true,
17966
+ streamInterceptAlreadyActive,
17967
+ })) {
17968
+ // UX-1: FIRST pairing on a page whose resident global streams were
17969
+ // opened unwrapped (or with the interceptor inactive) — wait out the
17970
+ // backend's row-reload window, then reload so the streams reopen
17971
+ // wrapped and the server's sessions enter the sidebar.
17972
+ setClaimBusy(false);
17973
+ void (0, settings_form_ts_1.awaitClientStatusReady)().then(() => window.location.reload());
17974
+ return;
17975
+ }
17628
17976
  if (landed) {
17629
17977
  // The draft did its job — it now equals the displayed value, and
17630
17978
  // keeping it would be a phantom edit (T60).
@@ -17753,8 +18101,29 @@ function SettingsSectionPage({ config, shares, t }) {
17753
18101
  ? t('settings.statusUnknown')
17754
18102
  : 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
18103
  const reloadsRow = saveReloadsRow();
17756
- void config.save().then((landed) => { if (landed)
17757
- saveLanded(reloadsRow); });
18104
+ // UX-1: captured BEFORE the save — whether the page ran as client,
18105
+ // and whether the backend's stream interceptor was already active
18106
+ // per the last client/status diagnostics.
18107
+ const wasEffectiveClient = clientRole;
18108
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true;
18109
+ void config.save().then((landed) => {
18110
+ if (!landed)
18111
+ return;
18112
+ if ((0, settings_form_ts_1.shouldReloadAfterClientActivation)({
18113
+ wasEffectiveClient,
18114
+ nowClientRole: config.savedRoleIsClient(),
18115
+ savedOrPaired: true,
18116
+ streamInterceptAlreadyActive,
18117
+ })) {
18118
+ // UX-1: the save first-activated the client interceptor while
18119
+ // this page's resident global streams were opened unwrapped —
18120
+ // wait out the row-reload window, then reload so the streams
18121
+ // reopen wrapped and the server's sessions enter the sidebar.
18122
+ void (0, settings_form_ts_1.awaitClientStatusReady)().then(() => window.location.reload());
18123
+ return;
18124
+ }
18125
+ saveLanded(reloadsRow);
18126
+ });
17758
18127
  }, 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
18128
  let current = {};
17760
18129
  try {
@@ -19620,6 +19989,12 @@ function GatewayWizard(props) {
19620
19989
  const runClientClaim = async () => {
19621
19990
  setFormError(null);
19622
19991
  setClientClaimFail(null);
19992
+ // UX-1: the facts that decide the post-pairing reload — whether the page
19993
+ // opened as an already-effective client, and whether the backend's
19994
+ // stream interceptor was already active (from the last client/status
19995
+ // diagnostics). Captured BEFORE the role switch and the claim below.
19996
+ const wasEffectiveClient = effectiveClient;
19997
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true;
19623
19998
  const trimmedUrl = clientServerUrl.trim();
19624
19999
  const normalizedCode = (0, settings_form_ts_1.normalizePairingCode)(clientPairCode);
19625
20000
  if (trimmedUrl === '' || !/^https?:\/\//i.test(trimmedUrl)) {
@@ -19661,7 +20036,21 @@ function GatewayWizard(props) {
19661
20036
  if (body?.ok === true && typeof body.token === 'string' && body.token !== '') {
19662
20037
  const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl;
19663
20038
  const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName;
19664
- await config.writeClientPairing(urlToWrite, body.token, echoed);
20039
+ const landed = await config.writeClientPairing(urlToWrite, body.token, echoed);
20040
+ if (landed && (0, settings_form_ts_1.shouldReloadAfterClientActivation)({
20041
+ wasEffectiveClient,
20042
+ nowClientRole: true,
20043
+ savedOrPaired: true,
20044
+ streamInterceptAlreadyActive,
20045
+ })) {
20046
+ // UX-1: this was the FIRST client activation while the page's three
20047
+ // resident global streams were opened unwrapped — wait out the
20048
+ // backend's row-reload window, then reload so the streams reopen
20049
+ // wrapped and the server's shared sessions enter the sidebar.
20050
+ await (0, settings_form_ts_1.awaitClientStatusReady)();
20051
+ window.location.reload();
20052
+ return;
20053
+ }
19665
20054
  setClientPairCode('');
19666
20055
  await loadClientStatus();
19667
20056
  setStep('verify');