basalt-ui 1.11.0 → 1.13.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 (301) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +63 -51
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/agent/rules/basalt-content.md +16 -3
  6. package/configs/oxlint-plugin.js +7 -3
  7. package/configs/oxlint-plugin.test.ts +34 -5
  8. package/configs/oxlint.json +4 -4
  9. package/dist/agent/adapter.d.ts +150 -0
  10. package/dist/agent/adapter.d.ts.map +1 -0
  11. package/dist/agent/adapter.js +482 -0
  12. package/dist/agent/adapter.test.d.ts +2 -0
  13. package/dist/agent/adapter.test.d.ts.map +1 -0
  14. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  15. package/dist/agent/ai-sdk-transport.js +6 -2
  16. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  17. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  18. package/dist/agent/coalesce.d.ts +20 -4
  19. package/dist/agent/coalesce.d.ts.map +1 -1
  20. package/dist/agent/coalesce.js +76 -8
  21. package/dist/agent/coalesce.test.d.ts +2 -0
  22. package/dist/agent/coalesce.test.d.ts.map +1 -0
  23. package/dist/agent/foreign.test.d.ts +2 -0
  24. package/dist/agent/foreign.test.d.ts.map +1 -0
  25. package/dist/agent/id.d.ts +78 -0
  26. package/dist/agent/id.d.ts.map +1 -1
  27. package/dist/agent/id.js +30 -0
  28. package/dist/agent/id.test.d.ts +2 -0
  29. package/dist/agent/id.test.d.ts.map +1 -0
  30. package/dist/agent/index.d.ts +2 -0
  31. package/dist/agent/index.d.ts.map +1 -1
  32. package/dist/agent/index.js +3 -0
  33. package/dist/agent/merge.d.ts.map +1 -1
  34. package/dist/agent/merge.js +19 -7
  35. package/dist/agent/merge.test.d.ts +2 -0
  36. package/dist/agent/merge.test.d.ts.map +1 -0
  37. package/dist/agent/part-list.d.ts +49 -34
  38. package/dist/agent/part-list.d.ts.map +1 -1
  39. package/dist/agent/part-list.js +42 -6
  40. package/dist/agent/part-list.test.d.ts +2 -0
  41. package/dist/agent/part-list.test.d.ts.map +1 -0
  42. package/dist/agent/parts.d.ts +46 -10
  43. package/dist/agent/parts.d.ts.map +1 -1
  44. package/dist/agent/parts.js +16 -6
  45. package/dist/agent/parts.test.d.ts +2 -0
  46. package/dist/agent/parts.test.d.ts.map +1 -0
  47. package/dist/agent/thread.d.ts +18 -0
  48. package/dist/agent/thread.d.ts.map +1 -1
  49. package/dist/agent/thread.js +8 -2
  50. package/dist/agent/thread.test.d.ts +2 -0
  51. package/dist/agent/thread.test.d.ts.map +1 -0
  52. package/dist/agent/transport.test.d.ts +2 -0
  53. package/dist/agent/transport.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  55. package/dist/agent/use-agent-stream.js +2 -2
  56. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  57. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  58. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  59. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  60. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  61. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  62. package/dist/agent/use-agent-thread-runs.js +55 -15
  63. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  64. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  66. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  67. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  69. package/dist/agent-chat/composer.d.ts +130 -8
  70. package/dist/agent-chat/composer.d.ts.map +1 -1
  71. package/dist/agent-chat/composer.js +189 -23
  72. package/dist/agent-chat/composer.test.d.ts +2 -0
  73. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  74. package/dist/agent-chat/index.d.ts +5 -1
  75. package/dist/agent-chat/index.d.ts.map +1 -1
  76. package/dist/agent-chat/index.js +2 -0
  77. package/dist/agent-chat/message-affordances.d.ts +59 -0
  78. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  79. package/dist/agent-chat/message-affordances.js +7 -0
  80. package/dist/agent-chat/relative-time.d.ts +27 -0
  81. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  82. package/dist/agent-chat/relative-time.js +27 -0
  83. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  84. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  85. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  86. package/dist/agent-chat/thread-detail-panel.js +1 -1
  87. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  88. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  89. package/dist/agent-chat/thread-feed-row.js +156 -0
  90. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  91. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  92. package/dist/agent-chat/thread-feed.d.ts +34 -5
  93. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  94. package/dist/agent-chat/thread-feed.js +52 -19
  95. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  96. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  97. package/dist/agent-chat/thread-message.d.ts +183 -2
  98. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  99. package/dist/agent-chat/thread-message.js +329 -37
  100. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  101. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  102. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  103. package/dist/agent-chat/thread-outcome-card.js +1 -16
  104. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  105. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  106. package/dist/agent-chat/thread-workspace.js +30 -4
  107. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  108. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  109. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  110. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  111. package/dist/agent-chat/virtualize.d.ts +123 -0
  112. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  113. package/dist/agent-chat/virtualize.js +9 -0
  114. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  115. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  116. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  117. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  118. package/dist/charts/utils/domain.test.d.ts +2 -0
  119. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  120. package/dist/cli/check-theme.test.d.ts +2 -0
  121. package/dist/cli/check-theme.test.d.ts.map +1 -0
  122. package/dist/cli/help.test.d.ts +2 -0
  123. package/dist/cli/help.test.d.ts.map +1 -0
  124. package/dist/cli/index.d.ts.map +1 -1
  125. package/dist/cli/init-sync.test.d.ts +2 -0
  126. package/dist/cli/init-sync.test.d.ts.map +1 -0
  127. package/dist/cli/placement-engine.test.d.ts +2 -0
  128. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  129. package/dist/cli/tarball-parity.test.d.ts +2 -0
  130. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  131. package/dist/cli/template-format.test.d.ts +2 -0
  132. package/dist/cli/template-format.test.d.ts.map +1 -0
  133. package/dist/cli/template-ledger.test.d.ts +2 -0
  134. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  135. package/dist/cli/tokens-css.test.d.ts +2 -0
  136. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  137. package/dist/content/article-actions.test.d.ts +2 -0
  138. package/dist/content/article-actions.test.d.ts.map +1 -0
  139. package/dist/content/article-model.test.d.ts +2 -0
  140. package/dist/content/article-model.test.d.ts.map +1 -0
  141. package/dist/content/block-split.test.d.ts +2 -0
  142. package/dist/content/block-split.test.d.ts.map +1 -0
  143. package/dist/content/fence-block.d.ts +37 -10
  144. package/dist/content/fence-block.d.ts.map +1 -1
  145. package/dist/content/fence-block.js +80 -14
  146. package/dist/content/heading-components.d.ts.map +1 -1
  147. package/dist/content/heading-components.js +7 -4
  148. package/dist/content/heading-components.test.d.ts +2 -0
  149. package/dist/content/heading-components.test.d.ts.map +1 -0
  150. package/dist/content/index.d.ts +6 -3
  151. package/dist/content/index.d.ts.map +1 -1
  152. package/dist/content/index.js +5 -1
  153. package/dist/content/markdown.d.ts +101 -14
  154. package/dist/content/markdown.d.ts.map +1 -1
  155. package/dist/content/markdown.js +141 -28
  156. package/dist/content/markdown.test.d.ts +2 -0
  157. package/dist/content/markdown.test.d.ts.map +1 -0
  158. package/dist/content/prose.module.css.test.d.ts +2 -0
  159. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  160. package/dist/content/sanitize.d.ts +170 -0
  161. package/dist/content/sanitize.d.ts.map +1 -0
  162. package/dist/content/sanitize.js +178 -0
  163. package/dist/content/sanitize.test.d.ts +2 -0
  164. package/dist/content/sanitize.test.d.ts.map +1 -0
  165. package/dist/content/slug.test.d.ts +2 -0
  166. package/dist/content/slug.test.d.ts.map +1 -0
  167. package/dist/dashboard/stat-card.test.d.ts +2 -0
  168. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  169. package/dist/data/index.d.ts +5 -1
  170. package/dist/data/index.d.ts.map +1 -1
  171. package/dist/data/virtual-list.d.ts.map +1 -1
  172. package/dist/data/virtual.d.ts +2 -1
  173. package/dist/data/virtual.d.ts.map +1 -1
  174. package/dist/guard/check-source.test.d.ts +2 -0
  175. package/dist/guard/check-source.test.d.ts.map +1 -0
  176. package/dist/guard/guard-hook.test.d.ts +2 -0
  177. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  178. package/dist/index.d.ts +4 -4
  179. package/dist/index.d.ts.map +1 -1
  180. package/dist/index.js +11 -1
  181. package/dist/provider/index.test.d.ts +2 -0
  182. package/dist/provider/index.test.d.ts.map +1 -0
  183. package/dist/provider/lab-theme.test.d.ts +2 -0
  184. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  186. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  187. package/dist/styles.floor.test.d.ts +2 -0
  188. package/dist/styles.floor.test.d.ts.map +1 -0
  189. package/dist/surfaces.d.ts +5 -5
  190. package/dist/surfaces.d.ts.map +1 -1
  191. package/dist/surfaces.js +14 -4
  192. package/dist/theme/border-coverage.test.d.ts +18 -0
  193. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  194. package/dist/theme/contrast.test.d.ts +2 -0
  195. package/dist/theme/contrast.test.d.ts.map +1 -0
  196. package/dist/theme/density-option.test.d.ts +2 -0
  197. package/dist/theme/density-option.test.d.ts.map +1 -0
  198. package/dist/theme/density-relations.test.d.ts +2 -0
  199. package/dist/theme/density-relations.test.d.ts.map +1 -0
  200. package/dist/theme/derive-theme.test.d.ts +2 -0
  201. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  202. package/dist/theme/fonts-option.test.d.ts +2 -0
  203. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  204. package/dist/theme/radius-option.test.d.ts +2 -0
  205. package/dist/theme/radius-option.test.d.ts.map +1 -0
  206. package/dist/theme/radius.test.d.ts +2 -0
  207. package/dist/theme/radius.test.d.ts.map +1 -0
  208. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  209. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  210. package/dist/theme/spacing.test.d.ts +2 -0
  211. package/dist/theme/spacing.test.d.ts.map +1 -0
  212. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  213. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  214. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  215. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  216. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  217. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  218. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  219. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  220. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  221. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  222. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  223. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  224. package/dist/tokens/density.test.d.ts +2 -0
  225. package/dist/tokens/density.test.d.ts.map +1 -0
  226. package/dist/tokens/derive-radius.test.d.ts +2 -0
  227. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  228. package/dist/tokens/derive.test.d.ts +2 -0
  229. package/dist/tokens/derive.test.d.ts.map +1 -0
  230. package/dist/tokens/palette.test.d.ts +2 -0
  231. package/dist/tokens/palette.test.d.ts.map +1 -0
  232. package/dist/vite-color-mix.test.d.ts +2 -0
  233. package/dist/vite-color-mix.test.d.ts.map +1 -0
  234. package/dist/vite.test.d.ts +2 -0
  235. package/dist/vite.test.d.ts.map +1 -0
  236. package/llms.txt +9 -9
  237. package/package.json +7 -2
  238. package/src/agent/adapter.test.ts +1640 -0
  239. package/src/agent/adapter.ts +945 -0
  240. package/src/agent/ai-sdk-transport.test.ts +117 -26
  241. package/src/agent/ai-sdk-transport.ts +8 -2
  242. package/src/agent/coalesce.test.ts +202 -9
  243. package/src/agent/coalesce.ts +126 -15
  244. package/src/agent/foreign.test.ts +5 -1
  245. package/src/agent/id.test.ts +101 -3
  246. package/src/agent/id.ts +118 -0
  247. package/src/agent/index.ts +8 -0
  248. package/src/agent/merge.test.ts +87 -0
  249. package/src/agent/merge.ts +57 -10
  250. package/src/agent/part-list.test.tsx +31 -1
  251. package/src/agent/part-list.tsx +77 -29
  252. package/src/agent/parts.test.ts +121 -4
  253. package/src/agent/parts.ts +78 -17
  254. package/src/agent/thread.test.ts +75 -2
  255. package/src/agent/thread.ts +25 -1
  256. package/src/agent/use-agent-stream.test.tsx +31 -5
  257. package/src/agent/use-agent-stream.ts +6 -3
  258. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  259. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  260. package/src/agent/use-agent-thread-runs.test.tsx +356 -17
  261. package/src/agent/use-agent-thread-runs.ts +159 -23
  262. package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
  263. package/src/agent-chat/composer.test.tsx +924 -0
  264. package/src/agent-chat/composer.tsx +521 -52
  265. package/src/agent-chat/index.ts +13 -1
  266. package/src/agent-chat/message-affordances.ts +61 -0
  267. package/src/agent-chat/relative-time.test.ts +95 -0
  268. package/src/agent-chat/relative-time.ts +55 -0
  269. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  270. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  271. package/src/agent-chat/thread-feed-row.tsx +274 -0
  272. package/src/agent-chat/thread-feed.test.tsx +241 -0
  273. package/src/agent-chat/thread-feed.tsx +119 -18
  274. package/src/agent-chat/thread-message.test.tsx +1078 -4
  275. package/src/agent-chat/thread-message.tsx +795 -42
  276. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  277. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  278. package/src/agent-chat/thread-workspace.tsx +70 -6
  279. package/src/agent-chat/virtualize.ts +132 -0
  280. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  281. package/src/cli/index.ts +0 -1
  282. package/src/cli/template-format.test.ts +2 -2
  283. package/src/content/fence-block.tsx +242 -15
  284. package/src/content/heading-components.test.tsx +65 -0
  285. package/src/content/heading-components.tsx +26 -5
  286. package/src/content/index.ts +19 -3
  287. package/src/content/markdown.test.tsx +603 -0
  288. package/src/content/markdown.tsx +355 -35
  289. package/src/content/sanitize.test.ts +659 -0
  290. package/src/content/sanitize.ts +510 -0
  291. package/src/dashboard/stat-card.test.tsx +6 -1
  292. package/src/data/index.ts +5 -1
  293. package/src/data/virtual-list.tsx +2 -1
  294. package/src/data/virtual.ts +2 -1
  295. package/src/index.ts +18 -1
  296. package/src/surfaces.ts +13 -3
  297. package/src/theme/contrast.test.ts +1 -4
  298. package/src/theme/density-relations.test.ts +4 -2
  299. package/src/theme/fonts-option.test.ts +7 -1
  300. package/src/theme/shadow-surfaces.test.ts +21 -6
  301. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -40,7 +40,7 @@
40
40
  import { useCallback, useEffect, useRef, useState } from 'react'
41
41
  import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
42
42
  import type { ChatMessage } from './history'
43
- import { withPartIds } from './id'
43
+ import { mintMessageId, mintThreadId, withPartIds } from './id'
44
44
  import { mergePart } from './merge'
45
45
  import type { PartLike } from './merge'
46
46
  import type { AgentOutcome, OutcomeResolver } from './outcome'
@@ -103,18 +103,39 @@ export type UseAgentThreadRunsArgs<TPart = AgentPart> = {
103
103
  export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
104
104
  /** Live stream state per thread id, for threads with a run in progress or just completed. */
105
105
  readonly runs: ReadonlyMap<string, ThreadRunState<TPart>>
106
- /** Start a new turn on `threadId`. No-op if that thread already has a stream in flight. */
106
+ /**
107
+ * Start a new turn on `threadId`. No-op if that thread already has a stream in flight.
108
+ *
109
+ * @throws {Error} synchronously, before any state is touched, on a host with no usable
110
+ * `crypto` at all (see `mintMessageId` in `./id.ts` — the id it mints here is the new user
111
+ * ChatMessage's idempotency key, so it degrades to a throw rather than a silent collision).
112
+ * This throw is a true no-op: it happens before `appendMessage`/`setStatus`/the run's
113
+ * controller are registered, so nothing is left half-started — the thread is exactly as it was
114
+ * before the call. A caller invoking `start` from an event handler on a target that must
115
+ * tolerate this (very old WebViews, non-DOM SSR shims) should wrap the call in its own
116
+ * try/catch; this rung is vanishingly rare in practice (see `mintMessageId`'s own doc).
117
+ */
107
118
  readonly start: (threadId: string, input: string) => void
108
119
  /**
109
120
  * Replay the last user input sent on `threadId` (same code path as `start`). No-op if
110
121
  * `threadId` has never had a turn started, or already has one in flight.
122
+ *
123
+ * @throws {Error} same condition and same true-no-op guarantee as `start` — see its doc.
111
124
  */
112
125
  readonly retry: (threadId: string) => void
113
126
  /**
114
- * Abort the in-flight stream for `threadId` (no-op if idle). Preserves whatever content had
115
- * already arrived: if the run had accumulated any parts, they're persisted as an assistant
127
+ * Abort the in-flight stream for `threadId` (true no-op only if BOTH `controllersRef` and
128
+ * `runs` have nothing for it — i.e. the thread genuinely isn't live). Preserves whatever content
129
+ * had already arrived: if the run had accumulated any parts, they're persisted as an assistant
116
130
  * ChatMessage with `finish: 'stopped'` (unless that message already landed — see finalizeStop),
117
131
  * then distilled into an outcome and the thread is settled to 'done'.
132
+ *
133
+ * Defense-in-depth: if `runs` reports `threadId` as `'streaming'` but no controller is
134
+ * registered for it (a phantom entry — not reachable via any path in this hook today, since the
135
+ * unmount-cleanup effect and `finalizeStop` both tear down `controllersRef` and `runs` together,
136
+ * but guarded against here so nothing in this file can EVER wedge a thread the UI shows as live
137
+ * with no way to reach a terminal state), `stop()` still settles it instead of silently
138
+ * no-opping forever.
118
139
  */
119
140
  readonly stop: (threadId: string) => void
120
141
  /** Abort every in-flight stream across all threads, settling each the same way stop() would. */
@@ -123,9 +144,14 @@ export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
123
144
 
124
145
  // ── defaults ──────────────────────────────────────────────────────────────────
125
146
 
126
- /** Default toUserParts: wraps raw input in a single text part. */
147
+ /**
148
+ * Default toUserParts: wraps raw input in a single text part. This part's id is a display/merge
149
+ * identity WITHIN a message's own `parts` array — it is never the value `appendMessage` idempotes
150
+ * on (that's the enclosing `ChatMessage.id`, minted separately by the caller) — so `mintThreadId`
151
+ * is the right low-collision-cost helper here, not `mintMessageId`.
152
+ */
127
153
  function defaultToUserParts(input: string): AgentPart[] {
128
- return [{ id: crypto.randomUUID(), type: 'text', text: input }]
154
+ return [{ id: mintThreadId(), type: 'text', text: input }]
129
155
  }
130
156
 
131
157
  /**
@@ -199,6 +225,18 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
199
225
  for await (const part of generator) {
200
226
  // Guard: a newer call superseded this stream's controller for this thread, or it
201
227
  // was aborted — stop updating state.
228
+ //
229
+ // Deliberately NOT a `runs` teardown site (considered and rejected): `controllersRef`
230
+ // mismatch here is ambiguous between "this run was aborted and nothing replaced it" and "a
231
+ // newer run (a mount-reconcile resume, keyed by the SAME threadId) has already taken over
232
+ // and is live in `runs` right now". Only the first case should ever clear `runs[threadId]`,
233
+ // and only the abort's OWN originator can tell the two apart without a race — the unmount-
234
+ // cleanup effect (see its doc) does that teardown synchronously, in the same tick as the
235
+ // abort, before any newer run could exist to be confused with. If this guard also deleted
236
+ // `runs[threadId]`, a superseding resume's fresh entry would be clobbered out from under it
237
+ // by its OWN predecessor's late-settling loop iteration — reintroducing a wedge instead of
238
+ // fixing one. `stop()`'s own abort path has the same property (finalizeStop's teardown runs
239
+ // synchronously before `stop()` returns, never racing a subsequent call).
202
240
  if (controllersRef.current.get(threadId) !== controller) return
203
241
  if (controller.signal.aborted) return
204
242
  if (isStartPart(part)) {
@@ -221,7 +259,9 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
221
259
  }
222
260
 
223
261
  const assistantMessage: ChatMessage<TPart> = {
224
- id: crypto.randomUUID(),
262
+ // appendMessage's idempotency key — mintMessageId, never mintThreadId (see that helper's
263
+ // doc): a collided id here would silently drop this message, not merge a duplicate thread.
264
+ id: mintMessageId(),
225
265
  role: 'assistant',
226
266
  parts,
227
267
  createdAt: Date.now(),
@@ -285,11 +325,37 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
285
325
  * invoking this. `alreadyAppended` is precomputed by the caller (stop()) from `appendedRef` — see
286
326
  * that ref's doc for why this is an explicit marker rather than an inferred comparison.
287
327
  *
288
- * Ordering: the append, forcing status to 'done', clearing the resume token, and tearing down the
289
- * run entry ALL happen synchronously (this function's body runs synchronously up to its one
290
- * `await`) — status is never derived from the resolved outcome, so it doesn't need to wait on one,
291
- * matching useAgentStream's stop(), which settles 'done' immediately rather than blocking on async
292
- * work. Only `setOutcome` — which genuinely needs the resolved value — happens after the await.
328
+ * Ordering / the append-failure guard: the append is attempted FIRST (so a successful stop still
329
+ * reads as 'done' with its partial content intact — this is the common case and the one that
330
+ * matters most), but it is wrapped in its own try/catch that CANNOT prevent the terminal
331
+ * transition below it. This is deliberate, not incidental: by the time this function runs, `stop()`
332
+ * has already deleted `threadId` from `controllersRef` and `appendedRef` (see `stop()`'s own doc),
333
+ * so a second `stop()` call is unconditionally a no-op regardless of what happens here — if the
334
+ * append (or `mintMessageId`, which now also throws on rung 3 — see `./id.ts`) throws and nothing
335
+ * downstream ran, the thread would be wedged at 'streaming' FOREVER with no way for the user to
336
+ * clear it. That is exactly the standing invariant this layer forbids (see this hook's module
337
+ * doc / the render-path degrade rule), so it must not depend on the append succeeding. The
338
+ * alternative ordering — set status/teardown first, append after — was considered and rejected:
339
+ * it would mark the thread 'done' before the content the user is about to lose is even attempted,
340
+ * which is a strictly worse failure mode for the (overwhelmingly common) success path than this
341
+ * try/catch is for the (vanishingly rare) failure path.
342
+ *
343
+ * On an append failure, status becomes 'error' instead of 'done' — mirroring
344
+ * `consumeAndFinalize`'s own catch path (`onFailureStatus`), which is the existing precedent for
345
+ * "the persist step failed, tell the user" in this file — and `resolveOutcome` is skipped
346
+ * entirely, again matching that catch path: there is no complete/consistent snapshot worth
347
+ * distilling into an outcome when the turn's own final message never made it into the store.
348
+ *
349
+ * `setStatus`/`setResumeToken` are guarded for the SAME reason, and this is not belt-and-braces:
350
+ * they are consumer code exactly as `appendMessage` is (a `createThreadsStore` adapter over a
351
+ * remote backend, a store that rejects a threadId removed mid-stream), and guarding only the
352
+ * append leaves the identical wedge one line further down. The single thing that must ALWAYS run
353
+ * is the `setRuns` teardown — that entry is the HOOK'S OWN state, it is what `runs.get(threadId)`
354
+ * reports to the UI as "a turn is in flight", and by this point `stop()` has already deleted the
355
+ * thread from `controllersRef`, so no later `stop()` can ever reach here again to clean it up. A
356
+ * throw above it therefore strands a phantom run for the lifetime of the hook. Each store call
357
+ * gets its OWN try so a failing `setStatus` cannot also skip clearing the resume token — a
358
+ * surviving token is separately harmful (it is what a later resume replays from).
293
359
  */
294
360
  async function finalizeStop<TPart extends PartLike>(args: {
295
361
  threadId: string
@@ -301,19 +367,39 @@ async function finalizeStop<TPart extends PartLike>(args: {
301
367
  }): Promise<void> {
302
368
  const { threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns } = args
303
369
 
370
+ let appendFailed = false
304
371
  if (!alreadyAppended && parts.length > 0) {
305
- const stoppedMessage: ChatMessage<TPart> = {
306
- id: crypto.randomUUID(),
307
- role: 'assistant',
308
- parts,
309
- createdAt: Date.now(),
310
- finish: 'stopped',
372
+ try {
373
+ const stoppedMessage: ChatMessage<TPart> = {
374
+ // Same idempotency-key reasoning as consumeAndFinalize's assistantMessage — mintMessageId.
375
+ // This can throw on rung 3 (no usable crypto — see ./id.ts) just like appendMessage
376
+ // (consumer code) can throw for its own reasons; both are caught below so neither can
377
+ // wedge the thread — see this function's doc.
378
+ id: mintMessageId(),
379
+ role: 'assistant',
380
+ parts,
381
+ createdAt: Date.now(),
382
+ finish: 'stopped',
383
+ }
384
+ storeRef.current.appendMessage(threadId, stoppedMessage)
385
+ } catch {
386
+ appendFailed = true
311
387
  }
312
- storeRef.current.appendMessage(threadId, stoppedMessage)
313
388
  }
314
389
 
315
- storeRef.current.setStatus(threadId, 'done')
316
- storeRef.current.setResumeToken(threadId, undefined)
390
+ let settleFailed = appendFailed
391
+ try {
392
+ storeRef.current.setStatus(threadId, appendFailed ? 'error' : 'done')
393
+ } catch {
394
+ settleFailed = true
395
+ }
396
+ try {
397
+ storeRef.current.setResumeToken(threadId, undefined)
398
+ } catch {
399
+ settleFailed = true
400
+ }
401
+
402
+ // Unconditional — the one step that cannot be allowed to be skipped. See this function's doc.
317
403
  setRuns((prev) => {
318
404
  if (!prev.has(threadId)) return prev
319
405
  const next = new Map(prev)
@@ -321,6 +407,10 @@ async function finalizeStop<TPart extends PartLike>(args: {
321
407
  return next
322
408
  })
323
409
 
410
+ // No outcome to resolve for a turn whose own terminal message never landed, or whose settle
411
+ // never took — see this function's doc on why this mirrors consumeAndFinalize's catch path.
412
+ if (settleFailed) return
413
+
324
414
  const snapshot = storeRef.current.threads.find((thread) => thread.id === threadId)
325
415
  if (snapshot === undefined) return
326
416
  const outcome: AgentOutcome = await resolveOutcome(snapshot)
@@ -419,11 +509,40 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
419
509
  // skipping the orphan-resume path and wedging the thread in 'streaming' forever. Reachable
420
510
  // whenever this fiber's effects re-run without the fiber itself unmounting (React 19 StrictMode
421
511
  // double-invoke; `<Activity>` hide/show) — see this hook's `@example`-adjacent doc / the F3 note.
512
+ //
513
+ // Also tears down the matching `runs` entries — the F3 fix above stopped this from wedging
514
+ // `controllersRef`/the *persisted* thread status, but left `runs` (this hook's own transient
515
+ // state) untouched, and on a re-run-without-unmount the component (and its `runs` state) survive
516
+ // this cleanup. Without this, the entry the aborted controller was updating stays in `runs`
517
+ // reporting `status: 'streaming'` forever: the mount-reconcile effect below DOES correctly settle
518
+ // the persisted thread (to a resumed run, or to 'interrupted' if not resumable), but a consumer
519
+ // deriving "is this thread live" from `runs.has(id)` — the whole reason `runs` exists — keeps
520
+ // seeing a phantom in-flight turn no controller is driving anymore, and `stop()` on it was a
521
+ // permanent no-op (nothing left in `controllersRef` to abort) — see `stop()`'s own doc. Scoped to
522
+ // exactly the threadIds this pass is aborting (not a blind `runs.clear()`): the mount-reconcile
523
+ // effect below runs AFTER this cleanup completes, in the same synchronous commit (both are
524
+ // `[]`-dep effects on this same fiber, so StrictMode/`<Activity>` destroy-then-recreate both
525
+ // together, in declaration order) — a resumable thread's reconcile pass registers a FRESH
526
+ // controller and a fresh `runs` entry there, and this teardown must never clobber that. It
527
+ // can't: by the time this cleanup runs, nothing has created a NEW entry for these threadIds yet
528
+ // (that only happens in the reconcile effect, still to come), so scoping to the ids this pass is
529
+ // itself aborting is exact, not merely defensive.
422
530
  useEffect(
423
531
  () => () => {
532
+ const abortedThreadIds = Array.from(controllersRef.current.keys())
424
533
  controllersRef.current.forEach((controller) => controller.abort())
425
534
  controllersRef.current.clear()
426
535
  appendedRef.current.clear()
536
+ if (abortedThreadIds.length === 0) return
537
+ setRuns((prev) => {
538
+ let next: Map<string, ThreadRunState<TPart>> | undefined
539
+ for (const threadId of abortedThreadIds) {
540
+ if (!prev.has(threadId)) continue
541
+ next ??= new Map(prev)
542
+ next.delete(threadId)
543
+ }
544
+ return next ?? prev
545
+ })
427
546
  },
428
547
  [],
429
548
  )
@@ -500,7 +619,8 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
500
619
  lastInputRef.current.set(threadId, input)
501
620
 
502
621
  const userMessage: ChatMessage<TPart> = {
503
- id: crypto.randomUUID(),
622
+ // Same idempotency-key reasoning as consumeAndFinalize's assistantMessage — mintMessageId.
623
+ id: mintMessageId(),
504
624
  role: 'user',
505
625
  parts: toUserPartsRef.current(input),
506
626
  createdAt: Date.now(),
@@ -557,7 +677,23 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
557
677
  // runsRef still holding the PREVIOUS render's (empty) map. Gating on runsRef here would read
558
678
  // `undefined`, wrongly treat this as a no-op, leave the controller registered and never
559
679
  // abort it — stranding the run in 'streaming' (see the F4 regression test).
560
- if (controller === undefined) return
680
+ if (controller === undefined) {
681
+ // No controller — but if `runs` still reports this thread as live, that's a phantom entry
682
+ // (see this hook's `stop()` JSDoc): nothing is driving it, and with no controller to key
683
+ // off of, this is the only remaining path that can ever settle it. Not reachable via any
684
+ // path in THIS file today (the unmount-cleanup effect and finalizeStop both tear down
685
+ // `controllersRef` and `runs` in the same synchronous step — see their docs), but a true
686
+ // no-op here would mean "the UI shows this thread streaming forever, and Stop can never
687
+ // clear it" the instant that invariant is ever broken by a future change. Settle it via the
688
+ // same finalizeStop() the normal path uses, so there is still exactly one teardown routine
689
+ // for "a run is ending" regardless of how it got here.
690
+ if (!runsRef.current.has(threadId)) return
691
+ const parts = runsRef.current.get(threadId)?.parts ?? []
692
+ const alreadyAppended = appendedRef.current.has(threadId)
693
+ appendedRef.current.delete(threadId)
694
+ void finalizeStop({ threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns })
695
+ return
696
+ }
561
697
 
562
698
  controllersRef.current.delete(threadId)
563
699
  // Read the accumulated parts straight out of the live run entry (runsRef mirrors `runs` as of
@@ -48,13 +48,25 @@
48
48
  * that the thread SETTLES (leaves `'streaming'`) rather than wedging — the resume() call count is
49
49
  * asserted too, but as an observed constant with the doubling explained, not as evidence of a
50
50
  * single attempt.
51
+ *
52
+ * (d) is a SEPARATE, later-release defect discovered in the same family: the F3 fix above made the
53
+ * PERSISTED thread settle correctly (to a resume, or to 'interrupted'), but left the hook's OWN
54
+ * `runs` state untouched by the unmount-cleanup effect — so on a fiber that survives (StrictMode
55
+ * double-invoke, `<Activity>` hide/show), `runs` kept reporting the aborted run as `'streaming'`
56
+ * forever, even once the persisted thread had already settled. Unlike (b)/(c) (which use a
57
+ * RESUMABLE transport, so a fresh `runs` entry gets created by the surviving resume), (d) uses a
58
+ * NON-resumable transport — the case where nothing ever replaces the phantom entry, and `stop()`
59
+ * on it was a permanent no-op (see this hook's `stop()` JSDoc). Verified manually: reverting the
60
+ * `runs` teardown in the unmount-cleanup effect makes (d) fail — `runs.has(threadId)` stays `true`
61
+ * after the thread settles to `'interrupted'` — while (a)/(b)/(c) are unaffected by that revert.
51
62
  */
52
63
  import { describe, expect, test } from 'bun:test'
64
+ import type { JSX } from 'react'
53
65
  import { Activity, StrictMode, useState } from 'react'
54
66
  import { act, render, renderHook, waitFor } from '@testing-library/react'
55
67
  import { useAgentThreadRuns } from './use-agent-thread-runs'
56
68
  import type { AgentThread, ThreadsStore } from './thread'
57
- import type { AgentTransport } from './transport'
69
+ import type { AgentTransport, ResumableAgentTransport } from './transport'
58
70
  import type { AgentPart } from './parts'
59
71
  import type { ChatMessage } from './history'
60
72
  import type { AgentOutcome } from './outcome'
@@ -134,6 +146,11 @@ function createTestThreadsStore(initial: AgentThread<AgentPart>[] = []): Threads
134
146
  threads = []
135
147
  activeId = null
136
148
  },
149
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
150
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
151
+ // stand-in with no async load path to fail.
152
+ hydrated: true,
153
+ error: undefined,
137
154
  }
138
155
  }
139
156
 
@@ -199,7 +216,7 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
199
216
  const userMessage: ChatMessage<AgentPart> = {
200
217
  id: crypto.randomUUID(),
201
218
  role: 'user',
202
- parts: [{ type: 'text', text: 'hello' }],
219
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
203
220
  createdAt: Date.now(),
204
221
  }
205
222
  const thread = makeThread({
@@ -210,11 +227,11 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
210
227
  const store = createTestThreadsStore([thread])
211
228
 
212
229
  let resumeCalls = 0
213
- const transport = {
230
+ const transport: ResumableAgentTransport<AgentPart, string> = {
214
231
  async *stream() {},
215
232
  async *resume() {
216
233
  resumeCalls++
217
- yield { type: 'text', text: 'resumed' }
234
+ yield { id: 'p1', type: 'text', text: 'resumed' }
218
235
  },
219
236
  idempotentReplay: true as const,
220
237
  }
@@ -235,7 +252,7 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
235
252
  const userMessage: ChatMessage<AgentPart> = {
236
253
  id: crypto.randomUUID(),
237
254
  role: 'user',
238
- parts: [{ type: 'text', text: 'hello' }],
255
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
239
256
  createdAt: Date.now(),
240
257
  }
241
258
  const thread = makeThread({
@@ -251,12 +268,12 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
251
268
  const gate = deferred<void>()
252
269
 
253
270
  let resumeCalls = 0
254
- const transport = {
271
+ const transport: ResumableAgentTransport<AgentPart, string> = {
255
272
  async *stream() {},
256
273
  async *resume() {
257
274
  resumeCalls++
258
275
  await gate.promise
259
- yield { type: 'text', text: 'resumed' }
276
+ yield { id: 'p1', type: 'text', text: 'resumed' }
260
277
  },
261
278
  idempotentReplay: true as const,
262
279
  }
@@ -298,4 +315,73 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
298
315
  })
299
316
  expect(resumeCalls).toBe(2)
300
317
  })
318
+
319
+ test('(d) BLOCKING regression: a non-resumable run aborted by an <Activity> hide/show cycle leaves no phantom `runs` entry, and stop() still reaches a terminal state', async () => {
320
+ const store = createTestThreadsStore()
321
+
322
+ // No `resume` at all — unlike (c), this transport cannot be resumed. Hangs until aborted;
323
+ // never resolves on its own (matching the real playground repro this test pins).
324
+ const transport: AgentTransport<AgentPart, string> = {
325
+ // oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
326
+ async *stream(_input, signal) {
327
+ await new Promise((resolve) => {
328
+ signal?.addEventListener('abort', () => resolve(undefined))
329
+ })
330
+ },
331
+ }
332
+
333
+ let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
334
+ let hookResult: ReturnType<typeof useAgentThreadRuns<AgentPart>> | undefined
335
+
336
+ function Probe(): null {
337
+ hookResult = useAgentThreadRuns({ transport, store, resolveOutcome })
338
+ return null
339
+ }
340
+
341
+ function Harness(): JSX.Element {
342
+ const [mode, setMode] = useState<'visible' | 'hidden'>('visible')
343
+ externalSetMode = setMode
344
+ return (
345
+ <Activity mode={mode}>
346
+ <Probe />
347
+ </Activity>
348
+ )
349
+ }
350
+
351
+ await act(async () => {
352
+ render(<Harness />)
353
+ })
354
+
355
+ const threadId = store.create()
356
+ act(() => {
357
+ hookResult?.start(threadId, 'hello')
358
+ })
359
+ expect(hookResult?.runs.get(threadId)?.status).toBe('streaming')
360
+
361
+ // Hide then show — the cleanup-without-unmount path. The fiber (and its refs/state) survive;
362
+ // only its effects are destroyed and recreated, exactly as React 19.2's <Activity> does in
363
+ // PRODUCTION (see the file doc above) or as StrictMode does on every dev mount.
364
+ await act(async () => {
365
+ externalSetMode?.('hidden')
366
+ })
367
+ await act(async () => {
368
+ externalSetMode?.('visible')
369
+ })
370
+
371
+ await waitFor(() => {
372
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('interrupted')
373
+ })
374
+
375
+ // The BLOCKING defect this test pins: `runs` must not still report this thread as 'streaming'
376
+ // once the persisted thread has settled to 'interrupted' — that phantom entry is what made
377
+ // stop() a permanent no-op.
378
+ expect(hookResult?.runs.has(threadId)).toBe(false)
379
+
380
+ // stop() on the now-settled thread reaches (stays at) a terminal state — not a wedge.
381
+ act(() => {
382
+ hookResult?.stop(threadId)
383
+ })
384
+ expect(store.threads.find((t) => t.id === threadId)?.status).toBe('interrupted')
385
+ expect(hookResult?.runs.has(threadId)).toBe(false)
386
+ })
301
387
  })