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
@@ -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 {
@@ -10,7 +10,20 @@ const PHONE_QUERY = '(max-width: 767px)'
10
10
  * NO aria-controls on 0.2.0 (the Menu primitive portals its list to body
11
11
  * and links nothing back), so the click is the only open signal there is.
12
12
  */
13
- const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]'
13
+ const PERM_TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]'
14
+
15
+ /**
16
+ * UX-3: the HERO page's two workspace triggers (WorkspaceSheet slot impl,
17
+ * verified in the dsh-client-ui-conversation bundle 2026-11-05):
18
+ * - `WorkspaceChip` — a bare `button` whose hashed class ends in
19
+ * `_workspace`, carrying `aria-haspopup="menu"` + `aria-expanded`;
20
+ * - the placeholder card — when a blank hero session has no workspace yet
21
+ * the whole `div[data-composer-card]` carries `cardWorkspaceTrigger` and
22
+ * its click opens the same menu (`onRequestWorkspace`).
23
+ * Both open a `body > div[role="menu"]` of `button[role="menuitem"]` items.
24
+ */
25
+ const WORKSPACE_TRIGGER_SELECTOR =
26
+ '[class$="_workspace"][aria-haspopup="menu"], [data-composer-card][class*="_cardWorkspaceTrigger"]'
14
27
 
15
28
  /**
16
29
  * What the Menu primitive portals on 0.2.0: a `role=menu` div DIRECTLY under
@@ -20,23 +33,46 @@ const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[cla
20
33
  * host has MANY `portal: true` Menus (agent-preset, chat preference rows,
21
34
  * the Enter-behavior row, the workspace sidebar's 「…」, open-in-app,
22
35
  * deliverables, the copy button's confirm, …), and none of them may become
23
- * a bottom sheet. That is why this effect exists: a click on the permission
24
- * trigger arms a short window and the next body-level menu to land is OURS
25
- * to mark — while a marked menu still on the page (the close click) keeps
26
- * the window shut.
36
+ * a bottom sheet. That is why this effect exists: a click on a sheet trigger
37
+ * arms a short window and the next body-level menu to land is OURS to mark —
38
+ * while a marked menu still on the page (the close click) keeps the window
39
+ * shut.
27
40
  */
28
41
  const PORTAL_MENU_SELECTOR = 'body > div[role="menu"]'
29
42
 
30
43
  /** The marker composer.css.ts section 4 styles into the bottom sheet. */
31
44
  const SHEET_MARKER = 'data-zen-sheet'
32
- const SHEET_VALUE = 'perm'
45
+ const SHEET_VALUE_PERM = 'perm'
46
+ const SHEET_VALUE_WORKSPACE = 'workspace'
47
+
48
+ /**
49
+ * UX-3: the switch lock. Picking a workspace in the hero sheet runs
50
+ * `selectWorkspace` → `POST /api/session/create` → `replaceMain` — the
51
+ * conversation unmounts and remounts (`useMaybeIncarnation` epoch+1) and the
52
+ * composer briefly still belongs to the OLD blank session. A message sent in
53
+ * that window lands on the old session and is then wiped by the remount,
54
+ * bouncing the UI back to the "choose a workspace" placeholder. While the
55
+ * `data-zen-ws-switching` attribute sits on `<html>`, composer.css dead-zones
56
+ * and dims the composer bar; the lock releases the moment the conversation
57
+ * root's `data-conversation-session` has MOVED and the placeholder card is
58
+ * gone — with a hard cap so a failed/no-op switch cannot freeze the input.
59
+ */
60
+ const SWITCH_MARKER = 'data-zen-ws-switching'
61
+ const SWITCH_LOCK_MS = 1_500
62
+ const SESSION_ATTR = 'data-conversation-session'
63
+ const SESSION_SELECTOR = `[${SESSION_ATTR}]`
64
+ /** The hero's inert "choose a workspace" state: the whole input card IS the trigger. */
65
+ const PLACEHOLDER_CARD_SELECTOR = '[data-composer-card][class*="_cardWorkspaceTrigger"]'
66
+ /** Spelled literally (not interpolated) — the test pins this exact text as the perm/workspace discriminator. */
67
+ const WS_MENUITEM_SELECTOR =
68
+ 'body > div[role="menu"][data-zen-sheet="workspace"] button[role="menuitem"]'
33
69
 
34
- /** The DOM face the two pure decisions below read — structural, so the
35
- * tests drive them with plain objects instead of a DOM. */
70
+ /** The DOM face the pure decisions below read — structural, so the tests
71
+ * drive them with plain objects instead of a DOM. */
36
72
  export interface PermissionSheetCandidate {
37
73
  matches(selectors: string): boolean
38
74
  getAttribute(name: string): string | null
39
- /** A truthy `closest` result means the click started in the seat. */
75
+ /** A truthy `closest` result means the click started inside the seat. */
40
76
  closest(selectors: string): unknown
41
77
  }
42
78
 
@@ -49,16 +85,63 @@ export function isUnmarkedPortalMenu(node: PermissionSheetCandidate): boolean {
49
85
  return node.matches(PORTAL_MENU_SELECTOR) && node.getAttribute(SHEET_MARKER) === null
50
86
  }
51
87
 
88
+ /** Which bottom sheet a trigger click arms. */
89
+ export type SheetKind = 'perm' | 'workspace'
90
+
91
+ const inSeat = (seat: unknown): boolean => seat !== undefined && seat !== null
92
+
93
+ /**
94
+ * Which sheet ONE click event's target arms: the phone shell only. The
95
+ * permission seat's trigger arms `perm` (T70); the hero page's two
96
+ * workspace triggers arm `workspace` (UX-3). Any other button — the
97
+ * sidebar's 「…」, a copy button — arms nothing, so the menus they open are
98
+ * never marked.
99
+ */
100
+ export function sheetKindForTrigger(target: PermissionSheetCandidate | null | undefined, phoneMatches: boolean): SheetKind | null {
101
+ if (!phoneMatches) return null
102
+ if (inSeat(target?.closest(PERM_TRIGGER_SELECTOR))) return 'perm'
103
+ if (inSeat(target?.closest(WORKSPACE_TRIGGER_SELECTOR))) return 'workspace'
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * UX-3: whether a pointerdown landed inside a WORKSPACE trigger — the only
109
+ * triggers whose open/close suffers the outside-pointerdown + toggle-click
110
+ * race (the Menu closes the portaled sheet on the outside pointerdown, and
111
+ * the trigger's own click would flip it straight back open). The permission
112
+ * trigger toggles by click alone and is never suppressed.
113
+ */
114
+ export function pointerdownOnWorkspaceTrigger(target: PermissionSheetCandidate | null | undefined): boolean {
115
+ return inSeat(target?.closest(WORKSPACE_TRIGGER_SELECTOR))
116
+ }
117
+
118
+ /**
119
+ * UX-3: whether a click started on a workspace item inside the MARKED
120
+ * workspace sheet — the pick that starts the switch lock. Keyed on the
121
+ * `workspace` marker value, so a permission option can never lock the
122
+ * composer.
123
+ */
124
+ export function menuitemClickStartsWorkspaceSwitch(target: PermissionSheetCandidate | null | undefined): boolean {
125
+ return inSeat(target?.closest(WS_MENUITEM_SELECTOR))
126
+ }
127
+
128
+ /** What the release check reads, injected so the tests stay DOM-free. */
129
+ export interface WorkspaceSwitchProbe {
130
+ /** The conversation root's current `data-conversation-session` value (null = none mounted). */
131
+ currentSessionValue(): string | null
132
+ /** Whether the hero's "choose a workspace" placeholder card is mounted. */
133
+ placeholderOpen(): boolean
134
+ }
135
+
52
136
  /**
53
- * Whether one click event's target arms the marking window: the phone shell
54
- * only, and only a click that started inside the permission seat's trigger.
55
- * Any other button — the sidebar's 「…」, a copy button — never arms, so
56
- * the menus they open are never marked.
137
+ * Whether the workspace switch has settled: the conversation root now names
138
+ * a DIFFERENT session than the one the lock was taken on, and the no-workspace
139
+ * placeholder card is gone (the optimistic `pendingWorkspaceId` display alone
140
+ * is not enough — the remount resets it).
57
141
  */
58
- export function clickArmsPermissionSheet(target: PermissionSheetCandidate | null | undefined, phoneMatches: boolean): boolean {
59
- if (!phoneMatches) return false
60
- const seat = target?.closest(TRIGGER_SELECTOR)
61
- return seat !== undefined && seat !== null
142
+ export function workspaceSwitchSettled(probe: WorkspaceSwitchProbe, lockedSessionValue: string | null): boolean {
143
+ if (probe.currentSessionValue() === lockedSessionValue) return false
144
+ return !probe.placeholderOpen()
62
145
  }
63
146
 
64
147
  /**
@@ -79,18 +162,36 @@ const ARM_MS = 1_500
79
162
  * selectors stopped matching, and the phone got a tiny floating popup. The
80
163
  * host offers no stable attribute to key the portal off — the anchor is a
81
164
  * per-instance useId, there is no id and no aria-controls — so this effect
82
- * marks it: the trigger's click arms a window, the observer stamps the next
83
- * body-level menu with `data-zen-sheet="perm"`, and the stylesheet (which
165
+ * marks it: the trigger's click arms a short window, the observer stamps the
166
+ * next body-level menu with `data-zen-sheet`, and the stylesheet (which
84
167
  * only the phone shell loads) turns THAT into the sheet. The portal menu
85
168
  * unmounts on close, so the marker's lifetime is exactly the menu's; the
86
169
  * 0.1.7 inline structure keeps working through the untouched descendant
87
170
  * selectors, and the desktop shell never loads this stylesheet at all.
171
+ *
172
+ * UX-3 extends the same machinery to the hero page's workspace menu:
173
+ * - the two workspace triggers arm a `data-zen-sheet="workspace"` sheet;
174
+ * - a pointerdown on a workspace trigger while a marked sheet is up
175
+ * suppresses the trigger's own click, so one tap reliably CLOSES (the
176
+ * host's outside-pointerdown close stands instead of being undone by the
177
+ * toggle click);
178
+ * - a pick inside the workspace sheet locks the composer for the length of
179
+ * the `selectWorkspace` remount (see SWITCH_MARKER above).
88
180
  */
89
181
  export function installPermissionSheet(ctx: ClientContext): void {
90
182
  ctx.effect(() => {
91
183
  const phone = window.matchMedia(PHONE_QUERY)
92
184
  let armed = false
185
+ let armedKind: string = SHEET_VALUE_PERM
93
186
  let armTimer: ReturnType<typeof setTimeout> | undefined
187
+ // UX-3 toggle debounce: recomputed on EVERY pointerdown, consumed by the
188
+ // next click — a pointerdown whose click never comes (drag cancel) cannot
189
+ // leave a stale swallow behind.
190
+ let suppressClick = false
191
+ // Set when a swallowed click found the marked menu STILL on the page
192
+ // (the pointerdown close did not land): the next tap passes through to
193
+ // the host's own toggle instead of being swallowed forever.
194
+ let allowToggle = false
94
195
 
95
196
  const disarm = (): void => {
96
197
  armed = false
@@ -100,9 +201,81 @@ export function installPermissionSheet(ctx: ClientContext): void {
100
201
  }
101
202
  }
102
203
 
204
+ const markedSheet = (): Element | null =>
205
+ document.querySelector(`${PORTAL_MENU_SELECTOR}[${SHEET_MARKER}]`)
206
+
207
+ const readSessionValue = (): string | null =>
208
+ document.querySelector(SESSION_SELECTOR)?.getAttribute(SESSION_ATTR) ?? null
209
+
210
+ const placeholderOpen = (): boolean => document.querySelector(PLACEHOLDER_CARD_SELECTOR) !== null
211
+
212
+ // -- UX-3 switch lock -------------------------------------------------
213
+ let lockedSessionValue: string | null = null
214
+ let lockTimer: ReturnType<typeof setTimeout> | undefined
215
+ let lockObserver: MutationObserver | undefined
216
+
217
+ const releaseLock = (): void => {
218
+ if (lockTimer !== undefined) {
219
+ clearTimeout(lockTimer)
220
+ lockTimer = undefined
221
+ }
222
+ lockObserver?.disconnect()
223
+ lockObserver = undefined
224
+ lockedSessionValue = null
225
+ document.documentElement.removeAttribute(SWITCH_MARKER)
226
+ }
227
+
228
+ const checkLock = (): void => {
229
+ if (lockedSessionValue === null) return
230
+ if (workspaceSwitchSettled({ currentSessionValue: readSessionValue, placeholderOpen }, lockedSessionValue)) {
231
+ releaseLock()
232
+ }
233
+ }
234
+
235
+ const startSwitchLock = (): void => {
236
+ if (lockedSessionValue === null) {
237
+ lockedSessionValue = readSessionValue()
238
+ document.documentElement.setAttribute(SWITCH_MARKER, '1')
239
+ lockObserver = new MutationObserver(checkLock)
240
+ // The session id moves on the conversation root (attribute) and the
241
+ // placeholder card mounts/unmounts (childList) — plus class flips on
242
+ // the card are attribute mutations too. One bounded subtree watch
243
+ // covers all of it.
244
+ lockObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true })
245
+ }
246
+ if (lockTimer !== undefined) clearTimeout(lockTimer)
247
+ lockTimer = setTimeout(releaseLock, SWITCH_LOCK_MS)
248
+ }
249
+ // ---------------------------------------------------------------------
250
+
251
+ const onCapturePointerDown = (event: Event): void => {
252
+ const target = event.target instanceof Element ? event.target : null
253
+ const onTrigger = pointerdownOnWorkspaceTrigger(target)
254
+ suppressClick = onTrigger && markedSheet() !== null && !allowToggle
255
+ if (!suppressClick) allowToggle = false
256
+ }
257
+
103
258
  const onCaptureClick = (event: Event): void => {
259
+ // UX-3: let the outside-pointerdown close stand — stopPropagation
260
+ // keeps the click from React's root listeners, so the trigger cannot
261
+ // toggle the sheet straight back open.
262
+ if (suppressClick) {
263
+ suppressClick = false
264
+ if (markedSheet() !== null) allowToggle = true
265
+ event.preventDefault()
266
+ event.stopPropagation()
267
+ return
268
+ }
104
269
  const target = event.target instanceof Element ? event.target : null
105
- if (!clickArmsPermissionSheet(target, phone.matches)) return
270
+ // UX-3: a pick inside the workspace sheet starts the switch lock — the
271
+ // composer must not accept input on the old blank session while
272
+ // selectWorkspace remounts the conversation underneath it.
273
+ if (phone.matches && menuitemClickStartsWorkspaceSwitch(target)) {
274
+ startSwitchLock()
275
+ return
276
+ }
277
+ const kind = sheetKindForTrigger(target, phone.matches)
278
+ if (kind === null) return
106
279
  // T70-fix: the trigger TOGGLES. A click that CLOSES the menu lands
107
280
  // while the marked menu is still in the DOM (capture runs before the
108
281
  // host's own handler unmounts it) — arming there would leave a live
@@ -110,8 +283,9 @@ export function installPermissionSheet(ctx: ClientContext): void {
110
283
  // body-level menu opening inside that window would be mis-marked as
111
284
  // ours. A marked menu present at click time means this click can only
112
285
  // be the close (or a no-op re-open), never an opening we must catch.
113
- if (document.querySelector(`${PORTAL_MENU_SELECTOR}[${SHEET_MARKER}="${SHEET_VALUE}"]`) !== null) return
286
+ if (markedSheet() !== null) return
114
287
  armed = true
288
+ armedKind = kind === 'workspace' ? SHEET_VALUE_WORKSPACE : SHEET_VALUE_PERM
115
289
  if (armTimer !== undefined) clearTimeout(armTimer)
116
290
  armTimer = setTimeout(disarm, ARM_MS)
117
291
  }
@@ -120,12 +294,13 @@ export function installPermissionSheet(ctx: ClientContext): void {
120
294
  const markIfArmed = (root: Element): void => {
121
295
  if (!armed) return
122
296
  disarm()
123
- root.setAttribute(SHEET_MARKER, SHEET_VALUE)
297
+ root.setAttribute(SHEET_MARKER, armedKind)
124
298
  }
125
299
 
126
300
  // Capture phase: the host's own handlers may stop the click's bubble
127
301
  // (the trigger toggles `open` itself), so listen on the document in
128
302
  // capture and never depend on propagation reaching us.
303
+ document.addEventListener('pointerdown', onCapturePointerDown, true)
129
304
  document.addEventListener('click', onCaptureClick, true)
130
305
  const observer = new MutationObserver((records) => {
131
306
  if (!armed) return
@@ -138,9 +313,11 @@ export function installPermissionSheet(ctx: ClientContext): void {
138
313
  observer.observe(document.body, { childList: true })
139
314
 
140
315
  return () => {
316
+ document.removeEventListener('pointerdown', onCapturePointerDown, true)
141
317
  document.removeEventListener('click', onCaptureClick, true)
142
318
  observer.disconnect()
143
319
  disarm()
320
+ releaseLock()
144
321
  // A still-open marked menu outliving this effect keeps its marker only
145
322
  // until the user closes it (the element unmounts); scrubbing here
146
323
  // would fight a React commit mid-flight for no visual gain.
@@ -12,6 +12,7 @@ export const zh = {
12
12
  'previewExitFullscreen': '退出全屏',
13
13
  'allWorkspaces': '全部',
14
14
  'switchWorkspace': '切换工作区',
15
+ 'pickWorkspaceNew': '选择工作区开始',
15
16
  'newSession': '新建会话',
16
17
  'noSessions': '暂无会话,点击右下角加号新建',
17
18
  'homeStatusOngoing': '运行中',
@@ -536,6 +537,7 @@ export const en: Record<MobileNavKey, string> = {
536
537
  'previewExitFullscreen': 'Exit fullscreen',
537
538
  'allWorkspaces': 'All',
538
539
  'switchWorkspace': 'Switch workspace',
540
+ 'pickWorkspaceNew': 'Pick a workspace to start',
539
541
  'newSession': 'New session',
540
542
  'noSessions': 'No sessions yet — tap + to start one',
541
543
  'homeStatusOngoing': 'Running',
@@ -48,6 +48,7 @@ import {
48
48
  CLIENT_DEBUG_REPORT_ROUTE,
49
49
  CLIENT_RECONNECT_ROUTE,
50
50
  CLIENT_STATUS_ROUTE,
51
+ awaitClientStatusReady,
51
52
  claimDeviceNameOf,
52
53
  createLatestGate,
53
54
  clientStatusLineOf,
@@ -56,6 +57,7 @@ import {
56
57
  localOpsAllowed,
57
58
  normalizePairingCode,
58
59
  shouldFollowServerDeviceName,
60
+ shouldReloadAfterClientActivation,
59
61
  } from '../../client-data/settings-form.ts'
60
62
  import { bridgeSectionState, togglePluginConsent } from '../../client-data/plugin-bridge.ts'
61
63
  import type { PluginBridgeStatusView } from '../../client-data/plugin-bridge.ts'
@@ -678,6 +680,11 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
678
680
  setClaimBusy(true)
679
681
  setClaimFail(null)
680
682
  setClaimWriteFailed(false)
683
+ // UX-1: captured BEFORE the pairing — whether the page ran as client, and
684
+ // whether the backend's stream interceptor was already active per the
685
+ // last client/status diagnostics.
686
+ const wasEffectiveClient = clientRole
687
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true
681
688
  // T60: one name for both halves of the round-trip — trimmed and legal
682
689
  // ({@link claimDeviceNameOf}), sent to the server AND written into the
683
690
  // row when the claim lands.
@@ -710,6 +717,20 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
710
717
  // could not store falls back to the name we actually sent.
711
718
  const echoed = typeof body.deviceName === 'string' && body.deviceName !== '' ? claimDeviceNameOf(body.deviceName, claimName) : claimName
712
719
  const landed = await config.writeClientPairing(urlToWrite, body.token, echoed)
720
+ if (landed && shouldReloadAfterClientActivation({
721
+ wasEffectiveClient,
722
+ nowClientRole: true,
723
+ savedOrPaired: true,
724
+ streamInterceptAlreadyActive,
725
+ })) {
726
+ // UX-1: FIRST pairing on a page whose resident global streams were
727
+ // opened unwrapped (or with the interceptor inactive) — wait out the
728
+ // backend's row-reload window, then reload so the streams reopen
729
+ // wrapped and the server's sessions enter the sidebar.
730
+ setClaimBusy(false)
731
+ void awaitClientStatusReady().then(() => window.location.reload())
732
+ return
733
+ }
713
734
  if (landed) {
714
735
  // The draft did its job — it now equals the displayed value, and
715
736
  // keeping it would be a phantom edit (T60).
@@ -990,7 +1011,28 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
990
1011
  state={form}
991
1012
  onSave={() => {
992
1013
  const reloadsRow = saveReloadsRow()
993
- void config.save().then((landed) => { if (landed) saveLanded(reloadsRow) })
1014
+ // UX-1: captured BEFORE the save — whether the page ran as client,
1015
+ // and whether the backend's stream interceptor was already active
1016
+ // per the last client/status diagnostics.
1017
+ const wasEffectiveClient = clientRole
1018
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true
1019
+ void config.save().then((landed) => {
1020
+ if (!landed) return
1021
+ if (shouldReloadAfterClientActivation({
1022
+ wasEffectiveClient,
1023
+ nowClientRole: config.savedRoleIsClient(),
1024
+ savedOrPaired: true,
1025
+ streamInterceptAlreadyActive,
1026
+ })) {
1027
+ // UX-1: the save first-activated the client interceptor while
1028
+ // this page's resident global streams were opened unwrapped —
1029
+ // wait out the row-reload window, then reload so the streams
1030
+ // reopen wrapped and the server's sessions enter the sidebar.
1031
+ void awaitClientStatusReady().then(() => window.location.reload())
1032
+ return
1033
+ }
1034
+ saveLanded(reloadsRow)
1035
+ })
994
1036
  }}
995
1037
  onDiscard={() => { config.discard() }}
996
1038
  >
@@ -345,6 +345,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
345
345
  …, open-in-app, deliverables, …), and none opens through this trigger
346
346
  while no marked menu is up. */
347
347
  body > div[role="menu"][data-zen-sheet="perm"],
348
+ /* UX-3: the hero page's workspace menu, marked by the same effect through
349
+ its own two triggers (the WorkspaceChip and the placeholder card) with
350
+ the workspace value — a distinct marker so the pick that starts the
351
+ workspace switch (effects/permission-sheet.ts) can key on THIS sheet
352
+ without ever touching permission options. */
353
+ body > div[role="menu"][data-zen-sheet="workspace"],
348
354
  ${MODEL_MENU} {
349
355
  position: fixed !important;
350
356
  left: 0 !important;
@@ -377,6 +383,7 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
377
383
  older hosts. */
378
384
  ${PERM} [role="menu"] [role="menuitem"],
379
385
  body > div[role="menu"][data-zen-sheet="perm"] [role="menuitem"],
386
+ body > div[role="menu"][data-zen-sheet="workspace"] [role="menuitem"],
380
387
  ${MODEL_MENU} [role="menuitem"],
381
388
  ${MODEL_MENU} [role="menuitemradio"],
382
389
  ${MODEL_MENU} [class$="_option"],
@@ -525,11 +532,28 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
525
532
  body:has(> [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"])) [data-chat-flow] + div,
526
533
  /* T70: the portaled permission menu (marked by effects/permission-sheet.ts). */
527
534
  body:has(> div[role="menu"][data-zen-sheet="perm"]) [data-chat-flow] + div,
535
+ /* UX-3: the portaled hero workspace menu, same treatment. */
536
+ body:has(> div[role="menu"][data-zen-sheet="workspace"]) [data-chat-flow] + div,
528
537
  /* T-issue30: an inline menu a parked chip opened (escaped to a fixed
529
538
  sheet by 4a). */
530
539
  body:has([data-zen-sheet-extras] [role="menu"]) [data-chat-flow] + div {
531
540
  display: none !important;
532
541
  }
542
+ /* --- 4c. UX-3: the workspace switch lock ---
543
+ Between picking a workspace in the hero sheet and the new blank session
544
+ landing (selectWorkspace → POST /api/session/create → replaceMain
545
+ remounts the conversation), the composer still belongs to the OLD blank
546
+ session: a message sent in that window lands there and is then wiped by
547
+ the remount, bouncing the UI back to the "choose a workspace" state.
548
+ effects/permission-sheet.ts stamps data-zen-ws-switching on <html> for
549
+ exactly that window — the lock releases when the conversation root's
550
+ data-conversation-session has moved and the placeholder card is gone,
551
+ with a 1.5 s cap — and the dim (not just pointer-events) makes the
552
+ freeze visible instead of reading as a dead input. */
553
+ html[data-zen-ws-switching] [data-slot="conversation.composer.bar"] {
554
+ pointer-events: none !important;
555
+ opacity: .6 !important;
556
+ }
533
557
  /* --- 5. input box: two lines minimum, five lines maximum ---
534
558
  The official autosizer drives the box off the hidden mirror's height, so
535
559
  a min-height on the mirror IS the min-height of the field; the scroll cap