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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-zen-remote",
3
- "version": "2.3.8",
3
+ "version": "2.3.10",
4
4
  "description": "一个插件、两种角色:主服务端把 DeepSeek Harness 变成手机能安全访问的 PWA(移动端界面、配对码网关、Web Push),并把选定会话共享给已配对的桌面端;子客户端在另一台 DSH 桌面端的侧边栏里远程使用这些会话。One DSH plugin, two roles: the host turns DeepSeek Harness into a phone-reachable PWA (mobile UI, pairing-code gateway behind your own reverse proxy, Web Push) and shares chosen sessions to paired desktops; the client uses those sessions remotely from another DSH desktop app's sidebar.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,8 +18,11 @@
18
18
  import assert from 'node:assert/strict'
19
19
  import {
20
20
  CLIENT_RECONNECT_ROUTE,
21
+ CLIENT_STATUS_ROUTE,
22
+ awaitClientStatusReady,
21
23
  claimDeviceNameOf,
22
24
  shouldFollowServerDeviceName,
25
+ shouldReloadAfterClientActivation,
23
26
  createLatestGate,
24
27
  clientStatusLineOf,
25
28
  deriveClientStatusView,
@@ -935,3 +938,63 @@ test('T59: hasDraft tracks the staged map; writeDeviceName lands one serverName
935
938
  assert.deepEqual(state.mutateCalls.at(-1).ops, [{ op: 'set', path: ['serverName'], value: '服务端的名' }])
936
939
  assert.equal(state.mutateCalls.at(-1).expectedRevision, 8, 'fenced like every direct write')
937
940
  })
941
+
942
+ // ---- UX-1: the reload-after-first-client-activation decision -----------------
943
+
944
+ test('shouldReloadAfterClientActivation truth table: nothing saved or not client never reloads', () => {
945
+ // The write did not land — nothing changed, no reload ever.
946
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: false, nowClientRole: true, savedOrPaired: false }), false)
947
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: true, nowClientRole: true, savedOrPaired: false }), false)
948
+ // The role is not (or not becoming) client — no reload even though it saved.
949
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: false, nowClientRole: false, savedOrPaired: true }), false)
950
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: true, nowClientRole: false, savedOrPaired: true }), false)
951
+ })
952
+
953
+ test('shouldReloadAfterClientActivation: a host→client switch always reloads', () => {
954
+ // The page opened while 'host': its three global streams are unwrapped, so
955
+ // the switch must reload no matter what the (absent) diagnostics said.
956
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: false, nowClientRole: true, savedOrPaired: true }), true)
957
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: false, nowClientRole: true, savedOrPaired: true, streamInterceptAlreadyActive: false }), true)
958
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: false, nowClientRole: true, savedOrPaired: true, streamInterceptAlreadyActive: true }), true, 'a stale positive flag cannot beat the switch')
959
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: false, nowClientRole: true, savedOrPaired: true, streamInterceptAlreadyActive: undefined }), true, 'unknown diagnostics still reloads on a role switch')
960
+ })
961
+
962
+ test('shouldReloadAfterClientActivation: already client reloads only when the intercept was NOT active', () => {
963
+ // First pairing completed on an already-client page whose backend
964
+ // interceptor had not installed yet (openWireStream never wrapped) — reload.
965
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: true, nowClientRole: true, savedOrPaired: true, streamInterceptAlreadyActive: false }), true)
966
+ // Re-pairing / renaming on an already-active client — NO reload (the
967
+ // restart-watcher rewraps in place, the open streams keep working).
968
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: true, nowClientRole: true, savedOrPaired: true, streamInterceptAlreadyActive: true }), false)
969
+ // Unknown diagnostics (an older server without the intercept field) never
970
+ // reloads by itself — the flag must be an explicit false.
971
+ assert.equal(shouldReloadAfterClientActivation({ wasEffectiveClient: true, nowClientRole: true, savedOrPaired: true, streamInterceptAlreadyActive: undefined }), false)
972
+ })
973
+
974
+ test('CLIENT_STATUS_ROUTE is the poll target for the post-activation wait', () => {
975
+ assert.equal(CLIENT_STATUS_ROUTE, '/_dsh/zen-remote/client/status')
976
+ })
977
+
978
+ test('awaitClientStatusReady resolves on the first ok answer', async () => {
979
+ let calls = 0
980
+ const fetchFn = async () => { calls += 1; return { ok: true } }
981
+ await awaitClientStatusReady(fetchFn, 5_000, 10)
982
+ assert.equal(calls, 1, 'one ok answer ends the wait immediately')
983
+ })
984
+
985
+ test('awaitClientStatusReady keeps polling until an ok answer arrives', async () => {
986
+ let calls = 0
987
+ const fetchFn = async () => { calls += 1; return calls < 3 ? { ok: false } : { ok: true } }
988
+ await awaitClientStatusReady(fetchFn, 5_000, 5)
989
+ assert.equal(calls, 3, 'polls through the not-ready answers')
990
+ })
991
+
992
+ test('awaitClientStatusReady gives up after the deadline and still resolves', async () => {
993
+ let calls = 0
994
+ const fetchFn = async () => { calls += 1; throw new Error('row reloading') }
995
+ const started = Date.now()
996
+ await assert.doesNotReject(awaitClientStatusReady(fetchFn, 60, 10))
997
+ const elapsed = Date.now() - started
998
+ assert.ok(elapsed >= 50, `waited out the deadline (elapsed ${elapsed}ms)`)
999
+ assert.ok(calls >= 2, `polled more than once before giving up (${calls} calls)`)
1000
+ })
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The dsh-better-sidebar adapter (PB-12), written against EXACTLY
3
- * versions 0.25.0, 0.26.0, 0.26.1, 0.28.0-alpha.0, 0.28.0, 0.28.1 and 0.28.2 (PB-15 widened the list; 0.26.1 is a
3
+ * versions 0.25.0, 0.26.0, 0.26.1, 0.28.0-alpha.0, 0.28.0, 0.28.1, 0.28.2 and 0.30.0 (PB-15 widened the list; 0.26.1 is a
4
4
  * perf-only patch with the same wire surface as 0.26.0; 0.26.0 is a JSON-api
5
5
  * superset of 0.25.0 — it grows fs.readPage and the sidechat dequeue/model/
6
6
  * models methods, and moves fs.write's content onto the new raw-text
@@ -42,6 +42,15 @@ import { SIDEBAR_METHODS, sidebarMethodNameOf } from './methods.js'
42
42
  * backend would accept anyway. */
43
43
  const SIDEBAR_MAX_BODY_BYTES = 1024 * 1024
44
44
 
45
+ /** 0.30.0: a `sidechat.prompt` carrying images reads its body under
46
+ * `sidechatPromptBodyLimit` (lib/index.js:7004) — 1 MiB of metadata plus the
47
+ * base64 of at most SIDECHAT_MAX_MESSAGE_IMAGE_BYTES = 20 MiB of images and
48
+ * 4 bytes per image (≤ 8). Every other method keeps the plugin's own 1 MiB
49
+ * (`readJsonBody`'s default), so the route cap only has to admit this one
50
+ * ceiling; the backend still enforces the per-method limit itself. */
51
+ const SIDEBAR_IMAGE_PROMPT_MAX_BODY_BYTES =
52
+ SIDEBAR_MAX_BODY_BYTES + 4 * Math.ceil((20 * 1024 * 1024) / 3) + 4 * 8
53
+
45
54
  /** The method name out of a `/sidebar/api/*` pathname, or `''`. */
46
55
  const apiMethodOf = (pathname: string): string => sidebarMethodNameOf(pathname) ?? ''
47
56
 
@@ -70,7 +79,13 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
70
79
  // full-log `sessionQuery.observeSession`, then the host defaultDirectory,
71
80
  // and only then a client `cwd` hint — so the server hook's cwd stripping
72
81
  // below stays correct (if anything, the client hint matters less).
73
- versions: ['0.25.0', '0.26.0', '0.26.1', '0.28.0-alpha.0', '0.28.0', '0.28.1', '0.28.2'],
82
+ // 0.30.0 (DSH 0.2.2-rc.1+): same JSON-api method set and WebSockets; one
83
+ // new EXACT GET route, `/sidebar/sidechat-image?childId&messageId&blockIndex`
84
+ // (lib/index.js:9869-9896), which streams a saved side-chat image — bridged
85
+ // below on its `childId`. `sidechat.prompt` grows an `images` array (base64,
86
+ // up to ~27.7 MiB body) — the api route cap widens to that. GenUI, quote
87
+ // chips and editor draft backup are client-only (no new host routes).
88
+ versions: ['0.25.0', '0.26.0', '0.26.1', '0.28.0-alpha.0', '0.28.0', '0.28.1', '0.28.2', '0.30.0'],
74
89
  // PB-19 (ADR-0003): the wire-surface whitelist. Keys are digest16 over the
75
90
  // PB-18 extractor's reduction (`surface.ts` beside this file) of the npm
76
91
  // builds' lib/index.js, computed off /tmp/bs-versions/{version}/lib/index.js:
@@ -78,6 +93,7 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
78
93
  // 96465143373a31a7 — the 0.26.0 generation (0.26.1 hashes identically)
79
94
  // 6ed29a896101e681 — the 0.28.0 generation (0.28.0-alpha.0 identically)
80
95
  // 65a0acaeb56f87d9 — the 0.28.1 generation (0.28.2 identically)
96
+ // 394de9f588d37092 — the 0.30.0 generation (+ /sidebar/sidechat-image)
81
97
  // An installed version outside `versions` still registers when its build
82
98
  // hashes into one of these (a wire-identical patch release bridges without
83
99
  // a zen-remote release); anything else fails closed to the unlisted-version
@@ -88,6 +104,7 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
88
104
  '96465143373a31a7': 'the 0.26.0 generation (0.26.1 identical)',
89
105
  '6ed29a896101e681': 'the 0.28.0 generation (0.28.0-alpha.0 identical)',
90
106
  '65a0acaeb56f87d9': 'the 0.28.1 generation (0.28.2 identical)',
107
+ '394de9f588d37092': 'the 0.30.0 generation',
91
108
  },
92
109
  routes: [
93
110
  {
@@ -97,7 +114,7 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
97
114
  path: '/sidebar/api',
98
115
  match: 'prefix',
99
116
  methods: ['POST'],
100
- maxBody: SIDEBAR_MAX_BODY_BYTES,
117
+ maxBody: SIDEBAR_IMAGE_PROMPT_MAX_BODY_BYTES,
101
118
  locator: { in: 'json-body', fields: ['sessionId', 'rootSessionId', 'childId'] },
102
119
  deny: ({ pathname, currentSessionId }) => {
103
120
  const name = apiMethodOf(pathname)
@@ -121,6 +138,9 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
121
138
  // claims the EXACT documented path — a sub-path (if a future version
122
139
  // grows one) stays local rather than being guessed at.
123
140
  { path: '/sidebar/file', match: 'exact', methods: ['GET'], locator: { in: 'query', name: 'sessionId' } },
141
+ // 0.30.0: a saved side-chat image (lib/index.js:9869-9896) — the child
142
+ // session id rides the query; the answer is raw image bytes with no ids.
143
+ { path: '/sidebar/sidechat-image', match: 'exact', methods: ['GET'], locator: { in: 'query', name: 'childId' } },
124
144
  // The archive download (lib/index.js:6949-6985).
125
145
  { path: '/sidebar/archive', match: 'exact', methods: ['GET'], locator: { in: 'query', name: 'sessionId' } },
126
146
  // The raw upload (lib/index.js:6905-6948): binary bytes, capped by the
@@ -21,6 +21,7 @@ import type { createNavStore } from './nav-store.ts'
21
21
  import type { WorkspaceFilter } from './nav-store.ts'
22
22
  import type { MobileNavKey } from './locales.ts'
23
23
  import { dotState } from './session-dot.ts'
24
+ import { decideNewSessionAction } from '../client-data/new-session.ts'
24
25
  import { MobileHomeChips, MobileHomeChipsSheetBody } from './MobileHomeChips.tsx'
25
26
 
26
27
  /** Full props for the phone home screen (shell.overlay entry). */
@@ -280,7 +281,7 @@ export function MobileHome({
280
281
  // list re-renders on any session change anyway (the running dots live here).
281
282
  const sessions = useSessions((s) => s)
282
283
  const workspaces = useWorkspaces((s) => s)
283
- const [sheet, setSheet] = useState<'filter' | 'chips' | null>(null)
284
+ const [sheet, setSheet] = useState<'filter' | 'chips' | 'new-session' | null>(null)
284
285
  /** The one row whose archive action is swiped open (null = none). */
285
286
  const [swipeOpen, setSwipeOpen] = useState<SessionId | null>(null)
286
287
  // 0.1.2 的待处理交互来源;0.1.1 没有这个标准 prop,读到 undefined 时用一个
@@ -382,7 +383,25 @@ export function MobileHome({
382
383
  return
383
384
  }
384
385
  frame.setAttribute('data-mobile-view', view)
385
- return () => frame.removeAttribute('data-mobile-view')
386
+ // DSH 0.2.2-rc.1+ puts `inert=""` on `[class$="_sidebarCol"]` while
387
+ // `drawer && sidebarCollapsed` (< 768px). Because home.css.ts already
388
+ // hides sidebarCol with `display: none !important`, `inert` is redundant
389
+ // for accessibility/tab order, but per HTML spec an `inert` ancestor
390
+ // suppresses synthetic `.click()` (breaking openSettings and harvested
391
+ // sidebar-footer chips) and blocks pointer events inside non-portal
392
+ // sidebar dialogs. Strip it and keep it stripped while the phone shell
393
+ // owns the viewport.
394
+ const sidebarCol = frame.querySelector<HTMLElement>(':scope > [class$="_sidebarCol"]')
395
+ const clearInert = (): void => {
396
+ if (sidebarCol?.hasAttribute('inert')) sidebarCol.removeAttribute('inert')
397
+ }
398
+ clearInert()
399
+ const observer = sidebarCol ? new MutationObserver(clearInert) : null
400
+ observer?.observe(sidebarCol!, { attributes: true, attributeFilter: ['inert'] })
401
+ return () => {
402
+ observer?.disconnect()
403
+ frame.removeAttribute('data-mobile-view')
404
+ }
386
405
  }, [phone, view])
387
406
 
388
407
  const enter = (start: () => void): void => {
@@ -560,7 +579,19 @@ export function MobileHome({
560
579
  data-mobile-nav="home-fab"
561
580
  aria-label={t('newSession')}
562
581
  title={t('newSession')}
563
- onClick={() => enter(() => startSession(selectedWorkspace?.workspaceId))}
582
+ onClick={() => {
583
+ // UX-3: never enter a dangling no-workspace session. With "all"
584
+ // selected (the common case — the host's own sessions belong to no
585
+ // workspace) the decision either auto-starts the only workspace or
586
+ // opens the workspace drawer first; the session then starts with a
587
+ // workspace attached from the first frame.
588
+ const action = decideNewSessionAction(
589
+ selectedWorkspace?.workspaceId,
590
+ workspaces.items.map((item) => item.workspaceId),
591
+ )
592
+ if (action.kind === 'start') enter(() => startSession(action.workspaceId))
593
+ else setSheet('new-session')
594
+ }}
564
595
  >
565
596
  <IconPlusOutlineRegular size={18} />
566
597
  <span>{t('newSession')}</span>
@@ -575,13 +606,15 @@ export function MobileHome({
575
606
  aria-label={t('close')}
576
607
  onClick={() => setSheet(null)}
577
608
  />
578
- {/* Two sheets share this one container (chrome + S6 drag-to-close
609
+ {/* Three sheets share this one container (chrome + S6 drag-to-close
579
610
  * + mask-click-close, all keyed off data-mobile-nav="home-sheet"
580
611
  * generically — effects/gestures.ts): the workspace switcher
581
- * (role="menu", unchanged) and the S5 chip-customize toggle list
582
- * (MobileHomeChipsSheetBody, no menu semantics — its rows are
583
- * switches, not menu items that close the sheet on click). */}
584
- <div data-mobile-nav="home-sheet" role={sheet === 'filter' ? 'menu' : undefined}>
612
+ * (role="menu", unchanged), the UX-3 new-session workspace pick
613
+ * (role="menu" — its rows navigate and close), and the S5
614
+ * chip-customize toggle list (MobileHomeChipsSheetBody, no menu
615
+ * semantics — its rows are switches, not menu items that close
616
+ * the sheet on click). */}
617
+ <div data-mobile-nav="home-sheet" role={sheet === 'chips' ? undefined : 'menu'}>
585
618
  {sheet === 'filter' && (
586
619
  <>
587
620
  <div data-mobile-nav="home-sheet-title">{t('switchWorkspace')}</div>
@@ -614,6 +647,28 @@ export function MobileHome({
614
647
  ))}
615
648
  </>
616
649
  )}
650
+ {sheet === 'new-session' && (
651
+ <>
652
+ <div data-mobile-nav="home-sheet-title">{t('pickWorkspaceNew')}</div>
653
+ {workspaces.items.map((item) => (
654
+ <button
655
+ key={item.workspaceId}
656
+ type="button"
657
+ role="menuitem"
658
+ data-mobile-nav="home-sheet-item"
659
+ onClick={() => {
660
+ // Pin the list to the picked workspace (so backing out
661
+ // of the blank session lands on ITS list) and start the
662
+ // session with that workspace from the first frame.
663
+ actions.filter(item.workspaceId)
664
+ enter(() => startSession(item.workspaceId))
665
+ }}
666
+ >
667
+ {item.title}
668
+ </button>
669
+ ))}
670
+ </>
671
+ )}
617
672
  {sheet === 'chips' && <MobileHomeChipsSheetBody t={t} />}
618
673
  </div>
619
674
  </div>
@@ -128,7 +128,9 @@ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
128
128
  if (document.querySelector('[aria-modal="true"]') !== null) return
129
129
  const target = event.target as HTMLElement | null
130
130
  if (target === null) return
131
- const drawer = document.querySelector<HTMLElement>('[data-mobile-nav="frame"] > :first-child')
131
+ const drawer = document.querySelector<HTMLElement>(
132
+ '[data-mobile-nav="frame"] > [class$="_sidebarCol"], [data-mobile-nav="frame"] > :first-child:not([data-sidebar-mask])',
133
+ )
132
134
  if (drawer === null || !drawer.contains(target)) return
133
135
  // A session row's own action buttons — the "Session actions" kebab
134
136
  // (delete / rename), revealed on hover / long-press — open an edit
@@ -74,7 +74,13 @@ type ActivityState = 'running' | 'done' | 'warning'
74
74
  * the root through `:has()` instead of naming it.
75
75
  */
76
76
  const HEADER = '[data-phase] header'
77
- const SUBAGENT_TRIGGER = `${HEADER} button[aria-haspopup="tree"]`
77
+ /* UX-2 (2026-11-04): narrowed by the action-button class tail. The bare
78
+ `button[aria-haspopup="tree"]` matched the breadcrumb's parent-session
79
+ switcher first (`…_switcherTrigger` — also a tree popup), so both the
80
+ overlay and this fallback check resolved to the wrong button. The suffix
81
+ matcher is case-sensitive and `_switcherTrigger` does not end in
82
+ `_trigger`. Must stay in lockstep with effects/native-trigger-overlay.ts. */
83
+ const SUBAGENT_TRIGGER = `${HEADER} button[class$="_trigger"][aria-haspopup="tree"]`
78
84
  const JOBS_TRIGGER = `${HEADER} button[class$="_trigger"]:not([aria-haspopup])`
79
85
 
80
86
  /** One glanceable group: icon + count + state dot; opens the official popover. */
@@ -26,38 +26,77 @@ import type { ClientContext } from '../compat/types.ts'
26
26
  const PHONE = '(max-width: 767px)'
27
27
  const HEADER = '[data-phase] header'
28
28
 
29
- /** Pill → the official trigger it fronts. Same ARIA split the pills use. */
29
+ /**
30
+ * Pill → the official trigger it fronts. Same ARIA split the pills use,
31
+ * narrowed by the action-button class tail (UX-2, 2026-11-04): the bare
32
+ * `button[aria-haspopup="tree"]` matched the breadcrumb's parent-session
33
+ * switcher first (`…_switcherTrigger` — also a tree popup, earlier in
34
+ * document order), so the pill got the wrong button laid over it. The
35
+ * suffix matcher is case-sensitive and `_switcherTrigger` does not end in
36
+ * `_trigger`, so the catalog trigger is the only match left.
37
+ */
30
38
  const PAIRS: ReadonlyArray<{ pill: string, trigger: string }> = [
31
- { pill: '[data-activity-kind="subagent"]', trigger: `${HEADER} button[aria-haspopup="tree"]` },
39
+ { pill: '[data-activity-kind="subagent"]', trigger: `${HEADER} button[class$="_trigger"][aria-haspopup="tree"]` },
32
40
  { pill: '[data-activity-kind="job"]', trigger: `${HEADER} button[class$="_trigger"]:not([aria-haspopup])` },
33
41
  ]
34
42
 
35
- /** Marks a trigger we have taken over, so cleanup can find it again. */
43
+ /** Marks a trigger (and its anchor container) we have taken over, so cleanup can find them again. */
36
44
  const TAKEN = 'data-zen-overlay'
37
45
 
46
+ /** The shared fixed-viewport box: exactly what the portalled popover measures against. */
47
+ function pin(el: HTMLElement, r: { left: number, top: number, width: number, height: number }, display?: string): void {
48
+ el.style.setProperty('position', 'fixed', 'important')
49
+ el.style.setProperty('left', `${r.left}px`, 'important')
50
+ el.style.setProperty('top', `${r.top}px`, 'important')
51
+ el.style.setProperty('width', `${r.width}px`, 'important')
52
+ el.style.setProperty('height', `${r.height}px`, 'important')
53
+ el.style.setProperty('margin', '0', 'important')
54
+ el.style.setProperty('padding', '0', 'important')
55
+ el.style.setProperty('border', '0', 'important')
56
+ if (display !== undefined) el.style.setProperty('display', display, 'important')
57
+ }
58
+
38
59
  function place(trigger: HTMLElement, pill: Element): void {
39
60
  const r = pill.getBoundingClientRect()
40
61
  if (r.width === 0 || r.height === 0) return
41
62
  trigger.setAttribute(TAKEN, '')
63
+ pin(trigger, r)
42
64
  // Fixed, not absolute: the header's containing block is not the pill's, and
43
65
  // fixed rects are exactly what the portalled popover measures against.
44
- trigger.style.setProperty('position', 'fixed', 'important')
45
- trigger.style.setProperty('left', `${r.left}px`, 'important')
46
- trigger.style.setProperty('top', `${r.top}px`, 'important')
47
- trigger.style.setProperty('width', `${r.width}px`, 'important')
48
- trigger.style.setProperty('height', `${r.height}px`, 'important')
49
- trigger.style.setProperty('margin', '0', 'important')
50
- trigger.style.setProperty('padding', '0', 'important')
51
- trigger.style.setProperty('border', '0', 'important')
52
66
  trigger.style.setProperty('opacity', '0', 'important')
53
67
  trigger.style.setProperty('overflow', 'hidden', 'important')
54
68
  trigger.style.setProperty('pointer-events', 'auto', 'important')
55
69
  trigger.style.setProperty('z-index', '3', 'important')
70
+ // The official popover (useAnchoredPosition) sizes itself from the OUTER
71
+ // anchor container — the trigger's parent div, the official rootRef — not
72
+ // the button. header.css.ts dissolves that container with
73
+ // display:contents, so its rect was all zeros and the panel landed at
74
+ // 12,12 inside the status bar (UX-2). Give the anchor the pill's exact
75
+ // box: the inline display beats the stylesheet's display:contents
76
+ // !important (the style attribute outranks selectors at author-important),
77
+ // so rootRef.getBoundingClientRect() finally sees the pill. pointer-events
78
+ // none keeps the invisible box from ever stealing a tap — the trigger
79
+ // child above it is the tappable one. z-index 3 is required on the anchor
80
+ // too: position:fixed unconditionally creates a stacking context (even at
81
+ // z-index:auto, stack level 0), which otherwise traps the trigger's own
82
+ // z-index:3 underneath [data-mobile-nav="header-activity"] (z-index:1).
83
+ const anchor = trigger.parentElement
84
+ if (anchor !== null && anchor.closest(HEADER) !== null) {
85
+ anchor.setAttribute(TAKEN, '')
86
+ pin(anchor, r, 'block')
87
+ anchor.style.setProperty('pointer-events', 'none', 'important')
88
+ anchor.style.setProperty('z-index', '3', 'important')
89
+ }
56
90
  }
57
91
 
58
92
  function release(trigger: HTMLElement): void {
59
93
  trigger.removeAttribute(TAKEN)
60
94
  trigger.style.cssText = ''
95
+ const anchor = trigger.parentElement
96
+ if (anchor !== null && anchor.hasAttribute(TAKEN)) {
97
+ anchor.removeAttribute(TAKEN)
98
+ anchor.style.cssText = ''
99
+ }
61
100
  }
62
101
 
63
102
  export function installNativeTriggerOverlay(ctx: ClientContext): void {