dsh-zen-remote 1.1.16 → 2.0.0
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 +10051 -4777
- 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 +363 -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 +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -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 +1792 -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/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/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 +164 -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 +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -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 +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -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/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 +151 -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 +251 -0
- package/src/client-data/current-session.ts +57 -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 +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -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 +59 -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,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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ClientContext, MobileSessionRow } from '../compat/types.ts'
|
|
2
|
+
import { mainSessionIdOf } from '../compat/types.ts'
|
|
3
|
+
import { isVirtual } from '../../virtual-id.ts'
|
|
4
|
+
import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The remote-session marker (T41b): the CURRENT main-view session id, read
|
|
8
|
+
* the way the official uiSession `publishMain` reads it (RT
|
|
9
|
+
* dsh-client-ui-session lib/client.js:279-291), stamped onto <html> as
|
|
10
|
+
* `data-zr-remote-session="1"` while it is a virtual (relay) id and removed
|
|
11
|
+
* otherwise. remote-session.css.ts turns the attribute into the hiding of
|
|
12
|
+
* every open-on-the-server-machine entry, so the decision lives at the one
|
|
13
|
+
* place that knows the session and never inside component DOM.
|
|
14
|
+
*
|
|
15
|
+
* The official tie-break is kept verbatim: when the previous answer is still
|
|
16
|
+
* mainView-retained it STAYS the answer, and only otherwise does the first
|
|
17
|
+
* mainView-retained row in catalog order win — so two simultaneously
|
|
18
|
+
* retained rows are decided by "current", never by iteration order. The
|
|
19
|
+
* current value comes from the uiSession service's own main binding source
|
|
20
|
+
* (`current.value.key`); where the service has not registered yet the scan
|
|
21
|
+
* (compat/types.ts mainSessionIdOf) stands in, and a later list or main
|
|
22
|
+
* change re-decides — which is why BOTH sources are subscribed below.
|
|
23
|
+
*
|
|
24
|
+
* Kept outside React for the same reason the header status dot is: the
|
|
25
|
+
* attribute must track the current session regardless of which panel is
|
|
26
|
+
* mounted, and an attribute + stylesheet survives every re-render untouched.
|
|
27
|
+
*/
|
|
28
|
+
export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
29
|
+
ctx.effect(() => {
|
|
30
|
+
const tag = document.createElement('style')
|
|
31
|
+
tag.dataset.plugin = 'dsh-zen-remote'
|
|
32
|
+
tag.dataset.pluginCss = 'dsh-zen-remote/remote-session.css'
|
|
33
|
+
tag.textContent = REMOTE_SESSION_CSS
|
|
34
|
+
document.head.appendChild(tag)
|
|
35
|
+
return () => {
|
|
36
|
+
tag.remove()
|
|
37
|
+
}
|
|
38
|
+
}, 'dsh-zen-remote: remote-session styles')
|
|
39
|
+
|
|
40
|
+
ctx.effect(() => {
|
|
41
|
+
const apply = (): void => {
|
|
42
|
+
const { byId } = ctx.sessions.list.getSnapshot()
|
|
43
|
+
// publishMain's tie-break (see the module comment): prefer the still-
|
|
44
|
+
// retained current main binding, else the first mainView row.
|
|
45
|
+
const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown }, subscribe?: (listener: () => void) => () => void } } | undefined
|
|
46
|
+
const current = uiSession?.current?.value?.key
|
|
47
|
+
const rows = byId as Readonly<Record<string, MobileSessionRow>>
|
|
48
|
+
const sessionId =
|
|
49
|
+
typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
|
|
50
|
+
? current
|
|
51
|
+
: mainSessionIdOf(byId)
|
|
52
|
+
const root = document.documentElement
|
|
53
|
+
if (sessionId !== undefined && isVirtual(sessionId)) root.setAttribute('data-zr-remote-session', '1')
|
|
54
|
+
else root.removeAttribute('data-zr-remote-session')
|
|
55
|
+
}
|
|
56
|
+
apply()
|
|
57
|
+
const offs: Array<() => void> = [ctx.sessions.list.subscribe(apply)]
|
|
58
|
+
// The main binding can move without a list event (a retention change on
|
|
59
|
+
// the current main re-publishes through uiSession's own retain watcher,
|
|
60
|
+
// RT lib/client.js:293-301); subscribe to that source too when it exists.
|
|
61
|
+
const current = (ctx.get('uiSession') as { current?: { subscribe?: (listener: () => void) => () => void } } | undefined)?.current
|
|
62
|
+
if (typeof current?.subscribe === 'function') offs.push(current.subscribe(apply))
|
|
63
|
+
return () => {
|
|
64
|
+
for (const off of offs) off()
|
|
65
|
+
document.documentElement.removeAttribute('data-zr-remote-session')
|
|
66
|
+
}
|
|
67
|
+
}, 'dsh-zen-remote: remote-session attribute')
|
|
68
|
+
}
|
|
@@ -44,6 +44,31 @@ async function desktopConfigured(): Promise<boolean> {
|
|
|
44
44
|
* marker, so scoping here is what keeps this effect Chat-only. */
|
|
45
45
|
const FLOW = '[data-chat-flow]'
|
|
46
46
|
|
|
47
|
+
/** DSH 0.1.7's official process-group root (ChatGroupSeat). A group nests
|
|
48
|
+
* its OWN `data-chat-flow` layer whose rows BORN_FOLDED would hide by kind,
|
|
49
|
+
* while {@link groupsOf} only walks the outer column — so a folded tool call
|
|
50
|
+
* inside a group would vanish with no chip to recover it. While the page has
|
|
51
|
+
* any official group the whole fold steps aside (rules too: the stylesheet
|
|
52
|
+
* keys its born-folded block on {@link ACTIVE_ATTR}, which the suppress path
|
|
53
|
+
* removes), and returns when the last group leaves the DOM. */
|
|
54
|
+
const OFFICIAL_GROUP = '[data-chat-group-key]'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One suppression state transition, given whether an official group is on
|
|
58
|
+
* the page and whether the fold is currently suppressed. `'enter'`: groups
|
|
59
|
+
* appeared — drop every mark and the ACTIVE_ATTR the stylesheet keys on.
|
|
60
|
+
* `'exit'`: the last group left — re-arm and fold again. `'hold'`: nothing
|
|
61
|
+
* to do. Exported pure so the state machine itself is testable off-DOM
|
|
62
|
+
* (test/fold-suppression.test.cjs); {@link scan} applies it.
|
|
63
|
+
*/
|
|
64
|
+
export function foldSuppressionTransition(
|
|
65
|
+
officialGroupPresent: boolean,
|
|
66
|
+
suppressed: boolean,
|
|
67
|
+
): 'enter' | 'exit' | 'hold' {
|
|
68
|
+
if (officialGroupPresent) return suppressed ? 'hold' : 'enter'
|
|
69
|
+
return suppressed ? 'exit' : 'hold'
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
/** Marker + selector of the injected summary row. */
|
|
48
73
|
const CHIP = 'turn-fold'
|
|
49
74
|
const CHIP_SELECTOR = '[data-mobile-nav="turn-fold"]'
|
|
@@ -199,6 +224,10 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
199
224
|
const expanded = new Set<string>()
|
|
200
225
|
let observer: MutationObserver | null = null
|
|
201
226
|
let frame = 0
|
|
227
|
+
/** True while an official process group is on the page (see OFFICIAL_GROUP):
|
|
228
|
+
* ACTIVE_ATTR is off and every mark is cleared; the observer stays open so
|
|
229
|
+
* the fold resumes by itself once the last group leaves. */
|
|
230
|
+
let suppressed = false
|
|
202
231
|
|
|
203
232
|
const label = (chip: HTMLElement, count: number, running: boolean): void => {
|
|
204
233
|
const next = running ? t('turnFoldRunning', { count }) : t('turnFold', { count })
|
|
@@ -208,6 +237,20 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
208
237
|
const scan = (): void => {
|
|
209
238
|
const flow = document.querySelector(FLOW)
|
|
210
239
|
if (flow === null) return
|
|
240
|
+
const transition = foldSuppressionTransition(
|
|
241
|
+
document.querySelector(OFFICIAL_GROUP) !== null,
|
|
242
|
+
suppressed,
|
|
243
|
+
)
|
|
244
|
+
if (transition === 'enter') {
|
|
245
|
+
suppressed = true
|
|
246
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
247
|
+
clear()
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (transition === 'exit') {
|
|
251
|
+
suppressed = false
|
|
252
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
253
|
+
}
|
|
211
254
|
// TurnStatus, ChatView's own running banner — present only while the
|
|
212
255
|
// last turn is still working (lib/client.js:5548).
|
|
213
256
|
const running = flow.querySelector(':scope > [role="status"]') !== null
|
|
@@ -284,6 +327,11 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
284
327
|
* characterData — the fold verdict only changes when elements do.
|
|
285
328
|
*/
|
|
286
329
|
const markWholeRows = (records: readonly MutationRecord[]): void => {
|
|
330
|
+
// Suppressed (an official group is on the page): reasoning-only rows in
|
|
331
|
+
// the OUTER column would still get marked here — the synchronous path
|
|
332
|
+
// runs before the next scan flips anything — and a marked row stays
|
|
333
|
+
// hidden with no chip to recover it.
|
|
334
|
+
if (suppressed) return
|
|
287
335
|
const rows = new Set<Element>()
|
|
288
336
|
for (const record of records) {
|
|
289
337
|
const target = record.target
|
|
@@ -305,6 +353,9 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
305
353
|
// to bring it back. It also drops rows detached since the batch.
|
|
306
354
|
const parent = row.parentElement
|
|
307
355
|
if (parent === null || !parent.matches(FLOW)) continue
|
|
356
|
+
// A group's inner flow layer also matches FLOW; rows inside an
|
|
357
|
+
// official group are never ours to fold (see OFFICIAL_GROUP).
|
|
358
|
+
if (row.closest(OFFICIAL_GROUP) !== null) continue
|
|
308
359
|
const thinks = [...row.querySelectorAll(THINK)]
|
|
309
360
|
if (thinks.length > 0 && foldsWholeRow(row, thinks)) row.setAttribute(FOLD, '')
|
|
310
361
|
else row.removeAttribute(FOLD)
|
|
@@ -366,6 +417,7 @@ export function installTurnFold(ctx: ClientContext): void {
|
|
|
366
417
|
if (frame !== 0) cancelAnimationFrame(frame)
|
|
367
418
|
frame = 0
|
|
368
419
|
expanded.clear()
|
|
420
|
+
suppressed = false
|
|
369
421
|
clear()
|
|
370
422
|
}
|
|
371
423
|
|