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
@@ -1,16 +1,18 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react'
2
2
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
3
3
  import {
4
- IconArchiveOutline20,
5
- IconBranchOutline16,
6
- IconCloseOutline16,
7
- IconDownloadOutline16,
8
- IconEditOutline16,
9
- IconShareOutline16,
4
+ IconArchiveOutlineRegular,
5
+ IconBranchOutlineRegular,
6
+ IconCloseOutlineRegular,
7
+ IconDownloadOutlineRegular,
8
+ IconEditOutlineRegular,
9
+ IconShareOutlineRegular,
10
10
  } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import { shareCardRemoteView, shareFailText, subscribeWhileOpen } from '../client-data/shares.ts'
12
+ import type { SharesStore } from '../client-data/shares.ts'
11
13
  import { workspaceTitleOf } from './compat/store.ts'
12
14
  import { agentPresetOf } from './compat/types.ts'
13
- import type { RenameResult, SessionId } from './compat/types.ts'
15
+ import type { RenameResult, SessionId, UseJobs } from './compat/types.ts'
14
16
  import { NS } from './locales.ts'
15
17
  import { GO_HOME_EVENT, SESSION_INFO_EVENT } from './nav-store.ts'
16
18
  import { hasLayer, popLayer, pushLayer } from './history-nav.ts'
@@ -50,7 +52,7 @@ export type MobileSessionInfoProps =
50
52
  & {
51
53
  /** Bound ctx.sessions.fork({sessionId}). */
52
54
  forkSession: (sessionId: SessionId) => Promise<SessionId>
53
- /** Bound ctx.sessions.open(id) — lands on the freshly forked session. */
55
+ /** Bound ctx.uiWorkspace.openSession(id) — lands on the freshly forked session. */
54
56
  openSession: (id: SessionId) => void
55
57
  /** Bound ctx.sessions.binding(id)?.session.rename(title); undefined when the binding is gone. */
56
58
  renameSession: (sessionId: SessionId, title: string) => RenameResult | undefined
@@ -58,6 +60,22 @@ export type MobileSessionInfoProps =
58
60
  archiveSession: (sessionId: SessionId) => Promise<void>
59
61
  /** Bound ctx.sessionLogDownload.download(sessionId) — owns its own progress/result modal. */
60
62
  downloadSessionLog: (sessionId: SessionId) => Promise<void>
63
+ /**
64
+ * Job-roster hook, always present (fixed empty source when the `jobs`
65
+ * service is absent — see index.tsx activityInject — so the hook is
66
+ * called on every render and the hook count never varies).
67
+ */
68
+ useJobs: UseJobs
69
+ /** Keeps the session's job roster stream open while the sheet's session is mounted. */
70
+ watchRows: (sessionId: SessionId) => () => void
71
+ /**
72
+ * The page-wide shares store (T67) — the SAME singleton the title-row
73
+ * icon and the menu item read, so this row adds no second poll loop.
74
+ * Subscribed ONLY while the sheet is open (the phone shell must not
75
+ * keep a 30 s admin/shares GET alive behind the gateway — the same
76
+ * rule that keeps the icon unsubscribed there).
77
+ */
78
+ shares: SharesStore
61
79
  }
62
80
 
63
81
  /* ---- StatsLine-identical formatting -------------------------------------
@@ -132,6 +150,9 @@ export function MobileSessionInfo({
132
150
  renameSession,
133
151
  archiveSession,
134
152
  downloadSessionLog,
153
+ useJobs,
154
+ watchRows,
155
+ shares,
135
156
  t,
136
157
  }: MobileSessionInfoProps) {
137
158
  const [open, setOpen] = useState(false)
@@ -145,6 +166,31 @@ export function MobileSessionInfo({
145
166
  const [shareOpen, setShareOpen] = useState(false)
146
167
  const [shareData, setShareData] = useState<ShareExportData | null>(null)
147
168
  const shareCardHostRef = useRef<HTMLDivElement>(null)
169
+ // T67: the remote-access row's own busy flag — distinct from the sheet's
170
+ // global one, so a slow share/unshare POST greys only this switch.
171
+ const [remoteBusy, setRemoteBusy] = useState(false)
172
+ // T68: the description line's id, so the switch can name it for screen
173
+ // readers (aria-describedby) — the line is the state explanation the
174
+ // switch itself carries no visible text for.
175
+ const remoteDescId = useId()
176
+
177
+ // T67: the shares table is subscribed ONLY while the sheet is open — a
178
+ // subscription is what keeps the store's 30 s admin/shares poll running,
179
+ // and the phone shell must not keep that alive behind the gateway (the
180
+ // same rule that leaves the title-row icon unsubscribed here). Opening
181
+ // also pulls once right away: the row must show the CURRENT table, not
182
+ // wait out the cadence.
183
+ const shareSnap = useSyncExternalStore(
184
+ useCallback(
185
+ (onStoreChange: () => void) => subscribeWhileOpen(shares, open, onStoreChange),
186
+ [shares, open],
187
+ ),
188
+ () => shares.getSnapshot(),
189
+ )
190
+ useEffect(() => {
191
+ if (!open) return
192
+ void shares.refresh()
193
+ }, [open, shares])
148
194
 
149
195
  useEffect(() => {
150
196
  const onOpen = (): void => {
@@ -161,8 +207,15 @@ export function MobileSessionInfo({
161
207
 
162
208
  const tabs = useViewTabs()
163
209
  const row = useSessions((s) => s.byId[sessionId])
164
- const subagentCount = useSessions((s) => s.subagentsByParent[sessionId]?.entries.length ?? 0)
165
- const jobCount = useSessions((s) => s.jobsBySession[sessionId]?.length ?? 0)
210
+ /* 0.1.7 sources — 0.1.5's per-parent subagent / per-session job snapshots
211
+ are deleted. The catalog is read straight off the explicit-read
212
+ projection store, no refresh from here (the official lineage reads the
213
+ same way; a refresh call would register this session for a re-read on
214
+ every reconnect); job rows come from the renderer-bound
215
+ useJobs hook, with a fixed empty source when the service is absent. */
216
+ useEffect(() => watchRows(sessionId), [sessionId, watchRows])
217
+ const subagentCount = useSessions((s) => s.projectionsBySession[sessionId]?.values.subagentCatalog?.length ?? 0)
218
+ const jobCount = useJobs((s) => s.rows[sessionId]?.length ?? 0)
166
219
  const stats = useProjection('sessionStats')
167
220
  const usage = useProjection('tokenUsage')
168
221
 
@@ -369,6 +422,31 @@ export function MobileSessionInfo({
369
422
  image: t('shareCardImage'),
370
423
  }
371
424
 
425
+ // T67: the row's derived view (visibility gate + description line), and
426
+ // the toggle — share / unshare with the row's own busy window, failures
427
+ // landing on the sheet's existing error row via the shared shareFailText
428
+ // copy. No confirm: the sheet's other destructive action (archive) has a
429
+ // window.confirm, but remote access is reversible with one more tap and
430
+ // the switch itself states the outcome.
431
+ const remoteRow = shareCardRemoteView({
432
+ ready: shareSnap.ready,
433
+ role: shareSnap.role,
434
+ subagent: row?.origin === 'subagent',
435
+ entry: shareSnap.entries.find((candidate) => candidate.sessionId === sessionId),
436
+ now: Date.now(),
437
+ t,
438
+ })
439
+ const onToggleRemote = (): void => {
440
+ if (remoteBusy) return
441
+ setRemoteBusy(true)
442
+ setError(null)
443
+ const action = remoteRow.shared ? shares.unshare(sessionId) : shares.share(sessionId)
444
+ void action.then((outcome) => {
445
+ setRemoteBusy(false)
446
+ if (!outcome.ok) setError(shareFailText(outcome, remoteRow.shared ? 'unshare' : 'share', t))
447
+ })
448
+ }
449
+
372
450
  const cells: Array<{ label: string; value: string; sub: string | undefined }> = [
373
451
  { label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
374
452
  { label: t('infoStatSteps'), value: stats === undefined ? NA : String(stats.steps), sub: undefined },
@@ -444,7 +522,7 @@ export function MobileSessionInfo({
444
522
  </div>
445
523
  )}
446
524
  <button type="button" data-mobile-nav="info-close" aria-label={t('infoClose')} onClick={close}>
447
- <IconCloseOutline16 size={16} />
525
+ <IconCloseOutlineRegular size={16} />
448
526
  </button>
449
527
  </div>
450
528
 
@@ -471,6 +549,29 @@ export function MobileSessionInfo({
471
549
  ))}
472
550
  </div>
473
551
 
552
+ {/* T67: the remote-access row — the same visibility gate as the
553
+ title-row icon and the menu item (host role, answered table,
554
+ never a subagent session), decided in shareCardRemoteView. */}
555
+ {remoteRow.visible && (
556
+ <div data-mobile-nav="info-remote">
557
+ <div data-mobile-nav="info-remote-text">
558
+ <span data-mobile-nav="info-remote-label">{t('infoRemoteAccess')}</span>
559
+ <span data-mobile-nav="info-remote-desc" id={remoteDescId}>{remoteRow.line}</span>
560
+ </div>
561
+ <button
562
+ type="button"
563
+ role="switch"
564
+ aria-checked={remoteRow.shared}
565
+ aria-label={t('infoRemoteAccess')}
566
+ aria-describedby={remoteDescId}
567
+ data-mobile-nav="info-remote-switch"
568
+ data-on={remoteRow.shared ? '' : undefined}
569
+ disabled={remoteBusy}
570
+ onClick={onToggleRemote}
571
+ />
572
+ </div>
573
+ )}
574
+
474
575
  {error !== null && <div data-mobile-nav="info-error">{t('infoActionError', { message: error })}</div>}
475
576
  {/* Degraded-outcome notice (review 07+08): same container metrics as
476
577
  the error row, but informational colors and no prefix template —
@@ -497,15 +598,15 @@ export function MobileSessionInfo({
497
598
 
498
599
  <div data-mobile-nav="info-actions">
499
600
  <button type="button" data-mobile-nav="info-action" disabled={busy} onClick={onExport}>
500
- <IconDownloadOutline16 size={16} />
601
+ <IconDownloadOutlineRegular size={16} />
501
602
  <span>{t('infoExport')}</span>
502
603
  </button>
503
604
  <button type="button" data-mobile-nav="info-action" disabled={busy} onClick={onRename}>
504
- <IconEditOutline16 size={16} />
605
+ <IconEditOutlineRegular size={16} />
505
606
  <span>{t('infoRename')}</span>
506
607
  </button>
507
608
  <button type="button" data-mobile-nav="info-action" disabled={busy} onClick={onFork}>
508
- <IconBranchOutline16 size={16} />
609
+ <IconBranchOutlineRegular size={16} />
509
610
  <span>{t('infoFork')}</span>
510
611
  </button>
511
612
  <button
@@ -515,7 +616,7 @@ export function MobileSessionInfo({
515
616
  disabled={busy}
516
617
  onClick={onArchive}
517
618
  >
518
- <IconArchiveOutline20 size={20} />
619
+ <IconArchiveOutlineRegular size={20} />
519
620
  <span>{t('infoArchive')}</span>
520
621
  </button>
521
622
  <button
@@ -524,7 +625,7 @@ export function MobileSessionInfo({
524
625
  disabled={busy || shareEmpty}
525
626
  onClick={onShare}
526
627
  >
527
- <IconShareOutline16 size={16} />
628
+ <IconShareOutlineRegular size={16} />
528
629
  <span>{t('shareAction')}</span>
529
630
  </button>
530
631
  </div>
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The composer's remote-status banner for the sub-client (T34) — one
3
+ * `conversation.input.dock` entry, the official full-width row ABOVE the
4
+ * input card (the slot anchor is display:contents, so this renders as one
5
+ * row of the composer column, ahead of the card). When the open session is a
6
+ * remote one and the server is offline, the token is revoked, the pairing
7
+ * is gone, or the server closed this session's remote access (T41a-fix2
8
+ * added the revoked / unpaired lines), the banner names why.
9
+ *
10
+ * The INPUT itself is disabled through the host's own component capability
11
+ * (T34-fix): `ctx.conversation.blocks.set(sessionId, { reason })` — the
12
+ * composer-block contract (`contract/composer-blocks.d.ts` in
13
+ * dsh-client-ui-conversation, "the registry face other plugins reach through
14
+ * `ctx.conversation.blocks`"). A raised block makes InputBar's `blocked`
15
+ * path disable the editor, the send button and the model controls, and
16
+ * shows the block's `reason` as the placeholder text; `set(id, undefined)`
17
+ * clears it. While the banner stands the block is raised with the banner's
18
+ * own copy, and the cleanup clears it — so a restored link re-enables the
19
+ * composer without any host DOM touching.
20
+ *
21
+ * Renders NOTHING on a local session, before the store's first answered GET,
22
+ * and whenever neither the offline nor a closed reason applies (then the
23
+ * block is cleared too). A local session also never SUBSCRIBES
24
+ * (subscribeIfVirtual): the subscription is what keeps the store polling,
25
+ * so a page showing only local sessions never sends a remote-status
26
+ * request.
27
+ */
28
+
29
+ import { useCallback, useLayoutEffect, useSyncExternalStore } from 'react'
30
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
31
+ import { bannerText, subscribeIfVirtual } from '../client-data/remote-status.ts'
32
+ import type { RemoteStatusStore } from '../client-data/remote-status.ts'
33
+ import { isVirtual } from '../virtual-id.js'
34
+ import { NS } from './locales.ts'
35
+
36
+ export interface RemoteComposerBannerProps extends PropsRuntime<'conversation.input.dock'>, PropsLocale<typeof NS> {
37
+ /** The shared remote-status store, bound through the registration's inject
38
+ * face. */
39
+ status: RemoteStatusStore
40
+ /** Raise or clear THIS session's composer block (the host conversation
41
+ * service's `blocks.set`, bound per session by the registration). `undefined`
42
+ * clears. A composition whose conversation service is absent (no composer)
43
+ * gets a no-op binding — the banner still renders, the disable degrades. */
44
+ setComposerBlock: (reason: string | undefined) => void
45
+ }
46
+
47
+ export function RemoteComposerBanner({ sessionId, status, t, setComposerBlock }: RemoteComposerBannerProps) {
48
+ // The subscription is virtual-id gated (subscribeIfVirtual) — hooks stay
49
+ // above the render gate.
50
+ const snap = useSyncExternalStore(
51
+ useCallback(
52
+ (onStoreChange: () => void) => subscribeIfVirtual(status, sessionId, onStoreChange),
53
+ [status, sessionId],
54
+ ),
55
+ () => status.getSnapshot(),
56
+ )
57
+ const virtual = isVirtual(sessionId)
58
+ const text = virtual && snap.ready ? bannerText(snap.view, sessionId, t) : undefined
59
+ // Raise the block in the same commit that draws the banner (layout effect:
60
+ // before paint). The cleanup is what restores the composer — when the
61
+ // link recovers, the session closes, or the component unmounts, the block
62
+ // goes with it.
63
+ useLayoutEffect(() => {
64
+ if (text === undefined) return
65
+ setComposerBlock(text)
66
+ return () => {
67
+ setComposerBlock(undefined)
68
+ }
69
+ }, [text, sessionId, setComposerBlock])
70
+ if (!virtual || !snap.ready || text === undefined) return null
71
+ return (
72
+ <div className="zr-remote-banner" data-zen-remote="remote-banner" role="status">
73
+ {text}
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Title-row remote-status icon (T33b entry 2) — one
3
+ * `conversation.session.header.actions` entry drawn entirely off the shared
4
+ * shares store: grey = not shared, brand colour = shared, brand colour plus
5
+ * a green dot = a desktop client is currently viewing (`viewers > 0`). The
6
+ * native `title` carries the hover prompt (idle time left, or the busy
7
+ * copy); clicking toggles the share behind a `window.confirm` — the desktop
8
+ * app is the main server, and Electron answers confirm (its missing dialog
9
+ * method is prompt, not confirm — the settings page's confirms run there).
10
+ * A refused action (subagent child, contentless session, anything else)
11
+ * alerts the mapped reason (shareFailText).
12
+ *
13
+ * Renders NOTHING while the table has never answered, on a non-host role
14
+ * (the registration wires the store's role from the same two-level decision
15
+ * the settings page makes — row document first, client-config probe as the
16
+ * fallback), and on a subagent session, which the server refuses to share
17
+ * alone (T33b-fix: same rule as the menu item).
18
+ *
19
+ * On the phone shell (a non-desktop-shell viewport at or under 767px) the
20
+ * mobile stylesheet blanket-hides every `conversation.session.header.actions`
21
+ * entry that is not the phone header's own (styles/header.css.ts), so this
22
+ * icon would never be seen — it does not SUBSCRIBE there either
23
+ * (subscribeIfNotPhoneShell): the subscription is what keeps the shares
24
+ * store polling, so the phone shell never sends the 30 s admin/shares GET
25
+ * through the gateway. On a desktop shell the phone CSS never applies
26
+ * (apply's gate returns before installing it), so the icon stays a live
27
+ * surface at any window width there.
28
+ */
29
+
30
+ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
31
+ import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
32
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
33
+ import { describeShare, shareFailText, subscribeIfNotPhoneShell } from '../client-data/shares.ts'
34
+ import type { SharesStore } from '../client-data/shares.ts'
35
+ import { isDesktopShell } from './compat/desktop.ts'
36
+ import { NS } from './locales.ts'
37
+
38
+ export interface RemoteHeaderIconProps extends PropsRuntime<'conversation.session.header.actions'>, PropsLocale<typeof NS> {
39
+ /** The shared shares store, bound through the registration's inject face. */
40
+ shares: SharesStore
41
+ }
42
+
43
+ /** The same breakpoint the phone stylesheet scopes itself to (styles/header.css.ts). */
44
+ const PHONE_QUERY = '(max-width: 767px)'
45
+
46
+ /** Live phone-shell flag. The desktop shell never carries the phone CSS, so
47
+ * its answer is a constant false; a plain browser tracks the breakpoint so
48
+ * a window dragged across 767px flips the subscription with it. */
49
+ function usePhoneShell(): boolean {
50
+ const [phone, setPhone] = useState(() => !isDesktopShell() && window.matchMedia(PHONE_QUERY).matches)
51
+ useEffect(() => {
52
+ if (isDesktopShell()) return
53
+ const query = window.matchMedia(PHONE_QUERY)
54
+ const sync = (): void => { setPhone(query.matches) }
55
+ sync()
56
+ query.addEventListener('change', sync)
57
+ return () => { query.removeEventListener('change', sync) }
58
+ }, [])
59
+ return phone
60
+ }
61
+
62
+ export function RemoteHeaderIcon({ sessionId, shares, useSessions, t }: RemoteHeaderIconProps) {
63
+ const phoneShell = usePhoneShell()
64
+ const snap = useSyncExternalStore(
65
+ useCallback(
66
+ (onStoreChange: () => void) => subscribeIfNotPhoneShell(shares, phoneShell, onStoreChange),
67
+ [shares, phoneShell],
68
+ ),
69
+ () => shares.getSnapshot(),
70
+ )
71
+ const row = useSessions((sessions) => sessions.byId[sessionId])
72
+ // Hooks stay above the gate: both subscriptions exist on every render
73
+ // path, the button only on a host with a ready table.
74
+ if (!snap.ready || snap.role !== 'host' || row?.origin === 'subagent') return null
75
+ const entry = snap.entries.find((candidate) => candidate.sessionId === sessionId)
76
+ const description = describeShare(entry, Date.now(), t)
77
+ const toggle = (): void => {
78
+ if (entry === undefined) {
79
+ if (!window.confirm(t('shareRemoteConfirmOn'))) return
80
+ void shares.share(sessionId).then((outcome) => {
81
+ if (!outcome.ok) window.alert(shareFailText(outcome, 'share', t))
82
+ })
83
+ } else {
84
+ if (!window.confirm(t('shareRemoteConfirmOff'))) return
85
+ void shares.unshare(sessionId).then((outcome) => {
86
+ if (!outcome.ok) window.alert(shareFailText(outcome, 'unshare', t))
87
+ })
88
+ }
89
+ }
90
+ return (
91
+ <button
92
+ type="button"
93
+ className="zr-remote-toggle"
94
+ data-zen-remote="remote-toggle"
95
+ data-state={description.state}
96
+ title={description.remainingText}
97
+ aria-label={description.remainingText}
98
+ onClick={toggle}
99
+ >
100
+ <IconGlobeOutlineRegular size={16} />
101
+ </button>
102
+ )
103
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Session "…" menu item for remote sharing (T33b entry 1) — one
3
+ * `sidebar.workspaces.session.menu.item` row beside the official
4
+ * pin/rename/fork/archive rows, reading the same shared shares store as the
5
+ * title-row icon and the settings list: 「开启远程」 when the session is not
6
+ * in the table, 「关闭远程」 when it is. No confirmation on either — the
7
+ * menu IS the deliberate action (the confirm lives on the title-row icon).
8
+ * The row closes the menu itself through the slot's `useMenuOpenState`
9
+ * hook, exactly like the official entries do. A refused action alerts the
10
+ * mapped reason (shareFailText).
11
+ *
12
+ * Hidden until the FIRST GET has answered (T33b-fix: an unshared session
13
+ * must not read as 「开启远程」 while the table is still in flight), on a
14
+ * non-host role (the registration wires the store's role from the same
15
+ * two-level decision the settings page makes), and on subagent children:
16
+ * the server refuses to share them alone, and the row can tell from the
17
+ * standard sessions table (`origin: 'subagent'`). The sidebar filters those
18
+ * rows anyway — this is defense in depth.
19
+ */
20
+
21
+ import { useCallback, useSyncExternalStore } from 'react'
22
+ import { IconLinkOutlineRegular, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
23
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
24
+ import { shareFailText } from '../client-data/shares.ts'
25
+ import type { SharesStore } from '../client-data/shares.ts'
26
+ import { NS } from './locales.ts'
27
+
28
+ export interface RemoteShareMenuProps extends PropsRuntime<'sidebar.workspaces.session.menu.item'>, PropsLocale<typeof NS> {
29
+ /** The shared shares store, bound through the registration's inject face. */
30
+ shares: SharesStore
31
+ }
32
+
33
+ export function RemoteShareMenuItem({ sessionId, shares, useSessions, useMenuOpenState, t }: RemoteShareMenuProps) {
34
+ const [, setMenuOpen] = useMenuOpenState()
35
+ const snap = useSyncExternalStore(
36
+ useCallback((onStoreChange: () => void) => shares.subscribe(onStoreChange), [shares]),
37
+ () => shares.getSnapshot(),
38
+ )
39
+ const row = useSessions((sessions) => sessions.byId[sessionId])
40
+ if (!snap.ready || snap.role !== 'host' || row?.origin === 'subagent') return null
41
+ const shared = snap.entries.some((entry) => entry.sessionId === sessionId)
42
+ return (
43
+ <MenuItemButton
44
+ icon={<IconLinkOutlineRegular />}
45
+ separatorBefore
46
+ onSelect={() => {
47
+ setMenuOpen(false)
48
+ const action = shared ? 'unshare' as const : 'share' as const
49
+ void (shared ? shares.unshare(sessionId) : shares.share(sessionId)).then((outcome) => {
50
+ if (!outcome.ok) window.alert(shareFailText(outcome, action, t))
51
+ })
52
+ }}
53
+ >
54
+ {t(shared ? 'shareRemoteOff' : 'shareRemoteOn')}
55
+ </MenuItemButton>
56
+ )
57
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Title-row remote CONNECTION icon for the sub-client (T34) — one
3
+ * `conversation.session.header.actions` entry beside the T33b share icon,
4
+ * drawn entirely off the shared remote-status store: lit (brand colour) =
5
+ * the relay is online, grey = the link is down (offline / connecting /
6
+ * incompatible), dim grey = `revoked` / `unpaired` — states of their own
7
+ * (T34-fix): the hover and the click say the precise word, never
8
+ * "reconnecting", because neither recovers without a user action — and
9
+ * yellow = online but the interface fingerprints differ. The native `title`
10
+ * carries the hover prompt; clicking closes the session's remote access from
11
+ * THIS machine behind a `window.confirm` (the backend forwards it to the
12
+ * server as an unshare with reason `client`). While the link is not serving
13
+ * the click only explains itself — there is nothing to reach.
14
+ *
15
+ * Renders NOTHING on a local session (the id is not a virtual id), so the
16
+ * two roles' icons never appear at once, and nothing before the store's
17
+ * first answered GET. A local session also never SUBSCRIBES
18
+ * (subscribeIfVirtual): the subscription is what keeps the store polling,
19
+ * so a page showing only local sessions never sends a remote-status
20
+ * request.
21
+ *
22
+ * On the phone shell no rule of its own is needed: the mobile stylesheet
23
+ * blanket-hides every `conversation.session.header.actions` entry that is
24
+ * not the phone header's own (styles/header.css.ts), so this icon is a
25
+ * desktop(-browser) surface, exactly like the T33b share icon beside it.
26
+ */
27
+
28
+ import { useCallback, useSyncExternalStore } from 'react'
29
+ import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
30
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
31
+ import { describeRemoteStatus, subscribeIfVirtual } from '../client-data/remote-status.ts'
32
+ import type { RemoteStatusStore } from '../client-data/remote-status.ts'
33
+ import { isVirtual } from '../virtual-id.js'
34
+ import { NS } from './locales.ts'
35
+
36
+ export interface RemoteStatusIconProps extends PropsRuntime<'conversation.session.header.actions'>, PropsLocale<typeof NS> {
37
+ /** The shared remote-status store, bound through the registration's inject
38
+ * face. */
39
+ status: RemoteStatusStore
40
+ }
41
+
42
+ export function RemoteStatusIcon({ sessionId, status, t }: RemoteStatusIconProps) {
43
+ // The subscription is virtual-id gated (subscribeIfVirtual) — hooks stay
44
+ // above the render gate; the button additionally waits for a remote
45
+ // session and the store's first answered GET.
46
+ const snap = useSyncExternalStore(
47
+ useCallback(
48
+ (onStoreChange: () => void) => subscribeIfVirtual(status, sessionId, onStoreChange),
49
+ [status, sessionId],
50
+ ),
51
+ () => status.getSnapshot(),
52
+ )
53
+ // The explicit virtual-id gate stays: the page-wide store's snapshot can
54
+ // still be ready from a PREVIOUS remote session when a local one opens.
55
+ if (!isVirtual(sessionId) || !snap.ready) return null
56
+ const description = describeRemoteStatus(snap.view, t)
57
+ const close = (): void => {
58
+ // Offline recovers on its own; revoked / unpaired do not (T34-fix) —
59
+ // each says its own word instead of "reconnecting".
60
+ if (description.state === 'offline') {
61
+ window.alert(t('remoteStatusOfflineClick'))
62
+ return
63
+ }
64
+ if (description.state === 'revoked' || description.state === 'unpaired') {
65
+ window.alert(description.hoverText)
66
+ return
67
+ }
68
+ if (!window.confirm(t('remoteStatusUnshareConfirm'))) return
69
+ void status.unshare(sessionId).then((outcome) => {
70
+ if (outcome.ok) return
71
+ // A refused close names the backend's error code when it sent one —
72
+ // the locale line carries the placeholder (each language its own
73
+ // brackets), never a hardcoded concatenation.
74
+ if (outcome.code === undefined) window.alert(t('remoteStatusUnshareFail'))
75
+ else window.alert(t('remoteStatusUnshareFailCode', { code: outcome.code }))
76
+ })
77
+ }
78
+ return (
79
+ <button
80
+ type="button"
81
+ className="zr-remote-status"
82
+ data-zen-remote="remote-status"
83
+ data-state={description.state}
84
+ title={description.hoverText}
85
+ aria-label={description.hoverText}
86
+ onClick={close}
87
+ >
88
+ <IconGlobeOutlineRegular size={16} />
89
+ </button>
90
+ )
91
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Desktop-shell detection for the mobile shell's master gate.
3
+ *
4
+ * The official desktop app is an Electron shell whose preload exposes a
5
+ * `dshDesktop` bridge on `window` before any page script runs
6
+ * (apps/desktop preload-app.ts, `contextBridge.exposeInMainWorld`), which is
7
+ * the same marker official ui-settings gates desktop-only UI on. The check
8
+ * is therefore synchronous and stable for the whole page lifetime.
9
+ *
10
+ * Plain web — phone browsers, the LAN gateway, desktop browsers at any
11
+ * window width — never has the bridge, so the mobile shell stays available
12
+ * there. This matters because an Electron window can be dragged down to
13
+ * ~520px wide, where every width-based gate would otherwise flip the phone
14
+ * shell on inside the desktop app.
15
+ *
16
+ * @param host - host object to probe; defaults to globalThis. Injectable so
17
+ * the two states are testable off-browser (test/desktop-shell.test.cjs) —
18
+ * apply() short-circuits the whole mobile shell on a true answer.
19
+ */
20
+ export function isDesktopShell(host: typeof globalThis = globalThis): boolean {
21
+ return 'dshDesktop' in host
22
+ }
@@ -64,10 +64,72 @@ export interface MobileSessionRow {
64
64
  completed?: boolean
65
65
  blank: boolean
66
66
  updatedAt: number
67
+ /**
68
+ * 0.1.7 的本地引用计数(retainedBy.mainView > 0 即「主视图正打开这个
69
+ * 会话」)。0.1.5 的 SessionListState.current 删掉了,主视图会话从行上
70
+ * 派生——与官方 uiSession.publishMain 同款读法(见 {@link mainSessionIdOf})。
71
+ */
72
+ retainedBy?: Readonly<{ mainView?: number }>
67
73
  /** 宿主投影值;本插件只读 sessionStats.turns,读的地方自己 cast。 */
68
74
  projectionValues?: unknown
69
75
  }
70
76
 
77
+ /**
78
+ * 0.1.5 的 `SessionListState.current` 在 0.1.7 删除;主视图会话改为从目录
79
+ * 行派生:被 mainView 来源 retain 的那一行。这正是官方 uiSession
80
+ * publishMain 的挑选方式(client/ui-session index.ts:先看当前绑定是否
81
+ * mainView,否则全表找第一条 mainView retain),本插件读同一条事实。
82
+ * @param byId - 会话目录表(useSessions 的 byId)。
83
+ * @returns 主视图会话 id;没有主视图会话时 undefined。
84
+ */
85
+ export function mainSessionIdOf(byId: Readonly<Record<string, MobileSessionRow>>): SessionId | undefined {
86
+ for (const row of Object.values(byId)) {
87
+ if ((row.retainedBy?.mainView ?? 0) > 0) return row.id
88
+ }
89
+ return undefined
90
+ }
91
+
92
+ /**
93
+ * 子代理目录条目的本地镜像(0.1.7 `subagentCatalog` projection 的元素)。
94
+ * 0.1.5 的「按父会话的子代理快照」字段删除;目录改经
95
+ * `projectionsBySession[parentId].values.subagentCatalog` 显式读,条目只
96
+ * 镜像本插件读到的字段(id;运行态按官方派生式从状态表/目录行取,见
97
+ * MobileSessionHeader.tsx)。
98
+ */
99
+ export interface SubagentCatalogEntryLike {
100
+ readonly id: SessionId
101
+ readonly mode: 'one-shot' | 'continuable' | 'unknown'
102
+ readonly label?: string
103
+ }
104
+
105
+ /** 0.1.7 job roster 行的本地镜像:本插件只读 status。 */
106
+ export interface JobRowLike {
107
+ readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
108
+ }
109
+
110
+ /**
111
+ * `ctx.jobs` 服务的本地最小接口(0.1.7 `dsh-api-job-controller` 的 client
112
+ * 面)。0.1.5 的「按会话的任务快照」字段删除;任务列表改由 job roster 服务给出,
113
+ * 官方 JobListAction 用同一条路(hooks.jobs + watchRows)。
114
+ */
115
+ export interface JobsLike {
116
+ /** Rosters and observations snapshot: `{ rows: Record<sessionId, JobRow[]> }`. */
117
+ readonly state: {
118
+ getSnapshot(): { readonly rows: Readonly<Record<string, readonly JobRowLike[]>> }
119
+ subscribe(listener: () => void): () => void
120
+ }
121
+ /** Keep one session's roster stream open while a viewer is mounted (ref-counted). */
122
+ watchRows(sessionId: SessionId): () => void
123
+ }
124
+
125
+ /**
126
+ * 渲染器把 inject 工厂里 `hooks.jobs`({@link JobsLike.state} 形状的
127
+ * observable)包装成的标准 hook prop(官方 ui-jobs 同款约定:`jobs` →
128
+ * `useJobs`)。可选:服务缺席(懒查失败)时组件收不到它,任务计数隐藏。
129
+ */
130
+ export type UseJobs =
131
+ <S>(sel: (snapshot: { readonly rows: Readonly<Record<string, readonly JobRowLike[]>> }) => S, eq?: (a: S, b: S) => boolean) => S
132
+
71
133
  /**
72
134
  * 0.1.1's session row carries `agentPreset`; 0.1.2 dropped it. Reading it
73
135
  * must not depend on which version's types are in scope in the current