basalt-ui 1.11.0 → 1.12.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 (237) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  11. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  12. package/dist/agent/coalesce.d.ts +20 -4
  13. package/dist/agent/coalesce.d.ts.map +1 -1
  14. package/dist/agent/coalesce.js +76 -8
  15. package/dist/agent/coalesce.test.d.ts +2 -0
  16. package/dist/agent/coalesce.test.d.ts.map +1 -0
  17. package/dist/agent/foreign.test.d.ts +2 -0
  18. package/dist/agent/foreign.test.d.ts.map +1 -0
  19. package/dist/agent/id.d.ts +30 -0
  20. package/dist/agent/id.d.ts.map +1 -1
  21. package/dist/agent/id.js +16 -0
  22. package/dist/agent/id.test.d.ts +2 -0
  23. package/dist/agent/id.test.d.ts.map +1 -0
  24. package/dist/agent/index.d.ts +2 -0
  25. package/dist/agent/index.d.ts.map +1 -1
  26. package/dist/agent/index.js +3 -0
  27. package/dist/agent/merge.d.ts.map +1 -1
  28. package/dist/agent/merge.js +19 -7
  29. package/dist/agent/merge.test.d.ts +2 -0
  30. package/dist/agent/merge.test.d.ts.map +1 -0
  31. package/dist/agent/part-list.d.ts +49 -34
  32. package/dist/agent/part-list.d.ts.map +1 -1
  33. package/dist/agent/part-list.js +42 -6
  34. package/dist/agent/part-list.test.d.ts +2 -0
  35. package/dist/agent/part-list.test.d.ts.map +1 -0
  36. package/dist/agent/parts.d.ts +46 -10
  37. package/dist/agent/parts.d.ts.map +1 -1
  38. package/dist/agent/parts.js +16 -6
  39. package/dist/agent/parts.test.d.ts +2 -0
  40. package/dist/agent/parts.test.d.ts.map +1 -0
  41. package/dist/agent/thread.d.ts +14 -0
  42. package/dist/agent/thread.d.ts.map +1 -1
  43. package/dist/agent/thread.js +8 -2
  44. package/dist/agent/thread.test.d.ts +2 -0
  45. package/dist/agent/thread.test.d.ts.map +1 -0
  46. package/dist/agent/transport.test.d.ts +2 -0
  47. package/dist/agent/transport.test.d.ts.map +1 -0
  48. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  49. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  50. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  51. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  52. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  53. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  55. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  56. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  57. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  58. package/dist/agent-chat/composer.d.ts +130 -8
  59. package/dist/agent-chat/composer.d.ts.map +1 -1
  60. package/dist/agent-chat/composer.js +189 -23
  61. package/dist/agent-chat/composer.test.d.ts +2 -0
  62. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  63. package/dist/agent-chat/index.d.ts +1 -1
  64. package/dist/agent-chat/index.d.ts.map +1 -1
  65. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  66. package/dist/agent-chat/thread-detail-panel.js +1 -1
  67. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  68. package/dist/agent-chat/thread-message.js +14 -3
  69. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  70. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  71. package/dist/agent-chat/thread-workspace.js +1 -1
  72. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  73. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  74. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  75. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  76. package/dist/charts/utils/domain.test.d.ts +2 -0
  77. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  78. package/dist/cli/check-theme.test.d.ts +2 -0
  79. package/dist/cli/check-theme.test.d.ts.map +1 -0
  80. package/dist/cli/help.test.d.ts +2 -0
  81. package/dist/cli/help.test.d.ts.map +1 -0
  82. package/dist/cli/index.d.ts.map +1 -1
  83. package/dist/cli/init-sync.test.d.ts +2 -0
  84. package/dist/cli/init-sync.test.d.ts.map +1 -0
  85. package/dist/cli/placement-engine.test.d.ts +2 -0
  86. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  87. package/dist/cli/tarball-parity.test.d.ts +2 -0
  88. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  89. package/dist/cli/template-format.test.d.ts +2 -0
  90. package/dist/cli/template-format.test.d.ts.map +1 -0
  91. package/dist/cli/template-ledger.test.d.ts +2 -0
  92. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  93. package/dist/cli/tokens-css.test.d.ts +2 -0
  94. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  95. package/dist/content/article-actions.test.d.ts +2 -0
  96. package/dist/content/article-actions.test.d.ts.map +1 -0
  97. package/dist/content/article-model.test.d.ts +2 -0
  98. package/dist/content/article-model.test.d.ts.map +1 -0
  99. package/dist/content/block-split.test.d.ts +2 -0
  100. package/dist/content/block-split.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts +37 -10
  102. package/dist/content/fence-block.d.ts.map +1 -1
  103. package/dist/content/fence-block.js +80 -14
  104. package/dist/content/heading-components.d.ts.map +1 -1
  105. package/dist/content/heading-components.js +7 -4
  106. package/dist/content/heading-components.test.d.ts +2 -0
  107. package/dist/content/heading-components.test.d.ts.map +1 -0
  108. package/dist/content/index.d.ts +6 -3
  109. package/dist/content/index.d.ts.map +1 -1
  110. package/dist/content/index.js +5 -1
  111. package/dist/content/markdown.d.ts +101 -14
  112. package/dist/content/markdown.d.ts.map +1 -1
  113. package/dist/content/markdown.js +141 -28
  114. package/dist/content/markdown.test.d.ts +2 -0
  115. package/dist/content/markdown.test.d.ts.map +1 -0
  116. package/dist/content/prose.module.css.test.d.ts +2 -0
  117. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  118. package/dist/content/sanitize.d.ts +170 -0
  119. package/dist/content/sanitize.d.ts.map +1 -0
  120. package/dist/content/sanitize.js +178 -0
  121. package/dist/content/sanitize.test.d.ts +2 -0
  122. package/dist/content/sanitize.test.d.ts.map +1 -0
  123. package/dist/content/slug.test.d.ts +2 -0
  124. package/dist/content/slug.test.d.ts.map +1 -0
  125. package/dist/dashboard/stat-card.test.d.ts +2 -0
  126. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  127. package/dist/guard/check-source.test.d.ts +2 -0
  128. package/dist/guard/check-source.test.d.ts.map +1 -0
  129. package/dist/guard/guard-hook.test.d.ts +2 -0
  130. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  131. package/dist/index.d.ts +3 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +9 -1
  134. package/dist/provider/index.test.d.ts +2 -0
  135. package/dist/provider/index.test.d.ts.map +1 -0
  136. package/dist/provider/lab-theme.test.d.ts +2 -0
  137. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  138. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  139. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  140. package/dist/styles.floor.test.d.ts +2 -0
  141. package/dist/styles.floor.test.d.ts.map +1 -0
  142. package/dist/surfaces.d.ts +4 -4
  143. package/dist/surfaces.d.ts.map +1 -1
  144. package/dist/surfaces.js +12 -3
  145. package/dist/theme/border-coverage.test.d.ts +18 -0
  146. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  147. package/dist/theme/contrast.test.d.ts +2 -0
  148. package/dist/theme/contrast.test.d.ts.map +1 -0
  149. package/dist/theme/density-option.test.d.ts +2 -0
  150. package/dist/theme/density-option.test.d.ts.map +1 -0
  151. package/dist/theme/density-relations.test.d.ts +2 -0
  152. package/dist/theme/density-relations.test.d.ts.map +1 -0
  153. package/dist/theme/derive-theme.test.d.ts +2 -0
  154. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  155. package/dist/theme/fonts-option.test.d.ts +2 -0
  156. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  157. package/dist/theme/radius-option.test.d.ts +2 -0
  158. package/dist/theme/radius-option.test.d.ts.map +1 -0
  159. package/dist/theme/radius.test.d.ts +2 -0
  160. package/dist/theme/radius.test.d.ts.map +1 -0
  161. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  162. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  163. package/dist/theme/spacing.test.d.ts +2 -0
  164. package/dist/theme/spacing.test.d.ts.map +1 -0
  165. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  166. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  167. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  168. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  169. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  170. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  171. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  172. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  173. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  174. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  175. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  176. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  177. package/dist/tokens/density.test.d.ts +2 -0
  178. package/dist/tokens/density.test.d.ts.map +1 -0
  179. package/dist/tokens/derive-radius.test.d.ts +2 -0
  180. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  181. package/dist/tokens/derive.test.d.ts +2 -0
  182. package/dist/tokens/derive.test.d.ts.map +1 -0
  183. package/dist/tokens/palette.test.d.ts +2 -0
  184. package/dist/tokens/palette.test.d.ts.map +1 -0
  185. package/dist/vite-color-mix.test.d.ts +2 -0
  186. package/dist/vite-color-mix.test.d.ts.map +1 -0
  187. package/dist/vite.test.d.ts +2 -0
  188. package/dist/vite.test.d.ts.map +1 -0
  189. package/llms.txt +6 -6
  190. package/package.json +6 -1
  191. package/src/agent/adapter.test.ts +1403 -0
  192. package/src/agent/adapter.ts +887 -0
  193. package/src/agent/ai-sdk-transport.test.ts +53 -26
  194. package/src/agent/coalesce.test.ts +202 -9
  195. package/src/agent/coalesce.ts +126 -15
  196. package/src/agent/foreign.test.ts +5 -1
  197. package/src/agent/id.test.ts +6 -2
  198. package/src/agent/id.ts +48 -0
  199. package/src/agent/index.ts +8 -0
  200. package/src/agent/merge.test.ts +87 -0
  201. package/src/agent/merge.ts +57 -10
  202. package/src/agent/part-list.test.tsx +31 -1
  203. package/src/agent/part-list.tsx +77 -29
  204. package/src/agent/parts.test.ts +121 -4
  205. package/src/agent/parts.ts +78 -17
  206. package/src/agent/thread.test.ts +75 -2
  207. package/src/agent/thread.ts +21 -1
  208. package/src/agent/use-agent-stream.test.tsx +9 -5
  209. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  210. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  211. package/src/agent/use-agent-thread-runs.test.tsx +30 -17
  212. package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
  213. package/src/agent-chat/composer.test.tsx +924 -0
  214. package/src/agent-chat/composer.tsx +521 -52
  215. package/src/agent-chat/index.ts +1 -1
  216. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  217. package/src/agent-chat/thread-message.test.tsx +141 -1
  218. package/src/agent-chat/thread-message.tsx +29 -4
  219. package/src/agent-chat/thread-workspace.tsx +1 -1
  220. package/src/cli/index.ts +0 -1
  221. package/src/cli/template-format.test.ts +2 -2
  222. package/src/content/fence-block.tsx +242 -15
  223. package/src/content/heading-components.test.tsx +65 -0
  224. package/src/content/heading-components.tsx +26 -5
  225. package/src/content/index.ts +19 -3
  226. package/src/content/markdown.test.tsx +603 -0
  227. package/src/content/markdown.tsx +355 -35
  228. package/src/content/sanitize.test.ts +659 -0
  229. package/src/content/sanitize.ts +510 -0
  230. package/src/dashboard/stat-card.test.tsx +6 -1
  231. package/src/index.ts +13 -1
  232. package/src/surfaces.ts +12 -3
  233. package/src/theme/contrast.test.ts +1 -4
  234. package/src/theme/density-relations.test.ts +4 -2
  235. package/src/theme/fonts-option.test.ts +7 -1
  236. package/src/theme/shadow-surfaces.test.ts +12 -6
  237. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -0,0 +1,924 @@
1
+ /**
2
+ * Composer — the slot-bearing prop set from AGENT-CHAT-SPEC.md §8: the `{ text, attachments }`
3
+ * submit payload, the clear-only-on-success draft contract, the keyboard contract (including the
4
+ * IME guard), the streaming/stop gate, and `draftKey` persistence across an unmount.
5
+ *
6
+ * `draftKey`s are UNIQUE PER TEST on purpose. Composer memoizes one store per key outside React
7
+ * and that store's in-memory value — not localStorage — is the snapshot authority, so clearing
8
+ * localStorage between tests would NOT reset a store that a previous test already hydrated.
9
+ */
10
+ import { MantineProvider } from '@mantine/core'
11
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
12
+ import { createRef } from 'react'
13
+ import { afterEach, describe, expect, test } from 'bun:test'
14
+ import { Composer } from './composer'
15
+ import type { ComposerAttachment, ComposerHandle, ComposerProps, ComposerSubmit } from './composer'
16
+
17
+ afterEach(cleanup)
18
+
19
+ function renderComposer(props: ComposerProps) {
20
+ return render(
21
+ <MantineProvider>
22
+ <Composer {...props} />
23
+ </MantineProvider>,
24
+ )
25
+ }
26
+
27
+ const noop = (): void => {}
28
+
29
+ function input(): HTMLTextAreaElement {
30
+ return screen.getByRole('textbox') as HTMLTextAreaElement
31
+ }
32
+
33
+ function type(text: string): void {
34
+ fireEvent.change(input(), { target: { value: text } })
35
+ }
36
+
37
+ /**
38
+ * Several tests below capture a callback's argument into a `let ... | null` variable, then read
39
+ * it back after a `fireEvent`/`act` call that (opaquely, from TS's point of view) triggers the
40
+ * callback synchronously. TS's control-flow analysis can't see through that call boundary, so at
41
+ * the read site it narrows the variable back to its `null` initializer instead of the declared
42
+ * union — `expect()`'s generic then infers `null` and the `[]`/`[ATTACHMENT]` expectation fails
43
+ * to match any overload. Reading through a NON-generic, explicitly-typed function sidesteps the
44
+ * over-narrowing: the return type comes from the fixed signature, not re-inferred from the
45
+ * (already over-narrowed) argument, the way a generic passthrough would.
46
+ */
47
+ function widen(value: readonly ComposerAttachment[] | null): readonly ComposerAttachment[] | null {
48
+ return value
49
+ }
50
+
51
+ const ATTACHMENT: ComposerAttachment = {
52
+ id: 'a1',
53
+ name: 'shot.png',
54
+ mediaType: 'image/png',
55
+ size: 1024,
56
+ url: 'blob:shot',
57
+ }
58
+
59
+ /**
60
+ * Builds a genuine native `Promise` whose prototype chain has been repointed away from THIS
61
+ * realm's `Promise.prototype` — the same shape a promise crossing an iframe/worker boundary would
62
+ * have. `Object.getOwnPropertyDescriptors(Promise.prototype)` copies `then`, `catch`, `finally`,
63
+ * `constructor` and `[Symbol.toStringTag]` onto a fresh object used as the new prototype, so every
64
+ * method is the ORIGINAL native implementation (nothing reimplemented) and the value still
65
+ * satisfies `Promise<T>` structurally with no cast. Only the prototype IDENTITY changes, which is
66
+ * exactly what `instanceof Promise` tests — so it reads `false` for this value while `.then`/
67
+ * `.catch` keep working, because those built-ins check the promise's internal slot, not its
68
+ * exposed prototype. Descriptors are copied via `Object.getOwnPropertyDescriptors` rather than an
69
+ * object-literal `{ then: ... }` so no literal `then` key appears in this file's source — that
70
+ * keeps `unicorn/no-thenable` (which flags authoring a `then` property on a plain object) from
71
+ * firing on what is actually a reflected copy of a real `Promise.prototype`, not a hand-rolled
72
+ * thenable.
73
+ */
74
+ function crossRealmPromise<T>(executor: ConstructorParameters<typeof Promise<T>>[0]): Promise<T> {
75
+ const native = new Promise<T>(executor)
76
+ const otherRealmProto: object = Object.create(
77
+ Object.prototype,
78
+ Object.getOwnPropertyDescriptors(Promise.prototype),
79
+ )
80
+ Object.setPrototypeOf(native, otherRealmProto)
81
+ return native
82
+ }
83
+
84
+ describe('submit payload + draft lifecycle', () => {
85
+ test('submits the trimmed text with an empty attachment list and clears the draft', () => {
86
+ const calls: ComposerSubmit[] = []
87
+ renderComposer({
88
+ onSubmit: (payload) => {
89
+ calls.push(payload)
90
+ },
91
+ })
92
+
93
+ type(' hello ')
94
+ fireEvent.click(screen.getByLabelText('Send message'))
95
+
96
+ expect(calls).toEqual([{ text: 'hello', attachments: [] }])
97
+ expect(input().value).toBe('')
98
+ })
99
+
100
+ test('forwards pending attachments and asks for a reset — an empty caption still sends', () => {
101
+ const calls: ComposerSubmit[] = []
102
+ let reset: readonly ComposerAttachment[] | null = null
103
+ renderComposer({
104
+ onSubmit: (payload) => {
105
+ calls.push(payload)
106
+ },
107
+ attachments: [ATTACHMENT],
108
+ onAttachmentsChange: (next) => {
109
+ reset = next
110
+ },
111
+ })
112
+
113
+ fireEvent.click(screen.getByLabelText('Send message'))
114
+
115
+ expect(calls).toEqual([{ text: '', attachments: [ATTACHMENT] }])
116
+ expect(widen(reset)).toEqual([])
117
+ })
118
+
119
+ test('an empty draft with no attachments cannot be submitted', () => {
120
+ let submits = 0
121
+ renderComposer({
122
+ onSubmit: () => {
123
+ submits += 1
124
+ },
125
+ })
126
+
127
+ expect((screen.getByLabelText('Send message') as HTMLButtonElement).disabled).toBe(true)
128
+ fireEvent.keyDown(input(), { key: 'Enter' })
129
+
130
+ expect(submits).toBe(0)
131
+ })
132
+
133
+ /**
134
+ * The "a failed send does not eat what was typed" guarantee, stated precisely: the clear is
135
+ * sequenced STRICTLY AFTER `onSubmit` returns and is not in a `finally`, so a parent that throws
136
+ * to reject the send never reaches it.
137
+ *
138
+ * The throw itself is deliberately NOT exercised here. React 19 does not let an error thrown in
139
+ * an event handler propagate out of `dispatchEvent` — it hands it to the global `reportError`,
140
+ * captured by `react.development.js` at MODULE EVALUATION (react.development.js:714), so it can
141
+ * be neither caught at the `fireEvent` call nor intercepted by stubbing `reportError` afterwards,
142
+ * and `bun test` fails the file. What IS observable — and is what actually delivers the
143
+ * guarantee — is the ordering.
144
+ *
145
+ * That ordering must be probed through STATE, not the DOM. `input().value` is worthless here:
146
+ * the value comes from `useSyncExternalStore`, and React batches the resulting re-render to the
147
+ * end of the discrete click event, so the textarea still reads 'keep me' inside the callback
148
+ * whether `store.set('')` ran before `onSubmit` or after it — an assertion that cannot fail.
149
+ * The store's localStorage write IS synchronous, so with a `draftKey` the persisted entry flips
150
+ * the instant the ordering is wrong.
151
+ */
152
+ test('the persisted draft is intact while onSubmit runs, and clears only once it returns', () => {
153
+ const storageKey = 'basalt:composer-draft:clear-after-onsubmit'
154
+ let persistedDuringSubmit: string | null = 'not observed'
155
+ renderComposer({
156
+ onSubmit: () => {
157
+ persistedDuringSubmit = window.localStorage.getItem(storageKey)
158
+ },
159
+ draftKey: 'clear-after-onsubmit',
160
+ })
161
+
162
+ type('keep me')
163
+ fireEvent.click(screen.getByLabelText('Send message'))
164
+
165
+ expect(persistedDuringSubmit).toBe(JSON.stringify({ v: 1, value: 'keep me' }))
166
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
167
+ expect(input().value).toBe('')
168
+ })
169
+ })
170
+
171
+ describe('async onSubmit — optimistic clear, rejection restores', () => {
172
+ /**
173
+ * A plain `void`-returning handler — the pre-existing contract — must keep working unchanged.
174
+ * This is the backward-compatibility guard for widening `onSubmit` to `void | Promise<void>`.
175
+ */
176
+ test('a void-returning onSubmit still clears the draft as before', () => {
177
+ const calls: ComposerSubmit[] = []
178
+ renderComposer({
179
+ onSubmit: (payload) => {
180
+ calls.push(payload)
181
+ },
182
+ })
183
+
184
+ type('plain handler')
185
+ fireEvent.click(screen.getByLabelText('Send message'))
186
+
187
+ expect(calls).toEqual([{ text: 'plain handler', attachments: [] }])
188
+ expect(input().value).toBe('')
189
+ })
190
+
191
+ test('a resolving async onSubmit leaves the draft and attachments cleared', async () => {
192
+ const storageKey = 'basalt:composer-draft:resolves'
193
+ let resolveSubmit: (() => void) | null = null
194
+ let reset: readonly ComposerAttachment[] | null = null
195
+ renderComposer({
196
+ onSubmit: () =>
197
+ new Promise<void>((resolve) => {
198
+ resolveSubmit = resolve
199
+ }),
200
+ attachments: [ATTACHMENT],
201
+ onAttachmentsChange: (next) => {
202
+ reset = next
203
+ },
204
+ draftKey: 'resolves',
205
+ })
206
+
207
+ type('will succeed')
208
+ fireEvent.click(screen.getByLabelText('Send message'))
209
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
210
+ expect(widen(reset)).toEqual([])
211
+
212
+ if (resolveSubmit === null) throw new Error('expected onSubmit to have been called')
213
+ await act(async () => {
214
+ resolveSubmit?.()
215
+ await Promise.resolve()
216
+ })
217
+
218
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
219
+ expect(input().value).toBe('')
220
+ })
221
+
222
+ test('a synchronous throw still keeps the draft and attachments (unchanged pre-async behaviour)', () => {
223
+ const storageKey = 'basalt:composer-draft:sync-throw'
224
+ let reset: readonly ComposerAttachment[] | null = null
225
+ renderComposer({
226
+ onSubmit: () => {
227
+ throw new Error('reject synchronously')
228
+ },
229
+ attachments: [ATTACHMENT],
230
+ onAttachmentsChange: (next) => {
231
+ reset = next
232
+ },
233
+ draftKey: 'sync-throw',
234
+ })
235
+
236
+ type('keep me on throw')
237
+ expect(() => fireEvent.click(screen.getByLabelText('Send message'))).toThrow()
238
+
239
+ expect(window.localStorage.getItem(storageKey)).toBe(
240
+ JSON.stringify({ v: 1, value: 'keep me on throw' }),
241
+ )
242
+ expect(reset).toBeNull()
243
+ })
244
+
245
+ test('a rejecting async onSubmit restores the text and the attachments', async () => {
246
+ const storageKey = 'basalt:composer-draft:rejects'
247
+ let rejectSubmit: ((reason: unknown) => void) | null = null
248
+ let lastAttachmentsChange: readonly ComposerAttachment[] | null = null
249
+ const { rerender } = renderComposer({
250
+ onSubmit: () =>
251
+ new Promise<void>((_resolve, reject) => {
252
+ rejectSubmit = reject
253
+ }),
254
+ attachments: [ATTACHMENT],
255
+ onAttachmentsChange: (next) => {
256
+ lastAttachmentsChange = next
257
+ },
258
+ draftKey: 'rejects',
259
+ })
260
+
261
+ type('network will fail')
262
+ fireEvent.click(screen.getByLabelText('Send message'))
263
+ // Optimistic clear already happened.
264
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
265
+ expect(widen(lastAttachmentsChange)).toEqual([])
266
+
267
+ // The caller applies the optimistic clear it was asked for — a controlled `attachments` prop
268
+ // round-trips back through a render, exactly like a real `useState` setter would.
269
+ rerender(
270
+ <MantineProvider>
271
+ <Composer
272
+ onSubmit={noop}
273
+ attachments={[]}
274
+ onAttachmentsChange={(next) => {
275
+ lastAttachmentsChange = next
276
+ }}
277
+ draftKey="rejects"
278
+ />
279
+ </MantineProvider>,
280
+ )
281
+
282
+ if (rejectSubmit === null) throw new Error('expected onSubmit to have been called')
283
+ await act(async () => {
284
+ rejectSubmit?.(new Error('network error'))
285
+ await Promise.resolve()
286
+ })
287
+
288
+ expect(window.localStorage.getItem(storageKey)).toBe(
289
+ JSON.stringify({ v: 1, value: 'network will fail' }),
290
+ )
291
+ expect(widen(lastAttachmentsChange)).toEqual([ATTACHMENT])
292
+ })
293
+
294
+ /**
295
+ * Finding 1 regression: `onSubmit` is typed `void | Promise<void>`, and a bare structural
296
+ * thenable does NOT actually satisfy that type (`PromiseLike<void>` is missing `catch`/
297
+ * `finally`/`[Symbol.toStringTag]`, which `Promise<void>` requires — TS2322). What DOES satisfy
298
+ * it, and DOES still defeat `result instanceof Promise`, is a promise from ANOTHER REALM: a
299
+ * genuine native promise whose `instanceof` reads false because `instanceof` compares prototype
300
+ * IDENTITY against THIS realm's `Promise`, not structural shape. `crossRealmPromise` builds
301
+ * exactly that. Its rejection must still restore the text and the attachments, exactly like a
302
+ * same-realm native `Promise` rejecting does above.
303
+ */
304
+ test('a cross-realm promise rejecting still restores the text and the attachments', async () => {
305
+ const storageKey = 'basalt:composer-draft:cross-realm-rejects'
306
+ let rejectSubmit: ((reason: unknown) => void) | null = null
307
+ const crossRealm = crossRealmPromise<void>((_resolve, reject) => {
308
+ rejectSubmit = reject
309
+ })
310
+ // The whole point of the fixture: prove it is genuinely NOT of this realm's `Promise` before
311
+ // relying on anything else about it — otherwise this test would silently degrade into a re-run
312
+ // of the native-promise case above and prove nothing about the regression it targets.
313
+ expect(crossRealm instanceof Promise).toBe(false)
314
+
315
+ let lastAttachmentsChange: readonly ComposerAttachment[] | null = null
316
+ const { rerender } = renderComposer({
317
+ onSubmit: () => crossRealm,
318
+ attachments: [ATTACHMENT],
319
+ onAttachmentsChange: (next) => {
320
+ lastAttachmentsChange = next
321
+ },
322
+ draftKey: 'cross-realm-rejects',
323
+ })
324
+
325
+ type('a cross-realm send')
326
+ fireEvent.click(screen.getByLabelText('Send message'))
327
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
328
+ expect(widen(lastAttachmentsChange)).toEqual([])
329
+
330
+ // Same controlled-prop round trip as the native-Promise version of this test above.
331
+ rerender(
332
+ <MantineProvider>
333
+ <Composer
334
+ onSubmit={noop}
335
+ attachments={[]}
336
+ onAttachmentsChange={(next) => {
337
+ lastAttachmentsChange = next
338
+ }}
339
+ draftKey="cross-realm-rejects"
340
+ />
341
+ </MantineProvider>,
342
+ )
343
+
344
+ if (rejectSubmit === null) throw new Error('expected onSubmit to have been called')
345
+ await act(async () => {
346
+ rejectSubmit?.(new Error('cross-realm rejection'))
347
+ await Promise.resolve()
348
+ })
349
+
350
+ expect(window.localStorage.getItem(storageKey)).toBe(
351
+ JSON.stringify({ v: 1, value: 'a cross-realm send' }),
352
+ )
353
+ expect(widen(lastAttachmentsChange)).toEqual([ATTACHMENT])
354
+ })
355
+
356
+ test('does not restore text the user already replaced by the time the rejection arrives', async () => {
357
+ const storageKey = 'basalt:composer-draft:typed-over'
358
+ let rejectSubmit: ((reason: unknown) => void) | null = null
359
+ renderComposer({
360
+ onSubmit: () =>
361
+ new Promise<void>((_resolve, reject) => {
362
+ rejectSubmit = reject
363
+ }),
364
+ draftKey: 'typed-over',
365
+ })
366
+
367
+ type('the failed message')
368
+ fireEvent.click(screen.getByLabelText('Send message'))
369
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
370
+
371
+ // The user starts a fresh, unrelated message before the rejection lands.
372
+ type('a brand new thought')
373
+
374
+ if (rejectSubmit === null) throw new Error('expected onSubmit to have been called')
375
+ await act(async () => {
376
+ rejectSubmit?.(new Error('network error'))
377
+ await Promise.resolve()
378
+ })
379
+
380
+ expect(window.localStorage.getItem(storageKey)).toBe(
381
+ JSON.stringify({ v: 1, value: 'a brand new thought' }),
382
+ )
383
+ })
384
+
385
+ test('a first submit rejecting after a second succeeded does not clobber the cleared draft', async () => {
386
+ const storageKey = 'basalt:composer-draft:superseded'
387
+ const rejections: Array<(reason: unknown) => void> = []
388
+ let call = 0
389
+ renderComposer({
390
+ onSubmit: () =>
391
+ new Promise<void>((resolve, reject) => {
392
+ call += 1
393
+ if (call === 1) rejections.push(reject)
394
+ else resolve()
395
+ }),
396
+ draftKey: 'superseded',
397
+ })
398
+
399
+ type('first message')
400
+ fireEvent.click(screen.getByLabelText('Send message'))
401
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
402
+
403
+ type('second message')
404
+ fireEvent.click(screen.getByLabelText('Send message'))
405
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
406
+
407
+ const rejectFirst = rejections[0]
408
+ if (rejectFirst === undefined) throw new Error('expected the first submit to have rejected')
409
+ await act(async () => {
410
+ rejectFirst(new Error('first send failed after the second already went through'))
411
+ await Promise.resolve()
412
+ })
413
+
414
+ // The second submit's own (successful) clear must win — the first message does not reappear.
415
+ expect(window.localStorage.getItem(storageKey)).toBeNull()
416
+ expect(input().value).toBe('')
417
+ })
418
+
419
+ test('does not restore attachments the caller already changed via the controlled prop', async () => {
420
+ let rejectSubmit: ((reason: unknown) => void) | null = null
421
+ let lastAttachmentsChange: readonly ComposerAttachment[] | null = null
422
+ const { rerender } = renderComposer({
423
+ onSubmit: () =>
424
+ new Promise<void>((_resolve, reject) => {
425
+ rejectSubmit = reject
426
+ }),
427
+ attachments: [ATTACHMENT],
428
+ onAttachmentsChange: (next) => {
429
+ lastAttachmentsChange = next
430
+ },
431
+ })
432
+
433
+ fireEvent.click(screen.getByLabelText('Send message'))
434
+ expect(widen(lastAttachmentsChange)).toEqual([])
435
+
436
+ // The caller applies the optimistic clear, then independently attaches something new —
437
+ // NOT via a second Composer submit, just its own unrelated state change.
438
+ const replacement: ComposerAttachment = { ...ATTACHMENT, id: 'a2', name: 'other.png' }
439
+ rerender(
440
+ <MantineProvider>
441
+ <Composer
442
+ onSubmit={noop}
443
+ attachments={[replacement]}
444
+ onAttachmentsChange={(next) => {
445
+ lastAttachmentsChange = next
446
+ }}
447
+ />
448
+ </MantineProvider>,
449
+ )
450
+
451
+ if (rejectSubmit === null) throw new Error('expected onSubmit to have been called')
452
+ await act(async () => {
453
+ rejectSubmit?.(new Error('network error'))
454
+ await Promise.resolve()
455
+ })
456
+
457
+ // The replacement attachment must survive — the failed send's original attachment is dropped.
458
+ expect(widen(lastAttachmentsChange)).toEqual([])
459
+ })
460
+
461
+ test('does not restore attachments into a different thread after draftKey changed', async () => {
462
+ let rejectSubmit: ((reason: unknown) => void) | null = null
463
+ let lastAttachmentsChange: readonly ComposerAttachment[] | null = null
464
+ const { rerender } = renderComposer({
465
+ onSubmit: () =>
466
+ new Promise<void>((_resolve, reject) => {
467
+ rejectSubmit = reject
468
+ }),
469
+ attachments: [ATTACHMENT],
470
+ onAttachmentsChange: (next) => {
471
+ lastAttachmentsChange = next
472
+ },
473
+ draftKey: 'thread-a',
474
+ })
475
+
476
+ fireEvent.click(screen.getByLabelText('Send message'))
477
+ expect(widen(lastAttachmentsChange)).toEqual([])
478
+
479
+ // The consumer switches this same Composer instance to a different thread before the
480
+ // rejection arrives — a fresh, still-empty attachments slot for that other thread.
481
+ rerender(
482
+ <MantineProvider>
483
+ <Composer
484
+ onSubmit={noop}
485
+ attachments={[]}
486
+ onAttachmentsChange={(next) => {
487
+ lastAttachmentsChange = next
488
+ }}
489
+ draftKey="thread-b"
490
+ />
491
+ </MantineProvider>,
492
+ )
493
+
494
+ if (rejectSubmit === null) throw new Error('expected onSubmit to have been called')
495
+ await act(async () => {
496
+ rejectSubmit?.(new Error('network error'))
497
+ await Promise.resolve()
498
+ })
499
+
500
+ // Thread B's attachments slot must stay untouched by thread A's failed send.
501
+ expect(widen(lastAttachmentsChange)).toEqual([])
502
+ })
503
+ })
504
+
505
+ describe('keyboard contract', () => {
506
+ test('Enter submits', () => {
507
+ let submits = 0
508
+ renderComposer({
509
+ onSubmit: () => {
510
+ submits += 1
511
+ },
512
+ })
513
+
514
+ type('go')
515
+ fireEvent.keyDown(input(), { key: 'Enter' })
516
+
517
+ expect(submits).toBe(1)
518
+ expect(input().value).toBe('')
519
+ })
520
+
521
+ test('Shift+Enter does not submit', () => {
522
+ let submits = 0
523
+ renderComposer({
524
+ onSubmit: () => {
525
+ submits += 1
526
+ },
527
+ })
528
+
529
+ type('go')
530
+ fireEvent.keyDown(input(), { key: 'Enter', shiftKey: true })
531
+
532
+ expect(submits).toBe(0)
533
+ expect(input().value).toBe('go')
534
+ })
535
+
536
+ test('Enter mid-IME-composition does not submit — it commits the candidate', () => {
537
+ let submits = 0
538
+ renderComposer({
539
+ onSubmit: () => {
540
+ submits += 1
541
+ },
542
+ })
543
+
544
+ type('にほん')
545
+ fireEvent.keyDown(input(), { key: 'Enter', isComposing: true })
546
+
547
+ expect(submits).toBe(0)
548
+ expect(input().value).toBe('にほん')
549
+ })
550
+ })
551
+
552
+ describe('streaming gate', () => {
553
+ test('streaming without onStop disables the input and keeps the send action', () => {
554
+ renderComposer({ onSubmit: noop, streaming: true })
555
+
556
+ expect(input().disabled).toBe(true)
557
+ expect(screen.getByLabelText('Send message')).toBeDefined()
558
+ expect(screen.queryByLabelText('Stop generating')).toBeNull()
559
+ })
560
+
561
+ test('streaming with onStop swaps send for a Stop action', () => {
562
+ let stops = 0
563
+ renderComposer({
564
+ onSubmit: noop,
565
+ streaming: true,
566
+ onStop: () => {
567
+ stops += 1
568
+ },
569
+ })
570
+
571
+ expect(screen.queryByLabelText('Send message')).toBeNull()
572
+ fireEvent.click(screen.getByLabelText('Stop generating'))
573
+
574
+ expect(stops).toBe(1)
575
+ })
576
+
577
+ /**
578
+ * The combination `allowSubmitWhileStreaming` exists for: the textarea stays live mid-run, so a
579
+ * mouse or touch user needs a Send button as much as a keyboard user needs Enter. Stop replacing
580
+ * Send here would make the send action keyboard-only.
581
+ */
582
+ test('allowSubmitWhileStreaming with onStop renders BOTH the stop and the send action', () => {
583
+ const calls: ComposerSubmit[] = []
584
+ let stops = 0
585
+ renderComposer({
586
+ onSubmit: (payload) => {
587
+ calls.push(payload)
588
+ },
589
+ streaming: true,
590
+ allowSubmitWhileStreaming: true,
591
+ onStop: () => {
592
+ stops += 1
593
+ },
594
+ })
595
+
596
+ type('interrupt')
597
+ fireEvent.click(screen.getByLabelText('Send message'))
598
+ expect(calls).toEqual([{ text: 'interrupt', attachments: [] }])
599
+
600
+ fireEvent.click(screen.getByLabelText('Stop generating'))
601
+ expect(stops).toBe(1)
602
+ })
603
+
604
+ test('allowSubmitWhileStreaming permits a submit mid-run', () => {
605
+ const calls: ComposerSubmit[] = []
606
+ renderComposer({
607
+ onSubmit: (payload) => {
608
+ calls.push(payload)
609
+ },
610
+ streaming: true,
611
+ allowSubmitWhileStreaming: true,
612
+ })
613
+
614
+ expect(input().disabled).toBe(false)
615
+ type('interrupt')
616
+ fireEvent.keyDown(input(), { key: 'Enter' })
617
+
618
+ expect(calls).toEqual([{ text: 'interrupt', attachments: [] }])
619
+ })
620
+ })
621
+
622
+ describe('draftKey', () => {
623
+ test('a draft survives an unmount and lands in the basalt: envelope', () => {
624
+ const { unmount } = renderComposer({ onSubmit: noop, draftKey: 'survives-unmount' })
625
+ type('half a thought')
626
+ unmount()
627
+
628
+ expect(window.localStorage.getItem('basalt:composer-draft:survives-unmount')).toBe(
629
+ JSON.stringify({ v: 1, value: 'half a thought' }),
630
+ )
631
+
632
+ renderComposer({ onSubmit: noop, draftKey: 'survives-unmount' })
633
+ expect(input().value).toBe('half a thought')
634
+ })
635
+
636
+ /**
637
+ * The REAL page-reload path, and the only test that exercises it. 'a draft survives an unmount'
638
+ * above proves much less than it looks: the store lives in a module-scope Map that outlives
639
+ * `cleanup()`, so the remount reuses the same already-hydrated object and never touches storage.
640
+ * Here the key has no store yet, so the first `get()` has to go through `readPersistedValue` —
641
+ * which is what a fresh process does after a reload. A wrong prefix on the read side survives
642
+ * every other test in this file and fails only this one.
643
+ */
644
+ test('hydrates a draft a previous session left in storage', () => {
645
+ window.localStorage.setItem(
646
+ 'basalt:composer-draft:hydrated-from-storage',
647
+ JSON.stringify({ v: 1, value: 'from a previous session' }),
648
+ )
649
+
650
+ renderComposer({ onSubmit: noop, draftKey: 'hydrated-from-storage' })
651
+
652
+ expect(input().value).toBe('from a previous session')
653
+ })
654
+
655
+ test('a stale-version envelope is ignored rather than hydrated', () => {
656
+ window.localStorage.setItem(
657
+ 'basalt:composer-draft:stale-envelope',
658
+ JSON.stringify({ v: 0, value: 'written by an older shape' }),
659
+ )
660
+
661
+ renderComposer({ onSubmit: noop, draftKey: 'stale-envelope' })
662
+
663
+ expect(input().value).toBe('')
664
+ })
665
+
666
+ test("switching draftKey swaps to that key's draft and does not leak the old one", () => {
667
+ const { rerender } = renderComposer({ onSubmit: noop, draftKey: 'swap-a' })
668
+ type('alpha')
669
+
670
+ rerender(
671
+ <MantineProvider>
672
+ <Composer onSubmit={noop} draftKey="swap-b" />
673
+ </MantineProvider>,
674
+ )
675
+ expect(input().value).toBe('')
676
+ type('beta')
677
+
678
+ rerender(
679
+ <MantineProvider>
680
+ <Composer onSubmit={noop} draftKey="swap-a" />
681
+ </MantineProvider>,
682
+ )
683
+ expect(input().value).toBe('alpha')
684
+ })
685
+
686
+ test('a successful submit removes the persisted entry rather than leaving an empty husk', () => {
687
+ renderComposer({ onSubmit: noop, draftKey: 'cleared-on-send' })
688
+ type('sent')
689
+ fireEvent.click(screen.getByLabelText('Send message'))
690
+
691
+ expect(window.localStorage.getItem('basalt:composer-draft:cleared-on-send')).toBeNull()
692
+ })
693
+
694
+ test('two keyless Composers do not share a draft', () => {
695
+ render(
696
+ <MantineProvider>
697
+ <Composer onSubmit={noop} placeholder="first" />
698
+ <Composer onSubmit={noop} placeholder="second" />
699
+ </MantineProvider>,
700
+ )
701
+
702
+ const boxes = screen.getAllByRole('textbox') as HTMLTextAreaElement[]
703
+ const [first, second] = [boxes[0], boxes[1]]
704
+ if (first === undefined || second === undefined) throw new Error('expected two composers')
705
+ fireEvent.change(first, { target: { value: 'only mine' } })
706
+
707
+ expect(first.value).toBe('only mine')
708
+ expect(second.value).toBe('')
709
+ })
710
+ })
711
+
712
+ describe('slots', () => {
713
+ test('leftSection and rightSection render inside the input row', () => {
714
+ renderComposer({
715
+ onSubmit: noop,
716
+ leftSection: <div data-testid="left" />,
717
+ rightSection: <div data-testid="right" />,
718
+ })
719
+
720
+ expect(screen.getByTestId('left')).toBeDefined()
721
+ expect(screen.getByTestId('right')).toBeDefined()
722
+ })
723
+
724
+ test('the default hint row is the Enter/Shift+Enter contract', () => {
725
+ renderComposer({ onSubmit: noop })
726
+
727
+ expect(screen.getByText('to send')).toBeDefined()
728
+ })
729
+
730
+ test('hint replaces the whole default row', () => {
731
+ renderComposer({ onSubmit: noop, hint: <div data-testid="hint" /> })
732
+
733
+ expect(screen.getByTestId('hint')).toBeDefined()
734
+ expect(screen.queryByText('to send')).toBeNull()
735
+ })
736
+
737
+ test('hint={null} drops the row entirely', () => {
738
+ renderComposer({ onSubmit: noop, hint: null })
739
+
740
+ expect(screen.queryByText('to send')).toBeNull()
741
+ })
742
+ })
743
+
744
+ describe('ComposerHandle (ref)', () => {
745
+ test('insertText appends at the end when the textarea has never been focused', () => {
746
+ const handle = createRef<ComposerHandle>()
747
+ renderComposer({ onSubmit: noop, ref: handle })
748
+
749
+ type('partial')
750
+ act(() => {
751
+ handle.current?.insertText(' more')
752
+ })
753
+
754
+ expect(input().value).toBe('partial more')
755
+ })
756
+
757
+ test('insertText inserts at the cursor position, not blindly at the end', () => {
758
+ const handle = createRef<ComposerHandle>()
759
+ renderComposer({ onSubmit: noop, ref: handle })
760
+
761
+ type('hello world')
762
+ const el = input()
763
+ el.focus()
764
+ el.setSelectionRange(5, 5) // caret right after "hello"
765
+ act(() => {
766
+ handle.current?.insertText(' there')
767
+ })
768
+
769
+ expect(el.value).toBe('hello there world')
770
+ })
771
+
772
+ test('insertText replaces a selection rather than inserting alongside it', () => {
773
+ const handle = createRef<ComposerHandle>()
774
+ renderComposer({ onSubmit: noop, ref: handle })
775
+
776
+ type('hello brave world')
777
+ const el = input()
778
+ el.focus()
779
+ el.setSelectionRange(6, 11) // "brave"
780
+ act(() => {
781
+ handle.current?.insertText('bold')
782
+ })
783
+
784
+ expect(el.value).toBe('hello bold world')
785
+ })
786
+
787
+ test('setValue replaces the whole draft value', () => {
788
+ const handle = createRef<ComposerHandle>()
789
+ renderComposer({ onSubmit: noop, ref: handle })
790
+
791
+ type('old text')
792
+ act(() => {
793
+ handle.current?.setValue('brand new')
794
+ })
795
+
796
+ expect(input().value).toBe('brand new')
797
+ })
798
+
799
+ test('focus moves focus to the textarea', () => {
800
+ const handle = createRef<ComposerHandle>()
801
+ renderComposer({ onSubmit: noop, ref: handle })
802
+
803
+ act(() => {
804
+ handle.current?.focus()
805
+ })
806
+
807
+ expect(document.activeElement).toBe(input())
808
+ })
809
+
810
+ test('an inserted value persists under draftKey and survives remount', () => {
811
+ const handle = createRef<ComposerHandle>()
812
+ const { unmount } = renderComposer({ onSubmit: noop, draftKey: 'ref-persists', ref: handle })
813
+
814
+ act(() => {
815
+ handle.current?.insertText('via ref')
816
+ })
817
+ unmount()
818
+
819
+ expect(window.localStorage.getItem('basalt:composer-draft:ref-persists')).toBe(
820
+ JSON.stringify({ v: 1, value: 'via ref' }),
821
+ )
822
+
823
+ renderComposer({ onSubmit: noop, draftKey: 'ref-persists' })
824
+ expect(input().value).toBe('via ref')
825
+ })
826
+
827
+ test('insertText works with no draftKey (the per-instance memory store)', () => {
828
+ const handle = createRef<ComposerHandle>()
829
+ renderComposer({ onSubmit: noop, ref: handle })
830
+
831
+ act(() => {
832
+ handle.current?.insertText('memory only')
833
+ })
834
+
835
+ expect(input().value).toBe('memory only')
836
+ })
837
+
838
+ test('a submit after an imperative insertText carries the inserted text in its payload', () => {
839
+ const calls: ComposerSubmit[] = []
840
+ const handle = createRef<ComposerHandle>()
841
+ renderComposer({
842
+ onSubmit: (payload) => {
843
+ calls.push(payload)
844
+ },
845
+ ref: handle,
846
+ })
847
+
848
+ act(() => {
849
+ handle.current?.insertText('from voice')
850
+ })
851
+ fireEvent.click(screen.getByLabelText('Send message'))
852
+
853
+ expect(calls).toEqual([{ text: 'from voice', attachments: [] }])
854
+ })
855
+
856
+ /**
857
+ * Regression for the stale `pendingCaretRef` bug: `insertText`/`setValue` used to arm the ref
858
+ * UNCONDITIONALLY, even when the write was a no-op (didn't change the store's value). Because the
859
+ * caret-consuming layout effect is keyed on `[value]`, a no-op write never triggers it — the ref
860
+ * stayed armed. The NEXT edit that actually changes the value (ordinary typing through
861
+ * `onChange`, which never touches this ref itself) then had that stale, unrelated position
862
+ * forced onto it instead of wherever the real edit naturally landed.
863
+ *
864
+ * Both tests below prove the fix by comparison, not by hard-coding a "natural" DOM/React
865
+ * selection-restoration value (which this harness cannot predict independently of the bug):
866
+ * the SAME real edit is run once with the no-op priming it and once without, and the resulting
867
+ * caret must be identical in both — a no-op write must be invisible to what follows.
868
+ */
869
+ describe('a no-op write does not arm a stale caret for the next real edit', () => {
870
+ test('insertText("") at a collapsed caret', () => {
871
+ const primed = createRef<ComposerHandle>()
872
+ renderComposer({ onSubmit: noop, ref: primed })
873
+ type('hellox')
874
+ const primedEl = input()
875
+ primedEl.focus()
876
+ primedEl.setSelectionRange(5, 5)
877
+ act(() => {
878
+ primed.current?.insertText('') // no-op: content unchanged
879
+ })
880
+ fireEvent.change(primedEl, { target: { value: 'hellofoox' } })
881
+ const primedCaret = primedEl.selectionStart
882
+
883
+ cleanup()
884
+
885
+ const control = createRef<ComposerHandle>()
886
+ renderComposer({ onSubmit: noop, ref: control })
887
+ type('hellox')
888
+ const controlEl = input()
889
+ controlEl.focus()
890
+ controlEl.setSelectionRange(5, 5)
891
+ // No insertText('') here — this is the same real edit, unprimed.
892
+ fireEvent.change(controlEl, { target: { value: 'hellofoox' } })
893
+ const controlCaret = controlEl.selectionStart
894
+
895
+ expect(primedCaret).toBe(controlCaret)
896
+ })
897
+
898
+ test('setValue with the value it already has', () => {
899
+ const primed = createRef<ComposerHandle>()
900
+ renderComposer({ onSubmit: noop, ref: primed })
901
+ type('abcdef')
902
+ const primedEl = input()
903
+ primedEl.focus()
904
+ act(() => {
905
+ primed.current?.setValue('abcdef') // no-op: already this value
906
+ })
907
+ fireEvent.change(primedEl, { target: { value: 'abXcdef' } })
908
+ const primedCaret = primedEl.selectionStart
909
+
910
+ cleanup()
911
+
912
+ const control = createRef<ComposerHandle>()
913
+ renderComposer({ onSubmit: noop, ref: control })
914
+ type('abcdef')
915
+ const controlEl = input()
916
+ controlEl.focus()
917
+ // No setValue('abcdef') here — this is the same real edit, unprimed.
918
+ fireEvent.change(controlEl, { target: { value: 'abXcdef' } })
919
+ const controlCaret = controlEl.selectionStart
920
+
921
+ expect(primedCaret).toBe(controlCaret)
922
+ })
923
+ })
924
+ })