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.
- package/CHANGELOG.md +41 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +1 -1
- package/docs/remote-access.en.md +1 -1
- package/docs/remote-access.md +1 -1
- package/lib/adapters/dsh-better-sidebar/index.js +21 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client.js +522 -89
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +51 -10
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/chips.css.d.ts +1 -1
- package/lib/types/client/styles/chips.css.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/home.css.d.ts +1 -1
- package/lib/types/client/styles/home.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client-data/new-session.d.ts +26 -0
- package/lib/types/client-data/new-session.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +36 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/check-settings-form.mjs +63 -0
- package/src/adapters/dsh-better-sidebar/index.ts +23 -3
- package/src/client/MobileHome.tsx +63 -8
- package/src/client/MobileNavOverlay.tsx +3 -1
- package/src/client/MobileSessionHeader.tsx +7 -1
- package/src/client/effects/native-trigger-overlay.ts +50 -11
- package/src/client/effects/permission-sheet.ts +199 -22
- package/src/client/locales.ts +2 -0
- package/src/client/settings/SettingsSection.tsx +43 -1
- package/src/client/styles/chips.css.ts +7 -2
- package/src/client/styles/composer.css.ts +24 -0
- package/src/client/styles/header.css.ts +58 -46
- package/src/client/styles/home.css.ts +6 -2
- package/src/client/styles/layout.css.ts +43 -2
- package/src/client/wizard/GatewayWizard.tsx +23 -1
- package/src/client-data/new-session.ts +30 -0
- 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
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
35
|
-
*
|
|
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
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
59
|
-
if (
|
|
60
|
-
|
|
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
|
|
83
|
-
* body-level menu with `data-zen-sheet
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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.
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|