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
@@ -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
  >
@@ -55,12 +55,17 @@ export const CHIPS_CSS = `/* ---------- chips row + settings entry (S5) --------
55
55
  logoRow/newSession/regionArea at \`display: flex\`, which is only
56
56
  hidden from view by being fully underneath the usage-stats panel's
57
57
  opaque body, not by this rule. */
58
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) {
58
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay),
59
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) {
59
60
  display: contents !important;
61
+ visibility: visible !important;
60
62
  }
61
63
  [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_logoRow"],
62
64
  [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_newSession"],
63
- [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"] {
65
+ [data-mobile-nav="frame"] > :first-child:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"],
66
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_logoRow"],
67
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_newSession"],
68
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"]:has([aria-modal="true"], [data-usage-stats-panel], .dshst-overlay) [class$="_regionArea"] {
64
69
  display: none !important;
65
70
  }
66
71
 
@@ -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
@@ -128,6 +128,14 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
128
128
  instead of centered. Pinning the row makes it one 48px band again. */
129
129
  [data-phase] header [class$="_titleRow"] {
130
130
  position: relative;
131
+ /* UX-2 (2026-11-04): the host gives this row container-type: inline-size,
132
+ which implies layout containment — making the row the containing block
133
+ for every descendant position:fixed. That shifted every viewport
134
+ coordinate effects/native-trigger-overlay.ts writes by the safe-area
135
+ top and the row's horizontal padding. normal hands fixed descendants
136
+ back to the viewport; the phone block owns this layout, so nothing
137
+ scoped container queries against the row here. */
138
+ container-type: normal !important;
131
139
  display: grid;
132
140
  grid-template-columns: 92px 1fr 92px;
133
141
  grid-template-rows: minmax(48px, auto);
@@ -195,55 +203,30 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
195
203
  min-width: 0;
196
204
  overflow: hidden;
197
205
  }
198
- /* Subagent sessions keep ONE ancestor crumb (2026-08-18): the PC header
199
- lets you tap the parent session's title to switch back — that affordance
200
- was lost on phone when the whole parent chain was hidden as "多余项".
201
- Show only the immediate parent (for agent-teams member sessions that IS
202
- the captain/main session; deeper ancestors stay hidden — a 3-level chain
203
- cannot fit the ~206px center column at 390px). The parent crumb is the
204
- official breadcrumb <button> (dsh-client-ui-conversation renders
205
- open(parentId) as its onClick), so tapping needs no JS from this plugin,
206
- and main sessions (chain length 1) have no :nth-last-child(2) seg at
207
- all — the centered single title is pixel-identical to before. */
208
- [data-phase] header [class$="_crumbSeg"]:not(:nth-last-child(-n+2)) {
206
+ /* Subagent sessions hide every ancestor crumb (UX-2, 2026-11-04 —
207
+ supersedes the 2026-08-18 "keep ONE ancestor" rule). DSH 0.2.1 turned
208
+ the parent crumb into a session-TREE popup trigger
209
+ (…_switcherTrigger[aria-haspopup="tree"]) instead of a direct
210
+ switch-back tap, and that popup is desktop geometry on the phone; the
211
+ back button and the home stack stay the way back. Main sessions render
212
+ a single span with no segs at all, so :not(:last-child) never matches
213
+ there. */
214
+ [data-phase] header [class$="_crumbSeg"]:not(:last-child) {
209
215
  display: none !important;
210
216
  }
211
- [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) {
212
- display: inline-flex;
213
- align-items: center;
214
- flex: none;
215
- min-width: 0;
216
- max-width: 38%;
217
- }
218
- /* Ancestors BEFORE the parent seg are hidden, which would leave the
219
- parent seg's own leading "/" dangling at the start of the title. */
220
- [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumbSep"] {
217
+ /* With the parent seg gone, the current seg's own leading "/" would
218
+ dangle before the title; the dissolved trigger container's stray
219
+ separator is covered by this same rule (its slot rules below hide it
220
+ too). One blanket rule instead of per-segment cases. */
221
+ [data-phase] header [class$="_crumbSep"] {
221
222
  display: none !important;
222
223
  }
223
- /* Parent crumb: small tertiary text so the 19px current title stays the
224
- visual anchor; min-height keeps a finger-sized target inside the 48px
225
- row. Specificity (0,4,1) outranks the shared 19px _crumb rule below. */
226
- [data-phase] header [class$="_crumbSeg"]:nth-last-child(2) [class$="_crumb"] {
227
- font-size: 13px;
228
- font-weight: 400;
229
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, .45));
230
- min-height: 44px;
231
- display: inline-flex;
232
- align-items: center;
233
- padding: 0 2px 0 6px;
234
- touch-action: manipulation;
235
- -webkit-tap-highlight-color: transparent;
236
- }
237
224
  [data-phase] header [class$="_crumbSeg"]:last-child {
238
225
  display: flex;
239
226
  align-items: center;
240
227
  min-width: 0;
241
228
  max-width: 100%;
242
229
  }
243
- /* The last seg's own "/" is the divider between parent and current title —
244
- it only exists when there IS an ancestor (index > 0), so it can simply
245
- stay visible now that the parent seg shows. (It used to be hidden here:
246
- with the parent hidden it dangled before the title.) */
247
230
  /* BOTH selectors are required: [class$=] matches the whole class
248
231
  ATTRIBUTE's suffix, and the current-session title button's attribute is
249
232
  "wSkVaW_crumb wSkVaW_crumbCurrent" — it ends in _crumbCurrent, so the _crumb
@@ -353,12 +336,17 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
353
336
  pointer-events on the trigger: that is exactly what would take the tap
354
337
  target away again.
355
338
 
356
- CSS keeps only two chores. Dissolve the root so its box (and its
357
- stray separator, hidden below) stops occupying the breadcrumb, and let
358
- the trigger participate from wherever the overlay pins it. And no menu
359
- re-anchoring anymore: the 0.1.1 popover portals to <body> as
360
- position:fixed and positions itself from the trigger's viewport rect
361
- (measured 336px wide, fully on screen at 411px). */
339
+ CSS keeps only the resting dissolve: the root's box (and its stray
340
+ separator, hidden below) stops occupying the breadcrumb until the
341
+ overlay pins it. The measurement chore moved to the overlay (UX-2,
342
+ 2026-11-04): the 0.1.1+ popover portals to <body> as position:fixed
343
+ and sizes itself from the OUTER anchor container (the official
344
+ rootRef, the trigger's parent div) — a display:contents root has no
345
+ box, so rootRef.getBoundingClientRect() was all zeros and the panel
346
+ landed at 12,12 inside the status bar. effects/
347
+ native-trigger-overlay.ts now pins container + trigger to the pill's
348
+ rect together (its inline display beats this rule's !important, style
349
+ attribute outranks selectors at author-important). */
362
350
  [data-phase] header [data-slot="conversation.session.header.actions"] > *:has(> button[class$="_trigger"]),
363
351
  [data-phase] header [data-slot="conversation.session.header.lineage"] > *:has(> button[class$="_trigger"]) {
364
352
  display: contents !important;
@@ -367,6 +355,18 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
367
355
  [data-phase] header [data-slot="conversation.session.header.lineage"] [class$="_separator"] {
368
356
  display: none !important;
369
357
  }
358
+ /* The popovers themselves portal to <body> as position:fixed layers
359
+ (class suffix _menu, z-index 100) and place themselves from the pill's
360
+ viewport rect with margin 12 — the overlay above is what makes that
361
+ rect correct. This is only a floor: the panels' 336px width overflows
362
+ 320px-class phones, and a tall list must not run under the notch or
363
+ the home indicator. [id$="-menu"] keeps the composer's model menu out
364
+ of it — composer.css.ts owns that surface, and this file sits before
365
+ it in styles/index.ts so its own rules still win the tie. */
366
+ body > [class$="_menu"]:not([id$="-menu"]) {
367
+ max-width: calc(100vw - 24px) !important;
368
+ max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px) !important;
369
+ }
370
370
  /* The agent-preset mode badge specifically survives the blanket hide
371
371
  above: layout.css.ts's shared <=1023px block targets it directly
372
372
  (header [class$="_label"]:has(> svg) { display: block !important }),
@@ -395,8 +395,12 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
395
395
  display: inline-flex !important;
396
396
  }
397
397
 
398
- /* Back button: 44pt touch target, leftmost column. */
398
+ /* Back button: 44pt touch target, leftmost column.
399
+ order: -1 keeps it to the left of @deepseek-ai/dsh-experimental-client-ui-agent-team's
400
+ TeamAction ([data-team-action], registered into header.actions at order -20 with
401
+ default flex order 0). */
399
402
  [data-mobile-nav="header-back"] {
403
+ order: -1;
400
404
  display: inline-flex;
401
405
  align-items: center;
402
406
  justify-content: center;
@@ -413,6 +417,13 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
413
417
  [data-mobile-nav="header-back"]:active {
414
418
  opacity: .6;
415
419
  }
420
+ /* TeamAction's own stylesheet hides .XdyUwa_triggerLabel via
421
+ @container (width<=480px) against _titleRow's container-type: inline-size.
422
+ Shedding container-type on _titleRow above (UX-2) disabled that container
423
+ query, so hide the label explicitly on phone and keep only the icon. */
424
+ [data-phase] header [data-team-action] [class$="_triggerLabel"] {
425
+ display: none !important;
426
+ }
416
427
 
417
428
  /* Info + workbench utility buttons, rightmost column. */
418
429
  [data-mobile-nav="header-info"],
@@ -503,6 +514,7 @@ export const HEADER_CSS = `/* ---------- session header five-piece reflow (< 768
503
514
  top:100% lands both on the same 28px line. z-index puts it above the
504
515
  row it overlaps. */
505
516
  [data-mobile-nav="header-activity"] {
517
+ pointer-events: none;
506
518
  position: absolute;
507
519
  right: 0;
508
520
  top: 100%;
@@ -72,8 +72,12 @@ export const HOME_CSS = `/* ---------- phone app shell (< 768px) ---------- */
72
72
 
73
73
  /* The official sidebar is no longer a drawer on a phone — the home screen
74
74
  replaced it. Hidden outright (not translated off-screen) so it cannot
75
- capture taps or hold layout. */
76
- [data-mobile-nav="frame"] > :first-child {
75
+ capture taps or hold layout. DSH 0.2.2-rc.1+ inserts [data-sidebar-mask]
76
+ before [class$="_sidebarCol"] and mounts [data-shell-leading] (the host's
77
+ own floating drawer opener) on < 768px; hide all three. */
78
+ [data-mobile-nav="frame"] > :first-child,
79
+ [data-mobile-nav="frame"] > [class$="_sidebarCol"],
80
+ [data-mobile-nav="frame"] > [data-shell-leading] {
77
81
  display: none !important;
78
82
  }
79
83
  /* The header's drawer toggle opened that sidebar; with it gone the button