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,164 @@
1
+ /**
2
+ * T62: the browser half of the current-session signal. The intercept layer
3
+ * that decides whether a closed-remote session hides runs in the BACKEND
4
+ * Node process (it wraps the host's typert gateway there — src/index.ts's
5
+ * installIntercept), so THIS is the only place the host's persisted
6
+ * selection (localStorage `dsh.sessions.current`, the workspace UI's
7
+ * selection store) is readable. The reporter watches it and POSTs every
8
+ * CHANGE to the backend route; the route stores the value and makes the
9
+ * intercept re-judge at once, with a 1s backend poll as the fallback.
10
+ *
11
+ * Re-check points: page load, the `storage` event (a DIFFERENT tab or
12
+ * window changed the key — same-tab writes fire no storage event), the
13
+ * page becoming visible again, and a 1s interval that doubles as the retry
14
+ * path: a failed POST leaves the last-reported value untouched, so the
15
+ * next tick re-sends it (same-tab navigations are what the interval is
16
+ * really for). Failures are silent — background plumbing must never log
17
+ * or toast.
18
+ *
19
+ * Runs only in the CLIENT role (a host has no relay and no intercept): the
20
+ * role comes from the SAME cached `client-config` probe the share parts
21
+ * use — a definite 'client' starts reporting, a 'host' never reports, and
22
+ * an undecided probe re-asks on each tick (once it lands the answer is
23
+ * stable for the process's lifetime).
24
+ */
25
+ import { CURRENT_SESSION_STORAGE_KEY, currentSessionReportEquals, parseCurrentSessionStorage } from '../client-data/current-session.ts'
26
+ import type { CurrentSessionReport } from '../client-data/current-session.ts'
27
+ import { probeClientConfigRole } from './remote-share-register.ts'
28
+
29
+ /** Default POST target, mirrored from src/client-routes.ts (the client
30
+ * tsconfig cannot reach the host file). */
31
+ const CURRENT_SESSION_ROUTE = '/_dsh/zen-remote/client/current-session'
32
+
33
+ /** The re-check cadence — the same 1s the backend's fallback poll uses. */
34
+ const CHECK_INTERVAL_MS = 1_000
35
+
36
+ /**
37
+ * How often the current value is re-sent even when unchanged (T66): the
38
+ * reporter otherwise posts only CHANGES, but the backend can be reset
39
+ * underneath the page — a plugin row reload re-runs apply() and drops its
40
+ * stored value — leaving the page convinced it already reported. One
41
+ * unconditional re-post per window keeps the two ends converging without
42
+ * waiting for the user to navigate.
43
+ */
44
+ const RESEND_INTERVAL_MS = 30_000
45
+
46
+ /** The storage face used, narrowed to the one method (structurally typed:
47
+ * this module compiles under the client tsconfig with DOM lib, but tests
48
+ * inject stubs). */
49
+ interface StorageLike {
50
+ getItem(key: string): string | null
51
+ }
52
+
53
+ export interface CurrentSessionReporterOptions {
54
+ /** The storage reader. Default: the page's localStorage; a broken or
55
+ * absent one makes every read `unavailable` and nothing is ever posted. */
56
+ storage?: StorageLike
57
+ /** Outbound POST transport. Default: the page's fetch. */
58
+ fetchImpl?: (url: string, init: RequestInit) => Promise<unknown>
59
+ /** The role probe. Default: the shared cached client-config probe. */
60
+ probeRole?: () => Promise<'host' | 'client' | undefined>
61
+ /** POST target. Default: the current-session route. */
62
+ route?: string
63
+ /** The re-check interval; default 1000ms. */
64
+ intervalMs?: number
65
+ /** The unconditional re-send window (T66); default 30000ms. */
66
+ resendIntervalMs?: number
67
+ }
68
+
69
+ /** One reportable value — `unavailable` never travels. */
70
+ type ReportableReport = Exclude<CurrentSessionReport, { kind: 'unavailable' }>
71
+
72
+ /**
73
+ * Start the reporter loop. Returns the disposer (listeners + interval) the
74
+ * mounting effect calls on teardown.
75
+ */
76
+ export function startCurrentSessionReporter(options: CurrentSessionReporterOptions = {}): () => void {
77
+ const readRaw = (): string | null | undefined => {
78
+ const storage = options.storage ?? (globalThis as { localStorage?: StorageLike | null }).localStorage
79
+ if (storage === null || storage === undefined || typeof storage.getItem !== 'function') return undefined
80
+ try {
81
+ return storage.getItem(CURRENT_SESSION_STORAGE_KEY)
82
+ } catch {
83
+ return undefined
84
+ }
85
+ }
86
+ const post = (report: ReportableReport): Promise<unknown> => {
87
+ const doFetch = options.fetchImpl ?? fetch
88
+ const body = report.kind === 'open' ? { sessionId: report.sessionId } : { sessionId: null }
89
+ return doFetch(options.route ?? CURRENT_SESSION_ROUTE, {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json; charset=utf-8' },
92
+ body: JSON.stringify(body),
93
+ })
94
+ }
95
+ const probeRole = options.probeRole ?? probeClientConfigRole
96
+
97
+ // The last value the backend ACCEPTED (undefined = nothing posted yet).
98
+ // A failed POST must not advance it — that is the whole retry rule: the
99
+ // value still differs, so the next change or the next check re-sends.
100
+ // T66: the timestamp of the last accepted post drives the slow unconditional
101
+ // re-send (0 = due immediately, which is the page-load post).
102
+ let lastReported: CurrentSessionReport | undefined
103
+ let lastSentAt = 0
104
+ const resendEveryMs = options.resendIntervalMs ?? RESEND_INTERVAL_MS
105
+ let role: 'host' | 'client' | undefined
106
+ let inFlight = false
107
+
108
+ const tick = async (): Promise<void> => {
109
+ if (role === undefined) {
110
+ const answer = await probeRole()
111
+ // A failed probe changes nothing — the next tick asks again; a
112
+ // definite answer is stable for the page's life.
113
+ if (answer !== undefined) role = answer
114
+ }
115
+ if (role !== 'client') return
116
+ const report = parseCurrentSessionStorage(readRaw())
117
+ if (report.kind === 'unavailable') return
118
+ const now = Date.now()
119
+ // T66: inside the re-send window an unchanged value says nothing; once
120
+ // the window passes, the CURRENT value re-posts even unchanged — the
121
+ // backend may have been reset underneath the page.
122
+ const resendDue = now - lastSentAt >= resendEveryMs
123
+ if (!resendDue && lastReported !== undefined && currentSessionReportEquals(report, lastReported)) return
124
+ try {
125
+ await post(report)
126
+ lastReported = report
127
+ lastSentAt = now
128
+ } catch {
129
+ // Silent: the next tick sees the unchanged lastReported and retries.
130
+ }
131
+ }
132
+ const tickOnce = (): void => {
133
+ // One re-check at a time: a slow POST must not pile up parallel tries.
134
+ if (inFlight) return
135
+ inFlight = true
136
+ void tick().finally(() => { inFlight = false })
137
+ }
138
+
139
+ const globalObj = globalThis as {
140
+ window?: { addEventListener(type: string, fn: () => void): void, removeEventListener(type: string, fn: () => void): void }
141
+ document?: { addEventListener(type: string, fn: () => void): void, removeEventListener(type: string, fn: () => void): void }
142
+ }
143
+ const onStorage = (): void => { tickOnce() }
144
+ const onVisibility = (): void => { tickOnce() }
145
+ globalObj.window?.addEventListener('storage', onStorage)
146
+ globalObj.document?.addEventListener('visibilitychange', onVisibility)
147
+ const timer = setInterval(() => { tickOnce() }, options.intervalMs ?? CHECK_INTERVAL_MS)
148
+ tickOnce()
149
+ return () => {
150
+ clearInterval(timer)
151
+ globalObj.window?.removeEventListener('storage', onStorage)
152
+ globalObj.document?.removeEventListener('visibilitychange', onVisibility)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Mount the reporter as one page-lifetime effect. The caller places this
158
+ BEFORE the desktop-shell gate in apply(): the desktop app in the client
159
+ * role is exactly where remote sessions live, so the report must not wait
160
+ * on a width gate. On a host the loop idles after one probe answer.
161
+ */
162
+ export function mountCurrentSessionReporter(ctx: { effect(fn: () => () => void, name: string): void }, options: CurrentSessionReporterOptions = {}): void {
163
+ ctx.effect(() => startCurrentSessionReporter(options), 'dsh-zen-remote: current-session report')
164
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ClientContext } from '../compat/types.ts'
2
+ import { mainSessionIdOf } from '../compat/types.ts'
2
3
  import { dotState } from '../session-dot.ts'
3
4
 
4
5
  /**
@@ -18,30 +19,33 @@ export function installHeaderStatusDot(ctx: ClientContext): void {
18
19
  const apply = (): void => {
19
20
  const frame = document.querySelector('[data-mobile-nav="frame"]')
20
21
  if (frame === null) return
21
- const { current, byId } = ctx.sessions.list.getSnapshot()
22
+ // 0.1.7: SessionListState.current is gone — the main-view session
23
+ // derives from the catalog rows (mainSessionIdOf). The pending flag
24
+ // moved with it: uiSession.pendingInteractions is gone too, the fact
25
+ // now rides ctx.uiSession.sessionStatus (running + pendingInteraction
26
+ // per session). Probed fresh on every dot recompute so registration
27
+ // order never matters, same as before.
28
+ const { byId } = ctx.sessions.list.getSnapshot()
29
+ const current = mainSessionIdOf(byId)
22
30
  const row = current === undefined ? undefined : byId[current]
23
- // 0.1.2 的待处理交互来源,每次现查:本插件的 inject 不含 uiSession(加了
24
- // 0.1.1 就不激活),apply 此刻服务未必注册好;每次算 dot 时现查,注册顺序
25
- // 就不再是问题。0.1.1 上没有这个服务,这里恒 undefined,黄点只由行字段决定。
26
- const uiSession = ctx.get('uiSession')
27
- const pending = current === undefined ? false
28
- : uiSession?.pendingInteractions.getSnapshot().has(current) === true
31
+ const status = current === undefined ? undefined
32
+ : ctx.get('uiSession')?.sessionStatus.getSnapshot().get(current)
33
+ const pending = status?.pendingInteraction !== undefined && status.pendingInteraction !== null
29
34
  const state = row === undefined ? undefined : dotState(row, pending)
30
35
  if (state === undefined) frame.removeAttribute('data-mobile-nav-dot')
31
36
  else frame.setAttribute('data-mobile-nav-dot', state)
32
37
  }
33
38
  apply()
34
- // Two sources can push the dot now (session list + the 0.1.2 pending
35
- // map); probe uiSession once more right here and subscribe to whichever
36
- // exist. 0.1.1 only ever has the first. Degradation note: if uiSession
37
- // has not registered by this moment (this plugin ran before it), only
38
- // the list subscription is attached — the dot stays correct because
39
- // every later list change re-runs apply(), whose fresh probe then sees
40
- // the service; a pending-only change before any list change would be
41
- // missed until then.
39
+ // Two sources can push the dot (session list + the unified session
40
+ // status table); probe uiSession once more right here and subscribe to
41
+ // whichever exist. Degradation note: if uiSession has not registered by
42
+ // this moment (this plugin ran before it), only the list subscription
43
+ // is attached — the dot stays correct because every later list change
44
+ // re-runs apply(), whose fresh probe then sees the service; a
45
+ // pending-only change before any list change would be missed until then.
42
46
  const unsubscribes: Array<() => void> = [ctx.sessions.list.subscribe(apply)]
43
- const pendingSource = ctx.get('uiSession')?.pendingInteractions
44
- if (pendingSource !== undefined) unsubscribes.push(pendingSource.subscribe(apply))
47
+ const statusSource = ctx.get('uiSession')?.sessionStatus
48
+ if (statusSource !== undefined) unsubscribes.push(statusSource.subscribe(apply))
45
49
  return () => {
46
50
  for (const off of unsubscribes) off()
47
51
  }
@@ -6,6 +6,23 @@ const PHONE_QUERY = '(max-width: 767px)'
6
6
  /** The official composer slot wrapper (same marker composer.css.ts styles). */
7
7
  const COMPOSER = '[data-slot="conversation.composer.bar"]'
8
8
 
9
+ /**
10
+ * T72: the model sheet's search input on DSH 0.2.0-rc.2 — a `role=searchbox`
11
+ * `<input>` inside the portaled model menu (composer.css.ts's MODEL_MENU
12
+ * body form; on rc.2 the model pane portals as `role=group` with the search
13
+ * row inside). The host focuses it the moment the user drills into the
14
+ * model pane, which pops the phone keyboard over the sheet.
15
+ */
16
+ const MODEL_MENU_ROOT = 'body > [id$="-menu"]'
17
+ const MODEL_SEARCH = `${MODEL_MENU_ROOT} [role="searchbox"]`
18
+ /** The focus target when the search's autofocus must be retracted: the
19
+ * currently selected model row, the exact element the host's own arrow-key
20
+ * navigation focuses first (model-selection-client.js moveFocus). */
21
+ const SELECTED_OPTION = '[role="menuitemradio"][aria-checked="true"]:not([disabled])'
22
+ /** The host's fallback when no row is checked (filtered/empty list): the
23
+ * first selectable option row. */
24
+ const ANY_OPTION = '[role="menuitemradio"]:not([disabled])'
25
+
9
26
  /** A tap/keystroke older than this no longer explains a focus. */
10
27
  const INTENT_WINDOW_MS = 1000
11
28
 
@@ -43,6 +60,10 @@ export function installKeyboardGuard(ctx: ClientContext): void {
43
60
  const narrow = window.matchMedia(PHONE_QUERY)
44
61
  let lastIntent = 0
45
62
  let granted = false
63
+ // T72: the search field's own grant — separate from the composer's,
64
+ // cleared when the field blurs, so a later programmatic focus (drilling
65
+ // back into the pane) is retracted again.
66
+ let searchGranted = false
46
67
  let observer: MutationObserver | null = null
47
68
  let frame = 0
48
69
 
@@ -60,6 +81,31 @@ export function installKeyboardGuard(ctx: ClientContext): void {
60
81
  return node.tagName === 'TEXTAREA' ? node : null
61
82
  }
62
83
 
84
+ /**
85
+ * T72: the model sheet's search input. Unlike the composer field this
86
+ * one is NOT blurred to the body — the model menu's root has an onBlur
87
+ * that CLOSES the menu when the focus leaves root and menu (rc.2 model-
88
+ * selection-client.js: `relatedTarget` outside both → `close()`), so
89
+ * blurring would slam the sheet shut. The focus moves INSIDE the menu
90
+ * instead: to the selected model row, the element the host's own
91
+ * keyboard navigation focuses first. Phone keyboards have no arrow keys,
92
+ * so the host's search-focus-follows-highlight logic losing focus costs
93
+ * nothing there.
94
+ */
95
+ const modelSearchField = (node: unknown): HTMLElement | null => {
96
+ if (!(node instanceof HTMLElement)) return null
97
+ if (node.getAttribute('role') !== 'searchbox') return null
98
+ return node.closest(MODEL_SEARCH)
99
+ }
100
+
101
+ /** Retract the search autofocus WITHOUT closing the menu (see above). */
102
+ const retractSearchFocus = (field: HTMLElement): void => {
103
+ const menu = field.closest(MODEL_MENU_ROOT)
104
+ const selected = menu?.querySelector<HTMLElement>(SELECTED_OPTION) ?? menu?.querySelector<HTMLElement>(ANY_OPTION)
105
+ if (selected !== null && selected !== undefined) selected.focus()
106
+ else field.blur()
107
+ }
108
+
63
109
  const onPointerDown = (event: PointerEvent): void => {
64
110
  // Only the textarea itself grants the keyboard. A tap on any other
65
111
  // composer control (slash-command toggle, attach, model menu, send)
@@ -69,12 +115,23 @@ export function installKeyboardGuard(ctx: ClientContext): void {
69
115
  lastIntent = Date.now()
70
116
  granted = true
71
117
  }
118
+ // T72: a tap ON the search field is the user asking for the keyboard
119
+ // — typing to filter models must work.
120
+ if (modelSearchField(event.target) !== null) searchGranted = true
72
121
  }
73
122
  const onKeyDown = (): void => {
74
123
  lastIntent = Date.now()
75
124
  if (composerField(document.activeElement) !== null) granted = true
76
125
  }
77
126
  const sweep = (): void => {
127
+ // The search field is judged on its own grant, and a retracted focus
128
+ // never leaves the menu (see modelSearchField — blur would close it).
129
+ const search = modelSearchField(document.activeElement)
130
+ if (search !== null) {
131
+ if (searchGranted) return
132
+ retractSearchFocus(search)
133
+ return
134
+ }
78
135
  const el = composerField(document.activeElement)
79
136
  if (el === null) return
80
137
  if (granted || Date.now() - lastIntent < INTENT_WINDOW_MS) {
@@ -84,10 +141,16 @@ export function installKeyboardGuard(ctx: ClientContext): void {
84
141
  el.blur()
85
142
  }
86
143
  const onFocusIn = (event: FocusEvent): void => {
87
- if (composerField(event.target) === null) return
144
+ if (composerField(event.target) === null && modelSearchField(event.target) === null) return
88
145
  sweep()
89
146
  }
90
147
  const onFocusOut = (event: FocusEvent): void => {
148
+ // T72: the search field's grant dies with its focus — the next
149
+ // programmatic focus is retracted again.
150
+ if (modelSearchField(event.target) !== null) {
151
+ searchGranted = false
152
+ return
153
+ }
91
154
  if (composerField(event.target) === null) return
92
155
  granted = false
93
156
  }
@@ -122,6 +185,7 @@ export function installKeyboardGuard(ctx: ClientContext): void {
122
185
  if (frame !== 0) window.clearTimeout(frame)
123
186
  frame = 0
124
187
  granted = false
188
+ searchGranted = false
125
189
  }
126
190
 
127
191
  if (narrow.matches) attach()
@@ -0,0 +1,149 @@
1
+ import type { ClientContext } from '../compat/types.ts'
2
+
3
+ /** Phone breakpoint — the same query every phone-only effect in this plugin uses. */
4
+ const PHONE_QUERY = '(max-width: 767px)'
5
+
6
+ /**
7
+ * The permission trigger, addressed the way composer.css.ts addresses the
8
+ * whole permission seat: a structured slot plus the build-suffix trigger
9
+ * class (DSH 0.2.0 keeps both). The trigger carries NO aria-expanded and
10
+ * NO aria-controls on 0.2.0 (the Menu primitive portals its list to body
11
+ * and links nothing back), so the click is the only open signal there is.
12
+ */
13
+ const TRIGGER_SELECTOR = '[data-slot="conversation.input.permission"] button[class$="_trigger"]'
14
+
15
+ /**
16
+ * What the Menu primitive portals on 0.2.0: a `role=menu` div DIRECTLY under
17
+ * body, carrying `data-menu-material` and a randomized `--dsh-menu-anchor`
18
+ * (a per-instance useId) but NO id and no back-reference from the trigger —
19
+ * there is no pure-CSS way to pick OUR menu out of the portal crowd: the
20
+ * host has MANY `portal: true` Menus (agent-preset, chat preference rows,
21
+ * the Enter-behavior row, the workspace sidebar's 「…」, open-in-app,
22
+ * deliverables, the copy button's confirm, …), and none of them may become
23
+ * a bottom sheet. That is why this effect exists: a click on the permission
24
+ * trigger arms a short window and the next body-level menu to land is OURS
25
+ * to mark — while a marked menu still on the page (the close click) keeps
26
+ * the window shut.
27
+ */
28
+ const PORTAL_MENU_SELECTOR = 'body > div[role="menu"]'
29
+
30
+ /** The marker composer.css.ts section 4 styles into the bottom sheet. */
31
+ const SHEET_MARKER = 'data-zen-sheet'
32
+ const SHEET_VALUE = 'perm'
33
+
34
+ /** The DOM face the two pure decisions below read — structural, so the
35
+ * tests drive them with plain objects instead of a DOM. */
36
+ export interface PermissionSheetCandidate {
37
+ matches(selectors: string): boolean
38
+ getAttribute(name: string): string | null
39
+ /** A truthy `closest` result means the click started in the seat. */
40
+ closest(selectors: string): unknown
41
+ }
42
+
43
+ /**
44
+ * Whether ONE body-level node is a portal menu that does not yet carry the
45
+ * sheet marker. Pure over the node's own methods: `matches` answers the
46
+ * body-level role=menu shape, `getAttribute` the marker.
47
+ */
48
+ export function isUnmarkedPortalMenu(node: PermissionSheetCandidate): boolean {
49
+ return node.matches(PORTAL_MENU_SELECTOR) && node.getAttribute(SHEET_MARKER) === null
50
+ }
51
+
52
+ /**
53
+ * Whether one click event's target arms the marking window: the phone shell
54
+ * only, and only a click that started inside the permission seat's trigger.
55
+ * Any other button — the sidebar's 「…」, a copy button — never arms, so
56
+ * the menus they open are never marked.
57
+ */
58
+ export function clickArmsPermissionSheet(target: PermissionSheetCandidate | null | undefined, phoneMatches: boolean): boolean {
59
+ if (!phoneMatches) return false
60
+ const seat = target?.closest(TRIGGER_SELECTOR)
61
+ return seat !== undefined && seat !== null
62
+ }
63
+
64
+ /**
65
+ * How long an OPEN click stays armed. The portal lands in the same React
66
+ * commit as the state flip, so milliseconds of real latency; the window is
67
+ * consumed by the menu that opens with it, or expires on its own — the
68
+ * close path never arms at all (the marked menu is still on the page, see
69
+ * the guard in onCaptureClick), so the ceiling's only job is retiring a
70
+ * window whose menu never came.
71
+ */
72
+ const ARM_MS = 1_500
73
+
74
+ /**
75
+ * T70: turn the portaled permission menu into the bottom sheet composer.css
76
+ * section 4 promises, on DSH 0.2.0. Up to 0.1.7 the permission menu rendered
77
+ * inside the permission seat and the stylesheet's descendant selectors
78
+ * reached it; 0.2.0 portals it to body (`portal: true` on the Menu), the
79
+ * selectors stopped matching, and the phone got a tiny floating popup. The
80
+ * host offers no stable attribute to key the portal off — the anchor is a
81
+ * per-instance useId, there is no id and no aria-controls — so this effect
82
+ * marks it: the trigger's click arms a window, the observer stamps the next
83
+ * body-level menu with `data-zen-sheet="perm"`, and the stylesheet (which
84
+ * only the phone shell loads) turns THAT into the sheet. The portal menu
85
+ * unmounts on close, so the marker's lifetime is exactly the menu's; the
86
+ * 0.1.7 inline structure keeps working through the untouched descendant
87
+ * selectors, and the desktop shell never loads this stylesheet at all.
88
+ */
89
+ export function installPermissionSheet(ctx: ClientContext): void {
90
+ ctx.effect(() => {
91
+ const phone = window.matchMedia(PHONE_QUERY)
92
+ let armed = false
93
+ let armTimer: ReturnType<typeof setTimeout> | undefined
94
+
95
+ const disarm = (): void => {
96
+ armed = false
97
+ if (armTimer !== undefined) {
98
+ clearTimeout(armTimer)
99
+ armTimer = undefined
100
+ }
101
+ }
102
+
103
+ const onCaptureClick = (event: Event): void => {
104
+ const target = event.target instanceof Element ? event.target : null
105
+ if (!clickArmsPermissionSheet(target, phone.matches)) return
106
+ // T70-fix: the trigger TOGGLES. A click that CLOSES the menu lands
107
+ // while the marked menu is still in the DOM (capture runs before the
108
+ // host's own handler unmounts it) — arming there would leave a live
109
+ // 1.5 s window with no menu coming to consume it, and any unrelated
110
+ // body-level menu opening inside that window would be mis-marked as
111
+ // ours. A marked menu present at click time means this click can only
112
+ // be the close (or a no-op re-open), never an opening we must catch.
113
+ if (document.querySelector(`${PORTAL_MENU_SELECTOR}[${SHEET_MARKER}="${SHEET_VALUE}"]`) !== null) return
114
+ armed = true
115
+ if (armTimer !== undefined) clearTimeout(armTimer)
116
+ armTimer = setTimeout(disarm, ARM_MS)
117
+ }
118
+
119
+ /** Mark it if this tick is armed; one menu consumes the arm. */
120
+ const markIfArmed = (root: Element): void => {
121
+ if (!armed) return
122
+ disarm()
123
+ root.setAttribute(SHEET_MARKER, SHEET_VALUE)
124
+ }
125
+
126
+ // Capture phase: the host's own handlers may stop the click's bubble
127
+ // (the trigger toggles `open` itself), so listen on the document in
128
+ // capture and never depend on propagation reaching us.
129
+ document.addEventListener('click', onCaptureClick, true)
130
+ const observer = new MutationObserver((records) => {
131
+ if (!armed) return
132
+ for (const record of records) {
133
+ for (const node of record.addedNodes) {
134
+ if (node instanceof Element && isUnmarkedPortalMenu(node)) markIfArmed(node)
135
+ }
136
+ }
137
+ })
138
+ observer.observe(document.body, { childList: true })
139
+
140
+ return () => {
141
+ document.removeEventListener('click', onCaptureClick, true)
142
+ observer.disconnect()
143
+ disarm()
144
+ // A still-open marked menu outliving this effect keeps its marker only
145
+ // until the user closes it (the element unmounts); scrubbing here
146
+ // would fight a React commit mid-flight for no visual gain.
147
+ }
148
+ }, 'dsh-mobile-nav: permission sheet marker')
149
+ }
@@ -0,0 +1,68 @@
1
+ import type { ClientContext, MobileSessionRow } from '../compat/types.ts'
2
+ import { mainSessionIdOf } from '../compat/types.ts'
3
+ import { isVirtual } from '../../virtual-id.ts'
4
+ import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
5
+
6
+ /**
7
+ * The remote-session marker (T41b): the CURRENT main-view session id, read
8
+ * the way the official uiSession `publishMain` reads it (RT
9
+ * dsh-client-ui-session lib/client.js:279-291), stamped onto <html> as
10
+ * `data-zr-remote-session="1"` while it is a virtual (relay) id and removed
11
+ * otherwise. remote-session.css.ts turns the attribute into the hiding of
12
+ * every open-on-the-server-machine entry, so the decision lives at the one
13
+ * place that knows the session and never inside component DOM.
14
+ *
15
+ * The official tie-break is kept verbatim: when the previous answer is still
16
+ * mainView-retained it STAYS the answer, and only otherwise does the first
17
+ * mainView-retained row in catalog order win — so two simultaneously
18
+ * retained rows are decided by "current", never by iteration order. The
19
+ * current value comes from the uiSession service's own main binding source
20
+ * (`current.value.key`); where the service has not registered yet the scan
21
+ * (compat/types.ts mainSessionIdOf) stands in, and a later list or main
22
+ * change re-decides — which is why BOTH sources are subscribed below.
23
+ *
24
+ * Kept outside React for the same reason the header status dot is: the
25
+ * attribute must track the current session regardless of which panel is
26
+ * mounted, and an attribute + stylesheet survives every re-render untouched.
27
+ */
28
+ export function installRemoteSessionGuard(ctx: ClientContext): void {
29
+ ctx.effect(() => {
30
+ const tag = document.createElement('style')
31
+ tag.dataset.plugin = 'dsh-zen-remote'
32
+ tag.dataset.pluginCss = 'dsh-zen-remote/remote-session.css'
33
+ tag.textContent = REMOTE_SESSION_CSS
34
+ document.head.appendChild(tag)
35
+ return () => {
36
+ tag.remove()
37
+ }
38
+ }, 'dsh-zen-remote: remote-session styles')
39
+
40
+ ctx.effect(() => {
41
+ const apply = (): void => {
42
+ const { byId } = ctx.sessions.list.getSnapshot()
43
+ // publishMain's tie-break (see the module comment): prefer the still-
44
+ // retained current main binding, else the first mainView row.
45
+ const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown }, subscribe?: (listener: () => void) => () => void } } | undefined
46
+ const current = uiSession?.current?.value?.key
47
+ const rows = byId as Readonly<Record<string, MobileSessionRow>>
48
+ const sessionId =
49
+ typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
50
+ ? current
51
+ : mainSessionIdOf(byId)
52
+ const root = document.documentElement
53
+ if (sessionId !== undefined && isVirtual(sessionId)) root.setAttribute('data-zr-remote-session', '1')
54
+ else root.removeAttribute('data-zr-remote-session')
55
+ }
56
+ apply()
57
+ const offs: Array<() => void> = [ctx.sessions.list.subscribe(apply)]
58
+ // The main binding can move without a list event (a retention change on
59
+ // the current main re-publishes through uiSession's own retain watcher,
60
+ // RT lib/client.js:293-301); subscribe to that source too when it exists.
61
+ const current = (ctx.get('uiSession') as { current?: { subscribe?: (listener: () => void) => () => void } } | undefined)?.current
62
+ if (typeof current?.subscribe === 'function') offs.push(current.subscribe(apply))
63
+ return () => {
64
+ for (const off of offs) off()
65
+ document.documentElement.removeAttribute('data-zr-remote-session')
66
+ }
67
+ }, 'dsh-zen-remote: remote-session attribute')
68
+ }
@@ -44,6 +44,31 @@ async function desktopConfigured(): Promise<boolean> {
44
44
  * marker, so scoping here is what keeps this effect Chat-only. */
45
45
  const FLOW = '[data-chat-flow]'
46
46
 
47
+ /** DSH 0.1.7's official process-group root (ChatGroupSeat). A group nests
48
+ * its OWN `data-chat-flow` layer whose rows BORN_FOLDED would hide by kind,
49
+ * while {@link groupsOf} only walks the outer column — so a folded tool call
50
+ * inside a group would vanish with no chip to recover it. While the page has
51
+ * any official group the whole fold steps aside (rules too: the stylesheet
52
+ * keys its born-folded block on {@link ACTIVE_ATTR}, which the suppress path
53
+ * removes), and returns when the last group leaves the DOM. */
54
+ const OFFICIAL_GROUP = '[data-chat-group-key]'
55
+
56
+ /**
57
+ * One suppression state transition, given whether an official group is on
58
+ * the page and whether the fold is currently suppressed. `'enter'`: groups
59
+ * appeared — drop every mark and the ACTIVE_ATTR the stylesheet keys on.
60
+ * `'exit'`: the last group left — re-arm and fold again. `'hold'`: nothing
61
+ * to do. Exported pure so the state machine itself is testable off-DOM
62
+ * (test/fold-suppression.test.cjs); {@link scan} applies it.
63
+ */
64
+ export function foldSuppressionTransition(
65
+ officialGroupPresent: boolean,
66
+ suppressed: boolean,
67
+ ): 'enter' | 'exit' | 'hold' {
68
+ if (officialGroupPresent) return suppressed ? 'hold' : 'enter'
69
+ return suppressed ? 'exit' : 'hold'
70
+ }
71
+
47
72
  /** Marker + selector of the injected summary row. */
48
73
  const CHIP = 'turn-fold'
49
74
  const CHIP_SELECTOR = '[data-mobile-nav="turn-fold"]'
@@ -199,6 +224,10 @@ export function installTurnFold(ctx: ClientContext): void {
199
224
  const expanded = new Set<string>()
200
225
  let observer: MutationObserver | null = null
201
226
  let frame = 0
227
+ /** True while an official process group is on the page (see OFFICIAL_GROUP):
228
+ * ACTIVE_ATTR is off and every mark is cleared; the observer stays open so
229
+ * the fold resumes by itself once the last group leaves. */
230
+ let suppressed = false
202
231
 
203
232
  const label = (chip: HTMLElement, count: number, running: boolean): void => {
204
233
  const next = running ? t('turnFoldRunning', { count }) : t('turnFold', { count })
@@ -208,6 +237,20 @@ export function installTurnFold(ctx: ClientContext): void {
208
237
  const scan = (): void => {
209
238
  const flow = document.querySelector(FLOW)
210
239
  if (flow === null) return
240
+ const transition = foldSuppressionTransition(
241
+ document.querySelector(OFFICIAL_GROUP) !== null,
242
+ suppressed,
243
+ )
244
+ if (transition === 'enter') {
245
+ suppressed = true
246
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
247
+ clear()
248
+ return
249
+ }
250
+ if (transition === 'exit') {
251
+ suppressed = false
252
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
253
+ }
211
254
  // TurnStatus, ChatView's own running banner — present only while the
212
255
  // last turn is still working (lib/client.js:5548).
213
256
  const running = flow.querySelector(':scope > [role="status"]') !== null
@@ -284,6 +327,11 @@ export function installTurnFold(ctx: ClientContext): void {
284
327
  * characterData — the fold verdict only changes when elements do.
285
328
  */
286
329
  const markWholeRows = (records: readonly MutationRecord[]): void => {
330
+ // Suppressed (an official group is on the page): reasoning-only rows in
331
+ // the OUTER column would still get marked here — the synchronous path
332
+ // runs before the next scan flips anything — and a marked row stays
333
+ // hidden with no chip to recover it.
334
+ if (suppressed) return
287
335
  const rows = new Set<Element>()
288
336
  for (const record of records) {
289
337
  const target = record.target
@@ -305,6 +353,9 @@ export function installTurnFold(ctx: ClientContext): void {
305
353
  // to bring it back. It also drops rows detached since the batch.
306
354
  const parent = row.parentElement
307
355
  if (parent === null || !parent.matches(FLOW)) continue
356
+ // A group's inner flow layer also matches FLOW; rows inside an
357
+ // official group are never ours to fold (see OFFICIAL_GROUP).
358
+ if (row.closest(OFFICIAL_GROUP) !== null) continue
308
359
  const thinks = [...row.querySelectorAll(THINK)]
309
360
  if (thinks.length > 0 && foldsWholeRow(row, thinks)) row.setAttribute(FOLD, '')
310
361
  else row.removeAttribute(FOLD)
@@ -366,6 +417,7 @@ export function installTurnFold(ctx: ClientContext): void {
366
417
  if (frame !== 0) cancelAnimationFrame(frame)
367
418
  frame = 0
368
419
  expanded.clear()
420
+ suppressed = false
369
421
  clear()
370
422
  }
371
423