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