@ljwei-stak/dsh-model-route-bot 0.66.3 → 0.67.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 (272) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +20 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/README.md +3 -3
  178. package/packages/model-router-plugin/package.json +1 -1
  179. package/packages/orchestration/src/binding-product.mjs +19 -0
  180. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  181. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  182. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  183. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  184. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  185. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  186. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  187. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  188. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  189. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  190. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  191. package/packages/orchestration/src/bot-loop.mjs +152 -19
  192. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  193. package/packages/orchestration/src/bot-policy.mjs +43 -4
  194. package/packages/orchestration/src/bot-profile.mjs +171 -0
  195. package/packages/orchestration/src/bot-routine.mjs +4 -4
  196. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  197. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  198. package/packages/orchestration/src/bot-serving.mjs +164 -0
  199. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  200. package/packages/orchestration/src/bot-settings.mjs +50 -11
  201. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  202. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  203. package/packages/orchestration/src/bot-threads.mjs +790 -0
  204. package/packages/orchestration/src/data-product-access.mjs +37 -0
  205. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  206. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  207. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  208. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  209. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  210. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  211. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  212. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  213. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  214. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  215. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  216. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  217. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  218. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  219. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  220. package/packages/orchestration/src/model-accounting.mjs +99 -35
  221. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  222. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  223. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  224. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  225. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  226. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  227. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  228. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  229. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  230. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  231. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  232. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  233. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  234. package/packages/orchestration/src/remote-machines.mjs +82 -14
  235. package/packages/orchestration/src/routine-execution.mjs +5 -3
  236. package/packages/orchestration/src/routine-session.mjs +19 -13
  237. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  238. package/packages/orchestration/src/task-journal.mjs +29 -0
  239. package/packages/orchestration/src/task-product.mjs +17 -0
  240. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  241. package/packages/orchestration/src/team-bridge.mjs +282 -0
  242. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  243. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  244. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  245. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  246. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  247. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  248. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  249. package/remote-computer-agent/README.md +39 -29
  250. package/remote-computer-agent/build.mjs +3 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  253. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  255. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  256. package/remote-computer-agent/dist/cli.mjs +1688 -168
  257. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  258. package/remote-computer-agent/dist/index.mjs +1579 -122
  259. package/remote-computer-agent/dist/index.mjs.map +4 -4
  260. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  261. package/remote-computer-agent/package-lock.json +308 -2
  262. package/remote-computer-agent/package.json +2 -1
  263. package/remote-computer-agent/pair-device.ps1 +20 -0
  264. package/remote-computer-agent/setup.ps1 +38 -16
  265. package/remote-computer-agent/src/cli.ts +24 -3
  266. package/remote-computer-agent/src/desktop-session.ts +11 -3
  267. package/remote-computer-agent/src/e2e-server.ts +81 -0
  268. package/remote-computer-agent/src/pairing-page.ts +35 -0
  269. package/remote-computer-agent/src/protocol.ts +25 -25
  270. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  271. package/remote-computer-agent/src/store.ts +20 -5
  272. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,142 @@
1
+ import { createElement as h, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { COMPOSER_DRAFT_LIMITS, createComposerDraftStore } from './composer-draft.mjs'
3
+ import { ComposerDraftBound } from './composer-draft-bound.jsx'
4
+
5
+ /**
6
+ * The Host namespace, read from the Host, and the slot wrapper the wiring uses.
7
+ *
8
+ * Why an adapter at all: a browser origin is not a Host identity. Two Hosts can
9
+ * serve the same `dsh-app://app` origin, so a namespace minted in the page would
10
+ * be shared by both and their drafts would mix. The Host therefore mints and
11
+ * persists its own id, and this component asks for it on every mount:
12
+ *
13
+ * productApi.dispatch('ui.namespace', {}, signal)
14
+ * -> { schemaVersion: 1, hostId: 'host-<16 hex>' }
15
+ *
16
+ * The Host keeps that value in its own SQLite
17
+ * (`packages/orchestration/src/ui-host-namespace.mjs`), so it survives restarts
18
+ * and differs per Host. Nothing here generates a namespace of its own: a read
19
+ * that fails, times out, is aborted or answers late leaves the drafts switched
20
+ * off rather than guessing an identity.
21
+ *
22
+ * Rules this holds:
23
+ * - until a validated namespace arrives, no read, no write, no restore;
24
+ * - a Host replacement aborts the read in flight and drops the old store
25
+ * before the new one is used, so a late answer cannot overwrite the new
26
+ * Host's drafts;
27
+ * - a failure is reported, not retried in the background; the caller re-reads
28
+ * explicitly by raising `attempt`.
29
+ */
30
+
31
+ const NAMESPACE_PATTERN = /^host-[0-9a-f]{16}$/
32
+
33
+ /** The one shape the Host answers with; anything else is a refusal. */
34
+ export function validateNamespace(value) {
35
+ if (!value || typeof value !== 'object') throw new Error('宿主没有返回界面存储标识。')
36
+ if (value.schemaVersion !== 1) throw new Error('宿主界面存储标识版本不被此客户端支持。')
37
+ if (typeof value.hostId !== 'string' || !NAMESPACE_PATTERN.test(value.hostId)) throw new Error('宿主界面存储标识格式无效。')
38
+ return value.hostId
39
+ }
40
+
41
+ const messageOf = error => (error?.message ? String(error.message) : String(error ?? '未知错误'))
42
+
43
+ /**
44
+ * Asks the Host for its namespace and renders the draft once it answers.
45
+ *
46
+ * @param {object} props
47
+ * @param {{ dispatch: (action: string, payload: unknown, signal?: AbortSignal) => Promise<unknown> }} props.productApi
48
+ * @param {number} [props.attempt] raising it re-reads the namespace explicitly
49
+ * @param {(status: { status: 'pending'|'ready'|'error', hostId: string, error: string }) => void} [props.onStatus]
50
+ * @param {(store: ReturnType<import('./composer-draft.mjs').createComposerDraftStore> | undefined) => void} [props.onStore]
51
+ * the store this Host namespace owns, or undefined while the drafts are off
52
+ * @param {Record<string, unknown>} [props.storage] defaults to the window's localStorage
53
+ */
54
+ export function ComposerDraftHost({ productApi, attempt = 0, namespaceTimeoutMs = 8000, onStatus, onStore, storage, children, ...slot }) {
55
+ const [state, setState] = useState({ status: 'pending', hostId: '', error: '' })
56
+ const [retry, setRetry] = useState(0)
57
+ // Effects run after rendering. Never expose A's ready store on the first
58
+ // render of B, even when the replacement RPC has not started yet.
59
+ const current = state.api === productApi && state.attempt === attempt && state.retry === retry ? state : { status: 'pending', hostId: '', error: '' }
60
+ const generation = useRef(0)
61
+
62
+ useEffect(() => {
63
+ const mine = (generation.current += 1)
64
+ const controller = new AbortController()
65
+ // A replacement Host retires the old store first: until its own namespace
66
+ // answers, this window serves no drafts at all, and a late answer for the
67
+ // Host it replaced cannot install itself.
68
+ const scope = { api: productApi, attempt, retry }
69
+ setState({ ...scope, status: 'pending', hostId: '', error: '' })
70
+ let timer
71
+ const read = async () => {
72
+ try {
73
+ const value = await Promise.race([
74
+ productApi.dispatch('ui.namespace', {}, controller.signal),
75
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('读取宿主草稿标识超时,请重试。')), namespaceTimeoutMs) }),
76
+ ])
77
+ // A later request (a Host replacement, a remount) owns the answer now.
78
+ if (generation.current !== mine || controller.signal.aborted) return
79
+ setState({ ...scope, status: 'ready', hostId: validateNamespace(value), error: '' })
80
+ } catch (error) {
81
+ if (generation.current !== mine || controller.signal.aborted) return
82
+ setState({ ...scope, status: 'error', hostId: '', error: messageOf(error) })
83
+ controller.abort()
84
+ } finally {
85
+ clearTimeout(timer)
86
+ }
87
+ }
88
+ void read()
89
+ return () => { clearTimeout(timer); controller.abort() }
90
+ }, [productApi, attempt, retry, namespaceTimeoutMs])
91
+
92
+ useEffect(() => {
93
+ if (typeof onStatus === 'function') onStatus({ status: current.status, hostId: current.hostId, error: current.error })
94
+ }, [state, onStatus, productApi, attempt, retry])
95
+ // One store per Host namespace. A replacement flushes what the retired Host
96
+ // still had pending and lets it go before the new one takes over.
97
+ const store = useMemo(() => (current.hostId
98
+ ? createComposerDraftStore({ storage: storage ?? globalThis.localStorage, hostId: current.hostId, limits: COMPOSER_DRAFT_LIMITS })
99
+ : undefined), [current.hostId, storage])
100
+ const held = useRef(undefined)
101
+ useEffect(() => {
102
+ if (typeof onStore === 'function') onStore(store ?? null)
103
+ }, [store, onStore])
104
+ useEffect(() => {
105
+ const previous = held.current
106
+ held.current = store
107
+ if (previous !== undefined && previous !== store) previous.flush()
108
+ }, [store])
109
+ useEffect(() => () => { held.current?.flush() }, [])
110
+
111
+ if (typeof children === 'function') return children({ store, status: current.status, hostId: current.hostId, error: current.error })
112
+ return h(Fragment, null, h(ComposerDraftBound, { ...slot, store, hostStatus: current.status }),
113
+ current.status === 'error' ? h('span', { className: 'bp-draft-recovery' },
114
+ h('span', { role: 'status' }, '草稿恢复暂不可用'),
115
+ h('button', { type: 'button', onClick: () => setRetry(value => value + 1) }, '重试草稿恢复')) : null)
116
+ }
117
+
118
+ /**
119
+ * The wrapper the wiring puts in the Host's composer slot. It keeps the original
120
+ * registration's component and its props untouched — the Host still renders the
121
+ * very component it registered — and adds the draft beside it.
122
+ *
123
+ * @param {React.ComponentType} component the component the slot already had
124
+ * @param {{ productApi: object }} options
125
+ */
126
+ export function withComposerDraft(component, options = {}) {
127
+ const { productApi } = options
128
+ const Wrapped = props => h(Fragment, null,
129
+ h(component, props),
130
+ h(ComposerDraftHost, { ...props, ...options, productApi, sessionBinding: options.sessionBindingOf?.(props.sessionId) }),
131
+ )
132
+ // Anything that probes the slot for the component it registered still finds it.
133
+ Wrapped.composerDraftOriginal = component
134
+ return Wrapped
135
+ }
136
+
137
+ /** A stable re-read handle for a caller that wants to retry explicitly. */
138
+ export function useNamespaceRetry() {
139
+ const [attempt, setAttempt] = useState(0)
140
+ const retry = useCallback(() => setAttempt(value => value + 1), [])
141
+ return { attempt, retry }
142
+ }
@@ -0,0 +1,287 @@
1
+ import { createElement as h, useEffect, useRef, useState } from 'react'
2
+ import { threadKeyFromRoster } from './composer-draft.mjs'
3
+ import { matchSubmission, newQueueRows } from './composer-draft-witness.mjs'
4
+
5
+ /**
6
+ * The composer draft of one conversation, bound to the Host composer.
7
+ *
8
+ * The Host owns the composer: its text lives in the input state a composer slot
9
+ * receives (`useInput`), and text goes back in through the insertion face the
10
+ * same slot receives (`inputActions.captureInsertion()` / `insertText()`). Those
11
+ * are the only two doors used here — the vendor's own composer extras use the
12
+ * same pair (vendor/ds-bot/src/client/composer.js:70,79 and enter-key.js:18), and
13
+ * this file adds no DOM query, no hashed class selector and no global
14
+ * contenteditable lookup, because none of those is a contract.
15
+ *
16
+ * What this never does: send. No submit, no turn, no model call, no approval, no
17
+ * attachment re-add. Restoring a draft fills an empty composer and stops.
18
+ *
19
+ * When a draft is dropped, and why nothing else may drop it:
20
+ * - the witness says this exact send was accepted — the session's own
21
+ * submission identity, read in composer-draft-witness.mjs. That is the only
22
+ * "yes" there is;
23
+ * - the composer was emptied with no send in flight, which is a person
24
+ * deleting what they typed, so nothing may come back later.
25
+ * A composer that emptied while a send was in flight keeps its draft until the
26
+ * witness answers, and remembers that a send is still open so a reload asks
27
+ * again. An unobserved outcome is never a success: no phase, no empty input and
28
+ * no elapsed time is read as one.
29
+ *
30
+ * What the Host has to provide for any of it to work: `useInput` (to read the
31
+ * draft) and `inputActions` (to put text back). Without the insertion face this
32
+ * component does nothing at all — it does not read, write or restore — because a
33
+ * half-wired draft is worse than none.
34
+ */
35
+
36
+ const readDraft = state => (typeof state?.draft === 'string' ? state.draft : '')
37
+ const readPhase = state => (typeof state?.phase === 'string' ? state.phase : '')
38
+ /** User-sourced queue rows as one stable string, for a store selector. */
39
+ const readQueue = state => (Array.isArray(state?.queue)
40
+ ? state.queue.filter(row => row?.source?.kind === 'user').map(row => String(row?.id ?? '')).join(',')
41
+ : '')
42
+ const readSubmissions = snapshot => {
43
+ const rows = Array.isArray(snapshot?.pendingSubmissions) ? snapshot.pendingSubmissions : []
44
+ // One stable string for a store selector, parsed back into the echo shape the
45
+ // witness helpers read.
46
+ return JSON.stringify(rows.map(row => ({ requestId: row?.requestId, text: row?.text, time: Number(row?.time ?? 0) })))
47
+ }
48
+
49
+ /** Phases in which the Host has taken the composer's text for a send. */
50
+ const IN_FLIGHT_PHASES = new Set(['submitting', 'adjudicating', 'claimed'])
51
+
52
+ const idsOf = value => (typeof value === 'string' && value ? value.split(',') : [])
53
+
54
+ const partsOf = key => {
55
+ const thread = key.indexOf(':thread:')
56
+ if (thread !== -1) {
57
+ const kind = key.slice(0, key.indexOf(':'))
58
+ return { kind, ownerId: key.slice(kind.length + 1, thread), threadId: key.slice(thread + ':thread:'.length) }
59
+ }
60
+ const colon = key.indexOf(':')
61
+ return colon === -1 ? null : { kind: key.slice(0, colon), ownerId: key.slice(colon + 1) }
62
+ }
63
+
64
+ /**
65
+ * The conversation a mounted session belongs to, as the Host roster names it. A
66
+ * session the roster does not know (an unowned or an Agent session) has no draft.
67
+ * `threadOf` maps a session to the stable threadId the Host directory gave it; a
68
+ * session it does not name is the Bot's default conversation, which every part of
69
+ * that Bot shares.
70
+ */
71
+ export function useComposerDraftKey(sessionId, useRoster, threadOf) {
72
+ return typeof useRoster === 'function'
73
+ ? useRoster(value => threadKeyFromRoster(sessionId, value, threadOf))
74
+ : ''
75
+ }
76
+
77
+ // A Host that publishes no insertion face is a different component, not a
78
+ // different hook order: the vendor's composer slots keep one hook order for a
79
+ // whole mount, and so does this. NoInput touches nothing — no read, no write, no
80
+ // restore, not even the conversation directory.
81
+ const NoInput = ({ onNote }) => {
82
+ const said = useRef(false)
83
+ useEffect(() => {
84
+ if (said.current) return
85
+ said.current = true
86
+ if (typeof onNote === 'function') onNote('no-input-face')
87
+ }, [onNote])
88
+ return null
89
+ }
90
+
91
+ const ReadInput = ({ useInput, useSubmissions, ...rest }) => h(DraftBody, {
92
+ ...rest,
93
+ draft: useInput(readDraft),
94
+ phase: useInput(readPhase),
95
+ queue: useInput(readQueue),
96
+ submissions: typeof useSubmissions === 'function' ? useSubmissions(readSubmissions) : '',
97
+ })
98
+
99
+ /**
100
+ * @param {object} props
101
+ * @param {ReturnType<import('./composer-draft.mjs').createComposerDraftStore>} [props.store]
102
+ * @param {string} props.sessionId the mounted part's session
103
+ * @param {(select: (state: unknown) => unknown) => unknown} [props.useRoster]
104
+ * @param {(select: (state: unknown) => unknown) => unknown} [props.useInput]
105
+ * @param {(select: (state: unknown) => unknown) => unknown} [props.useSubmissions]
106
+ * the Host's session hook, when this slot carries one; it is what makes a send
107
+ * identifiable instead of merely suspicious
108
+ * @param {{ captureInsertion?: () => unknown, insertText?: (text: string, span: unknown) => unknown }} [props.inputActions]
109
+ * @param {(context: { text: string, at: number, requestId?: string }) => 'accepted'|'failed'|undefined} [props.witness]
110
+ * @param {(sessionId: string) => string | undefined} [props.threadOf]
111
+ * @param {(note: string) => void} [props.onNote]
112
+ */
113
+ export function ComposerDraft(props) {
114
+ const face = props.inputActions
115
+ const usable = typeof face?.captureInsertion === 'function' && typeof face?.insertText === 'function'
116
+ return usable && typeof props.useInput === 'function' ? h(ReadInput, props) : h(NoInput, props)
117
+ }
118
+
119
+ function DraftBody({ store, sessionId, useRoster, inputActions, witness, threadOf, onNote, draft, phase, queue, submissions, evidenceRevision }) {
120
+ const key = useComposerDraftKey(sessionId, useRoster, threadOf)
121
+ const [status, setStatus] = useState('idle')
122
+ const shown = useRef('idle')
123
+ const latest = useRef({ draft, phase, queue, submissions, witness })
124
+ latest.current = { draft, phase, queue, submissions, witness }
125
+ const local = useRef({ key: '', revision: 0, seen: '', saved: '', restored: '', queueBefore: '', submissionsBefore: [], foreign: false, flight: null, timer: 0 })
126
+
127
+ const note = value => {
128
+ if (typeof onNote === 'function') onNote(value)
129
+ if (shown.current === value) return
130
+ shown.current = value
131
+ setStatus(value)
132
+ }
133
+ const face = useRef(inputActions)
134
+ face.current = inputActions
135
+
136
+ // Dropping one draft. Only a witness that says this send was accepted, or a
137
+ // deletion this conversation can be held to, gets here.
138
+ const drop = (parts, row) => {
139
+ row.flight = null
140
+ row.revision += 1
141
+ const dropped = store?.clear({ ...parts, revision: row.revision })
142
+ note(dropped?.ok ? 'cleared' : `kept:${dropped?.reason}`)
143
+ }
144
+
145
+ // A conversation is mounted: the surface serving it makes its draft reachable,
146
+ // and an unsent line from this same conversation goes back into an empty
147
+ // composer. What the person already typed always wins over a stored read.
148
+ useEffect(() => {
149
+ const row = local.current
150
+ if (!key || !store) {
151
+ row.key = ''
152
+ return undefined
153
+ }
154
+ const parts = partsOf(key)
155
+ row.key = key
156
+ row.saved = ''
157
+ row.restored = ''
158
+ row.flight = null
159
+ clearTimeout(row.timer)
160
+ row.timer = 0
161
+ row.queueBefore = latest.current.queue
162
+ row.submissionsBefore = JSON.parse(latest.current.submissions || '[]').map(row => row.requestId)
163
+ row.seen = latest.current.draft
164
+ store.see({ ...parts, sessionId })
165
+ const record = store.read(parts)
166
+ row.revision = record?.revision ?? 0
167
+ // Text this conversation never saved — another conversation's, or another
168
+ // Host's — is not ours to drop when it goes: only a deletion of text we can
169
+ // attribute to this composer clears this conversation's draft.
170
+ row.foreign = Boolean(latest.current.draft) && latest.current.draft !== (record?.text ?? '')
171
+ if (!record?.text) { note('empty'); return () => store.flush() }
172
+ // A send that was still unanswered when this window closed: the witness gets
173
+ // the first word, so an accepted send does not come back as text.
174
+ if (record.pendingAt) {
175
+ const pending = { text: record.text, at: record.pendingAt, requestId: record.pendingRequestId || undefined }
176
+ const answer = latest.current.witness?.(pending)
177
+ if (answer === 'accepted') {
178
+ row.revision += 1
179
+ const dropped = store.clear({ ...parts, revision: row.revision })
180
+ note(dropped?.ok ? 'cleared' : `kept:${dropped?.reason}`)
181
+ return () => store.flush()
182
+ }
183
+ row.flight = pending
184
+ note('kept-unknown')
185
+ return () => store.flush()
186
+ }
187
+ if (latest.current.draft) { note('kept-typing'); return () => store.flush() }
188
+ if (face.current.insertText(record.text, face.current.captureInsertion()) === false) {
189
+ note('restore-refused')
190
+ return () => store.flush()
191
+ }
192
+ row.restored = record.text
193
+ row.seen = record.text
194
+ row.revision = record.revision
195
+ note('restored')
196
+ return () => { store.flush() }
197
+ }, [key, store, sessionId])
198
+
199
+ // Every change of the composer's text, and every new piece of evidence about
200
+ // the send that emptied it, lands here.
201
+ useEffect(() => {
202
+ const row = local.current
203
+ if (!row.key || !store) return
204
+ const parts = partsOf(row.key)
205
+ if (draft) {
206
+ if (draft === row.seen) return
207
+ // Typing again cancels whatever the previous empty might have meant, and
208
+ // remembers the queue this text was typed beside.
209
+ row.seen = draft
210
+ row.foreign = false
211
+ row.flight = null
212
+ row.queueBefore = latest.current.queue
213
+ row.submissionsBefore = JSON.parse(latest.current.submissions || '[]').map(row => row.requestId)
214
+ clearTimeout(row.timer)
215
+ row.timer = 0
216
+ row.revision += 1
217
+ const stored = store.save({ ...parts, sessionId, text: draft, revision: row.revision })
218
+ if (stored?.ok) row.saved = draft
219
+ note(stored?.ok ? 'saved' : `refused:${stored?.reason}`)
220
+ return
221
+ }
222
+ if (row.flight) {
223
+ // New evidence about a send that is still open.
224
+ const answer = latest.current.witness?.(row.flight)
225
+ if (answer === 'accepted') { drop(parts, row); return }
226
+ if (answer === 'failed') { row.flight = null; note('kept-failed'); return }
227
+ return
228
+ }
229
+ if (draft === row.seen) return
230
+ const emptied = row.seen
231
+ row.seen = ''
232
+ if (!emptied) return
233
+
234
+ const at = Date.now()
235
+ const candidates = JSON.parse(latest.current.submissions || '[]')
236
+ const echo = matchSubmission(candidates, { text: emptied, at, before: row.submissionsBefore })
237
+ const tookQueue = newQueueRows(idsOf(latest.current.queue), idsOf(row.queueBefore)).length > 0
238
+ const inFlight = echo !== null || candidates.some(item => !row.submissionsBefore.includes(item.requestId)) || tookQueue ||
239
+ (evidenceRevision === undefined && IN_FLIGHT_PHASES.has(latest.current.phase))
240
+ if (inFlight) {
241
+ // The Host took the text. Whether it was accepted is the witness's word.
242
+ row.flight = { text: emptied, at, requestId: echo?.requestId }
243
+ const answer = latest.current.witness?.(row.flight)
244
+ if (answer === 'accepted') { drop(parts, row); return }
245
+ if (answer === 'failed') { row.flight = null; note('kept-failed'); return }
246
+ // Unknown: keep the text, and remember that a send is still open so a
247
+ // reload asks again instead of quietly showing the sent line back.
248
+ row.revision += 1
249
+ const marked = store.save({ ...parts, sessionId, text: emptied, revision: row.revision, pendingAt: at, pendingRequestId: echo?.requestId })
250
+ note(marked?.ok ? 'kept-unknown' : `refused:${marked?.reason}`)
251
+ return
252
+ }
253
+ if (row.foreign) { note('kept-foreign'); return }
254
+ // A person deleted what they typed. Nothing may come back later — but the
255
+ // deletion is attributed to this conversation first, because a Host that
256
+ // clears its composer while switching sessions must not take the draft of
257
+ // the conversation it is leaving down with it.
258
+ const owner = row.key
259
+ clearTimeout(row.timer)
260
+ row.timer = setTimeout(() => {
261
+ row.timer = 0
262
+ if (local.current.key !== owner || latest.current.draft) return
263
+ drop(partsOf(owner), row)
264
+ }, 0)
265
+ }, [draft, store, sessionId, queue, submissions, phase, evidenceRevision])
266
+
267
+ // A window that goes away writes what is pending before it does.
268
+ useEffect(() => () => { clearTimeout(local.current.timer); store?.flush() }, [store])
269
+
270
+ if (status !== 'kept-unknown') return null
271
+ const recover = () => {
272
+ const row = local.current, parts = partsOf(row.key), record = store?.read(parts)
273
+ if (!record?.text || latest.current.draft) return
274
+ if (face.current.insertText(record.text, face.current.captureInsertion()) === false) return
275
+ row.flight = null
276
+ note('restored')
277
+ }
278
+ return h('span', { className: 'bp-draft-recovery' },
279
+ h('span', { role: 'status' }, '发送结果待确认,草稿已保留'),
280
+ h('button', { type: 'button', onClick: recover }, '恢复草稿'),
281
+ h('button', { type: 'button', onClick: () => drop(partsOf(local.current.key), local.current) }, '清除草稿'))
282
+ }
283
+
284
+ export const COMPOSER_DRAFT_STATUSES = Object.freeze([
285
+ 'idle', 'empty', 'kept-typing', 'no-input-face', 'restore-refused',
286
+ 'restored', 'saved', 'cleared', 'kept-unknown', 'kept-failed', 'kept-foreign',
287
+ ])
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Did this exact send reach the Host? The only honest answers are the ones the
3
+ * session contract already publishes.
4
+ *
5
+ * The contract (verified against the installed SDK, not guessed):
6
+ * - `ISession.beginSubmission()` registers one local echo in
7
+ * `snapshot.pendingSubmissions` synchronously, before the prompt is
8
+ * serialized, and mints the `requestId` the prompt must carry.
9
+ * `@deepseek-ai/dsh-api-session-controller/lib/types/client/contract/session.d.ts:63-84`
10
+ * - `ISession.prompt(content, mode, signal, requestId)` answers
11
+ * `{accepted:true}` or the business error, and mirrors a failure into
12
+ * `snapshot.promptError` with `op:'send'`. (same file, 74-84)
13
+ * - A pending echo retires with `reason:'observed'` once its durable
14
+ * `user/message` event or its host queue occurrence arrived, and with
15
+ * `reason:'failed'` when the prompt was rejected, threw, or was aborted
16
+ * before acceptance. (same file, 18-28)
17
+ * - `PendingSubmission.requestId` is "the prompt RPC identity; the durable
18
+ * `user/message` source echoes it as `rpcId`".
19
+ * `.../client/contract/snapshot.d.ts:38-49`
20
+ *
21
+ * So the identity of one send is the requestId, and acceptance is that requestId
22
+ * showing up as a durable user message. Nothing else counts: a composer phase,
23
+ * an empty input, an elapsed timeout, a historical message with the same text, or
24
+ * a Bot reply. This module only reads those published fields.
25
+ *
26
+ * Everything here is pure and offline; the React side passes the snapshots in.
27
+ */
28
+
29
+ /** How long after the composer emptied an echo may still be matched to it. */
30
+ export const SUBMISSION_MATCH_WINDOW_MS = 5000
31
+
32
+ const textOf = value => (typeof value === 'string' ? value : '')
33
+
34
+ /**
35
+ * The echo this send produced, if one is there. The text and the moment only
36
+ * *find* the echo; the identity that comes back is the requestId, which is what
37
+ * later evidence has to match. A historical message with the same text is not an
38
+ * echo: it is not in `pendingSubmissions` at all.
39
+ *
40
+ * @param {readonly {requestId?: unknown, text?: unknown, time?: unknown}[]} submissions
41
+ * @param {{ text: string, at: number, windowMs?: number }} send
42
+ */
43
+ export function matchSubmission(submissions, { text, at, windowMs = SUBMISSION_MATCH_WINDOW_MS, before = [] }) {
44
+ const wanted = textOf(text)
45
+ const moment = Number(at)
46
+ if (!wanted || !Number.isFinite(moment) || !Array.isArray(submissions)) return null
47
+ const known = new Set(before)
48
+ const matches = new Map()
49
+ for (const echo of submissions) {
50
+ if (typeof echo?.requestId !== 'string' || !echo.requestId || known.has(echo.requestId)) continue
51
+ if (textOf(echo?.text) !== wanted) continue
52
+ const started = Number(echo?.time ?? 0)
53
+ if (!Number.isFinite(started)) continue
54
+ // The echo belongs to this send when it began with it, not minutes later.
55
+ if (started < moment - windowMs || started > moment + windowMs) continue
56
+ matches.set(echo.requestId, { time: started, requestId: echo.requestId })
57
+ }
58
+ if (matches.size !== 1) return null
59
+ const found = [...matches.values()][0]
60
+ return { requestId: found.requestId, text: wanted, time: found.time }
61
+ }
62
+
63
+ /**
64
+ * The requestIds the durable conversation already carries. A user message node
65
+ * echoes its prompt identity as `source.rpcId`; a Bot's own reply carries none,
66
+ * so a reply can never be mistaken for this send.
67
+ */
68
+ export function observedRequestIds(events) {
69
+ const seen = new Set()
70
+ if (!Array.isArray(events)) return seen
71
+ for (const event of events) {
72
+ if (event?.type !== 'user/message' || event?.data?.source?.kind !== 'user') continue
73
+ const rpcId = event.data.source.rpcId
74
+ if (typeof rpcId === 'string' && rpcId) seen.add(rpcId)
75
+ }
76
+ return seen
77
+ }
78
+
79
+ /** A send failure the session published: `promptError.op === 'send'`. */
80
+ export const sendFailed = promptError => promptError?.op === 'send'
81
+
82
+ /**
83
+ * The outcome of one identified send, or `undefined` when the Host has not said.
84
+ * `undefined` is the common case and it means "keep the draft": an outcome that
85
+ * was never observed is not a success.
86
+ *
87
+ * @param {object} state
88
+ * @param {{requestId:string}} state.pending the identity captured when the send began
89
+ * @param {readonly object[]} [state.events] durable conversation events
90
+ * @param {readonly string[]} [state.acceptedRequestIds] exact durable admissions
91
+ * @param {readonly string[]} [state.failedRequestIds] identified failure receipts
92
+ */
93
+ export function readSendOutcome({ pending, events, acceptedRequestIds = [], failedRequestIds = [] } = {}) {
94
+ if (!pending || typeof pending.requestId !== 'string' || !pending.requestId) return undefined
95
+ if (observedRequestIds(events).has(pending.requestId) || acceptedRequestIds.includes(pending.requestId)) return 'accepted'
96
+ // promptError has no request id. A stale send error cannot settle this send.
97
+ if (failedRequestIds.includes(pending.requestId)) return 'failed'
98
+ return undefined
99
+ }
100
+
101
+ /** User-sourced rows the input face reports as queued right now. */
102
+ export function queuedUserRows(queue) {
103
+ if (!Array.isArray(queue)) return []
104
+ return queue.filter(row => row?.source?.kind === 'user')
105
+ }
106
+
107
+ /**
108
+ * The queue rows this read has that the previous one did not. Used only to decide
109
+ * "a send is in flight, so do not clear yet" — it never clears anything on its
110
+ * own, and a queue row is never read as acceptance of a specific send.
111
+ */
112
+ export function newQueueRows(current, before) {
113
+ const known = new Set((Array.isArray(before) ? before : []).filter(id => typeof id === 'string'))
114
+ return (Array.isArray(current) ? current : []).filter(id => typeof id === 'string' && !known.has(id))
115
+ }
@@ -0,0 +1,4 @@
1
+ body[data-bot-product-ui] .bp-draft-recovery { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; max-width: 100%; font-size: 12px; color: var(--bp-chat-muted, #687486); }
2
+ body[data-bot-product-ui] .bp-draft-recovery button { border: 1px solid var(--bp-chat-line-strong, #7f8691); border-radius: 8px; padding: 5px 8px; background: var(--bp-chat-surface, #fff); color: var(--bp-chat-ink, #253247); cursor: pointer; }
3
+ body[data-bot-product-ui] .bp-draft-recovery button:focus-visible { outline: 2px solid var(--bp-chat-accent, #5174a0); outline-offset: 2px; }
4
+ @media (max-width: 600px) { body[data-bot-product-ui] .bp-draft-recovery button { min-height: 40px; } }