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,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T64: the keeper that remembers and restores the SERVER groups' expansion
|
|
3
|
+
* across page loads. The host purges its own expansion record of every key
|
|
4
|
+
* not in the current workspace list the moment that list goes ready
|
|
5
|
+
* (`retainAccountKeys`) — on a sub-client the local workspaces go ready
|
|
6
|
+
* first, so the `zr~…` keys die on every load and every server group comes
|
|
7
|
+
* back collapsed. This module mirrors the host's record into the plugin's
|
|
8
|
+
* OWN storage key while it exists and re-applies it once the host has
|
|
9
|
+
* dropped it, by clicking the group row — a real user click, so the host
|
|
10
|
+
* writes its own state back and the two never disagree.
|
|
11
|
+
*
|
|
12
|
+
* Per tick: sync the host-recorded `zr~` keys into the plugin record
|
|
13
|
+
* (the host's value IS the user's latest choice), then for each server
|
|
14
|
+
* group row the host holds NO record for, click it when its
|
|
15
|
+
* `aria-expanded` disagrees with the wanted state — the plugin record's
|
|
16
|
+
* value, defaulting to EXPANDED (the group was showing its sessions before
|
|
17
|
+
* the purge). A group the host already records is never touched. One group
|
|
18
|
+
* is auto-clicked at most ONCE per page load — if the click did not land
|
|
19
|
+
* (React lag, a lost race with the host's own write), re-clicking would
|
|
20
|
+
* only fight the host. A DOM that does not match the expected shape (no
|
|
21
|
+
* row, no `aria-expanded`) is skipped silently.
|
|
22
|
+
*
|
|
23
|
+
* Runs only in the CLIENT role, via the SAME cached client-config probe as
|
|
24
|
+
* the current-session reporter (T62) — a host has no server groups. The
|
|
25
|
+
* tick cadence is its own 1s interval (the shared shape, not a shared
|
|
26
|
+
* timer): the restore must land right after the workspace list merges,
|
|
27
|
+
* whenever that happens.
|
|
28
|
+
*/
|
|
29
|
+
import {
|
|
30
|
+
HOST_WORKSPACE_VIEW_KEY,
|
|
31
|
+
REMOTE_GROUP_EXPANSION_KEY,
|
|
32
|
+
parseExpansionRecord,
|
|
33
|
+
parseHostGroupExpansion,
|
|
34
|
+
planRemoteGroupExpansion,
|
|
35
|
+
} from '../client-data/remote-group-expansion.ts'
|
|
36
|
+
import { probeClientConfigRole } from './remote-share-register.ts'
|
|
37
|
+
|
|
38
|
+
const CHECK_INTERVAL_MS = 1_000
|
|
39
|
+
|
|
40
|
+
const ROW_SELECTOR = '[role="treeitem"][data-row-key^="workspace:zr~"]'
|
|
41
|
+
const ROW_KEY_PREFIX = 'workspace:'
|
|
42
|
+
|
|
43
|
+
/** The one row face used: read the two attributes, click like a user. */
|
|
44
|
+
interface RowElement {
|
|
45
|
+
getAttribute(name: string): string | null
|
|
46
|
+
click(): void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The storage face used, narrowed to the two methods (structurally typed:
|
|
50
|
+
* this module compiles under the client tsconfig with DOM lib, but tests
|
|
51
|
+
* inject stubs). */
|
|
52
|
+
interface StorageLike {
|
|
53
|
+
getItem(key: string): string | null
|
|
54
|
+
setItem(key: string, value: string): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface DocumentLike {
|
|
58
|
+
querySelectorAll(selectors: string): Iterable<Element>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One matched row: the group id, its current expansion, and the click. */
|
|
62
|
+
interface RowHandle {
|
|
63
|
+
key: string
|
|
64
|
+
expanded: boolean
|
|
65
|
+
click(): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RemoteGroupExpansionKeeperOptions {
|
|
69
|
+
/** Both storage reads and writes. Default: the page's localStorage; a
|
|
70
|
+
* broken or absent one makes every tick a no-op. */
|
|
71
|
+
storage?: StorageLike
|
|
72
|
+
/** The role probe. Default: the shared cached client-config probe. */
|
|
73
|
+
probeRole?: () => Promise<'host' | 'client' | undefined>
|
|
74
|
+
/** The sidebar's server group rows. Default: the real DOM query — rows
|
|
75
|
+
* without a readable `aria-expanded` are skipped. Tests inject fake rows
|
|
76
|
+
* with click recorders. */
|
|
77
|
+
queryRows?: () => RowHandle[]
|
|
78
|
+
/** The re-check interval; default 1000ms. */
|
|
79
|
+
intervalMs?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Start the keeper loop. Returns the disposer (interval) the mounting
|
|
84
|
+
* effect calls on teardown.
|
|
85
|
+
*/
|
|
86
|
+
export function startRemoteGroupExpansionKeeper(options: RemoteGroupExpansionKeeperOptions = {}): () => void {
|
|
87
|
+
const readStorage = (): StorageLike | undefined => {
|
|
88
|
+
const storage = options.storage ?? (globalThis as { localStorage?: StorageLike | null }).localStorage
|
|
89
|
+
return storage !== null && storage !== undefined && typeof storage.getItem === 'function' && typeof storage.setItem === 'function'
|
|
90
|
+
? storage
|
|
91
|
+
: undefined
|
|
92
|
+
}
|
|
93
|
+
const probeRole = options.probeRole ?? probeClientConfigRole
|
|
94
|
+
const queryRows = options.queryRows ?? (() => {
|
|
95
|
+
const doc = (globalThis as { document?: DocumentLike }).document
|
|
96
|
+
if (doc === undefined) return []
|
|
97
|
+
const out: RowHandle[] = []
|
|
98
|
+
for (const el of doc.querySelectorAll(ROW_SELECTOR)) {
|
|
99
|
+
const row = el as unknown as RowElement
|
|
100
|
+
const rowKey = row.getAttribute('data-row-key')
|
|
101
|
+
const expanded = row.getAttribute('aria-expanded')
|
|
102
|
+
if (rowKey === null || expanded === null) continue
|
|
103
|
+
out.push({ key: rowKey.slice(ROW_KEY_PREFIX.length), expanded: expanded === 'true', click: () => row.click() })
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
let role: 'host' | 'client' | undefined
|
|
109
|
+
// One auto-click per group per page load, ever — not per tick. (T66: a
|
|
110
|
+
// group whose ROW disappears has its marker dropped — see the tick.)
|
|
111
|
+
const clickedOnce = new Set<string>()
|
|
112
|
+
let inFlight = false
|
|
113
|
+
|
|
114
|
+
// T66: a storage write/read can throw (a private-mode quota, a poisoned
|
|
115
|
+
// extension wrapper) — one bad key must not reject the tick's promise or
|
|
116
|
+
// kill the loop. A failed read degrades to "absent" (the pure layer's
|
|
117
|
+
// empty-record rule), a failed write is skipped until the next tick.
|
|
118
|
+
const readKey = (storage: StorageLike, key: string): string | null => {
|
|
119
|
+
try {
|
|
120
|
+
return storage.getItem(key)
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const writeKey = (storage: StorageLike, key: string, value: string): void => {
|
|
126
|
+
try {
|
|
127
|
+
storage.setItem(key, value)
|
|
128
|
+
} catch {
|
|
129
|
+
// Silent: the next tick recomputes and tries again.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tick = async (): Promise<void> => {
|
|
134
|
+
if (role === undefined) {
|
|
135
|
+
const answer = await probeRole()
|
|
136
|
+
// A failed probe changes nothing — the next tick asks again; a
|
|
137
|
+
// definite answer is stable for the page's life.
|
|
138
|
+
if (answer !== undefined) role = answer
|
|
139
|
+
}
|
|
140
|
+
if (role !== 'client') return
|
|
141
|
+
const storage = readStorage()
|
|
142
|
+
if (storage === undefined) return
|
|
143
|
+
// A malformed value on either side degrades to an empty record (the
|
|
144
|
+
// pure layer's rule) — a wiped plugin record then re-applies the
|
|
145
|
+
// default-expanded state, which is the honest reading of "no memory".
|
|
146
|
+
const hostRecord = parseHostGroupExpansion(readKey(storage, HOST_WORKSPACE_VIEW_KEY))
|
|
147
|
+
const pluginRecord = parseExpansionRecord(readKey(storage, REMOTE_GROUP_EXPANSION_KEY))
|
|
148
|
+
const rowHandles = queryRows()
|
|
149
|
+
// T66: a group whose row left the DOM (a server switch, a workspace
|
|
150
|
+
// list refresh) loses its once-per-load marker — when the row comes
|
|
151
|
+
// back it may be restored once more, exactly like on a fresh load.
|
|
152
|
+
const presentKeys = new Set(rowHandles.map((handle) => handle.key))
|
|
153
|
+
for (const key of [...clickedOnce]) {
|
|
154
|
+
if (!presentKeys.has(key)) clickedOnce.delete(key)
|
|
155
|
+
}
|
|
156
|
+
const plan = planRemoteGroupExpansion({
|
|
157
|
+
hostRecord,
|
|
158
|
+
pluginRecord,
|
|
159
|
+
rows: rowHandles.map(({ key, expanded }) => ({ key, expanded })),
|
|
160
|
+
})
|
|
161
|
+
const nextRecordJson = JSON.stringify(plan.record)
|
|
162
|
+
if (nextRecordJson !== JSON.stringify(pluginRecord)) {
|
|
163
|
+
writeKey(storage, REMOTE_GROUP_EXPANSION_KEY, nextRecordJson)
|
|
164
|
+
}
|
|
165
|
+
for (const key of plan.clicks) {
|
|
166
|
+
if (clickedOnce.has(key)) continue
|
|
167
|
+
clickedOnce.add(key)
|
|
168
|
+
rowHandles.find((candidate) => candidate.key === key)?.click()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const tickOnce = (): void => {
|
|
172
|
+
// One re-check at a time: a slow tick must not pile up parallel tries.
|
|
173
|
+
if (inFlight) return
|
|
174
|
+
inFlight = true
|
|
175
|
+
void tick().finally(() => { inFlight = false })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const timer = setInterval(() => { tickOnce() }, options.intervalMs ?? CHECK_INTERVAL_MS)
|
|
179
|
+
tickOnce()
|
|
180
|
+
return () => { clearInterval(timer) }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Mount the keeper as one page-lifetime effect, beside the current-session
|
|
185
|
+
* reporter (T62): before the desktop-shell gate in apply(), same client-role
|
|
186
|
+
* probe, same 1s cadence — its own timer, its own file.
|
|
187
|
+
*/
|
|
188
|
+
export function mountRemoteGroupExpansionKeeper(ctx: { effect(fn: () => () => void, name: string): void }, options: RemoteGroupExpansionKeeperOptions = {}): void {
|
|
189
|
+
ctx.effect(() => startRemoteGroupExpansionKeeper(options), 'dsh-zen-remote: remote group expansion keeper')
|
|
190
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles for the T33b session-sharing parts — the title-row remote icon's
|
|
3
|
+
* three states and nothing else (the menu item is an official
|
|
4
|
+
* MenuItemButton and needs no CSS; the settings list draws on the settings
|
|
5
|
+
* block's own zr-settings-* sheet). Injected as one
|
|
6
|
+
* `<style data-plugin="dsh-zen-remote-remote-share">` tag BEFORE apply's
|
|
7
|
+
* desktop gate: the desktop app IS the main server and its title row is
|
|
8
|
+
* where the icon lives. Every selector is scoped under the button's own
|
|
9
|
+
* attribute, so nothing here can reach host elements (the title row's
|
|
10
|
+
* layout especially — this box is a fixed 28px grid item and shrinks for
|
|
11
|
+
* nobody).
|
|
12
|
+
*
|
|
13
|
+
* On the phone shell the icon needs no rules of its own: the mobile
|
|
14
|
+
* stylesheet blanket-hides every child of the
|
|
15
|
+
* `conversation.session.header.actions` slot wrapper (styles/header.css.ts),
|
|
16
|
+
* which this entry is.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const REMOTE_SHARE_CSS = `
|
|
20
|
+
button.zr-remote-toggle[data-zen-remote="remote-toggle"] {
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
width: 28px;
|
|
25
|
+
height: 28px;
|
|
26
|
+
flex-shrink: 0;
|
|
27
|
+
padding: 0;
|
|
28
|
+
border: none;
|
|
29
|
+
border-radius: 6px;
|
|
30
|
+
background: transparent;
|
|
31
|
+
color: var(--dsw-alias-label-tertiary);
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
position: relative;
|
|
34
|
+
}
|
|
35
|
+
button.zr-remote-toggle[data-zen-remote="remote-toggle"]:hover {
|
|
36
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
37
|
+
}
|
|
38
|
+
button.zr-remote-toggle[data-zen-remote="remote-toggle"][data-state="on"],
|
|
39
|
+
button.zr-remote-toggle[data-zen-remote="remote-toggle"][data-state="watched"] {
|
|
40
|
+
color: var(--dsw-alias-state-business-primary, #4f6ef7);
|
|
41
|
+
}
|
|
42
|
+
/* The "a desktop client is watching" dot: a live signal outside the glyph's
|
|
43
|
+
box so it never collides with the 16px artwork. */
|
|
44
|
+
button.zr-remote-toggle[data-zen-remote="remote-toggle"][data-state="watched"]::after {
|
|
45
|
+
content: '';
|
|
46
|
+
position: absolute;
|
|
47
|
+
top: 3px;
|
|
48
|
+
right: 3px;
|
|
49
|
+
width: 6px;
|
|
50
|
+
height: 6px;
|
|
51
|
+
border-radius: 50%;
|
|
52
|
+
background: var(--dsw-alias-state-success-primary, #16a34a);
|
|
53
|
+
}
|
|
54
|
+
`
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration for the T33b session-sharing parts — the work that must run
|
|
3
|
+
* BEFORE apply's desktop-shell gate (the desktop app IS the main server:
|
|
4
|
+
* its title row and session menus are exactly where these parts live),
|
|
5
|
+
* extracted so the Node-side desktop-gate test can drive it against a fake
|
|
6
|
+
* ctx (the components are .tsx; they arrive as parameters, keeping this
|
|
7
|
+
* module JSX-free and importable under Node's type stripping — the same
|
|
8
|
+
* trick register-settings.ts uses).
|
|
9
|
+
*
|
|
10
|
+
* Registers the title-row remote icon on
|
|
11
|
+
* `conversation.session.header.actions` (order 25, past the official jobs
|
|
12
|
+
* entry at 20 so the two never tie) and the remote toggle on
|
|
13
|
+
* `sidebar.workspaces.session.menu.item` (order 500, under the official
|
|
14
|
+
* archive row, behind a group hairline), plus the one stylesheet both
|
|
15
|
+
* surfaces need.
|
|
16
|
+
*
|
|
17
|
+
* The parts render and poll only on the HOST role (T33b-fix): this module
|
|
18
|
+
* wires the shares store's role from the SAME two-level decision the
|
|
19
|
+
* settings page makes (settings-form.ts's shared `settingsRoleOf`) — the
|
|
20
|
+
* configForms row document first, and while the row is silent the effective
|
|
21
|
+
* role probed from `/_dsh/mobile-nav/client-config` (T17: the route carries
|
|
22
|
+
* the merged role). The decision re-runs on every scope snapshot update, so
|
|
23
|
+
* a snapshot that resolves late — or a row whose role appears after the
|
|
24
|
+
* probe already answered — re-decides, and a deployment switched back to
|
|
25
|
+
* host recovers. The wiring hangs off a lazy `ctx.inject(['configForms'], …)`;
|
|
26
|
+
* where the settings service never arrives, one fallback probe wires the
|
|
27
|
+
* role on its own (guarded: a snapshot answer always wins over it).
|
|
28
|
+
*
|
|
29
|
+
* The `sidebar.workspaces.session.menu.item` SlotMap entry is declared here
|
|
30
|
+
* as a structural mirror: this package does not depend on
|
|
31
|
+
* dsh-client-ui-workspace (the slot's owner), whose type-only merges would
|
|
32
|
+
* normally provide it. The mirror records what the workspace browser
|
|
33
|
+
* actually hands its entries: the row props (`sessionId`, `displayTitle`),
|
|
34
|
+
* the `[open, setOpen]` pair it passes as the render occurrence's
|
|
35
|
+
* hookContext, and the list-level inject whose `menuOpenState` hook factory
|
|
36
|
+
* binds that pair — official rows call it `useMenuOpenState` and close the
|
|
37
|
+
* menu with it.
|
|
38
|
+
*/
|
|
39
|
+
import type { ReactNode } from 'react'
|
|
40
|
+
import type { SessionId } from './compat/types.ts'
|
|
41
|
+
import type { ClientContext } from './compat/types.ts'
|
|
42
|
+
import { getSharesStore } from '../client-data/shares.ts'
|
|
43
|
+
import type { SharesStore } from '../client-data/shares.ts'
|
|
44
|
+
import { CLIENT_CONFIG_ROUTE, savedRowRole, settingsRoleOf } from '../client-data/settings-form.ts'
|
|
45
|
+
import type { ClientConfigBody } from '../client-data/settings-form.ts'
|
|
46
|
+
import { NS } from './locales.ts'
|
|
47
|
+
import { REMOTE_SHARE_CSS } from './remote-share-css.ts'
|
|
48
|
+
import { SETTINGS_ENTRY_ID } from './settings/register-settings.ts'
|
|
49
|
+
import type { RemoteHeaderIconProps } from './RemoteHeaderIcon.tsx'
|
|
50
|
+
import type { RemoteShareMenuProps } from './RemoteShareMenu.tsx'
|
|
51
|
+
|
|
52
|
+
/** The `[open, setOpen]` pair the workspace Menu hands every row entry. */
|
|
53
|
+
export type SessionMenuOpenState = readonly [boolean, (open: boolean) => void]
|
|
54
|
+
|
|
55
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
56
|
+
interface SlotMap {
|
|
57
|
+
'sidebar.workspaces.session.menu.item': {
|
|
58
|
+
kind: 'list'
|
|
59
|
+
scope: 'root'
|
|
60
|
+
owner: { sessionId: SessionId, displayTitle: string }
|
|
61
|
+
hookContext: SessionMenuOpenState
|
|
62
|
+
inject: { hooks: { menuOpenState: (context: unknown) => () => SessionMenuOpenState } }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The header icon component (a .tsx factory result; parameter for Node). */
|
|
68
|
+
export type RemoteHeaderIconComponent = (props: RemoteHeaderIconProps) => ReactNode
|
|
69
|
+
/** The menu-item component (a .tsx factory result; parameter for Node). */
|
|
70
|
+
export type RemoteShareMenuComponent = (props: RemoteShareMenuProps) => ReactNode
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The page's one client-config probe (T17's fallback role source). Only a
|
|
74
|
+
* DEFINITE answer is cached — the route answered with a body, so its role
|
|
75
|
+
* reading is stable for the process's lifetime (the role cannot change
|
|
76
|
+
* without a restart). A FAILED probe (network error, non-2xx, unparseable
|
|
77
|
+
* body) resolves `undefined` and is NOT cached: the next snapshot update
|
|
78
|
+
* probes again, and until one answers the role reads unknown — never a
|
|
79
|
+
* guessed host (T33b-fix2). `refetch` bypasses the cache and replaces it
|
|
80
|
+
* with a fresh answer; the wiring uses it when the form scope's status
|
|
81
|
+
* changed (the settings page re-probes on the same signal). Exported since
|
|
82
|
+
* T62: the current-session reporter reuses the same cached probe (one
|
|
83
|
+
* route read per page, whatever part asks first).
|
|
84
|
+
*/
|
|
85
|
+
let probeAnswer: 'host' | 'client' | undefined
|
|
86
|
+
let probeInFlight: Promise<'host' | 'client' | undefined> | undefined
|
|
87
|
+
export function probeClientConfigRole(refetch = false): Promise<'host' | 'client' | undefined> {
|
|
88
|
+
if (!refetch) {
|
|
89
|
+
if (probeAnswer !== undefined) return Promise.resolve(probeAnswer)
|
|
90
|
+
if (probeInFlight !== undefined) return probeInFlight
|
|
91
|
+
}
|
|
92
|
+
// Always tracked so a settled probe — failed ones especially — never
|
|
93
|
+
// lingers: a stale in-flight promise would hand future probes its old
|
|
94
|
+
// `undefined` without asking the route again.
|
|
95
|
+
probeInFlight = fetch(CLIENT_CONFIG_ROUTE)
|
|
96
|
+
.then((res) => (res.ok ? res.json() as Promise<ClientConfigBody> : undefined))
|
|
97
|
+
.then((body) => {
|
|
98
|
+
// A body without a role field (an older build) still reads host —
|
|
99
|
+
// resolveConfig's "anything not exactly 'client'" default; only a
|
|
100
|
+
// request-level failure stays undefined.
|
|
101
|
+
const answer: 'host' | 'client' | undefined = body === undefined ? undefined : body.role === 'client' ? 'client' : 'host'
|
|
102
|
+
if (answer !== undefined) probeAnswer = answer
|
|
103
|
+
return answer
|
|
104
|
+
})
|
|
105
|
+
.catch(() => undefined)
|
|
106
|
+
.finally(() => { probeInFlight = undefined })
|
|
107
|
+
return probeInFlight
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How one probe is issued — the real route reader, or the check/test
|
|
111
|
+
* double. `refetch` mirrors {@link probeClientConfigRole}: true drops any
|
|
112
|
+
* cached answer and asks again. */
|
|
113
|
+
export type ClientConfigProbe = (refetch?: boolean) => Promise<'host' | 'client' | undefined>
|
|
114
|
+
|
|
115
|
+
export interface WireSharesRoleOptions {
|
|
116
|
+
/** Called once a definite verdict was applied (the no-configForms
|
|
117
|
+
* fallback checks this before trusting its own probe). */
|
|
118
|
+
onVerdict?: () => void
|
|
119
|
+
/** The probe to consult while the row document is silent. Defaults to
|
|
120
|
+
* the real client-config reader; tests inject a controllable double so
|
|
121
|
+
* they never depend on module-level probe state. */
|
|
122
|
+
probe?: ClientConfigProbe
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wire a shares store's role off one configForms scope: the saved row role
|
|
127
|
+
* wins (settingsRoleOf), while the row is silent the client-config probe
|
|
128
|
+
* fills the gap, and every scope update re-decides. Verdicts of
|
|
129
|
+
* `'unknown'` — snapshot still loading, probe pending or FAILED — leave
|
|
130
|
+
* the store untouched: nothing polls and nothing renders (never a guessed
|
|
131
|
+
* host). A scope STATUS change (loading→ready, a mirror resync) drops the
|
|
132
|
+
* probe answer and probes again, the same two signals the settings page's
|
|
133
|
+
* probe effect re-runs on.
|
|
134
|
+
* @param scope - the plugin row's shared configuration form scope.
|
|
135
|
+
* @param store - the shares store whose role this wiring drives.
|
|
136
|
+
* @param options - verdict callback and probe injection.
|
|
137
|
+
* @returns disposer releasing the scope subscription.
|
|
138
|
+
*/
|
|
139
|
+
export function wireSharesRole(
|
|
140
|
+
scope: {
|
|
141
|
+
getSnapshot(): { status: 'loading' | 'ready' | 'unavailable', value?: unknown, user?: unknown }
|
|
142
|
+
subscribe(listener: () => void): () => void
|
|
143
|
+
},
|
|
144
|
+
store: Pick<SharesStore, 'setRole'>,
|
|
145
|
+
options: WireSharesRoleOptions = {},
|
|
146
|
+
): () => void {
|
|
147
|
+
const { onVerdict, probe = probeClientConfigRole } = options
|
|
148
|
+
let probed: 'host' | 'client' | undefined
|
|
149
|
+
let lastStatus: 'loading' | 'ready' | 'unavailable' | undefined
|
|
150
|
+
let probing = false
|
|
151
|
+
/** Re-decide. `fromProbe` marks the re-entry after a probe settled: the
|
|
152
|
+
* answer is already in `probed`, and starting another probe here would
|
|
153
|
+
* loop (a failed answer re-probing forever, a cached one starving the
|
|
154
|
+
* event loop). Snapshot updates enter through `apply` instead. */
|
|
155
|
+
const evaluate = (fromProbe: boolean): void => {
|
|
156
|
+
const snap = scope.getSnapshot()
|
|
157
|
+
// The form scope moved between loading/ready/unavailable: the previous
|
|
158
|
+
// probe answer no longer stands in for this state — ask again (the
|
|
159
|
+
// settings page's effect re-runs on exactly this signal).
|
|
160
|
+
const statusChanged = lastStatus !== undefined && snap.status !== lastStatus
|
|
161
|
+
lastStatus = snap.status
|
|
162
|
+
if (statusChanged) probed = undefined
|
|
163
|
+
// The row document answers first; only a row-silent snapshot needs the
|
|
164
|
+
// probe (the settings page's exact rule — see settingsRoleOf). A failed
|
|
165
|
+
// probe is not remembered: the NEXT snapshot update probes again — the
|
|
166
|
+
// probe's own completion path must not (see fromProbe).
|
|
167
|
+
const rowRole = savedRowRole(snap)
|
|
168
|
+
if (!fromProbe && snap.status !== 'loading' && rowRole === undefined && !probing) {
|
|
169
|
+
probing = true
|
|
170
|
+
void probe(statusChanged).then((answer) => {
|
|
171
|
+
probing = false
|
|
172
|
+
// A FAILED probe changes no verdict — leave the role exactly as it
|
|
173
|
+
// was (unknown stays unknown; never a guessed host) and wait for
|
|
174
|
+
// the next snapshot update to try again.
|
|
175
|
+
if (answer === undefined) return
|
|
176
|
+
probed = answer
|
|
177
|
+
evaluate(true)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
const verdict = settingsRoleOf(snap.status, snap, probed)
|
|
181
|
+
if (verdict === 'unknown') return
|
|
182
|
+
onVerdict?.()
|
|
183
|
+
store.setRole(verdict)
|
|
184
|
+
}
|
|
185
|
+
const apply = (): void => { evaluate(false) }
|
|
186
|
+
apply()
|
|
187
|
+
return scope.subscribe(apply)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Register the stylesheet and the two sharing parts. Gate-independent by
|
|
192
|
+
* design — the caller (apply) places this before its desktop gate.
|
|
193
|
+
* @param ctx - client root context.
|
|
194
|
+
* @param headerIcon - the title-row icon component.
|
|
195
|
+
* @param menuItem - the session-menu toggle component.
|
|
196
|
+
*/
|
|
197
|
+
export function registerRemoteShareUi(
|
|
198
|
+
ctx: ClientContext,
|
|
199
|
+
headerIcon: RemoteHeaderIconComponent,
|
|
200
|
+
menuItem: RemoteShareMenuComponent,
|
|
201
|
+
): void {
|
|
202
|
+
ctx.effect(() => {
|
|
203
|
+
const tag = document.createElement('style')
|
|
204
|
+
tag.dataset.plugin = 'dsh-zen-remote-remote-share'
|
|
205
|
+
tag.textContent = REMOTE_SHARE_CSS
|
|
206
|
+
document.head.appendChild(tag)
|
|
207
|
+
return () => { tag.remove() }
|
|
208
|
+
}, 'dsh-zen-remote: remote-share styles')
|
|
209
|
+
|
|
210
|
+
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
211
|
+
name: 'conversation.session.header.actions',
|
|
212
|
+
id: 'remote-share-icon',
|
|
213
|
+
order: 25,
|
|
214
|
+
locale: NS,
|
|
215
|
+
inject: () => ({ shares: getSharesStore() }),
|
|
216
|
+
}, headerIcon))
|
|
217
|
+
|
|
218
|
+
ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
|
|
219
|
+
name: 'sidebar.workspaces.session.menu.item',
|
|
220
|
+
id: 'remote-share',
|
|
221
|
+
order: 500,
|
|
222
|
+
locale: NS,
|
|
223
|
+
inject: () => ({ shares: getSharesStore() }),
|
|
224
|
+
}, menuItem))
|
|
225
|
+
|
|
226
|
+
// The role wiring is lazy on configForms: where the service exists the
|
|
227
|
+
// scope snapshot decides (and keeps deciding on every update); where it
|
|
228
|
+
// never arrives, the fallback probe below wires the role on its own —
|
|
229
|
+
// but a snapshot answer always wins over it.
|
|
230
|
+
const store = getSharesStore()
|
|
231
|
+
let snapshotAnswered = false
|
|
232
|
+
ctx.inject(['configForms'], (formsCtx) => {
|
|
233
|
+
const scope = formsCtx.configForms.get(SETTINGS_ENTRY_ID)
|
|
234
|
+
const off = wireSharesRole(scope, store, { onVerdict: () => { snapshotAnswered = true } })
|
|
235
|
+
formsCtx.effect(() => off, 'dsh-zen-remote: remote-share role')
|
|
236
|
+
})
|
|
237
|
+
void probeClientConfigRole().then((answer) => {
|
|
238
|
+
// A definite answer wires the role; a FAILED probe wires nothing —
|
|
239
|
+
// the parts stay dark (unknown) rather than guessing host
|
|
240
|
+
// (T33b-fix2), and a wiring verdict that arrives later still wins.
|
|
241
|
+
if (snapshotAnswered || answer === undefined) return
|
|
242
|
+
store.setRole(answer)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles for the T34 sub-client status parts — the title-row connection
|
|
3
|
+
* icon's states and the composer's readonly banner. Injected as one
|
|
4
|
+
* `<style data-plugin="dsh-zen-remote-remote-status">` tag BEFORE apply's
|
|
5
|
+
* desktop gate (a desktop app in the client role has remote sessions too; on
|
|
6
|
+
* a host the parts render nothing for local sessions, so the desktop stays
|
|
7
|
+
* pixel-identical).
|
|
8
|
+
*
|
|
9
|
+
* T34-fix: the input itself is disabled through the HOST's component
|
|
10
|
+
* capability — `ctx.conversation.blocks.set(sessionId, { reason })`, the
|
|
11
|
+
* composer-block contract — not through any CSS override; what remains here
|
|
12
|
+
* is scoped under the parts' own attributes and can reach nothing but them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const REMOTE_STATUS_CSS = `
|
|
16
|
+
button.zr-remote-status[data-zen-remote="remote-status"] {
|
|
17
|
+
display: inline-flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
width: 28px;
|
|
21
|
+
height: 28px;
|
|
22
|
+
flex-shrink: 0;
|
|
23
|
+
padding: 0;
|
|
24
|
+
border: none;
|
|
25
|
+
border-radius: 6px;
|
|
26
|
+
background: transparent;
|
|
27
|
+
color: var(--dsw-alias-label-tertiary);
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
}
|
|
30
|
+
button.zr-remote-status[data-zen-remote="remote-status"]:hover {
|
|
31
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
32
|
+
}
|
|
33
|
+
button.zr-remote-status[data-zen-remote="remote-status"][data-state="online"] {
|
|
34
|
+
color: var(--dsw-alias-state-business-primary, #4f6ef7);
|
|
35
|
+
}
|
|
36
|
+
/* offline / revoked / unpaired: the default grey is the state, dimmed a
|
|
37
|
+
little so a dead or unpaired link reads instantly */
|
|
38
|
+
button.zr-remote-status[data-zen-remote="remote-status"][data-state="offline"],
|
|
39
|
+
button.zr-remote-status[data-zen-remote="remote-status"][data-state="revoked"],
|
|
40
|
+
button.zr-remote-status[data-zen-remote="remote-status"][data-state="unpaired"] {
|
|
41
|
+
color: var(--dsw-alias-label-tertiary);
|
|
42
|
+
opacity: 0.75;
|
|
43
|
+
}
|
|
44
|
+
button.zr-remote-status[data-zen-remote="remote-status"][data-state="mismatch"] {
|
|
45
|
+
color: var(--dsw-alias-state-warning-primary, #d97706);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* The composer banner: a dock entry, so on desktop it is one full-width row
|
|
49
|
+
of the composer column (the slot anchor is display:contents and the
|
|
50
|
+
composer column is a flex column — the item stretches to the column
|
|
51
|
+
width WITHOUT an explicit width, which would stack with the side margins
|
|
52
|
+
and overflow the input column, T34-fix). Styled after the host's own
|
|
53
|
+
composer notice, standalone. */
|
|
54
|
+
.zr-remote-banner[data-zen-remote="remote-banner"] {
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
margin: 0 max(16px, var(--dsh-composer-side-clearance, 16px));
|
|
57
|
+
border-radius: var(--dsw-radius-md, 8px);
|
|
58
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
|
|
59
|
+
color: var(--dsw-alias-label-secondary, inherit);
|
|
60
|
+
padding: 6px 10px;
|
|
61
|
+
font-size: 12px;
|
|
62
|
+
line-height: 18px;
|
|
63
|
+
}
|
|
64
|
+
`
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration for the T34 sub-client status parts — the work that must run
|
|
3
|
+
* BEFORE apply's desktop gate (a desktop app in the client role has remote
|
|
4
|
+
* sessions too), extracted so the Node-side desktop-gate test can drive it
|
|
5
|
+
* against a fake ctx (the components are .tsx; they arrive as parameters,
|
|
6
|
+
* keeping this module JSX-free and importable under Node's type stripping —
|
|
7
|
+
* the same trick register-settings.ts / remote-share-register.ts use).
|
|
8
|
+
*
|
|
9
|
+
* Registers the title-row connection icon on
|
|
10
|
+
* `conversation.session.header.actions` (order 26, past the T33b share icon
|
|
11
|
+
* at 25 so the two never tie) and the composer banner on
|
|
12
|
+
* `conversation.input.dock` (order 15, between the official todo panel at 0
|
|
13
|
+
* and the queue dock at 20), plus the one stylesheet both surfaces need.
|
|
14
|
+
*
|
|
15
|
+
* The banner's disable rides the HOST's composer-block contract (T34-fix):
|
|
16
|
+
* the inject face hands the component a per-session `setComposerBlock`
|
|
17
|
+
* binding over `ctx.conversation.blocks` (dsh-client-ui-conversation's
|
|
18
|
+
* `ComposerBlocks`, resolved lazily at call time — the row context may still
|
|
19
|
+
* be assembling when this registration runs, and the composer that consumes
|
|
20
|
+
* the block is the conversation plugin's own UI, so the service is there by
|
|
21
|
+
* the time a banner can stand). A composition without the service degrades
|
|
22
|
+
* to a no-op binding: the banner still renders, the disable is simply absent.
|
|
23
|
+
*
|
|
24
|
+
* No role wiring here, unlike the T33b parts: the store only polls while a
|
|
25
|
+
* part is SUBSCRIBED, and both parts gate the subscription itself on the
|
|
26
|
+
* virtual id (remote-status.ts's subscribeIfVirtual — a local session never
|
|
27
|
+
* opens one) — so a host page never polls, without needing the settings
|
|
28
|
+
* role to tell it apart.
|
|
29
|
+
*/
|
|
30
|
+
import type { ReactNode } from 'react'
|
|
31
|
+
import type { ClientContext, SessionId } from './compat/types.ts'
|
|
32
|
+
import { getRemoteStatusStore } from '../client-data/remote-status.ts'
|
|
33
|
+
import { NS } from './locales.ts'
|
|
34
|
+
import { REMOTE_STATUS_CSS } from './remote-status-css.ts'
|
|
35
|
+
import type { RemoteStatusIconProps } from './RemoteStatusIcon.tsx'
|
|
36
|
+
import type { RemoteComposerBannerProps } from './RemoteComposerBanner.tsx'
|
|
37
|
+
|
|
38
|
+
/** The icon component (a .tsx factory result; parameter for Node). */
|
|
39
|
+
export type RemoteStatusIconComponent = (props: RemoteStatusIconProps) => ReactNode
|
|
40
|
+
/** The composer banner component (a .tsx factory result; parameter for Node). */
|
|
41
|
+
export type RemoteComposerBannerComponent = (props: RemoteComposerBannerProps) => ReactNode
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The one face of `ctx.conversation.blocks` (dsh-client-ui-conversation,
|
|
45
|
+
* `contract/composer-blocks.d.ts`) this plugin uses. A structural mirror:
|
|
46
|
+
* the providing package is a peerDependency whose type-only merges the
|
|
47
|
+
* tsconfig paths already pull in for slots, but the service itself is read
|
|
48
|
+
* lazily through `ctx.get` — the mirror keeps this module honest without
|
|
49
|
+
* hard-depending on the host's type layout.
|
|
50
|
+
*/
|
|
51
|
+
interface ComposerBlocksLike {
|
|
52
|
+
set(sessionId: SessionId, block: { readonly reason: string } | undefined): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the composer-block registry at CALL time, or undefined where the
|
|
56
|
+
* conversation service never arrived (compositions without a composer — or a
|
|
57
|
+
* context shape without the lazy getter at all). */
|
|
58
|
+
function composerBlocksOf(ctx: ClientContext): ComposerBlocksLike | undefined {
|
|
59
|
+
if (typeof ctx.get !== 'function') return undefined
|
|
60
|
+
const conversation = ctx.get('conversation') as { blocks?: ComposerBlocksLike } | undefined
|
|
61
|
+
return conversation?.blocks
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register the stylesheet and the two status parts. Gate-independent by
|
|
66
|
+
* design — the caller (apply) places this before its desktop gate.
|
|
67
|
+
* @param ctx - client root context.
|
|
68
|
+
* @param icon - the title-row connection icon component.
|
|
69
|
+
* @param banner - the composer banner component.
|
|
70
|
+
*/
|
|
71
|
+
export function registerRemoteStatusUi(
|
|
72
|
+
ctx: ClientContext,
|
|
73
|
+
icon: RemoteStatusIconComponent,
|
|
74
|
+
banner: RemoteComposerBannerComponent,
|
|
75
|
+
): void {
|
|
76
|
+
ctx.effect(() => {
|
|
77
|
+
const tag = document.createElement('style')
|
|
78
|
+
tag.dataset.plugin = 'dsh-zen-remote-remote-status'
|
|
79
|
+
tag.textContent = REMOTE_STATUS_CSS
|
|
80
|
+
document.head.appendChild(tag)
|
|
81
|
+
return () => { tag.remove() }
|
|
82
|
+
}, 'dsh-zen-remote: remote-status styles')
|
|
83
|
+
|
|
84
|
+
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
85
|
+
name: 'conversation.session.header.actions',
|
|
86
|
+
id: 'remote-status-icon',
|
|
87
|
+
order: 26,
|
|
88
|
+
locale: NS,
|
|
89
|
+
inject: () => ({ status: getRemoteStatusStore() }),
|
|
90
|
+
}, icon))
|
|
91
|
+
|
|
92
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
93
|
+
name: 'conversation.input.dock',
|
|
94
|
+
id: 'remote-status-banner',
|
|
95
|
+
order: 15,
|
|
96
|
+
locale: NS,
|
|
97
|
+
inject: (sessionId: SessionId) => ({
|
|
98
|
+
status: getRemoteStatusStore(),
|
|
99
|
+
// Raise/clear THIS session's composer block (undefined clears — the
|
|
100
|
+
// contract's own clear method; `forget` would drop the store
|
|
101
|
+
// entirely and is the host's teardown verb, not ours).
|
|
102
|
+
setComposerBlock: (reason: string | undefined) => {
|
|
103
|
+
composerBlocksOf(ctx)?.set(sessionId, reason === undefined ? undefined : { reason })
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
}, banner))
|
|
107
|
+
}
|