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
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * createThreadsStore — ring-buffer caps (maxThreads/maxMessagesPerThread), newest-first ordering,
3
- * setResumeToken's delete-the-key-on-undefined semantics, and same-tick action accumulation.
3
+ * setResumeToken's delete-the-key-on-undefined semantics, same-tick action accumulation, and the
4
+ * constant hydrated/error pair the async sibling store (./adapter) actually varies.
4
5
  *
5
6
  * Uses the REAL localStorage-backed store (happy-dom provides `window.localStorage`), so every
6
7
  * test uses its own unique `key` — the same convention as
@@ -13,7 +14,12 @@ import { createThreadsStore } from './thread'
13
14
  import type { ChatMessage } from './history'
14
15
 
15
16
  function makeMessage(id: string): ChatMessage {
16
- return { id, role: 'user', parts: [{ type: 'text', text: id }], createdAt: Date.now() }
17
+ return {
18
+ id,
19
+ role: 'user',
20
+ parts: [{ id: `${id}-p0`, type: 'text', text: id }],
21
+ createdAt: Date.now(),
22
+ }
17
23
  }
18
24
 
19
25
  describe('createThreadsStore', () => {
@@ -144,4 +150,71 @@ describe('createThreadsStore', () => {
144
150
  expect(thread?.status).toBe('streaming')
145
151
  expect(thread?.resumeToken).toBe('tok-accum')
146
152
  })
153
+
154
+ test('hydrated is always true and error always undefined — localStorage resolves synchronously', () => {
155
+ const useThreads = createThreadsStore({ key: 'thread-hydrated-a', version: 1 })
156
+ const { result } = renderHook(() => useThreads())
157
+
158
+ // Pinned because these two exist for the async adapter store (./adapter); the localStorage
159
+ // store must fill them with the "nothing to wait for, nothing to report" constants on the
160
+ // very first render, before any action has run.
161
+ expect(result.current.hydrated).toBe(true)
162
+ expect(result.current.error).toBeUndefined()
163
+
164
+ act(() => {
165
+ result.current.create()
166
+ })
167
+
168
+ expect(result.current.hydrated).toBe(true)
169
+ expect(result.current.error).toBeUndefined()
170
+ })
171
+
172
+ test('create() mints distinct ids via crypto.getRandomValues when randomUUID is unavailable', () => {
173
+ const originalCrypto = globalThis.crypto
174
+ Object.defineProperty(globalThis, 'crypto', {
175
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
176
+ configurable: true,
177
+ })
178
+ try {
179
+ const useThreads = createThreadsStore({ key: 'thread-crypto-a', version: 1 })
180
+ const { result } = renderHook(() => useThreads())
181
+
182
+ let first = ''
183
+ let second = ''
184
+ act(() => {
185
+ first = result.current.create()
186
+ second = result.current.create()
187
+ })
188
+
189
+ expect(first).not.toBe('')
190
+ expect(second).not.toBe('')
191
+ expect(first).not.toBe(second)
192
+ } finally {
193
+ // Restore the real crypto so no later test in the process is affected.
194
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
195
+ }
196
+ })
197
+
198
+ test('create() still mints distinct ids with no usable crypto at all (randomUUID and getRandomValues both absent)', () => {
199
+ const originalCrypto = globalThis.crypto
200
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
201
+ try {
202
+ const useThreads = createThreadsStore({ key: 'thread-crypto-b', version: 1 })
203
+ const { result } = renderHook(() => useThreads())
204
+
205
+ let first = ''
206
+ let second = ''
207
+ act(() => {
208
+ first = result.current.create()
209
+ second = result.current.create()
210
+ })
211
+
212
+ expect(first).not.toBe('')
213
+ expect(second).not.toBe('')
214
+ expect(first).not.toBe(second)
215
+ } finally {
216
+ // Restore the real crypto so no later test in the process is affected.
217
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
218
+ }
219
+ })
147
220
  })
@@ -24,6 +24,7 @@
24
24
  import { useCallback, useRef } from 'react'
25
25
  import { createPersistedState } from '../state'
26
26
  import type { ChatMessage } from './history'
27
+ import { mintThreadId } from './id'
27
28
  import type { AgentOutcome } from './outcome'
28
29
  import type { AgentPart } from './parts'
29
30
 
@@ -138,6 +139,24 @@ export type ThreadsStore<TPart = AgentPart> = {
138
139
  readonly remove: (id: string) => void
139
140
  /** Remove all threads and clear the active selection. */
140
141
  readonly clear: () => void
142
+ /**
143
+ * Whether `threads` reflects the backing store yet. Always `true` for the localStorage store
144
+ * (createPersistedState resolves synchronously); meaningful only for an async, server-backed
145
+ * store built with `createAdapterThreadsStore` (./adapter), where it stays `false` until the
146
+ * first `listThreads` SUCCEEDS. Pair it with `error` — `!hydrated && error !== undefined` is a
147
+ * failed load, `!hydrated && error === undefined` is still loading.
148
+ *
149
+ * Gating an empty state on this is the CALLER's job when hand-assembling `ThreadFeed` /
150
+ * `ThreadDetailPanel`; the shipped `ThreadWorkspace` already does it internally, so an
151
+ * async store never flashes "no threads yet" through it before it actually knows.
152
+ */
153
+ readonly hydrated: boolean
154
+ /**
155
+ * The most recent persistence failure, or `undefined`. Always `undefined` for the localStorage
156
+ * store, which has no failure the caller can act on. An async store surfaces the rejected
157
+ * load/write here after rolling its optimistic edit back.
158
+ */
159
+ readonly error: unknown
141
160
  }
142
161
 
143
162
  // ── createThreadsStore ────────────────────────────────────────────────────────
@@ -198,7 +217,7 @@ export function createThreadsStore<TPart = AgentPart>(
198
217
 
199
218
  const create = useCallback(
200
219
  (createOpts?: { readonly meta?: Record<string, unknown> }): string => {
201
- const id = crypto.randomUUID()
220
+ const id = mintThreadId()
202
221
  const now = Date.now()
203
222
  const thread: AgentThread<TPart> = {
204
223
  id,
@@ -307,6 +326,11 @@ export function createThreadsStore<TPart = AgentPart>(
307
326
  markRead,
308
327
  remove,
309
328
  clear,
329
+ // localStorage resolves synchronously and swallows its own quota errors, so there is never
330
+ // a pending load to await nor a failure a caller could act on. See ./adapter for the async
331
+ // store where these two carry real information.
332
+ hydrated: true,
333
+ error: undefined,
310
334
  }
311
335
  }
312
336
  }
@@ -11,7 +11,7 @@ import { StrictMode, useEffect } from 'react'
11
11
  import { act, render, renderHook, waitFor } from '@testing-library/react'
12
12
  import { useAgentStream } from './use-agent-stream'
13
13
  import type { AgentTransport } from './transport'
14
- import type { AgentPart } from './parts'
14
+ import type { AgentPart, AgentPartDraft } from './parts'
15
15
 
16
16
  function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
17
17
  let resolve!: (value: T) => void
@@ -96,17 +96,21 @@ describe('useAgentStream', () => {
96
96
  })
97
97
 
98
98
  test('F1 regression: id-less drafts of different types within one send() do not destroy each other', async () => {
99
+ // Yields id-less drafts on purpose — the whole point of this regression is that TWO drafts
100
+ // (of different types, so a real bug wouldn't be "same id" but "no id at all") must not
101
+ // collide via `undefined === undefined`. The cast documents that the type says AgentPart
102
+ // (identified) while a transport is always permitted to omit id (AgentPartDraft).
99
103
  const transport: AgentTransport<AgentPart, string> = {
100
104
  async *stream() {
101
- yield { type: 'text', text: 'Hi' }
105
+ yield { type: 'text', text: 'Hi' } as AgentPartDraft as AgentPart
102
106
  yield {
103
107
  type: 'tool',
104
108
  toolCallId: 'c1',
105
109
  toolName: 'search',
106
110
  state: 'input-available',
107
111
  input: { q: 'x' },
108
- }
109
- yield { type: 'text', text: 'Bye' }
112
+ } as AgentPartDraft as AgentPart
113
+ yield { type: 'text', text: 'Bye' } as AgentPartDraft as AgentPart
110
114
  },
111
115
  }
112
116
 
@@ -131,7 +135,7 @@ describe('useAgentStream', () => {
131
135
  async *stream() {
132
136
  throw boom
133
137
  // eslint-disable-next-line no-unreachable
134
- yield { type: 'text', text: 'unreachable' }
138
+ yield { id: 'unreachable', type: 'text', text: 'unreachable' }
135
139
  },
136
140
  }
137
141
 
@@ -258,4 +262,26 @@ describe('useAgentStream', () => {
258
262
  // Only the SECOND (surviving) call's part made it into final state — the first is discarded.
259
263
  expect(capturedParts).toEqual([{ id: 'call-2', type: 'text', text: 'call-2' }])
260
264
  })
265
+
266
+ test('send() still streams without throwing on a host with no usable crypto at all — its runId is a low-collision-cost mint (mintThreadId), never the message-id one', async () => {
267
+ const originalCrypto = globalThis.crypto
268
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
269
+ try {
270
+ const transport: AgentTransport<AgentPart, string> = {
271
+ async *stream() {
272
+ yield { type: 'text', text: 'a' } as AgentPartDraft as AgentPart
273
+ },
274
+ }
275
+ const { result } = renderHook(() => useAgentStream({ transport }))
276
+
277
+ await act(async () => {
278
+ await result.current.send('hi')
279
+ })
280
+
281
+ expect(result.current.status).toBe('done')
282
+ expect(result.current.parts).toHaveLength(1)
283
+ } finally {
284
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
285
+ }
286
+ })
261
287
  })
@@ -27,7 +27,7 @@
27
27
  * }
28
28
  */
29
29
  import { useCallback, useEffect, useRef, useState } from 'react'
30
- import { withPartIds } from './id'
30
+ import { mintThreadId, withPartIds } from './id'
31
31
  import { isStartPart } from './parts'
32
32
  import { mergePart } from './merge'
33
33
  import type { PartLike } from './merge'
@@ -107,8 +107,11 @@ export function useAgentStream<TPart extends PartLike = AgentPart, TInput = stri
107
107
  // fully resets `parts` anyway, so a fresh id per call is enough to keep two id-less drafts
108
108
  // ARRIVING WITHIN THE SAME SEND from colliding on `undefined === undefined` in mergePart (the
109
109
  // F1 bug — text→tool→text destroying each other). Not a replay mechanism — this hook has no
110
- // resume() path — see withPartIds' own doc for why it can't be one anyway.
111
- const runId = crypto.randomUUID()
110
+ // resume() path — see withPartIds' own doc for why it can't be one anyway. Low collision
111
+ // cost (this hook persists nothing — `parts` is discarded on the next send()/unmount), so
112
+ // `mintThreadId` — never `mintMessageId` — is the right rung-3 behavior here: this must not
113
+ // throw on a host with no usable crypto at all.
114
+ const runId = mintThreadId()
112
115
 
113
116
  try {
114
117
  for await (const part of withPartIds(runId, transport.stream(input, controller.signal))) {
@@ -86,6 +86,11 @@ function createTestThreadsStore(): ThreadsStore<TranscriptPart> {
86
86
  threads = []
87
87
  activeId = null
88
88
  },
89
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
90
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
91
+ // stand-in with no async load path to fail.
92
+ hydrated: true,
93
+ error: undefined,
89
94
  }
90
95
  }
91
96
 
@@ -16,7 +16,7 @@ import { describe, expect, test } from 'bun:test'
16
16
  import { renderHook, waitFor } from '@testing-library/react'
17
17
  import { useAgentThreadRuns } from './use-agent-thread-runs'
18
18
  import type { AgentThread, ThreadsStore } from './thread'
19
- import type { AgentTransport } from './transport'
19
+ import type { AgentTransport, ResumableAgentTransport } from './transport'
20
20
  import type { AgentPart } from './parts'
21
21
  import type { ChatMessage } from './history'
22
22
  import type { AgentOutcome } from './outcome'
@@ -90,6 +90,11 @@ function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore
90
90
  threads = []
91
91
  activeId = null
92
92
  },
93
+ // Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
94
+ // (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
95
+ // stand-in with no async load path to fail.
96
+ hydrated: true,
97
+ error: undefined,
93
98
  }
94
99
  }
95
100
 
@@ -100,7 +105,7 @@ function makeStreamingThread(
100
105
  const userMessage: ChatMessage<AgentPart> = {
101
106
  id: crypto.randomUUID(),
102
107
  role: 'user',
103
- parts: [{ type: 'text', text: 'hello' }],
108
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
104
109
  createdAt: now,
105
110
  }
106
111
  return {
@@ -142,7 +147,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
142
147
  const userMessage: ChatMessage<AgentPart> = {
143
148
  id: crypto.randomUUID(),
144
149
  role: 'user',
145
- parts: [{ type: 'text', text: 'hello' }],
150
+ parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
146
151
  createdAt: now,
147
152
  }
148
153
  const thread: AgentThread<AgentPart> = {
@@ -157,11 +162,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
157
162
  const store = createTestThreadsStore([thread])
158
163
 
159
164
  let resumeCalls = 0
160
- const transport = {
165
+ const transport: ResumableAgentTransport<AgentPart, string> = {
161
166
  async *stream() {},
162
167
  async *resume() {
163
168
  resumeCalls++
164
- yield { type: 'text', text: 'resumed' }
169
+ yield { id: 'p1', type: 'text', text: 'resumed' }
165
170
  },
166
171
  idempotentReplay: true as const,
167
172
  }
@@ -179,11 +184,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
179
184
  const store = createTestThreadsStore([thread])
180
185
 
181
186
  let resumeCalls = 0
182
- const transport = {
187
+ const transport: ResumableAgentTransport<AgentPart, string> = {
183
188
  async *stream() {},
184
189
  async *resume() {
185
190
  resumeCalls++
186
- yield { type: 'text', text: 'resumed' }
191
+ yield { id: 'p1', type: 'text', text: 'resumed' }
187
192
  },
188
193
  idempotentReplay: true as const,
189
194
  }
@@ -205,7 +210,7 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
205
210
  async *stream() {},
206
211
  async *resume() {
207
212
  resumeCalls++
208
- yield { type: 'text', text: 'resumed' }
213
+ yield { id: 'p1', type: 'text', text: 'resumed' }
209
214
  },
210
215
  // deliberately NO idempotentReplay: true
211
216
  }
@@ -223,11 +228,11 @@ describe('useAgentThreadRuns — mount-time reconcile', () => {
223
228
  const store = createTestThreadsStore([thread])
224
229
 
225
230
  const resumeArgs: string[] = []
226
- const transport = {
231
+ const transport: ResumableAgentTransport<AgentPart, string> = {
227
232
  async *stream() {},
228
- async *resume(token: string) {
233
+ async *resume(token) {
229
234
  resumeArgs.push(token)
230
- yield { type: 'text', text: 'resumed' }
235
+ yield { id: 'p1', type: 'text', text: 'resumed' }
231
236
  },
232
237
  idempotentReplay: true as const,
233
238
  }