dsh-zen-remote 1.1.16 → 2.0.1
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/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +11498 -5990
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +365 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3181 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1172 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1813 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts +13 -0
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -0
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.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/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/model-group-side.d.ts +44 -0
- package/lib/types/client-data/model-group-side.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +166 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +442 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +398 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +52 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +66 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/model-group-side.ts +132 -0
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +158 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +274 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/model-group-side.ts +65 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +410 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3510 -0
- package/src/merge-streams.ts +1279 -0
- package/src/relay-access.ts +672 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2063 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +74 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T62: the browser half of the current-session signal. The intercept layer
|
|
3
|
+
* that decides whether a closed-remote session hides runs in the BACKEND
|
|
4
|
+
* Node process (it wraps the host's typert gateway there — src/index.ts's
|
|
5
|
+
* installIntercept), so THIS is the only place the host's persisted
|
|
6
|
+
* selection (localStorage `dsh.sessions.current`, the workspace UI's
|
|
7
|
+
* selection store) is readable. The reporter watches it and POSTs every
|
|
8
|
+
* CHANGE to the backend route; the route stores the value and makes the
|
|
9
|
+
* intercept re-judge at once, with a 1s backend poll as the fallback.
|
|
10
|
+
*
|
|
11
|
+
* Re-check points: page load, the `storage` event (a DIFFERENT tab or
|
|
12
|
+
* window changed the key — same-tab writes fire no storage event), the
|
|
13
|
+
* page becoming visible again, and a 1s interval that doubles as the retry
|
|
14
|
+
* path: a failed POST leaves the last-reported value untouched, so the
|
|
15
|
+
* next tick re-sends it (same-tab navigations are what the interval is
|
|
16
|
+
* really for). Failures are silent — background plumbing must never log
|
|
17
|
+
* or toast.
|
|
18
|
+
*
|
|
19
|
+
* Runs only in the CLIENT role (a host has no relay and no intercept): the
|
|
20
|
+
* role comes from the SAME cached `client-config` probe the share parts
|
|
21
|
+
* use — a definite 'client' starts reporting, a 'host' never reports, and
|
|
22
|
+
* an undecided probe re-asks on each tick (once it lands the answer is
|
|
23
|
+
* stable for the process's lifetime).
|
|
24
|
+
*/
|
|
25
|
+
import { CURRENT_SESSION_STORAGE_KEY, currentSessionReportEquals, parseCurrentSessionStorage } from '../client-data/current-session.ts'
|
|
26
|
+
import type { CurrentSessionReport } from '../client-data/current-session.ts'
|
|
27
|
+
import { probeClientConfigRole } from './remote-share-register.ts'
|
|
28
|
+
|
|
29
|
+
/** Default POST target, mirrored from src/client-routes.ts (the client
|
|
30
|
+
* tsconfig cannot reach the host file). */
|
|
31
|
+
const CURRENT_SESSION_ROUTE = '/_dsh/zen-remote/client/current-session'
|
|
32
|
+
|
|
33
|
+
/** The re-check cadence — the same 1s the backend's fallback poll uses. */
|
|
34
|
+
const CHECK_INTERVAL_MS = 1_000
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How often the current value is re-sent even when unchanged (T66): the
|
|
38
|
+
* reporter otherwise posts only CHANGES, but the backend can be reset
|
|
39
|
+
* underneath the page — a plugin row reload re-runs apply() and drops its
|
|
40
|
+
* stored value — leaving the page convinced it already reported. One
|
|
41
|
+
* unconditional re-post per window keeps the two ends converging without
|
|
42
|
+
* waiting for the user to navigate.
|
|
43
|
+
*/
|
|
44
|
+
const RESEND_INTERVAL_MS = 30_000
|
|
45
|
+
|
|
46
|
+
/** The storage face used, narrowed to the one method (structurally typed:
|
|
47
|
+
* this module compiles under the client tsconfig with DOM lib, but tests
|
|
48
|
+
* inject stubs). */
|
|
49
|
+
interface StorageLike {
|
|
50
|
+
getItem(key: string): string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CurrentSessionReporterOptions {
|
|
54
|
+
/** The storage reader. Default: the page's localStorage; a broken or
|
|
55
|
+
* absent one makes every read `unavailable` and nothing is ever posted. */
|
|
56
|
+
storage?: StorageLike
|
|
57
|
+
/** Outbound POST transport. Default: the page's fetch. */
|
|
58
|
+
fetchImpl?: (url: string, init: RequestInit) => Promise<unknown>
|
|
59
|
+
/** The role probe. Default: the shared cached client-config probe. */
|
|
60
|
+
probeRole?: () => Promise<'host' | 'client' | undefined>
|
|
61
|
+
/** POST target. Default: the current-session route. */
|
|
62
|
+
route?: string
|
|
63
|
+
/** The re-check interval; default 1000ms. */
|
|
64
|
+
intervalMs?: number
|
|
65
|
+
/** The unconditional re-send window (T66); default 30000ms. */
|
|
66
|
+
resendIntervalMs?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One reportable value — `unavailable` never travels. */
|
|
70
|
+
type ReportableReport = Exclude<CurrentSessionReport, { kind: 'unavailable' }>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Start the reporter loop. Returns the disposer (listeners + interval) the
|
|
74
|
+
* mounting effect calls on teardown.
|
|
75
|
+
*/
|
|
76
|
+
export function startCurrentSessionReporter(options: CurrentSessionReporterOptions = {}): () => void {
|
|
77
|
+
const readRaw = (): string | null | undefined => {
|
|
78
|
+
const storage = options.storage ?? (globalThis as { localStorage?: StorageLike | null }).localStorage
|
|
79
|
+
if (storage === null || storage === undefined || typeof storage.getItem !== 'function') return undefined
|
|
80
|
+
try {
|
|
81
|
+
return storage.getItem(CURRENT_SESSION_STORAGE_KEY)
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const post = (report: ReportableReport): Promise<unknown> => {
|
|
87
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
88
|
+
const body = report.kind === 'open' ? { sessionId: report.sessionId } : { sessionId: null }
|
|
89
|
+
return doFetch(options.route ?? CURRENT_SESSION_ROUTE, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
const probeRole = options.probeRole ?? probeClientConfigRole
|
|
96
|
+
|
|
97
|
+
// The last value the backend ACCEPTED (undefined = nothing posted yet).
|
|
98
|
+
// A failed POST must not advance it — that is the whole retry rule: the
|
|
99
|
+
// value still differs, so the next change or the next check re-sends.
|
|
100
|
+
// T66: the timestamp of the last accepted post drives the slow unconditional
|
|
101
|
+
// re-send (0 = due immediately, which is the page-load post).
|
|
102
|
+
let lastReported: CurrentSessionReport | undefined
|
|
103
|
+
let lastSentAt = 0
|
|
104
|
+
const resendEveryMs = options.resendIntervalMs ?? RESEND_INTERVAL_MS
|
|
105
|
+
let role: 'host' | 'client' | undefined
|
|
106
|
+
let inFlight = false
|
|
107
|
+
|
|
108
|
+
const tick = async (): Promise<void> => {
|
|
109
|
+
if (role === undefined) {
|
|
110
|
+
const answer = await probeRole()
|
|
111
|
+
// A failed probe changes nothing — the next tick asks again; a
|
|
112
|
+
// definite answer is stable for the page's life.
|
|
113
|
+
if (answer !== undefined) role = answer
|
|
114
|
+
}
|
|
115
|
+
if (role !== 'client') return
|
|
116
|
+
const report = parseCurrentSessionStorage(readRaw())
|
|
117
|
+
if (report.kind === 'unavailable') return
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
// T66: inside the re-send window an unchanged value says nothing; once
|
|
120
|
+
// the window passes, the CURRENT value re-posts even unchanged — the
|
|
121
|
+
// backend may have been reset underneath the page.
|
|
122
|
+
const resendDue = now - lastSentAt >= resendEveryMs
|
|
123
|
+
if (!resendDue && lastReported !== undefined && currentSessionReportEquals(report, lastReported)) return
|
|
124
|
+
try {
|
|
125
|
+
await post(report)
|
|
126
|
+
lastReported = report
|
|
127
|
+
lastSentAt = now
|
|
128
|
+
} catch {
|
|
129
|
+
// Silent: the next tick sees the unchanged lastReported and retries.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const tickOnce = (): void => {
|
|
133
|
+
// One re-check at a time: a slow POST must not pile up parallel tries.
|
|
134
|
+
if (inFlight) return
|
|
135
|
+
inFlight = true
|
|
136
|
+
void tick().finally(() => { inFlight = false })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const globalObj = globalThis as {
|
|
140
|
+
window?: { addEventListener(type: string, fn: () => void): void, removeEventListener(type: string, fn: () => void): void }
|
|
141
|
+
document?: { addEventListener(type: string, fn: () => void): void, removeEventListener(type: string, fn: () => void): void }
|
|
142
|
+
}
|
|
143
|
+
const onStorage = (): void => { tickOnce() }
|
|
144
|
+
const onVisibility = (): void => { tickOnce() }
|
|
145
|
+
globalObj.window?.addEventListener('storage', onStorage)
|
|
146
|
+
globalObj.document?.addEventListener('visibilitychange', onVisibility)
|
|
147
|
+
const timer = setInterval(() => { tickOnce() }, options.intervalMs ?? CHECK_INTERVAL_MS)
|
|
148
|
+
tickOnce()
|
|
149
|
+
return () => {
|
|
150
|
+
clearInterval(timer)
|
|
151
|
+
globalObj.window?.removeEventListener('storage', onStorage)
|
|
152
|
+
globalObj.document?.removeEventListener('visibilitychange', onVisibility)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mount the reporter as one page-lifetime effect. The caller places this
|
|
158
|
+
BEFORE the desktop-shell gate in apply(): the desktop app in the client
|
|
159
|
+
* role is exactly where remote sessions live, so the report must not wait
|
|
160
|
+
* on a width gate. On a host the loop idles after one probe answer.
|
|
161
|
+
*/
|
|
162
|
+
export function mountCurrentSessionReporter(ctx: { effect(fn: () => () => void, name: string): void }, options: CurrentSessionReporterOptions = {}): void {
|
|
163
|
+
ctx.effect(() => startCurrentSessionReporter(options), 'dsh-zen-remote: current-session report')
|
|
164
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ClientContext } from '../compat/types.ts'
|
|
2
|
+
import { mainSessionIdOf } from '../compat/types.ts'
|
|
2
3
|
import { dotState } from '../session-dot.ts'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -18,30 +19,33 @@ export function installHeaderStatusDot(ctx: ClientContext): void {
|
|
|
18
19
|
const apply = (): void => {
|
|
19
20
|
const frame = document.querySelector('[data-mobile-nav="frame"]')
|
|
20
21
|
if (frame === null) return
|
|
21
|
-
|
|
22
|
+
// 0.1.7: SessionListState.current is gone — the main-view session
|
|
23
|
+
// derives from the catalog rows (mainSessionIdOf). The pending flag
|
|
24
|
+
// moved with it: uiSession.pendingInteractions is gone too, the fact
|
|
25
|
+
// now rides ctx.uiSession.sessionStatus (running + pendingInteraction
|
|
26
|
+
// per session). Probed fresh on every dot recompute so registration
|
|
27
|
+
// order never matters, same as before.
|
|
28
|
+
const { byId } = ctx.sessions.list.getSnapshot()
|
|
29
|
+
const current = mainSessionIdOf(byId)
|
|
22
30
|
const row = current === undefined ? undefined : byId[current]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const uiSession = ctx.get('uiSession')
|
|
27
|
-
const pending = current === undefined ? false
|
|
28
|
-
: uiSession?.pendingInteractions.getSnapshot().has(current) === true
|
|
31
|
+
const status = current === undefined ? undefined
|
|
32
|
+
: ctx.get('uiSession')?.sessionStatus.getSnapshot().get(current)
|
|
33
|
+
const pending = status?.pendingInteraction !== undefined && status.pendingInteraction !== null
|
|
29
34
|
const state = row === undefined ? undefined : dotState(row, pending)
|
|
30
35
|
if (state === undefined) frame.removeAttribute('data-mobile-nav-dot')
|
|
31
36
|
else frame.setAttribute('data-mobile-nav-dot', state)
|
|
32
37
|
}
|
|
33
38
|
apply()
|
|
34
|
-
// Two sources can push the dot
|
|
35
|
-
//
|
|
36
|
-
// exist.
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// missed until then.
|
|
39
|
+
// Two sources can push the dot (session list + the unified session
|
|
40
|
+
// status table); probe uiSession once more right here and subscribe to
|
|
41
|
+
// whichever exist. Degradation note: if uiSession has not registered by
|
|
42
|
+
// this moment (this plugin ran before it), only the list subscription
|
|
43
|
+
// is attached — the dot stays correct because every later list change
|
|
44
|
+
// re-runs apply(), whose fresh probe then sees the service; a
|
|
45
|
+
// pending-only change before any list change would be missed until then.
|
|
42
46
|
const unsubscribes: Array<() => void> = [ctx.sessions.list.subscribe(apply)]
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
47
|
+
const statusSource = ctx.get('uiSession')?.sessionStatus
|
|
48
|
+
if (statusSource !== undefined) unsubscribes.push(statusSource.subscribe(apply))
|
|
45
49
|
return () => {
|
|
46
50
|
for (const off of unsubscribes) off()
|
|
47
51
|
}
|
|
@@ -6,6 +6,23 @@ const PHONE_QUERY = '(max-width: 767px)'
|
|
|
6
6
|
/** The official composer slot wrapper (same marker composer.css.ts styles). */
|
|
7
7
|
const COMPOSER = '[data-slot="conversation.composer.bar"]'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* T72: the model sheet's search input on DSH 0.2.0-rc.2 — a `role=searchbox`
|
|
11
|
+
* `<input>` inside the portaled model menu (composer.css.ts's MODEL_MENU
|
|
12
|
+
* body form; on rc.2 the model pane portals as `role=group` with the search
|
|
13
|
+
* row inside). The host focuses it the moment the user drills into the
|
|
14
|
+
* model pane, which pops the phone keyboard over the sheet.
|
|
15
|
+
*/
|
|
16
|
+
const MODEL_MENU_ROOT = 'body > [id$="-menu"]'
|
|
17
|
+
const MODEL_SEARCH = `${MODEL_MENU_ROOT} [role="searchbox"]`
|
|
18
|
+
/** The focus target when the search's autofocus must be retracted: the
|
|
19
|
+
* currently selected model row, the exact element the host's own arrow-key
|
|
20
|
+
* navigation focuses first (model-selection-client.js moveFocus). */
|
|
21
|
+
const SELECTED_OPTION = '[role="menuitemradio"][aria-checked="true"]:not([disabled])'
|
|
22
|
+
/** The host's fallback when no row is checked (filtered/empty list): the
|
|
23
|
+
* first selectable option row. */
|
|
24
|
+
const ANY_OPTION = '[role="menuitemradio"]:not([disabled])'
|
|
25
|
+
|
|
9
26
|
/** A tap/keystroke older than this no longer explains a focus. */
|
|
10
27
|
const INTENT_WINDOW_MS = 1000
|
|
11
28
|
|
|
@@ -43,6 +60,10 @@ export function installKeyboardGuard(ctx: ClientContext): void {
|
|
|
43
60
|
const narrow = window.matchMedia(PHONE_QUERY)
|
|
44
61
|
let lastIntent = 0
|
|
45
62
|
let granted = false
|
|
63
|
+
// T72: the search field's own grant — separate from the composer's,
|
|
64
|
+
// cleared when the field blurs, so a later programmatic focus (drilling
|
|
65
|
+
// back into the pane) is retracted again.
|
|
66
|
+
let searchGranted = false
|
|
46
67
|
let observer: MutationObserver | null = null
|
|
47
68
|
let frame = 0
|
|
48
69
|
|
|
@@ -60,6 +81,31 @@ export function installKeyboardGuard(ctx: ClientContext): void {
|
|
|
60
81
|
return node.tagName === 'TEXTAREA' ? node : null
|
|
61
82
|
}
|
|
62
83
|
|
|
84
|
+
/**
|
|
85
|
+
* T72: the model sheet's search input. Unlike the composer field this
|
|
86
|
+
* one is NOT blurred to the body — the model menu's root has an onBlur
|
|
87
|
+
* that CLOSES the menu when the focus leaves root and menu (rc.2 model-
|
|
88
|
+
* selection-client.js: `relatedTarget` outside both → `close()`), so
|
|
89
|
+
* blurring would slam the sheet shut. The focus moves INSIDE the menu
|
|
90
|
+
* instead: to the selected model row, the element the host's own
|
|
91
|
+
* keyboard navigation focuses first. Phone keyboards have no arrow keys,
|
|
92
|
+
* so the host's search-focus-follows-highlight logic losing focus costs
|
|
93
|
+
* nothing there.
|
|
94
|
+
*/
|
|
95
|
+
const modelSearchField = (node: unknown): HTMLElement | null => {
|
|
96
|
+
if (!(node instanceof HTMLElement)) return null
|
|
97
|
+
if (node.getAttribute('role') !== 'searchbox') return null
|
|
98
|
+
return node.closest(MODEL_SEARCH)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Retract the search autofocus WITHOUT closing the menu (see above). */
|
|
102
|
+
const retractSearchFocus = (field: HTMLElement): void => {
|
|
103
|
+
const menu = field.closest(MODEL_MENU_ROOT)
|
|
104
|
+
const selected = menu?.querySelector<HTMLElement>(SELECTED_OPTION) ?? menu?.querySelector<HTMLElement>(ANY_OPTION)
|
|
105
|
+
if (selected !== null && selected !== undefined) selected.focus()
|
|
106
|
+
else field.blur()
|
|
107
|
+
}
|
|
108
|
+
|
|
63
109
|
const onPointerDown = (event: PointerEvent): void => {
|
|
64
110
|
// Only the textarea itself grants the keyboard. A tap on any other
|
|
65
111
|
// composer control (slash-command toggle, attach, model menu, send)
|
|
@@ -69,12 +115,23 @@ export function installKeyboardGuard(ctx: ClientContext): void {
|
|
|
69
115
|
lastIntent = Date.now()
|
|
70
116
|
granted = true
|
|
71
117
|
}
|
|
118
|
+
// T72: a tap ON the search field is the user asking for the keyboard
|
|
119
|
+
// — typing to filter models must work.
|
|
120
|
+
if (modelSearchField(event.target) !== null) searchGranted = true
|
|
72
121
|
}
|
|
73
122
|
const onKeyDown = (): void => {
|
|
74
123
|
lastIntent = Date.now()
|
|
75
124
|
if (composerField(document.activeElement) !== null) granted = true
|
|
76
125
|
}
|
|
77
126
|
const sweep = (): void => {
|
|
127
|
+
// The search field is judged on its own grant, and a retracted focus
|
|
128
|
+
// never leaves the menu (see modelSearchField — blur would close it).
|
|
129
|
+
const search = modelSearchField(document.activeElement)
|
|
130
|
+
if (search !== null) {
|
|
131
|
+
if (searchGranted) return
|
|
132
|
+
retractSearchFocus(search)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
78
135
|
const el = composerField(document.activeElement)
|
|
79
136
|
if (el === null) return
|
|
80
137
|
if (granted || Date.now() - lastIntent < INTENT_WINDOW_MS) {
|
|
@@ -84,10 +141,16 @@ export function installKeyboardGuard(ctx: ClientContext): void {
|
|
|
84
141
|
el.blur()
|
|
85
142
|
}
|
|
86
143
|
const onFocusIn = (event: FocusEvent): void => {
|
|
87
|
-
if (composerField(event.target) === null) return
|
|
144
|
+
if (composerField(event.target) === null && modelSearchField(event.target) === null) return
|
|
88
145
|
sweep()
|
|
89
146
|
}
|
|
90
147
|
const onFocusOut = (event: FocusEvent): void => {
|
|
148
|
+
// T72: the search field's grant dies with its focus — the next
|
|
149
|
+
// programmatic focus is retracted again.
|
|
150
|
+
if (modelSearchField(event.target) !== null) {
|
|
151
|
+
searchGranted = false
|
|
152
|
+
return
|
|
153
|
+
}
|
|
91
154
|
if (composerField(event.target) === null) return
|
|
92
155
|
granted = false
|
|
93
156
|
}
|
|
@@ -122,6 +185,7 @@ export function installKeyboardGuard(ctx: ClientContext): void {
|
|
|
122
185
|
if (frame !== 0) window.clearTimeout(frame)
|
|
123
186
|
frame = 0
|
|
124
187
|
granted = false
|
|
188
|
+
searchGranted = false
|
|
125
189
|
}
|
|
126
190
|
|
|
127
191
|
if (narrow.matches) attach()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ClientContext } from '../compat/types.ts'
|
|
2
|
+
import { classifyModelGroups, type ModelGroupHeading } from '../../client-data/model-group-side.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* T74: stamp every model-menu group with the side of the pairing it belongs
|
|
6
|
+
* to, so remote-session.css.ts can hide the groups the open session cannot
|
|
7
|
+
* run. The stamps are `data-zr-group="remote|local"` on each
|
|
8
|
+
* `section[role="group"]`; which sections get one (and which get none) is
|
|
9
|
+
* the pure classifier's container rule (client-data/model-group-side.ts) —
|
|
10
|
+
* only a container holding at least one marked (WORD JOINER-prefixed, see
|
|
11
|
+
* virtual-id.ts) heading is decided at all, so every other menu and the
|
|
12
|
+
* whole rest of the page is never touched.
|
|
13
|
+
*
|
|
14
|
+
* Synchronous in the observer callback, never behind rAF: the callback is a
|
|
15
|
+
* microtask and runs before this frame paints, so a group that must be
|
|
16
|
+
* hidden is hidden the first time it is drawn. A rAF would land a frame late
|
|
17
|
+
* and show one frame of the groups the CSS is about to hide (AGENTS.md's
|
|
18
|
+
* "晚一帧" rule — the same shape the turn-fold and model-sheet-extras bugs
|
|
19
|
+
* shared). The cheap batch gate below keeps the full rescan off the hot
|
|
20
|
+
* path: most mutations (chat streaming, composer typing) touch no group.
|
|
21
|
+
*/
|
|
22
|
+
const GROUP_SELECTOR = 'section[role="group"]'
|
|
23
|
+
|
|
24
|
+
/** The stamp this effect writes; remote-session.css.ts hides
|
|
25
|
+
* `[data-zr-group="local"]` in a remote session and
|
|
26
|
+
* `[data-zr-group="remote"]` everywhere else. */
|
|
27
|
+
const STAMP = 'data-zr-group'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The heading text a group section renders, across the two DSH layouts:
|
|
31
|
+
* 0.1.7 points `aria-labelledby` at a heading div (id `<useId>-<group.id>`);
|
|
32
|
+
* 0.2.0's MenuGroup points it at its own useId heading — which is also the
|
|
33
|
+
* first `[data-menu-group-heading]` child — and its first child is an
|
|
34
|
+
* id-less position sentinel span. Falls through in that order, then to the
|
|
35
|
+
* first id-bearing child (0.1.7's heading, should `aria-labelledby` ever be
|
|
36
|
+
* dropped), and gives up with `null`.
|
|
37
|
+
*/
|
|
38
|
+
export function headingTextOf(section: Element, getElementById: (id: string) => Element | null): string | null {
|
|
39
|
+
const labelledby = section.getAttribute('aria-labelledby')
|
|
40
|
+
if (labelledby !== null) {
|
|
41
|
+
const heading = getElementById(labelledby)
|
|
42
|
+
if (heading !== null) return heading.textContent
|
|
43
|
+
}
|
|
44
|
+
const own = section.querySelector(':scope > [data-menu-group-heading]')
|
|
45
|
+
if (own !== null) return own.textContent
|
|
46
|
+
for (const child of section.children) {
|
|
47
|
+
if (child.id !== '') return child.textContent
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does this mutation involve a group section, inside or as a node? Groups
|
|
53
|
+
* are rare; everything else (streaming text, composer typing) bails here. */
|
|
54
|
+
function touchesGroup(mutation: MutationRecord): boolean {
|
|
55
|
+
if (mutation.type === 'childList') {
|
|
56
|
+
for (const node of mutation.addedNodes) if (isGroupish(node)) return true
|
|
57
|
+
for (const node of mutation.removedNodes) if (isGroupish(node)) return true
|
|
58
|
+
}
|
|
59
|
+
const target = mutation.target
|
|
60
|
+
const el = target.nodeType === 1 ? (target as Element) : target.parentElement
|
|
61
|
+
return el !== null && el.closest(GROUP_SELECTOR) !== null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isGroupish(node: Node): boolean {
|
|
65
|
+
if (node.nodeType !== 1) return false
|
|
66
|
+
const el = node as Element
|
|
67
|
+
return el.matches(GROUP_SELECTOR) || el.querySelector(GROUP_SELECTOR) !== null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function installModelGroupSide(ctx: ClientContext): void {
|
|
71
|
+
ctx.effect(() => {
|
|
72
|
+
const applyVerdict = (section: Element, verdict: 'remote' | 'local' | undefined): void => {
|
|
73
|
+
// Write only on change — the observer does not watch attributes (so a
|
|
74
|
+
// stamp cannot re-trigger it), but a removed section's container may
|
|
75
|
+
// be re-decided, and an unconditional set would still churn
|
|
76
|
+
// style/attribute machinery for no effect.
|
|
77
|
+
if (verdict === undefined) {
|
|
78
|
+
if (section.hasAttribute(STAMP)) section.removeAttribute(STAMP)
|
|
79
|
+
} else if (section.getAttribute(STAMP) !== verdict) {
|
|
80
|
+
section.setAttribute(STAMP, verdict)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const scan = (): void => {
|
|
85
|
+
const sections = document.querySelectorAll(GROUP_SELECTOR)
|
|
86
|
+
// Group by parent element, in document order: querySelectorAll walks
|
|
87
|
+
// the document, and one parent's children form a contiguous run of it,
|
|
88
|
+
// so a running number per distinct parent is a stable container key.
|
|
89
|
+
const containerKeys = new Map<Element, number>()
|
|
90
|
+
const rows: Array<ModelGroupHeading & { section: Element }> = []
|
|
91
|
+
for (const section of sections) {
|
|
92
|
+
const parent = section.parentElement
|
|
93
|
+
if (parent === null) continue
|
|
94
|
+
let key = containerKeys.get(parent)
|
|
95
|
+
if (key === undefined) {
|
|
96
|
+
key = containerKeys.size
|
|
97
|
+
containerKeys.set(parent, key)
|
|
98
|
+
}
|
|
99
|
+
const text = headingTextOf(section, (id) => document.getElementById(id))
|
|
100
|
+
rows.push({ section, containerKey: key, headingText: text ?? '' })
|
|
101
|
+
}
|
|
102
|
+
const verdicts = classifyModelGroups(rows)
|
|
103
|
+
rows.forEach((row, i) => applyVerdict(row.section, verdicts[i]))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// childList + subtree catches mounted/unmounted/re-parented groups,
|
|
107
|
+
// characterData catches heading texts changing in place. Attributes are
|
|
108
|
+
// deliberately unwatched: this effect is their only writer.
|
|
109
|
+
const observer = new MutationObserver((mutations) => {
|
|
110
|
+
for (const mutation of mutations) {
|
|
111
|
+
if (touchesGroup(mutation)) {
|
|
112
|
+
scan()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
118
|
+
// The initial paint may already hold groups (session restored under an
|
|
119
|
+
// open menu): stamp them synchronously at install, like every observer
|
|
120
|
+
// effect here does.
|
|
121
|
+
scan()
|
|
122
|
+
|
|
123
|
+
return () => {
|
|
124
|
+
observer.disconnect()
|
|
125
|
+
// Undo every stamp so the host DOM reads exactly as before the effect
|
|
126
|
+
// ran — the CSS rules key on the stamp, so this un-hides everything.
|
|
127
|
+
for (const section of document.querySelectorAll(`${GROUP_SELECTOR}[${STAMP}]`)) {
|
|
128
|
+
section.removeAttribute(STAMP)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, 'dsh-zen-remote: model group side')
|
|
132
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { ClientContext } from '../compat/types.ts'
|
|
2
|
+
|
|
3
|
+
/** Phone breakpoint — the same query every phone-only effect in this plugin uses. */
|
|
4
|
+
const PHONE_QUERY = '(max-width: 767px)'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The permission trigger, addressed the way composer.css.ts addresses the
|
|
8
|
+
* whole permission seat: a structured slot plus the build-suffix trigger
|
|
9
|
+
* class (DSH 0.2.0 keeps both). The trigger carries NO aria-expanded and
|
|
10
|
+
* NO aria-controls on 0.2.0 (the Menu primitive portals its list to body
|
|
11
|
+
* and links nothing back), so the click is the only open signal there is.
|
|
12
|
+
*/
|
|
13
|
+
const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the Menu primitive portals on 0.2.0: a `role=menu` div DIRECTLY under
|
|
17
|
+
* body, carrying `data-menu-material` and a randomized `--dsh-menu-anchor`
|
|
18
|
+
* (a per-instance useId) but NO id and no back-reference from the trigger —
|
|
19
|
+
* there is no pure-CSS way to pick OUR menu out of the portal crowd: the
|
|
20
|
+
* host has MANY `portal: true` Menus (agent-preset, chat preference rows,
|
|
21
|
+
* the Enter-behavior row, the workspace sidebar's 「…」, open-in-app,
|
|
22
|
+
* 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.
|
|
27
|
+
*/
|
|
28
|
+
const PORTAL_MENU_SELECTOR = 'body > div[role="menu"]'
|
|
29
|
+
|
|
30
|
+
/** The marker composer.css.ts section 4 styles into the bottom sheet. */
|
|
31
|
+
const SHEET_MARKER = 'data-zen-sheet'
|
|
32
|
+
const SHEET_VALUE = 'perm'
|
|
33
|
+
|
|
34
|
+
/** The DOM face the two pure decisions below read — structural, so the
|
|
35
|
+
* tests drive them with plain objects instead of a DOM. */
|
|
36
|
+
export interface PermissionSheetCandidate {
|
|
37
|
+
matches(selectors: string): boolean
|
|
38
|
+
getAttribute(name: string): string | null
|
|
39
|
+
/** A truthy `closest` result means the click started in the seat. */
|
|
40
|
+
closest(selectors: string): unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether ONE body-level node is a portal menu that does not yet carry the
|
|
45
|
+
* sheet marker. Pure over the node's own methods: `matches` answers the
|
|
46
|
+
* body-level role=menu shape, `getAttribute` the marker.
|
|
47
|
+
*/
|
|
48
|
+
export function isUnmarkedPortalMenu(node: PermissionSheetCandidate): boolean {
|
|
49
|
+
return node.matches(PORTAL_MENU_SELECTOR) && node.getAttribute(SHEET_MARKER) === null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
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
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How long an OPEN click stays armed. The portal lands in the same React
|
|
66
|
+
* commit as the state flip, so milliseconds of real latency; the window is
|
|
67
|
+
* consumed by the menu that opens with it, or expires on its own — the
|
|
68
|
+
* close path never arms at all (the marked menu is still on the page, see
|
|
69
|
+
* the guard in onCaptureClick), so the ceiling's only job is retiring a
|
|
70
|
+
* window whose menu never came.
|
|
71
|
+
*/
|
|
72
|
+
const ARM_MS = 1_500
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* T70: turn the portaled permission menu into the bottom sheet composer.css
|
|
76
|
+
* section 4 promises, on DSH 0.2.0. Up to 0.1.7 the permission menu rendered
|
|
77
|
+
* inside the permission seat and the stylesheet's descendant selectors
|
|
78
|
+
* reached it; 0.2.0 portals it to body (`portal: true` on the Menu), the
|
|
79
|
+
* selectors stopped matching, and the phone got a tiny floating popup. The
|
|
80
|
+
* host offers no stable attribute to key the portal off — the anchor is a
|
|
81
|
+
* 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
|
|
84
|
+
* only the phone shell loads) turns THAT into the sheet. The portal menu
|
|
85
|
+
* unmounts on close, so the marker's lifetime is exactly the menu's; the
|
|
86
|
+
* 0.1.7 inline structure keeps working through the untouched descendant
|
|
87
|
+
* selectors, and the desktop shell never loads this stylesheet at all.
|
|
88
|
+
*/
|
|
89
|
+
export function installPermissionSheet(ctx: ClientContext): void {
|
|
90
|
+
ctx.effect(() => {
|
|
91
|
+
const phone = window.matchMedia(PHONE_QUERY)
|
|
92
|
+
let armed = false
|
|
93
|
+
let armTimer: ReturnType<typeof setTimeout> | undefined
|
|
94
|
+
|
|
95
|
+
const disarm = (): void => {
|
|
96
|
+
armed = false
|
|
97
|
+
if (armTimer !== undefined) {
|
|
98
|
+
clearTimeout(armTimer)
|
|
99
|
+
armTimer = undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const onCaptureClick = (event: Event): void => {
|
|
104
|
+
const target = event.target instanceof Element ? event.target : null
|
|
105
|
+
if (!clickArmsPermissionSheet(target, phone.matches)) return
|
|
106
|
+
// T70-fix: the trigger TOGGLES. A click that CLOSES the menu lands
|
|
107
|
+
// while the marked menu is still in the DOM (capture runs before the
|
|
108
|
+
// host's own handler unmounts it) — arming there would leave a live
|
|
109
|
+
// 1.5 s window with no menu coming to consume it, and any unrelated
|
|
110
|
+
// body-level menu opening inside that window would be mis-marked as
|
|
111
|
+
// ours. A marked menu present at click time means this click can only
|
|
112
|
+
// 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
|
|
114
|
+
armed = true
|
|
115
|
+
if (armTimer !== undefined) clearTimeout(armTimer)
|
|
116
|
+
armTimer = setTimeout(disarm, ARM_MS)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Mark it if this tick is armed; one menu consumes the arm. */
|
|
120
|
+
const markIfArmed = (root: Element): void => {
|
|
121
|
+
if (!armed) return
|
|
122
|
+
disarm()
|
|
123
|
+
root.setAttribute(SHEET_MARKER, SHEET_VALUE)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Capture phase: the host's own handlers may stop the click's bubble
|
|
127
|
+
// (the trigger toggles `open` itself), so listen on the document in
|
|
128
|
+
// capture and never depend on propagation reaching us.
|
|
129
|
+
document.addEventListener('click', onCaptureClick, true)
|
|
130
|
+
const observer = new MutationObserver((records) => {
|
|
131
|
+
if (!armed) return
|
|
132
|
+
for (const record of records) {
|
|
133
|
+
for (const node of record.addedNodes) {
|
|
134
|
+
if (node instanceof Element && isUnmarkedPortalMenu(node)) markIfArmed(node)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
observer.observe(document.body, { childList: true })
|
|
139
|
+
|
|
140
|
+
return () => {
|
|
141
|
+
document.removeEventListener('click', onCaptureClick, true)
|
|
142
|
+
observer.disconnect()
|
|
143
|
+
disarm()
|
|
144
|
+
// A still-open marked menu outliving this effect keeps its marker only
|
|
145
|
+
// until the user closes it (the element unmounts); scrubbing here
|
|
146
|
+
// would fight a React commit mid-flight for no visual gain.
|
|
147
|
+
}
|
|
148
|
+
}, 'dsh-mobile-nav: permission sheet marker')
|
|
149
|
+
}
|