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.
Files changed (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. 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
+ }