dsh-zen-remote 2.3.10 → 2.4.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 (244) hide show
  1. package/CHANGELOG.md +44 -72
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +573 -366
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-store.js +65 -27
  70. package/lib/share-store.js.map +1 -1
  71. package/lib/state-store.js +400 -0
  72. package/lib/state-store.js.map +1 -0
  73. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  74. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  75. package/lib/types/admin-routes.d.ts.map +1 -1
  76. package/lib/types/cli/args.d.ts +78 -0
  77. package/lib/types/cli/args.d.ts.map +1 -0
  78. package/lib/types/cli/bootstrap.d.ts +170 -0
  79. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  80. package/lib/types/cli/command-setup.d.ts +72 -0
  81. package/lib/types/cli/command-setup.d.ts.map +1 -0
  82. package/lib/types/cli/commands-client.d.ts +36 -0
  83. package/lib/types/cli/commands-client.d.ts.map +1 -0
  84. package/lib/types/cli/commands-host.d.ts +82 -0
  85. package/lib/types/cli/commands-host.d.ts.map +1 -0
  86. package/lib/types/cli/commands-status.d.ts +46 -0
  87. package/lib/types/cli/commands-status.d.ts.map +1 -0
  88. package/lib/types/cli/config-writer.d.ts +92 -0
  89. package/lib/types/cli/config-writer.d.ts.map +1 -0
  90. package/lib/types/cli/context.d.ts +39 -0
  91. package/lib/types/cli/context.d.ts.map +1 -0
  92. package/lib/types/cli/i18n.d.ts +32 -0
  93. package/lib/types/cli/i18n.d.ts.map +1 -0
  94. package/lib/types/cli/ipc-client.d.ts +83 -0
  95. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-server.d.ts +113 -0
  97. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  98. package/lib/types/cli/main.d.ts +57 -0
  99. package/lib/types/cli/main.d.ts.map +1 -0
  100. package/lib/types/cli/qr-terminal.d.ts +17 -0
  101. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  102. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  103. package/lib/types/client/current-session-report.d.ts +4 -0
  104. package/lib/types/client/current-session-report.d.ts.map +1 -1
  105. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  106. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  107. package/lib/types/client/locales.d.ts +47 -3
  108. package/lib/types/client/locales.d.ts.map +1 -1
  109. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  110. package/lib/types/client/settings/settings-css.d.ts +1 -1
  111. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  112. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  113. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  114. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  115. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  116. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  117. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  118. package/lib/types/client-data/model-group-side.d.ts +23 -2
  119. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  120. package/lib/types/client-data/remote-status.d.ts +47 -1
  121. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  122. package/lib/types/client-data/settings-form.d.ts +85 -1
  123. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  124. package/lib/types/client-data/wizard.d.ts +56 -1
  125. package/lib/types/client-data/wizard.d.ts.map +1 -1
  126. package/lib/types/client-routes.d.ts +25 -1
  127. package/lib/types/client-routes.d.ts.map +1 -1
  128. package/lib/types/config.d.ts +40 -14
  129. package/lib/types/config.d.ts.map +1 -1
  130. package/lib/types/debug-report.d.ts.map +1 -1
  131. package/lib/types/fetch-route-intercept.d.ts +66 -8
  132. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  133. package/lib/types/index.d.ts +9 -2
  134. package/lib/types/index.d.ts.map +1 -1
  135. package/lib/types/intercept.d.ts +72 -10
  136. package/lib/types/intercept.d.ts.map +1 -1
  137. package/lib/types/merge-streams.d.ts +79 -1
  138. package/lib/types/merge-streams.d.ts.map +1 -1
  139. package/lib/types/plugin-route-intercept.d.ts +25 -9
  140. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  141. package/lib/types/relay-access.d.ts +3 -1
  142. package/lib/types/relay-access.d.ts.map +1 -1
  143. package/lib/types/relay-client.d.ts +23 -2
  144. package/lib/types/relay-client.d.ts.map +1 -1
  145. package/lib/types/relay-pool.d.ts +148 -0
  146. package/lib/types/relay-pool.d.ts.map +1 -0
  147. package/lib/types/relay-server.d.ts +30 -3
  148. package/lib/types/relay-server.d.ts.map +1 -1
  149. package/lib/types/restart-fields.d.ts +5 -5
  150. package/lib/types/restart-fields.d.ts.map +1 -1
  151. package/lib/types/share-store.d.ts.map +1 -1
  152. package/lib/types/state-store.d.ts +179 -0
  153. package/lib/types/state-store.d.ts.map +1 -0
  154. package/lib/types/upload-limits.d.ts +15 -0
  155. package/lib/types/upload-limits.d.ts.map +1 -1
  156. package/lib/types/virtual-id.d.ts +21 -0
  157. package/lib/types/virtual-id.d.ts.map +1 -1
  158. package/lib/types/wizard/probe.d.ts.map +1 -1
  159. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  160. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  161. package/lib/types/wizard/routes.d.ts +1 -0
  162. package/lib/types/wizard/routes.d.ts.map +1 -1
  163. package/lib/types/wizard/status.d.ts +9 -0
  164. package/lib/types/wizard/status.d.ts.map +1 -1
  165. package/lib/types/wizard/verify.d.ts +3 -0
  166. package/lib/types/wizard/verify.d.ts.map +1 -1
  167. package/lib/upload-limits.js +15 -0
  168. package/lib/upload-limits.js.map +1 -1
  169. package/lib/virtual-id.js +60 -0
  170. package/lib/virtual-id.js.map +1 -1
  171. package/lib/wizard/probe.js +46 -1
  172. package/lib/wizard/probe.js.map +1 -1
  173. package/lib/wizard/proxy-snippets.js +14 -1
  174. package/lib/wizard/proxy-snippets.js.map +1 -1
  175. package/lib/wizard/routes.js +23 -4
  176. package/lib/wizard/routes.js.map +1 -1
  177. package/lib/wizard/status.js +21 -0
  178. package/lib/wizard/status.js.map +1 -1
  179. package/lib/wizard/verify.js +31 -8
  180. package/lib/wizard/verify.js.map +1 -1
  181. package/package.json +8 -4
  182. package/pwa/icons/badge-96.png +0 -0
  183. package/pwa/icons/badge.svg +1 -0
  184. package/pwa/manifest.json +2 -1
  185. package/pwa/sw.js +3 -1
  186. package/scripts/check-debug-report.mjs +41 -1
  187. package/scripts/check-settings-form.mjs +14 -3
  188. package/scripts/check-settings-view.mjs +247 -0
  189. package/scripts/check-wizard-ui.mjs +37 -0
  190. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  191. package/src/admin-routes.ts +10 -2
  192. package/src/cli/args.ts +289 -0
  193. package/src/cli/bootstrap.ts +569 -0
  194. package/src/cli/command-setup.ts +540 -0
  195. package/src/cli/commands-client.ts +369 -0
  196. package/src/cli/commands-host.ts +1127 -0
  197. package/src/cli/commands-status.ts +296 -0
  198. package/src/cli/config-writer.ts +354 -0
  199. package/src/cli/context.ts +38 -0
  200. package/src/cli/i18n.ts +645 -0
  201. package/src/cli/ipc-client.ts +216 -0
  202. package/src/cli/ipc-server.ts +531 -0
  203. package/src/cli/main.ts +285 -0
  204. package/src/cli/qr-terminal.ts +49 -0
  205. package/src/client/RemoteStatusIcon.tsx +4 -1
  206. package/src/client/current-session-report.ts +28 -4
  207. package/src/client/effects/model-group-side.ts +19 -5
  208. package/src/client/effects/remote-session.ts +26 -6
  209. package/src/client/locales.ts +96 -6
  210. package/src/client/settings/SettingsSection.tsx +287 -92
  211. package/src/client/settings/settings-css.ts +18 -0
  212. package/src/client/styles/remote-session.css.ts +15 -1
  213. package/src/client/wizard/GatewayWizard.tsx +470 -109
  214. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  215. package/src/client/wizard/wizard-css.ts +23 -0
  216. package/src/client-data/model-group-side.ts +37 -10
  217. package/src/client-data/remote-status.ts +112 -17
  218. package/src/client-data/settings-form.ts +164 -8
  219. package/src/client-data/wizard.ts +112 -2
  220. package/src/client-routes.ts +334 -26
  221. package/src/config.ts +62 -16
  222. package/src/debug-report.ts +27 -1
  223. package/src/fetch-route-intercept.ts +381 -32
  224. package/src/index.ts +575 -370
  225. package/src/intercept.ts +1866 -286
  226. package/src/merge-streams.ts +155 -10
  227. package/src/plugin-route-intercept.ts +120 -71
  228. package/src/relay-access.ts +14 -3
  229. package/src/relay-client.ts +163 -5
  230. package/src/relay-pool.ts +387 -0
  231. package/src/relay-server.ts +312 -16
  232. package/src/restart-fields.ts +6 -4
  233. package/src/share-store.ts +59 -26
  234. package/src/state-store.ts +428 -0
  235. package/src/upload-limits.ts +16 -0
  236. package/src/virtual-id.ts +57 -0
  237. package/src/wizard/probe.ts +54 -2
  238. package/src/wizard/proxy-snippets.ts +17 -2
  239. package/src/wizard/routes.ts +24 -5
  240. package/src/wizard/status.ts +28 -0
  241. package/src/wizard/verify.ts +33 -11
  242. package/AGENTS.md +0 -185
  243. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  244. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -48,7 +48,10 @@ import {
48
48
  CLIENT_DEBUG_REPORT_ROUTE,
49
49
  CLIENT_RECONNECT_ROUTE,
50
50
  CLIENT_STATUS_ROUTE,
51
+ CLIENT_UNPAIR_ROUTE,
52
+ MAX_PAIRED_SERVERS,
51
53
  awaitClientStatusReady,
54
+ canSubmitClientPairing,
52
55
  claimDeviceNameOf,
53
56
  createLatestGate,
54
57
  clientStatusLineOf,
@@ -56,7 +59,6 @@ import {
56
59
  deriveSettingsView,
57
60
  localOpsAllowed,
58
61
  normalizePairingCode,
59
- shouldFollowServerDeviceName,
60
62
  shouldReloadAfterClientActivation,
61
63
  } from '../../client-data/settings-form.ts'
62
64
  import { bridgeSectionState, togglePluginConsent } from '../../client-data/plugin-bridge.ts'
@@ -68,6 +70,7 @@ import type {
68
70
  ClaimRouteBody,
69
71
  ClientConfigBody,
70
72
  ClientConnectionView,
73
+ ClientServerView,
71
74
  ClientStatusBody,
72
75
  SettingsDeviceView,
73
76
  SettingsFieldState,
@@ -242,10 +245,40 @@ function pairFailText(fail: PairFail, t: SectionT): string {
242
245
  }
243
246
  case 'unreachable': return t('settings.client.failUnreachable')
244
247
  case 'role-mismatch': return t('settings.client.failRoleMismatch')
248
+ case 'limit-reached': return t('settings.client.failLimitReached')
245
249
  default: return t('settings.client.failUnexpected')
246
250
  }
247
251
  }
248
252
 
253
+ /** Whether one paired server has anything for the per-server diagnostics
254
+ * group (MS-7): a last error, a compat block, or bridge verdicts. */
255
+ function serverHasDiag(server: ClientServerView): boolean {
256
+ return server.lastError !== '' || server.compat !== undefined || server.pluginBridge.length > 0
257
+ }
258
+
259
+ /** One paired server's state badge (MS-7): copy plus the tone the badge
260
+ * wears — `live` green for connected, `warn` for an offline row (with the
261
+ * retry countdown when one is armed), `invalid` red for a verdict only a
262
+ * re-pair or an upgrade fixes. */
263
+ function serverStateBadge(server: ClientServerView, now: number, t: SectionT): { text: string, tone: 'live' | 'warn' | 'invalid' | undefined } {
264
+ switch (server.state) {
265
+ case 'connected': return { text: t('settings.client.serverStateConnected'), tone: 'live' }
266
+ case 'unreachable':
267
+ case 'unexpected': {
268
+ if (server.nextRetryAt === undefined) return { text: t('settings.client.serverStateOffline'), tone: 'warn' }
269
+ const seconds = Math.max(0, Math.ceil((server.nextRetryAt - now) / 1000))
270
+ return {
271
+ text: seconds > 0 ? t('settings.client.serverStateRetry', { seconds }) : t('settings.client.serverStateRetrySoon'),
272
+ tone: 'warn',
273
+ }
274
+ }
275
+ case 'revoked': return { text: t('settings.client.serverStateRevoked'), tone: 'invalid' }
276
+ case 'incompatible': return { text: t('settings.client.serverStateIncompatible'), tone: 'invalid' }
277
+ case 'invalid-url': return { text: t('settings.client.serverStateInvalidUrl'), tone: 'invalid' }
278
+ default: return { text: t('settings.client.serverStateUnpaired'), tone: undefined }
279
+ }
280
+ }
281
+
249
282
  function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
250
283
  const form = useSyncExternalStore(
251
284
  useCallback((cb: () => void) => config.subscribe(cb), [config]),
@@ -284,11 +317,16 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
284
317
  const [pairCode, setPairCode] = useState('')
285
318
  const [claimBusy, setClaimBusy] = useState(false)
286
319
  const [claimFail, setClaimFail] = useState<PairFail | null>(null)
287
- const [claimWriteFailed, setClaimWriteFailed] = useState(false)
288
320
  const [unpairBusy, setUnpairBusy] = useState(false)
289
321
  const [unpairDone, setUnpairDone] = useState(false)
290
322
  // T43: the 立即重连 button's in-flight marker.
291
323
  const [reconnectBusy, setReconnectBusy] = useState(false)
324
+ // MS-7: the pool list's in-flight row action — the entry id, or 'all' for
325
+ // the remove-all — and whether the last one failed.
326
+ const [entryBusy, setEntryBusy] = useState<string | null>(null)
327
+ const [entryActionFailed, setEntryActionFailed] = useState(false)
328
+ // MS-7: the 重新配对 action focuses the code box.
329
+ const pairCodeRef = useRef<HTMLInputElement | null>(null)
292
330
 
293
331
  const loadStatus = useCallback(() => {
294
332
  const ticket = statusGate.next()
@@ -320,24 +358,9 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
320
358
  })
321
359
  }, [statusGate])
322
360
 
323
- // T59: the SERVER's record of this device's name wins over the row —
324
- // unless the user is mid-edit (a staged draft in the field). One direct
325
- // write lands it in the row's serverName; that write's own volatile
326
- // update reaches the backend with both sides now EQUAL, so the push path
327
- // stays silent and the two ends cannot bounce the name back and forth.
328
- // T59-fix: the decision runs HERE, on each FRESH answer, judging the row
329
- // and the draft AS THEY ARE at that moment — never off a stale
330
- // `clientView` racing a row change (an effect keyed on the row value
331
- // would fire on every save with the PREVIOUS poll's name and write the
332
- // old one straight back over the user's fresh save).
333
- const followServerDeviceName = useCallback((view: ClientConnectionView): void => {
334
- if (!config.savedRoleIsClient()) return
335
- if (view.state !== 'connected') return
336
- const rowName = typeof config.rowValue('serverName') === 'string' ? config.rowValue('serverName') as string : ''
337
- if (!shouldFollowServerDeviceName(view.deviceName, rowName, config.hasDraft('serverName'))) return
338
- void config.writeDeviceName(view.deviceName)
339
- }, [config])
340
-
361
+ // MS-7: the device name flows ONE way — a saved row serverName is
362
+ // broadcast to every paired server by the backend pool; a server-side
363
+ // record of this device's name never writes back into the row.
341
364
  const loadClientStatus = useCallback(() => {
342
365
  const ticket = clientGate.next()
343
366
  fetch(CLIENT_STATUS_ROUTE)
@@ -347,9 +370,7 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
347
370
  })
348
371
  .then((body) => {
349
372
  if (!clientGate.isLatest(ticket)) return
350
- const view = deriveClientStatusView(body)
351
- setClientLoad({ state: 'ready', view })
352
- followServerDeviceName(view)
373
+ setClientLoad({ state: 'ready', view: deriveClientStatusView(body) })
353
374
  })
354
375
  .catch(() => {
355
376
  if (!clientGate.isLatest(ticket)) return
@@ -357,18 +378,39 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
357
378
  ? { state: 'stale', view: prev.view }
358
379
  : { state: 'error' })
359
380
  })
360
- }, [clientGate, followServerDeviceName])
381
+ }, [clientGate])
361
382
 
362
383
  // The client group's rendered view, hoisted for the countdown ticker and
363
384
  // the retry-follow refresh below (T43).
364
385
  const clientView = clientLoad.state === 'ready' || clientLoad.state === 'stale' ? clientLoad.view : undefined
386
+ // MS-7: the paired-server pool, in pairing order (empty on a legacy body),
387
+ // and the cap the counters and the red alert read.
388
+ const serverRows: readonly ClientServerView[] = clientView?.servers ?? []
389
+ const maxServers = clientView?.maxServers ?? MAX_PAIRED_SERVERS
365
390
  // RD-3: the remote drop guard's browser-side counters, read at render
366
391
  // time — no snapshot state of its own: the initial mount, the 15s
367
392
  // connected-status poll and the countdown ticker all re-render this page,
368
393
  // and each render takes the values as they are right then.
369
394
  const dropDiag = remoteDropDiagnostics()
370
- const clientRetryAt = clientView?.nextRetryAt
395
+ // MS-7: the SOONEST armed retry across the top level and every pool row —
396
+ // each offline row counts down in its own badge, and the follow refresh
397
+ // below fires after whichever retry comes due first.
398
+ const clientRetryAt = [clientView?.nextRetryAt, ...serverRows.map((server) => server.nextRetryAt)]
399
+ .reduce<number | undefined>((soonest, at) => (at !== undefined && (soonest === undefined || at < soonest) ? at : soonest), undefined)
371
400
  const clientCountingDown = clientRetryAt !== undefined
401
+ // MS-7 diagnostics gates: the per-server group, the pre-pool fallback to
402
+ // the top-level mirrors, and whether the section has any line at all.
403
+ const legacyTopDiag = clientView !== undefined && serverRows.length === 0
404
+ const hasServerDiag = serverRows.some(serverHasDiag)
405
+ const diagVisible = clientView !== undefined && (
406
+ clientView.intercept !== undefined
407
+ || clientView.pluginRouteIntercept !== undefined
408
+ || clientView.uploadProbe !== undefined
409
+ || dropDiag.installed
410
+ || dropDiag.notInstalledReason !== undefined
411
+ || hasServerDiag
412
+ || (legacyTopDiag && (clientView.lastError !== '' || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0))
413
+ )
372
414
 
373
415
  // Offline with a pending retry: refresh the status shortly after the
374
416
  // retry comes due, so a server that came back flips the line to connected
@@ -670,24 +712,30 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
670
712
  }, [pairUrlTouched, pairUrl, rowServerUrl])
671
713
 
672
714
  const codeNormalized = normalizePairingCode(pairCode)
673
- // T60: an INVALID device-name draft (red box — blank-but-nonempty or over
674
- // the cap) blocks the pairing: the claim would silently substitute the
675
- // default copy and the server would register a name the user never asked
676
- // for. A VALID draft pairs fine — it lands in the row with the write.
677
- const claimAllowed = form.available && form.writable && !claimBusy && codeNormalized.length === 8 && !form.serverName.invalid
715
+ // MS-7: the whole gate is the MS-6 pure truth table — an address and a
716
+ // complete code, a quiet page, a valid device-name draft (T60: a red-boxed
717
+ // name would silently pair under the fallback copy), and the 8-server cap,
718
+ // which blocks only a NEW address: re-entering an already-paired one
719
+ // re-pairs it in place, at 8/8 too.
720
+ const claimVerdict = canSubmitClientPairing({
721
+ pairUrl,
722
+ pairCode: codeNormalized,
723
+ servers: serverRows,
724
+ maxServers,
725
+ busy: claimBusy,
726
+ deviceNameInvalid: form.serverName.invalid,
727
+ })
728
+ const claimAllowed = form.available && form.writable && claimVerdict.allowed
678
729
 
679
730
  const runClaim = async (): Promise<void> => {
680
731
  setClaimBusy(true)
681
732
  setClaimFail(null)
682
- setClaimWriteFailed(false)
683
733
  // UX-1: captured BEFORE the pairing — whether the page ran as client, and
684
734
  // whether the backend's stream interceptor was already active per the
685
735
  // last client/status diagnostics.
686
736
  const wasEffectiveClient = clientRole
687
737
  const streamInterceptAlreadyActive = clientView?.intercept?.installed === true
688
- // T60: one name for both halves of the round-trip — trimmed and legal
689
- // ({@link claimDeviceNameOf}), sent to the server AND written into the
690
- // row when the claim lands.
738
+ // T60: the name registers trimmed and legal ({@link claimDeviceNameOf}).
691
739
  const claimName = claimDeviceNameOf(form.serverName.text, t('settings.client.deviceNameDefault'))
692
740
  try {
693
741
  const res = await fetch(CLIENT_CLAIM_ROUTE, {
@@ -695,8 +743,7 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
695
743
  headers: { 'Content-Type': 'application/json' },
696
744
  // T57: the name registers under the shared 设备名称 field's current
697
745
  // displayed value (a staged draft counts), falling back to the
698
- // default copy when the field is empty — the client page reads no
699
- // admin baseline (T55), so an unstored name displays empty here.
746
+ // default copy when the field is empty.
700
747
  body: JSON.stringify({
701
748
  serverUrl: pairUrl.trim(),
702
749
  code: codeNormalized,
@@ -704,49 +751,30 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
704
751
  }),
705
752
  })
706
753
  const body = await res.json().catch(() => ({})) as ClaimRouteBody
707
- if (res.ok && body.ok === true && typeof body.token === 'string' && body.token !== '') {
708
- // The backend echoes the address it validated; write the credentials
709
- // AND the registered name through ONE mutate, then clear the code
710
- // and re-read the connection state. The name is the SERVER's
711
- // confirmed echo when the claim carried one (the gateway may have
712
- // trimmed or uniquified it at registration), else the name we
713
- // actually sent (T60) — either way the row now matches the server.
714
- const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : pairUrl.trim()
715
- // T59-fix: the echo is judged by the SAME rules as our own draft
716
- // (claimDeviceNameOf trims and validates) — a server answer we
717
- // could not store falls back to the name we actually sent.
718
- const echoed = typeof body.deviceName === 'string' && body.deviceName !== '' ? claimDeviceNameOf(body.deviceName, claimName) : claimName
719
- const landed = await config.writeClientPairing(urlToWrite, body.token, echoed)
720
- if (landed && shouldReloadAfterClientActivation({
754
+ if (res.ok && body.ok === true) {
755
+ // MS-5/MS-7: the backend landed the pairing straight into
756
+ // zen-remote-state.json (the token never reaches the browser), so
757
+ // there is nothing to write here — only the FIRST activation still
758
+ // reloads, so the page's resident global streams reopen wrapped.
759
+ if (shouldReloadAfterClientActivation({
721
760
  wasEffectiveClient,
722
761
  nowClientRole: true,
723
762
  savedOrPaired: true,
724
763
  streamInterceptAlreadyActive,
725
764
  })) {
726
- // UX-1: FIRST pairing on a page whose resident global streams were
727
- // opened unwrapped (or with the interceptor inactive) — wait out the
728
- // backend's row-reload window, then reload so the streams reopen
729
- // wrapped and the server's sessions enter the sidebar.
730
765
  setClaimBusy(false)
731
766
  void awaitClientStatusReady().then(() => window.location.reload())
732
767
  return
733
768
  }
734
- if (landed) {
735
- // The draft did its job — it now equals the displayed value, and
736
- // keeping it would be a phantom edit (T60).
737
- config.discardField('serverName')
738
- setPairCode('')
739
- setPairUrl(urlToWrite)
740
- setUnpairDone(false)
741
- loadClientStatus()
742
- } else {
743
- setClaimWriteFailed(true)
744
- }
769
+ setPairCode('')
770
+ if (typeof body.serverUrl === 'string' && body.serverUrl !== '') setPairUrl(body.serverUrl)
771
+ setUnpairDone(false)
772
+ loadClientStatus()
745
773
  } else {
746
- // Every other shape is a failure (T16-fix): a non-OK status, a body
747
- // whose `ok` is not true, a missing token — shown with the classified
748
- // code when the route sent one. The body's `message` is ignored: the
749
- // wording is this build's dictionary (pairFailText).
774
+ // Every other shape is a failure (T16-fix), shown with the classified
775
+ // code when the route sent one (MS-5 adds `limit-reached`). The
776
+ // body's `message` is ignored: the wording is this build's
777
+ // dictionary (pairFailText).
750
778
  const fail: PairFail = { code: typeof body.code === 'string' && body.code !== '' ? body.code : 'unexpected' }
751
779
  if (typeof body.retryAfterMs === 'number') fail.retryAfterMs = body.retryAfterMs
752
780
  setClaimFail(fail)
@@ -768,6 +796,67 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
768
796
  }
769
797
  }
770
798
 
799
+ /** MS-7: one POST of the pool list's row actions — whether it answered ok. */
800
+ const postClientRoute = async (route: string, payload: string): Promise<boolean> => {
801
+ try {
802
+ const res = await fetch(route, {
803
+ method: 'POST',
804
+ headers: { 'Content-Type': 'application/json' },
805
+ body: payload,
806
+ })
807
+ return res.ok
808
+ } catch {
809
+ return false
810
+ }
811
+ }
812
+
813
+ // MS-7 立即重连 for ONE server: refresh now (the state is at least
814
+ // "connecting") and once more after the attempt can have settled — the
815
+ // T43 top-level button's cadence, per entry.
816
+ const runEntryReconnect = async (entryId: string): Promise<void> => {
817
+ setEntryBusy(entryId)
818
+ setEntryActionFailed(false)
819
+ if (await postClientRoute(CLIENT_RECONNECT_ROUTE, JSON.stringify({ entryId }))) {
820
+ loadClientStatus()
821
+ refreshTimers.current.push(window.setTimeout(() => { loadClientStatus() }, 1500))
822
+ } else {
823
+ setEntryActionFailed(true)
824
+ }
825
+ setEntryBusy(null)
826
+ }
827
+
828
+ // MS-7 移除: forget one paired server (the server's own device record is
829
+ // untouched — the confirm copy says so).
830
+ const runUnpairEntry = async (entryId: string): Promise<void> => {
831
+ if (!window.confirm(t('settings.client.unpairServerConfirm'))) return
832
+ setEntryBusy(entryId)
833
+ setEntryActionFailed(false)
834
+ if (await postClientRoute(CLIENT_UNPAIR_ROUTE, JSON.stringify({ entryId }))) loadClientStatus()
835
+ else setEntryActionFailed(true)
836
+ setEntryBusy(null)
837
+ }
838
+
839
+ // MS-7 全部移除: forget every paired server at once.
840
+ const runUnpairAllServers = async (): Promise<void> => {
841
+ if (!window.confirm(t('settings.client.unpairAllServersConfirm'))) return
842
+ setEntryBusy('all')
843
+ setEntryActionFailed(false)
844
+ if (await postClientRoute(CLIENT_UNPAIR_ROUTE, JSON.stringify({ all: true }))) loadClientStatus()
845
+ else setEntryActionFailed(true)
846
+ setEntryBusy(null)
847
+ }
848
+
849
+ // MS-7 重新配对: point the pairing form at this server — its address in the
850
+ // box (an already-paired address passes the cap gate), a blank code, the
851
+ // focus on the code box so the user only types the fresh code.
852
+ const repurposeServer = (server: ClientServerView): void => {
853
+ setPairUrl(server.serverUrl)
854
+ setPairUrlTouched(true)
855
+ setPairCode('')
856
+ setClaimFail(null)
857
+ pairCodeRef.current?.focus()
858
+ }
859
+
771
860
  const formDisabled = !form.available || !form.writable
772
861
 
773
862
  // T17: saving any restart-required field (the role among them) moves the
@@ -867,29 +956,27 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
867
956
  )
868
957
 
869
958
  // Boolean control (the shared fields have no checkbox): label row + switch.
870
- const bool = (state: SettingsFieldState, field: string, label: string, hint: string) => (
871
- <div className="zr-settings-field" key={field}>
959
+ const bool = (state: SettingsFieldState, field: string, label: string, hint: string, parentDisabled = false) => (
960
+ <div className="zr-settings-field" key={field} data-disabled={parentDisabled ? 'true' : undefined}>
872
961
  <div className="zr-settings-head">
873
- <label htmlFor={`zr-settings-${field}`}>{label}</label>
962
+ <span className="zr-settings-head-title">{label}</span>
874
963
  {state.overridden && <span className="zr-settings-badge">{t('settings.overridden')}</span>}
875
964
  {state.overridden && (
876
- <button type="button" className="zr-settings-reset" disabled={formDisabled || state.locked} onClick={() => { config.resetField(field) }}>{t('settings.reset')}</button>
965
+ <button type="button" className="zr-settings-reset" disabled={formDisabled || state.locked || parentDisabled} onClick={() => { config.resetField(field) }}>{t('settings.reset')}</button>
877
966
  )}
878
- <span className="zr-settings-check-row">
879
- <input
880
- id={`zr-settings-${field}`}
881
- type="checkbox"
882
- disabled={formDisabled || state.locked}
883
- checked={state.text === 'true'}
884
- onChange={(e) => { config.stage(field, e.currentTarget.checked ? 'true' : 'false') }}
885
- />
886
- </span>
967
+ <Switch
968
+ disabled={formDisabled || state.locked || parentDisabled}
969
+ checked={!parentDisabled && state.text === 'true'}
970
+ label={label}
971
+ onChange={(checked) => { config.stage(field, checked ? 'true' : 'false') }}
972
+ />
887
973
  </div>
888
974
  <p className="zr-settings-hint">{hint}</p>
889
975
  {clearPreviewNote(state)}
890
976
  {annotations(field)}
891
977
  </div>
892
978
  )
979
+ const pushOff = form.pushEnabled.text !== 'true'
893
980
 
894
981
  const deviceRow = (device: SettingsDeviceView) => (
895
982
  <div className="zr-settings-device" key={device.id}>
@@ -1064,9 +1151,14 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1064
1151
  {text(form.vapidSubject, 'vapidSubject', t('settings.fieldVapidSubject'), t('settings.fieldVapidSubjectHint'), '')}
1065
1152
 
1066
1153
  <h3 className="zr-settings-card-title">{t('settings.pushTitle')}</h3>
1067
- {bool(form.pushSummary, 'pushSummary', t('settings.fieldPushSummary'), t('settings.fieldPushSummaryHint'))}
1068
- {bool(form.pushTurnEnd, 'pushTurnEnd', t('settings.fieldPushTurnEnd'), t('settings.fieldPushTurnEndHint'))}
1069
- {bool(form.pushTool, 'pushTool', t('settings.fieldPushTool'), t('settings.fieldPushToolHint'))}
1154
+ {/* P55-1: the master switch leads the group; when off, the
1155
+ dependent push switches below are disabled and visually
1156
+ switched off, and apply() wires nothing until it is back on. */}
1157
+ {bool(form.pushEnabled, 'pushEnabled', t('settings.fieldPushEnabled'), t('settings.fieldPushEnabledHint'))}
1158
+ {bool(form.pushSessionName, 'pushSessionName', t('settings.fieldPushSessionName'), t('settings.fieldPushSessionNameHint'), pushOff)}
1159
+ {bool(form.pushSummary, 'pushSummary', t('settings.fieldPushSummary'), t('settings.fieldPushSummaryHint'), pushOff)}
1160
+ {bool(form.pushTurnEnd, 'pushTurnEnd', t('settings.fieldPushTurnEnd'), t('settings.fieldPushTurnEndHint'), pushOff)}
1161
+ {bool(form.pushTool, 'pushTool', t('settings.fieldPushTool'), t('settings.fieldPushToolHint'), pushOff)}
1070
1162
  {text(form.pushDebounceMs, 'pushDebounceMs', t('settings.fieldPushDebounceMs'), t('settings.fieldPushDebounceMsHint'), t('settings.invalidNonNegativeInt'), true)}
1071
1163
  {select(form.lang, 'lang', t('settings.fieldLang'), t('settings.fieldLangHint'), ['auto', 'zh', 'en'], (option) => (
1072
1164
  option === 'auto' ? t('settings.langAuto') : option === 'zh' ? t('settings.langZh') : t('settings.langEn')
@@ -1209,7 +1301,19 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1209
1301
  operations, so they render even while the namespace is read-only. */}
1210
1302
  {clientRole && (
1211
1303
  <div className="zr-settings-card">
1212
- <h3 className="zr-settings-card-title">{t('settings.client.connectTitle')}</h3>
1304
+ <h3 className="zr-settings-card-title">
1305
+ {t('settings.client.connectTitle')}
1306
+ <span className="zr-settings-badge" data-invalid={clientView?.limitReached === true ? 'true' : undefined}>{t('settings.client.poolCount', { count: serverRows.length, max: maxServers })}</span>
1307
+ </h3>
1308
+ <p className="zr-settings-hint" style={{ paddingTop: 4 }}>{t('settings.client.poolCapNote', { max: maxServers })}</p>
1309
+ {/* MS-7: the full pool's alert — loud red bold on purpose: a new
1310
+ address is refused until a server is removed, while re-entering
1311
+ an already-paired address still re-pairs it in place. */}
1312
+ {clientView?.limitReached === true && (
1313
+ <p className="zr-settings-limit-alert" data-invalid="true">
1314
+ {t('settings.client.limitReached', { count: serverRows.length, max: maxServers })}
1315
+ </p>
1316
+ )}
1213
1317
  <div className="zr-settings-field">
1214
1318
  <div className="zr-settings-head">
1215
1319
  <label htmlFor="zr-settings-client-server">{t('settings.client.serverUrl')}</label>
@@ -1231,6 +1335,7 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1231
1335
  </div>
1232
1336
  <input
1233
1337
  id="zr-settings-client-code"
1338
+ ref={pairCodeRef}
1234
1339
  className="zr-settings-input zr-settings-code-input"
1235
1340
  type="text"
1236
1341
  autoComplete="off"
@@ -1252,9 +1357,59 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1252
1357
  draft is invalid — this line says why. */}
1253
1358
  {form.serverName.invalid && <span className="zr-settings-hint" data-invalid="true">{t('settings.client.fixDeviceName')}</span>}
1254
1359
  {claimFail !== null && <span className="zr-settings-hint" data-invalid="true">{pairFailText(claimFail, t)}</span>}
1255
- {claimWriteFailed && <span className="zr-settings-hint" data-invalid="true">{t('settings.client.failWrite')}</span>}
1256
1360
  </div>
1257
1361
 
1362
+ {/* MS-7: the paired main servers, in pairing order — one row per
1363
+ pool entry with its own state badge and actions. The cap only
1364
+ ever gates NEW addresses, so 重新配对 stays usable at 8/8. */}
1365
+ <h3 className="zr-settings-card-title">{t('settings.client.serversTitle')}</h3>
1366
+ {serverRows.length === 0 && (
1367
+ <p className="zr-settings-hint" style={{ padding: '8px 0 12px' }}>{t('settings.client.serversEmpty', { max: maxServers })}</p>
1368
+ )}
1369
+ {serverRows.map((server) => {
1370
+ const badge = serverStateBadge(server, now, t)
1371
+ return (
1372
+ <div className="zr-settings-device" key={server.entryId}>
1373
+ <div className="zr-settings-device-head">
1374
+ <span className="zr-settings-device-name">{server.displayName !== '' ? server.displayName : server.serverUrl}</span>
1375
+ <span
1376
+ className="zr-settings-badge"
1377
+ data-live={badge.tone === 'live' ? 'true' : undefined}
1378
+ data-warn={badge.tone === 'warn' ? 'true' : undefined}
1379
+ data-invalid={badge.tone === 'invalid' ? 'true' : undefined}
1380
+ >
1381
+ {badge.text}
1382
+ </span>
1383
+ <span className="zr-settings-hint">{server.serverUrl}</span>
1384
+ </div>
1385
+ <div className="zr-settings-device-actions">
1386
+ <Button
1387
+ variant="ghost"
1388
+ size="sm"
1389
+ disabled={entryBusy !== null || server.nextRetryAt === undefined}
1390
+ onClick={() => { void runEntryReconnect(server.entryId) }}
1391
+ >
1392
+ {entryBusy === server.entryId ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1393
+ </Button>
1394
+ <Button variant="ghost" size="sm" disabled={entryBusy !== null} onClick={() => { repurposeServer(server) }}>
1395
+ {t('settings.client.repair')}
1396
+ </Button>
1397
+ <Button variant="ghost" size="sm" disabled={entryBusy !== null} onClick={() => { void runUnpairEntry(server.entryId) }}>
1398
+ {t('settings.client.serverRemove')}
1399
+ </Button>
1400
+ </div>
1401
+ </div>
1402
+ )
1403
+ })}
1404
+ {entryActionFailed && <p className="zr-settings-hint" data-invalid="true">{t('settings.actionFail')}</p>}
1405
+ {serverRows.length > 0 && (
1406
+ <div className="zr-settings-row" style={{ paddingBottom: 12 }}>
1407
+ <Button variant="ghost" size="sm" disabled={entryBusy !== null} onClick={() => { void runUnpairAllServers() }}>
1408
+ {t('settings.client.unpairAllServers')}
1409
+ </Button>
1410
+ </div>
1411
+ )}
1412
+
1258
1413
  <h3 className="zr-settings-card-title">{t('settings.client.statusTitle')}</h3>
1259
1414
  <div className="zr-settings-row" style={{ paddingBottom: 4 }}>
1260
1415
  <p className="zr-settings-status-line" style={{ flex: 1 }} data-down={clientView !== undefined && (clientView.state === 'unreachable' || clientView.state === 'unexpected' || clientView.state === 'revoked' || clientView.state === 'invalid-url' || clientView.state === 'incompatible') ? 'true' : undefined}>
@@ -1291,11 +1446,16 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1291
1446
  groups render only when the response carried their fields —
1292
1447
  earlier servers answer neither, and the section itself only
1293
1448
  exists when there is at least one line to show. PB-5 adds the
1294
- bridge rows and the rerouting note on the same presence gate. */}
1295
- {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined || dropDiag.installed || dropDiag.notInstalledReason !== undefined) && (
1449
+ bridge rows and the rerouting note on the same presence gate.
1450
+ MS-7: the interceptor, the attachment-entry guard and the
1451
+ upload redirect are GLOBAL (one per machine); the last error,
1452
+ the fingerprint groups and the bridge verdicts are PER SERVER
1453
+ (the pool group at the bottom). A legacy body without servers[]
1454
+ keeps the top-level mirrors (legacyTopDiag). */}
1455
+ {diagVisible && clientView !== undefined && (
1296
1456
  <>
1297
1457
  <h3 className="zr-settings-card-title">{t('settings.client.diagTitle')}</h3>
1298
- {clientView.lastError !== '' && (
1458
+ {legacyTopDiag && clientView.lastError !== '' && (
1299
1459
  <p className="zr-settings-hint" data-invalid={clientView.state === 'unreachable' ? 'true' : undefined}>
1300
1460
  {t('settings.client.diagLastError', { code: clientView.lastError })}
1301
1461
  </p>
@@ -1396,7 +1556,7 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1396
1556
  </div>
1397
1557
  )
1398
1558
  })()}
1399
- {clientView.compat !== undefined && (
1559
+ {legacyTopDiag && clientView.compat !== undefined && (
1400
1560
  <>
1401
1561
  {clientView.compat.mismatchedGroups.length > 0 && (
1402
1562
  <p className="zr-settings-hint" data-invalid="true">
@@ -1417,7 +1577,7 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1417
1577
  )}
1418
1578
  </>
1419
1579
  )}
1420
- {(clientView.pluginBridge?.length ?? 0) > 0 && clientView.pluginBridge !== undefined && (
1580
+ {legacyTopDiag && (clientView.pluginBridge?.length ?? 0) > 0 && clientView.pluginBridge !== undefined && (
1421
1581
  <div className="zr-settings-field">
1422
1582
  <div className="zr-settings-head">
1423
1583
  <label>{t('settings.bridge.title')}</label>
@@ -1437,6 +1597,41 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1437
1597
  {t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' })}
1438
1598
  </p>
1439
1599
  )}
1600
+ {hasServerDiag && (
1601
+ <div className="zr-settings-field">
1602
+ <div className="zr-settings-head">
1603
+ <label>{t('settings.client.serverDiagTitle')}</label>
1604
+ </div>
1605
+ {serverRows.filter(serverHasDiag).map((server) => (
1606
+ <div className="zr-settings-device" key={server.entryId}>
1607
+ <span className="zr-settings-device-name">{server.displayName !== '' ? server.displayName : server.serverUrl}</span>
1608
+ {server.lastError !== '' && (
1609
+ <p className="zr-settings-hint" data-invalid={server.state === 'unreachable' ? 'true' : undefined}>
1610
+ {t('settings.client.diagLastError', { code: server.lastError })}
1611
+ </p>
1612
+ )}
1613
+ {server.compat !== undefined && server.compat.mismatchedGroups.length > 0 && (
1614
+ <p className="zr-settings-hint" data-invalid="true">
1615
+ {t('settings.client.diagCompatGroups', { groups: server.compat.mismatchedGroups.join(', ') })}
1616
+ </p>
1617
+ )}
1618
+ {server.compat?.recentCalls.map((failure, index) => (
1619
+ <p key={index} className="zr-settings-hint">
1620
+ {t('settings.client.diagCompatCalls')} · {new Date(failure.time).toLocaleString()} · {failure.method !== '' ? failure.method : '—'} · {failure.code}
1621
+ </p>
1622
+ ))}
1623
+ {server.pluginBridge.map((row: PluginBridgeStatusView) => (
1624
+ <p key={row.plugin} className="zr-settings-hint">
1625
+ {t('settings.bridge.title')} · {row.plugin} · {row.localVersion !== '' ? `v${row.localVersion}` : '—'} ·{' '}
1626
+ {row.stateKey === 'settings.bridge.stateVersionMismatch'
1627
+ ? t(row.stateKey, { version: row.serverVersion ?? '?' })
1628
+ : t(row.stateKey)}
1629
+ </p>
1630
+ ))}
1631
+ </div>
1632
+ ))}
1633
+ </div>
1634
+ )}
1440
1635
  </>
1441
1636
  )}
1442
1637
  </div>
@@ -52,6 +52,9 @@ export const SETTINGS_CSS = `
52
52
  [data-zen-remote="settings"] .zr-settings-field + .zr-settings-field {
53
53
  border-top: 1px solid var(--dsw-alias-border-l2);
54
54
  }
55
+ [data-zen-remote="settings"] .zr-settings-field[data-disabled="true"] {
56
+ opacity: 0.5;
57
+ }
55
58
  [data-zen-remote="settings"] .zr-settings-head {
56
59
  display: flex;
57
60
  align-items: center;
@@ -227,6 +230,21 @@ export const SETTINGS_CSS = `
227
230
  [data-zen-remote="settings"] .zr-settings-badge[data-live="true"] {
228
231
  color: var(--dsw-alias-state-success-primary, #16a34a);
229
232
  }
233
+ [data-zen-remote="settings"] .zr-settings-badge[data-invalid="true"] {
234
+ color: var(--dsw-alias-state-error-primary, #d5304f);
235
+ }
236
+ /* MS-7: the full-pool alert must be impossible to miss — red, bold, framed. */
237
+ [data-zen-remote="settings"] .zr-settings-limit-alert,
238
+ [data-zen-remote="settings"] .zr-settings-limit-alert[data-invalid="true"] {
239
+ font-size: 13px;
240
+ font-weight: 700;
241
+ line-height: 1.5;
242
+ color: var(--dsw-alias-state-error-primary, #d5304f);
243
+ border: 1px solid var(--dsw-alias-state-error-primary, #d5304f);
244
+ border-radius: 8px;
245
+ padding: 8px 12px;
246
+ margin: 8px 0 4px;
247
+ }
230
248
  [data-zen-remote="settings"] .zr-settings-footer {
231
249
  display: flex;
232
250
  align-items: center;
@@ -245,13 +245,27 @@ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [d
245
245
 
246
246
  KNOWN BOUNDARY (accepted, not fixed): in a remote session, searching the
247
247
  model menu and pressing Enter can commit a HIDDEN local model — the
248
- interceptor refuses it with its usual message. */
248
+ interceptor refuses it with its usual message.
249
+
250
+ MS-4, several paired servers: each remote group heading also carries its
251
+ server id (zero-width bits after the mark, virtual-id.ts
252
+ encodeServerIdZeroWidth), and the effect reads the open session's server
253
+ off html[data-zr-remote-server] (effects/remote-session.ts). A remote
254
+ group of a DIFFERENT server is stamped "remote-other": hidden inside a
255
+ remote session (only the open session's own server's models can run
256
+ there) and, like every remote group, outside one. */
249
257
  html[data-zr-remote-session="1"] section[role="group"][data-zr-group="local"] {
250
258
  display: none !important;
251
259
  }
260
+ html[data-zr-remote-session="1"] section[role="group"][data-zr-group="remote-other"] {
261
+ display: none !important;
262
+ }
252
263
  html:not([data-zr-remote-session]) section[role="group"][data-zr-group="remote"] {
253
264
  display: none !important;
254
265
  }
266
+ html:not([data-zr-remote-session]) section[role="group"][data-zr-group="remote-other"] {
267
+ display: none !important;
268
+ }
255
269
 
256
270
  /* ---------- T52-fix: hide the virtual groups in NATIVE <option> pickers ----
257
271
  The model menu above is React DOM, but dsh-vision-router's settings render