basalt-ui 1.10.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 (273) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +25 -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.d.ts +3 -52
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +108 -24
  13. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  14. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  15. package/dist/agent/coalesce.d.ts +42 -0
  16. package/dist/agent/coalesce.d.ts.map +1 -0
  17. package/dist/agent/coalesce.js +137 -0
  18. package/dist/agent/coalesce.test.d.ts +2 -0
  19. package/dist/agent/coalesce.test.d.ts.map +1 -0
  20. package/dist/agent/foreign.d.ts +97 -0
  21. package/dist/agent/foreign.d.ts.map +1 -0
  22. package/dist/agent/foreign.js +18 -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/history.d.ts +2 -0
  26. package/dist/agent/history.d.ts.map +1 -1
  27. package/dist/agent/id.d.ts +65 -0
  28. package/dist/agent/id.d.ts.map +1 -0
  29. package/dist/agent/id.js +30 -0
  30. package/dist/agent/id.test.d.ts +2 -0
  31. package/dist/agent/id.test.d.ts.map +1 -0
  32. package/dist/agent/index.d.ts +11 -4
  33. package/dist/agent/index.d.ts.map +1 -1
  34. package/dist/agent/index.js +18 -3
  35. package/dist/agent/merge.d.ts +8 -0
  36. package/dist/agent/merge.d.ts.map +1 -0
  37. package/dist/agent/merge.js +35 -0
  38. package/dist/agent/merge.test.d.ts +2 -0
  39. package/dist/agent/merge.test.d.ts.map +1 -0
  40. package/dist/agent/part-list.d.ts +49 -34
  41. package/dist/agent/part-list.d.ts.map +1 -1
  42. package/dist/agent/part-list.js +53 -7
  43. package/dist/agent/part-list.test.d.ts +2 -0
  44. package/dist/agent/part-list.test.d.ts.map +1 -0
  45. package/dist/agent/parts.d.ts +135 -20
  46. package/dist/agent/parts.d.ts.map +1 -1
  47. package/dist/agent/parts.js +138 -11
  48. package/dist/agent/parts.test.d.ts +2 -0
  49. package/dist/agent/parts.test.d.ts.map +1 -0
  50. package/dist/agent/thread.d.ts +14 -0
  51. package/dist/agent/thread.d.ts.map +1 -1
  52. package/dist/agent/thread.js +8 -2
  53. package/dist/agent/thread.test.d.ts +2 -0
  54. package/dist/agent/thread.test.d.ts.map +1 -0
  55. package/dist/agent/transport.d.ts +39 -3
  56. package/dist/agent/transport.d.ts.map +1 -1
  57. package/dist/agent/transport.js +20 -17
  58. package/dist/agent/transport.test.d.ts +2 -0
  59. package/dist/agent/transport.test.d.ts.map +1 -0
  60. package/dist/agent/use-agent-stream.d.ts +2 -1
  61. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  62. package/dist/agent/use-agent-stream.js +5 -2
  63. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  64. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  66. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  67. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  69. package/dist/agent/use-agent-thread-runs.js +75 -23
  70. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  71. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  72. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  73. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  74. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  75. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  76. package/dist/agent-chat/composer.d.ts +130 -8
  77. package/dist/agent-chat/composer.d.ts.map +1 -1
  78. package/dist/agent-chat/composer.js +189 -23
  79. package/dist/agent-chat/composer.test.d.ts +2 -0
  80. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  81. package/dist/agent-chat/index.d.ts +3 -1
  82. package/dist/agent-chat/index.d.ts.map +1 -1
  83. package/dist/agent-chat/index.js +2 -0
  84. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  85. package/dist/agent-chat/thread-detail-panel.js +1 -1
  86. package/dist/agent-chat/thread-message.d.ts +24 -7
  87. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  88. package/dist/agent-chat/thread-message.js +115 -45
  89. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  90. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  91. package/dist/agent-chat/thread-workspace.js +1 -1
  92. package/dist/agent-chat/tool-chip.d.ts +21 -0
  93. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  94. package/dist/agent-chat/tool-chip.js +120 -0
  95. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  96. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  97. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  98. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  99. package/dist/charts/utils/domain.test.d.ts +2 -0
  100. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  101. package/dist/cli/check-theme.test.d.ts +2 -0
  102. package/dist/cli/check-theme.test.d.ts.map +1 -0
  103. package/dist/cli/help.test.d.ts +2 -0
  104. package/dist/cli/help.test.d.ts.map +1 -0
  105. package/dist/cli/index.d.ts.map +1 -1
  106. package/dist/cli/init-sync.test.d.ts +2 -0
  107. package/dist/cli/init-sync.test.d.ts.map +1 -0
  108. package/dist/cli/placement-engine.test.d.ts +2 -0
  109. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  110. package/dist/cli/tarball-parity.test.d.ts +2 -0
  111. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  112. package/dist/cli/template-format.test.d.ts +2 -0
  113. package/dist/cli/template-format.test.d.ts.map +1 -0
  114. package/dist/cli/template-ledger.test.d.ts +2 -0
  115. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  116. package/dist/cli/tokens-css.test.d.ts +2 -0
  117. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  118. package/dist/content/article-actions.test.d.ts +2 -0
  119. package/dist/content/article-actions.test.d.ts.map +1 -0
  120. package/dist/content/article-model.test.d.ts +2 -0
  121. package/dist/content/article-model.test.d.ts.map +1 -0
  122. package/dist/content/block-split.test.d.ts +2 -0
  123. package/dist/content/block-split.test.d.ts.map +1 -0
  124. package/dist/content/fence-block.d.ts +37 -10
  125. package/dist/content/fence-block.d.ts.map +1 -1
  126. package/dist/content/fence-block.js +80 -14
  127. package/dist/content/heading-components.d.ts.map +1 -1
  128. package/dist/content/heading-components.js +7 -4
  129. package/dist/content/heading-components.test.d.ts +2 -0
  130. package/dist/content/heading-components.test.d.ts.map +1 -0
  131. package/dist/content/index.d.ts +6 -3
  132. package/dist/content/index.d.ts.map +1 -1
  133. package/dist/content/index.js +5 -1
  134. package/dist/content/markdown.d.ts +101 -14
  135. package/dist/content/markdown.d.ts.map +1 -1
  136. package/dist/content/markdown.js +141 -28
  137. package/dist/content/markdown.test.d.ts +2 -0
  138. package/dist/content/markdown.test.d.ts.map +1 -0
  139. package/dist/content/prose.module.css.test.d.ts +2 -0
  140. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  141. package/dist/content/sanitize.d.ts +170 -0
  142. package/dist/content/sanitize.d.ts.map +1 -0
  143. package/dist/content/sanitize.js +178 -0
  144. package/dist/content/sanitize.test.d.ts +2 -0
  145. package/dist/content/sanitize.test.d.ts.map +1 -0
  146. package/dist/content/slug.test.d.ts +2 -0
  147. package/dist/content/slug.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.test.d.ts +2 -0
  149. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  150. package/dist/guard/check-source.test.d.ts +2 -0
  151. package/dist/guard/check-source.test.d.ts.map +1 -0
  152. package/dist/guard/guard-hook.test.d.ts +2 -0
  153. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +1 -3
  156. package/dist/index.d.ts +4 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +12 -2
  159. package/dist/provider/index.test.d.ts +2 -0
  160. package/dist/provider/index.test.d.ts.map +1 -0
  161. package/dist/provider/lab-theme.test.d.ts +2 -0
  162. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  163. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  164. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  165. package/dist/styles.floor.test.d.ts +2 -0
  166. package/dist/styles.floor.test.d.ts.map +1 -0
  167. package/dist/surfaces.d.ts +5 -5
  168. package/dist/surfaces.d.ts.map +1 -1
  169. package/dist/surfaces.js +13 -4
  170. package/dist/theme/border-coverage.test.d.ts +18 -0
  171. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  172. package/dist/theme/contrast.test.d.ts +2 -0
  173. package/dist/theme/contrast.test.d.ts.map +1 -0
  174. package/dist/theme/density-option.test.d.ts +2 -0
  175. package/dist/theme/density-option.test.d.ts.map +1 -0
  176. package/dist/theme/density-relations.test.d.ts +2 -0
  177. package/dist/theme/density-relations.test.d.ts.map +1 -0
  178. package/dist/theme/derive-theme.test.d.ts +2 -0
  179. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  180. package/dist/theme/fonts-option.test.d.ts +2 -0
  181. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  182. package/dist/theme/radius-option.test.d.ts +2 -0
  183. package/dist/theme/radius-option.test.d.ts.map +1 -0
  184. package/dist/theme/radius.test.d.ts +2 -0
  185. package/dist/theme/radius.test.d.ts.map +1 -0
  186. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  187. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  188. package/dist/theme/spacing.test.d.ts +2 -0
  189. package/dist/theme/spacing.test.d.ts.map +1 -0
  190. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  191. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  192. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  193. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  194. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  195. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  196. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  197. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  198. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  199. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  200. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  201. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  202. package/dist/tokens/density.test.d.ts +2 -0
  203. package/dist/tokens/density.test.d.ts.map +1 -0
  204. package/dist/tokens/derive-radius.test.d.ts +2 -0
  205. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  206. package/dist/tokens/derive.test.d.ts +2 -0
  207. package/dist/tokens/derive.test.d.ts.map +1 -0
  208. package/dist/tokens/palette.test.d.ts +2 -0
  209. package/dist/tokens/palette.test.d.ts.map +1 -0
  210. package/dist/vite-color-mix.test.d.ts +2 -0
  211. package/dist/vite-color-mix.test.d.ts.map +1 -0
  212. package/dist/vite.test.d.ts +2 -0
  213. package/dist/vite.test.d.ts.map +1 -0
  214. package/llms.txt +7 -7
  215. package/package.json +6 -1
  216. package/src/agent/adapter.test.ts +1403 -0
  217. package/src/agent/adapter.ts +887 -0
  218. package/src/agent/ai-sdk-transport.test.ts +380 -55
  219. package/src/agent/ai-sdk-transport.ts +206 -53
  220. package/src/agent/coalesce.test.ts +387 -0
  221. package/src/agent/coalesce.ts +229 -0
  222. package/src/agent/foreign.test.ts +51 -0
  223. package/src/agent/foreign.ts +140 -0
  224. package/src/agent/history.ts +2 -0
  225. package/src/agent/id.test.ts +75 -0
  226. package/src/agent/id.ts +91 -0
  227. package/src/agent/index.ts +34 -3
  228. package/src/agent/merge.test.ts +206 -0
  229. package/src/agent/merge.ts +114 -0
  230. package/src/agent/part-list.test.tsx +112 -0
  231. package/src/agent/part-list.tsx +108 -35
  232. package/src/agent/parts.test.ts +516 -57
  233. package/src/agent/parts.ts +323 -32
  234. package/src/agent/thread.test.ts +75 -2
  235. package/src/agent/thread.ts +21 -1
  236. package/src/agent/transport.test.ts +70 -0
  237. package/src/agent/transport.ts +72 -20
  238. package/src/agent/use-agent-stream.test.tsx +53 -19
  239. package/src/agent/use-agent-stream.ts +13 -3
  240. package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
  241. package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
  242. package/src/agent/use-agent-thread-runs.test.tsx +418 -22
  243. package/src/agent/use-agent-thread-runs.ts +170 -26
  244. package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
  245. package/src/agent-chat/composer.test.tsx +924 -0
  246. package/src/agent-chat/composer.tsx +521 -52
  247. package/src/agent-chat/index.ts +5 -1
  248. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  249. package/src/agent-chat/thread-message.test.tsx +351 -0
  250. package/src/agent-chat/thread-message.tsx +262 -64
  251. package/src/agent-chat/thread-workspace.tsx +1 -1
  252. package/src/agent-chat/tool-chip.test.tsx +139 -0
  253. package/src/agent-chat/tool-chip.tsx +219 -0
  254. package/src/cli/index.ts +0 -1
  255. package/src/cli/template-format.test.ts +2 -2
  256. package/src/content/fence-block.tsx +242 -15
  257. package/src/content/heading-components.test.tsx +65 -0
  258. package/src/content/heading-components.tsx +26 -5
  259. package/src/content/index.ts +19 -3
  260. package/src/content/markdown.test.tsx +603 -0
  261. package/src/content/markdown.tsx +355 -35
  262. package/src/content/sanitize.test.ts +659 -0
  263. package/src/content/sanitize.ts +510 -0
  264. package/src/dashboard/stat-card.test.tsx +6 -1
  265. package/src/guard/check-source.test.ts +8 -4
  266. package/src/guard/index.ts +1 -3
  267. package/src/index.ts +15 -1
  268. package/src/surfaces.ts +13 -4
  269. package/src/theme/contrast.test.ts +1 -4
  270. package/src/theme/density-relations.test.ts +4 -2
  271. package/src/theme/fonts-option.test.ts +7 -1
  272. package/src/theme/shadow-surfaces.test.ts +12 -6
  273. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -1,7 +1,10 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { withPartIds } from "./id.js";
3
+ import { mergePart } from "./merge.js";
2
4
  import { isStartPart } from "./parts.js";
5
+ import { isResumable } from "./transport.js";
3
6
  function defaultToUserParts(input) {
4
- return [{ type: "text", text: input }];
7
+ return [{ id: crypto.randomUUID(), type: "text", text: input }];
5
8
  }
6
9
  function buildDoneSnapshot(priorThread, threadId, userMessage, assistantMessage) {
7
10
  const updatedAt = Date.now();
@@ -30,12 +33,13 @@ async function consumeAndFinalize(args) {
30
33
  generator,
31
34
  userMessage,
32
35
  controllersRef,
36
+ appendedRef,
33
37
  storeRef,
34
38
  resolveOutcome,
35
39
  setRuns,
36
40
  onFailureStatus
37
41
  } = args;
38
- const parts = [];
42
+ let parts = [];
39
43
  try {
40
44
  for await (const part of generator) {
41
45
  if (controllersRef.current.get(threadId) !== controller) return;
@@ -44,10 +48,10 @@ async function consumeAndFinalize(args) {
44
48
  storeRef.current.setResumeToken(threadId, part.resumeToken);
45
49
  continue;
46
50
  }
47
- parts.push(part);
51
+ parts = mergePart(parts, part);
48
52
  setRuns((prev) => {
49
53
  const next = new Map(prev);
50
- next.set(threadId, { status: "streaming", parts: [...parts] });
54
+ next.set(threadId, { status: "streaming", parts });
51
55
  return next;
52
56
  });
53
57
  }
@@ -58,16 +62,22 @@ async function consumeAndFinalize(args) {
58
62
  id: crypto.randomUUID(),
59
63
  role: "assistant",
60
64
  parts,
61
- createdAt: Date.now()
65
+ createdAt: Date.now(),
66
+ finish: "complete"
62
67
  };
63
68
  storeRef.current.appendMessage(threadId, assistantMessage);
69
+ appendedRef.current.set(threadId, controller);
64
70
  const priorThread = storeRef.current.threads.find((thread) => thread.id === threadId);
65
71
  const snapshot = buildDoneSnapshot(priorThread, threadId, userMessage, assistantMessage);
66
72
  const outcome = await resolveOutcome(snapshot);
73
+ if (controllersRef.current.get(threadId) !== controller || controller.signal.aborted) {
74
+ return;
75
+ }
67
76
  storeRef.current.setOutcome(threadId, outcome);
68
77
  storeRef.current.setStatus(threadId, outcome.status);
69
78
  storeRef.current.setResumeToken(threadId, void 0);
70
79
  controllersRef.current.delete(threadId);
80
+ appendedRef.current.delete(threadId);
71
81
  setRuns((prev) => {
72
82
  if (!prev.has(threadId)) return prev;
73
83
  const next = new Map(prev);
@@ -80,6 +90,7 @@ async function consumeAndFinalize(args) {
80
90
  if (controller.signal.aborted) return;
81
91
  storeRef.current.setStatus(threadId, onFailureStatus);
82
92
  controllersRef.current.delete(threadId);
93
+ appendedRef.current.delete(threadId);
83
94
  setRuns((prev) => {
84
95
  if (!prev.has(threadId)) return prev;
85
96
  const next = new Map(prev);
@@ -88,6 +99,31 @@ async function consumeAndFinalize(args) {
88
99
  });
89
100
  }
90
101
  }
102
+ async function finalizeStop(args) {
103
+ const { threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns } = args;
104
+ if (!alreadyAppended && parts.length > 0) {
105
+ const stoppedMessage = {
106
+ id: crypto.randomUUID(),
107
+ role: "assistant",
108
+ parts,
109
+ createdAt: Date.now(),
110
+ finish: "stopped"
111
+ };
112
+ storeRef.current.appendMessage(threadId, stoppedMessage);
113
+ }
114
+ storeRef.current.setStatus(threadId, "done");
115
+ storeRef.current.setResumeToken(threadId, void 0);
116
+ setRuns((prev) => {
117
+ if (!prev.has(threadId)) return prev;
118
+ const next = new Map(prev);
119
+ next.delete(threadId);
120
+ return next;
121
+ });
122
+ const snapshot = storeRef.current.threads.find((thread) => thread.id === threadId);
123
+ if (snapshot === void 0) return;
124
+ const outcome = await resolveOutcome(snapshot);
125
+ storeRef.current.setOutcome(threadId, outcome);
126
+ }
91
127
  function useAgentThreadRuns({
92
128
  transport,
93
129
  store,
@@ -95,7 +131,10 @@ function useAgentThreadRuns({
95
131
  toUserParts
96
132
  }) {
97
133
  const [runs, setRuns] = useState(/* @__PURE__ */ new Map());
134
+ const runsRef = useRef(runs);
135
+ runsRef.current = runs;
98
136
  const controllersRef = useRef(/* @__PURE__ */ new Map());
137
+ const appendedRef = useRef(/* @__PURE__ */ new Map());
99
138
  const lastInputRef = useRef(/* @__PURE__ */ new Map());
100
139
  const storeRef = useRef(store);
101
140
  storeRef.current = store;
@@ -118,6 +157,7 @@ function useAgentThreadRuns({
118
157
  () => () => {
119
158
  controllersRef.current.forEach((controller) => controller.abort());
120
159
  controllersRef.current.clear();
160
+ appendedRef.current.clear();
121
161
  },
122
162
  []
123
163
  );
@@ -128,7 +168,7 @@ function useAgentThreadRuns({
128
168
  const resumeToken = thread.resumeToken;
129
169
  const lastUserMessage = thread.messages.filter((message) => message.role === "user").at(-1);
130
170
  const resolvedTransport = resolveTransport(thread.id);
131
- if (resolvedTransport.resume === void 0 || resumeToken === void 0 || lastUserMessage === void 0) {
171
+ if (!isResumable(resolvedTransport) || resumeToken === void 0 || lastUserMessage === void 0) {
132
172
  storeRef.current.setStatus(thread.id, "interrupted");
133
173
  continue;
134
174
  }
@@ -143,9 +183,17 @@ function useAgentThreadRuns({
143
183
  void consumeAndFinalize({
144
184
  threadId: thread.id,
145
185
  controller,
146
- generator: resume(resumeToken, controller.signal),
186
+ // Namespaced by thread.id (stable across a thread's whole lifetime, including this
187
+ // resume) rather than per-call — see withPartIds' own doc for why this is NOT a replay-
188
+ // convergence guarantee on its own: it only protects against two id-less drafts within
189
+ // ONE generator colliding (the F1 bug). A transport whose resume() needs genuine replay
190
+ // safety must assert `idempotentReplay: true` AND mint its own content-stable ids (as
191
+ // aiSdkTransport does) — withPartIds is then a no-op passthrough for it, not the mechanism
192
+ // providing that safety.
193
+ generator: withPartIds(thread.id, resume(resumeToken, controller.signal)),
147
194
  userMessage: lastUserMessage,
148
195
  controllersRef,
196
+ appendedRef,
149
197
  storeRef,
150
198
  resolveOutcome,
151
199
  setRuns,
@@ -176,9 +224,11 @@ function useAgentThreadRuns({
176
224
  void consumeAndFinalize({
177
225
  threadId,
178
226
  controller,
179
- generator: resolvedTransport.stream(input, controller.signal),
227
+ // Namespaced by threadId — same choice, same caveat, as the mount-time resume call above.
228
+ generator: withPartIds(threadId, resolvedTransport.stream(input, controller.signal)),
180
229
  userMessage,
181
230
  controllersRef,
231
+ appendedRef,
182
232
  storeRef,
183
233
  resolveOutcome,
184
234
  setRuns,
@@ -195,22 +245,24 @@ function useAgentThreadRuns({
195
245
  },
196
246
  [start]
197
247
  );
198
- const stop = useCallback((threadId) => {
199
- controllersRef.current.get(threadId)?.abort();
200
- controllersRef.current.delete(threadId);
201
- storeRef.current.setStatus(threadId, "done");
202
- setRuns((prev) => {
203
- if (!prev.has(threadId)) return prev;
204
- const next = new Map(prev);
205
- next.delete(threadId);
206
- return next;
207
- });
208
- }, []);
248
+ const stop = useCallback(
249
+ (threadId) => {
250
+ const controller = controllersRef.current.get(threadId);
251
+ if (controller === void 0) return;
252
+ controllersRef.current.delete(threadId);
253
+ const parts = runsRef.current.get(threadId)?.parts ?? [];
254
+ controller.abort();
255
+ const alreadyAppended = appendedRef.current.get(threadId) === controller;
256
+ appendedRef.current.delete(threadId);
257
+ void finalizeStop({ threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns });
258
+ },
259
+ [resolveOutcome]
260
+ );
209
261
  const stopAll = useCallback(() => {
210
- controllersRef.current.forEach((controller) => controller.abort());
211
- controllersRef.current.clear();
212
- setRuns(/* @__PURE__ */ new Map());
213
- }, []);
262
+ for (const threadId of Array.from(controllersRef.current.keys())) {
263
+ stop(threadId);
264
+ }
265
+ }, [stop]);
214
266
  return { runs, start, retry, stop, stopAll };
215
267
  }
216
268
  export {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=use-agent-thread-runs.resume.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-agent-thread-runs.resume.test.d.ts","sourceRoot":"","sources":["../../src/agent/use-agent-thread-runs.resume.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=use-agent-thread-runs.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-agent-thread-runs.test.d.ts","sourceRoot":"","sources":["../../src/agent/use-agent-thread-runs.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=use-agent-thread-runs.wedge.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-agent-thread-runs.wedge.test.d.ts","sourceRoot":"","sources":["../../src/agent/use-agent-thread-runs.wedge.test.tsx"],"names":[],"mappings":""}
@@ -1,18 +1,140 @@
1
- import type { JSX } from 'react';
1
+ import type { ClipboardEvent, JSX, ReactNode, Ref } from 'react';
2
+ export type ComposerAttachment = {
3
+ readonly id: string;
4
+ readonly name: string;
5
+ readonly mediaType: string;
6
+ readonly size: number;
7
+ readonly url: string;
8
+ };
9
+ export type ComposerSubmit = {
10
+ readonly text: string;
11
+ readonly attachments: readonly ComposerAttachment[];
12
+ };
13
+ /**
14
+ * Imperative escape hatch for slot content that must write into the composer from OUTSIDE the
15
+ * render tree — a voice recorder streaming transcript chunks, a suggestion chip inserting at the
16
+ * cursor. Obtained via `ref`. Kept to exactly the three primitives that cover both real use cases
17
+ * (every method here is API surface fixed for a major, so this is deliberately not more):
18
+ *
19
+ * - `insertText` — the one write primitive. Inserts at the CURRENT SELECTION, replacing it if
20
+ * non-collapsed, so a mid-speech voice chunk lands where the caret is and a suggestion never
21
+ * clobbers a selection the user made on purpose. See its own doc for what "current selection"
22
+ * means when the textarea isn't focused.
23
+ * - `setValue` — a whole-value replace (loading a canned reply, discarding the draft), for when
24
+ * there is no meaningful selection to insert around.
25
+ * - `focus` — hands focus back to the textarea (e.g. once a recorder finishes).
26
+ *
27
+ * All three write through the SAME draft store `onChange` does — same `draftKey` persistence,
28
+ * same subscriber notification, same value the existing submit/clear/restore logic already sees.
29
+ * There is no second source of truth.
30
+ *
31
+ * A slot control that inserts at the caret must not steal focus first. A plain `<button>` in
32
+ * `leftSection`/`rightSection` blurs the textarea on mousedown, which collapses the selection before
33
+ * `onClick` runs — so every insert silently lands at the end and the at-caret path is unreachable by
34
+ * mouse. Suppress it on the control, not here:
35
+ *
36
+ * ```tsx
37
+ * <ActionIcon onMouseDown={(e) => e.preventDefault()} onClick={record}>
38
+ * ```
39
+ */
40
+ export type ComposerHandle = {
41
+ /**
42
+ * Inserts `text` at the textarea's current selection, replacing it if non-collapsed, then moves
43
+ * the caret to just after the inserted text.
44
+ *
45
+ * "Current selection" only exists while the textarea IS the focused element — a `<textarea>`'s
46
+ * `selectionStart`/`selectionEnd` are not a reliable caret once focus has moved elsewhere, so:
47
+ * - **focused, collapsed caret**: inserts at that caret position.
48
+ * - **focused, with a range selected**: replaces the selected range (never inserted alongside
49
+ * it — that would silently keep text the user was in the middle of deleting).
50
+ * - **not focused, including never focused**: appends to the end of the current value. This is
51
+ * the safe default for a voice recorder streaming chunks before the user has clicked into
52
+ * the box at all.
53
+ */
54
+ readonly insertText: (text: string) => void;
55
+ /** Replaces the whole draft value and moves the caret to the end. */
56
+ readonly setValue: (value: string) => void;
57
+ /** Focuses the textarea. */
58
+ readonly focus: () => void;
59
+ };
2
60
  export type ComposerProps = {
3
- /** Called with the trimmed, non-empty text on submit. The input is cleared afterward. */
4
- readonly onSubmit: (text: string) => void;
5
- /** Disables the textarea and the send action (e.g. while a run is streaming). */
6
- readonly disabled?: boolean;
61
+ /** Imperative write/focus handle — see `ComposerHandle`. */
62
+ readonly ref?: Ref<ComposerHandle>;
63
+ /**
64
+ * Called with the trimmed text plus the pending attachments. May return `void` (fire-and-forget,
65
+ * validated only synchronously) or a `Promise<void>` (its outcome is awaited, its timing is not).
66
+ *
67
+ * The draft and attachments clear OPTIMISTICALLY, immediately, before this settles — a chat
68
+ * composer has to feel instant, so Composer never sits on the typed text for a round trip.
69
+ * Rejecting restores them instead:
70
+ * - THROW synchronously to refuse a send outright (bad input, offline, …) — the clear never
71
+ * runs, exactly as before this prop went async.
72
+ * - Return a promise that REJECTS to report a failure discovered later (a network error) — the
73
+ * text and attachments are put back, UNLESS something has since made that unsafe:
74
+ * - the user already typed something new into the (now-empty) box — their new text wins;
75
+ * the failed one is not restored (surface a retry via the failed message instead);
76
+ * - a LATER submit has already gone through this same draft — that one's own optimistic
77
+ * clear (whether it went on to succeed or fail) wins over restoring this older one;
78
+ * - the attachments are no longer empty (the caller already put something else there), or
79
+ * `draftKey` has since changed (this Composer now points at a different thread/consumer
80
+ * destination) — either way the attachments are left as they are rather than risking a
81
+ * leak into whatever is current now.
82
+ * A resolved promise clears and stays cleared, same as a `void` return.
83
+ */
84
+ readonly onSubmit: (payload: ComposerSubmit) => void | Promise<void>;
7
85
  readonly placeholder?: string;
8
86
  /** Autofocuses the textarea on mount. */
9
87
  readonly autoFocus?: boolean;
88
+ /** Autosize ceiling for the textarea. Default 6. */
89
+ readonly maxRows?: number;
90
+ /** Hard-disable, independent of streaming (e.g. offline). */
91
+ readonly disabled?: boolean;
92
+ /** A run is in flight on this thread. */
93
+ readonly streaming?: boolean;
94
+ /** Default false — preserves the historical `disabled={streaming}` behaviour. */
95
+ readonly allowSubmitWhileStreaming?: boolean;
96
+ /** When given, the send action becomes a Stop action while `streaming`. */
97
+ readonly onStop?: () => void;
98
+ /**
99
+ * Opaque slot rendered before the textarea (a voice recorder, a mode switch, …). Slot content
100
+ * that needs to WRITE into the composer — append a transcript, insert a suggestion — does so
101
+ * through the `ref` handle (`ComposerHandle`), not a callback prop here; see the file's
102
+ * `@example`.
103
+ */
104
+ readonly leftSection?: ReactNode;
105
+ /** Opaque slot rendered between the textarea and the send action (an attach button, …). */
106
+ readonly rightSection?: ReactNode;
107
+ /**
108
+ * Pending attachments, owned by the caller. Composer only forwards them on submit and asks for a
109
+ * reset afterwards — rendering previews is the caller's job, via `leftSection`/`rightSection`.
110
+ */
111
+ readonly attachments?: readonly ComposerAttachment[];
112
+ /**
113
+ * Called with `[]` optimistically the instant a submit goes out WITH pending attachments — a
114
+ * submit that had none to begin with skips the call rather than reporting a no-op change — and,
115
+ * subject to the restoration guards documented on `onSubmit`, with the original attachments
116
+ * again if that submit later rejects.
117
+ */
118
+ readonly onAttachmentsChange?: (next: readonly ComposerAttachment[]) => void;
119
+ readonly onPaste?: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
120
+ /**
121
+ * Persists the unsent draft under `basalt:composer-draft:<key>`; switching keys swaps drafts, and
122
+ * the entry is removed (not blanked) on a successful submit.
123
+ *
124
+ * "Successful" means `onSubmit` returned (or resolved) without throwing or rejecting — a send
125
+ * that fails, synchronously or later over the network, restores this persisted draft instead of
126
+ * leaving it erased (subject to the restoration guards documented on `onSubmit`).
127
+ */
128
+ readonly draftKey?: string;
129
+ /** Replaces the whole Enter/Shift+Enter hint row. Pass `null` to drop it entirely. */
130
+ readonly hint?: ReactNode;
10
131
  };
11
132
  /**
12
- * An autosize Textarea + send ActionIcon. Enter submits, Shift+Enter inserts a newline.
133
+ * An autosize Textarea + send ActionIcon, with slots either side. Enter submits, Shift+Enter
134
+ * inserts a newline, and `draftKey` survives an unmount.
13
135
  *
14
136
  * @example
15
- * <Composer onSubmit={onSend} disabled={streaming} />
137
+ * <Composer onSubmit={({ text }) => onSend(text)} streaming={streaming} onStop={stop} />
16
138
  */
17
- export declare function Composer({ onSubmit, disabled, placeholder, autoFocus, }: ComposerProps): JSX.Element;
139
+ export declare function Composer({ ref, onSubmit, placeholder, autoFocus, maxRows, disabled, streaming, allowSubmitWhileStreaming, onStop, leftSection, rightSection, attachments, onAttachmentsChange, onPaste, draftKey, hint, }: ComposerProps): JSX.Element;
18
140
  //# sourceMappingURL=composer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"composer.d.ts","sourceRoot":"","sources":["../../src/agent-chat/composer.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAkB,GAAG,EAAiB,MAAM,OAAO,CAAA;AAoB/D,MAAM,MAAM,aAAa,GAAG;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IACzC,iFAAiF;IACjF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,yCAAyC;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAC7B,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,EACvB,QAAQ,EACR,QAAgB,EAChB,WAAW,EACX,SAAS,GACV,EAAE,aAAa,GAAG,GAAG,CAAC,OAAO,CA8E7B"}
1
+ {"version":3,"file":"composer.d.ts","sourceRoot":"","sources":["../../src/agent-chat/composer.tsx"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { ActionIcon, Group, Kbd, Stack, Text, Textarea } from "@mantine/core";
3
- import { useState } from "react";
3
+ import { useImperativeHandle, useLayoutEffect, useRef, useSyncExternalStore } from "react";
4
+ import { readPersistedValue } from "../state.js";
4
5
  import { VX } from "../tokens/index.js";
5
6
  function SendGlyph() {
6
7
  return /* @__PURE__ */ jsx("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", "aria-hidden": true, children: /* @__PURE__ */ jsx(
@@ -14,21 +15,178 @@ function SendGlyph() {
14
15
  }
15
16
  ) });
16
17
  }
18
+ function StopGlyph() {
19
+ return /* @__PURE__ */ jsx("svg", { width: 16, height: 16, viewBox: "0 0 24 24", "aria-hidden": true, children: /* @__PURE__ */ jsx("rect", { x: 7, y: 7, width: 10, height: 10, rx: 2, fill: "currentColor" }) });
20
+ }
21
+ const DRAFT_VERSION = 1;
22
+ function createDraftStore(key) {
23
+ const storageKey = key === null ? null : `basalt:composer-draft:${key}`;
24
+ const listeners = /* @__PURE__ */ new Set();
25
+ let cache = key === null ? "" : null;
26
+ let storageHandler = null;
27
+ let submissionId = 0;
28
+ const notify = () => {
29
+ for (const cb of listeners) cb();
30
+ };
31
+ return {
32
+ subscribe: (cb) => {
33
+ listeners.add(cb);
34
+ if (storageKey !== null && storageHandler === null) {
35
+ storageHandler = (e) => {
36
+ if (e.key !== storageKey) return;
37
+ cache = null;
38
+ notify();
39
+ };
40
+ window.addEventListener("storage", storageHandler);
41
+ }
42
+ return () => {
43
+ listeners.delete(cb);
44
+ if (listeners.size === 0 && storageHandler !== null) {
45
+ window.removeEventListener("storage", storageHandler);
46
+ storageHandler = null;
47
+ }
48
+ };
49
+ },
50
+ get: () => {
51
+ if (cache === null) {
52
+ const persisted = key === null ? null : readPersistedValue(`composer-draft:${key}`, DRAFT_VERSION);
53
+ cache = typeof persisted === "string" ? persisted : "";
54
+ }
55
+ return cache;
56
+ },
57
+ set: (next) => {
58
+ cache = next;
59
+ if (storageKey !== null) {
60
+ try {
61
+ if (next.length === 0) window.localStorage.removeItem(storageKey);
62
+ else
63
+ window.localStorage.setItem(
64
+ storageKey,
65
+ JSON.stringify({ v: DRAFT_VERSION, value: next })
66
+ );
67
+ } catch {
68
+ }
69
+ }
70
+ notify();
71
+ },
72
+ bumpSubmissionId: () => {
73
+ submissionId += 1;
74
+ return submissionId;
75
+ },
76
+ currentSubmissionId: () => submissionId
77
+ };
78
+ }
79
+ const draftStores = /* @__PURE__ */ new Map();
80
+ function persistedDraftStore(key) {
81
+ const existing = draftStores.get(key);
82
+ if (existing !== void 0) return existing;
83
+ const created = createDraftStore(key);
84
+ draftStores.set(key, created);
85
+ return created;
86
+ }
87
+ const isServer = typeof window === "undefined";
88
+ const noopSubscribe = (_cb) => () => {
89
+ };
90
+ const emptyDraft = () => "";
91
+ const NO_ATTACHMENTS = [];
92
+ function liveSelection(el) {
93
+ if (document.activeElement !== el) return null;
94
+ const { selectionStart, selectionEnd } = el;
95
+ if (selectionStart === null || selectionEnd === null) return null;
96
+ return { start: selectionStart, end: selectionEnd };
97
+ }
98
+ function DefaultHint() {
99
+ return /* @__PURE__ */ jsxs(Group, { gap: 4, children: [
100
+ /* @__PURE__ */ jsx(Kbd, { size: "xs", children: "Enter" }),
101
+ /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", style: { fontFamily: "var(--basalt-font-mono)" }, children: "to send" }),
102
+ /* @__PURE__ */ jsx(Kbd, { size: "xs", children: "Shift" }),
103
+ /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", style: { fontFamily: "var(--basalt-font-mono)" }, children: "+" }),
104
+ /* @__PURE__ */ jsx(Kbd, { size: "xs", children: "Enter" }),
105
+ /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", style: { fontFamily: "var(--basalt-font-mono)" }, children: "for a new line" })
106
+ ] });
107
+ }
17
108
  function Composer({
109
+ ref,
18
110
  onSubmit,
19
- disabled = false,
20
111
  placeholder,
21
- autoFocus
112
+ autoFocus,
113
+ maxRows = 6,
114
+ disabled = false,
115
+ streaming = false,
116
+ allowSubmitWhileStreaming = false,
117
+ onStop,
118
+ leftSection,
119
+ rightSection,
120
+ attachments,
121
+ onAttachmentsChange,
122
+ onPaste,
123
+ draftKey,
124
+ hint
22
125
  }) {
23
- const [value, setValue] = useState("");
126
+ const memoryStore = useRef(null);
127
+ memoryStore.current ??= createDraftStore(null);
128
+ const store = draftKey === void 0 ? memoryStore.current : persistedDraftStore(draftKey);
129
+ const value = useSyncExternalStore(
130
+ isServer ? noopSubscribe : store.subscribe,
131
+ isServer ? emptyDraft : store.get,
132
+ emptyDraft
133
+ );
134
+ const inputDisabled = disabled || streaming && !allowSubmitWhileStreaming;
135
+ const showStop = streaming && onStop !== void 0;
136
+ const showSend = !showStop || allowSubmitWhileStreaming;
137
+ const pending = attachments ?? NO_ATTACHMENTS;
138
+ const trimmed = value.trim();
139
+ const hasPayload = trimmed.length > 0 || pending.length > 0;
140
+ const attachmentsRef = useRef(pending);
141
+ attachmentsRef.current = pending;
142
+ const draftKeyRef = useRef(draftKey);
143
+ draftKeyRef.current = draftKey;
144
+ const textareaRef = useRef(null);
145
+ const pendingCaretRef = useRef(null);
146
+ useLayoutEffect(() => {
147
+ const caret = pendingCaretRef.current;
148
+ if (caret === null) return;
149
+ pendingCaretRef.current = null;
150
+ textareaRef.current?.setSelectionRange(caret, caret);
151
+ }, [value]);
152
+ const insertText = (text) => {
153
+ const current = store.get();
154
+ const selection = textareaRef.current === null ? null : liveSelection(textareaRef.current);
155
+ const start = selection?.start ?? current.length;
156
+ const end = selection?.end ?? current.length;
157
+ const next = current.slice(0, start) + text + current.slice(end);
158
+ store.set(next);
159
+ pendingCaretRef.current = next === value ? null : start + text.length;
160
+ };
161
+ const setValue = (next) => {
162
+ store.set(next);
163
+ pendingCaretRef.current = next === value ? null : next.length;
164
+ };
165
+ const focusTextarea = () => {
166
+ textareaRef.current?.focus();
167
+ };
168
+ useImperativeHandle(ref, () => ({ insertText, setValue, focus: focusTextarea }));
24
169
  const submit = () => {
25
- const trimmed = value.trim();
26
- if (trimmed.length === 0 || disabled) return;
27
- onSubmit(trimmed);
28
- setValue("");
170
+ if (inputDisabled || !hasPayload) return;
171
+ const submittedText = trimmed;
172
+ const submittedAttachments = pending;
173
+ const submittedDraftKey = draftKey;
174
+ const mySubmissionId = store.bumpSubmissionId();
175
+ const result = onSubmit({ text: submittedText, attachments: submittedAttachments });
176
+ store.set("");
177
+ if (submittedAttachments.length > 0) onAttachmentsChange?.([]);
178
+ void Promise.resolve(result).catch(() => {
179
+ if (store.currentSubmissionId() === mySubmissionId && store.get() === "") {
180
+ store.set(submittedText);
181
+ }
182
+ if (submittedAttachments.length > 0 && attachmentsRef.current.length === 0 && draftKeyRef.current === submittedDraftKey) {
183
+ onAttachmentsChange?.(submittedAttachments);
184
+ }
185
+ });
29
186
  };
30
187
  const handleKeyDown = (event) => {
31
188
  if (event.key !== "Enter" || event.shiftKey) return;
189
+ if (event.nativeEvent.isComposing) return;
32
190
  event.preventDefault();
33
191
  submit();
34
192
  };
@@ -36,10 +194,10 @@ function Composer({
36
194
  flex: 1,
37
195
  autosize: true,
38
196
  minRows: 1,
39
- maxRows: 6,
197
+ maxRows,
40
198
  value,
41
- disabled,
42
- onChange: (event) => setValue(event.currentTarget.value),
199
+ disabled: inputDisabled,
200
+ onChange: (event) => store.set(event.currentTarget.value),
43
201
  onKeyDown: handleKeyDown,
44
202
  // Composer input surface (docs/DESIGN-SPEC.md §5): panel + control radius (6px, VX.radiusCtrl);
45
203
  // the ring lives in the shadow, so the input carries no separate border. Depth is deliberately
@@ -56,30 +214,38 @@ function Composer({
56
214
  };
57
215
  if (placeholder !== void 0) textareaProps.placeholder = placeholder;
58
216
  if (autoFocus !== void 0) textareaProps.autoFocus = autoFocus;
217
+ if (onPaste !== void 0) textareaProps.onPaste = onPaste;
59
218
  return /* @__PURE__ */ jsxs(Stack, { gap: 6, children: [
60
219
  /* @__PURE__ */ jsxs(Group, { gap: "xs", align: "flex-end", wrap: "nowrap", children: [
61
- /* @__PURE__ */ jsx(Textarea, { ...textareaProps }),
62
- /* @__PURE__ */ jsx(
220
+ leftSection,
221
+ /* @__PURE__ */ jsx(Textarea, { ...textareaProps, ref: textareaRef }),
222
+ rightSection,
223
+ showStop ? /* @__PURE__ */ jsx(
224
+ ActionIcon,
225
+ {
226
+ size: 42,
227
+ radius: "md",
228
+ variant: "filled",
229
+ color: "red",
230
+ onClick: onStop,
231
+ "aria-label": "Stop generating",
232
+ children: /* @__PURE__ */ jsx(StopGlyph, {})
233
+ }
234
+ ) : null,
235
+ showSend ? /* @__PURE__ */ jsx(
63
236
  ActionIcon,
64
237
  {
65
238
  size: 42,
66
239
  radius: "md",
67
240
  variant: "filled",
68
241
  onClick: submit,
69
- disabled: disabled || value.trim().length === 0,
242
+ disabled: inputDisabled || !hasPayload,
70
243
  "aria-label": "Send message",
71
244
  children: /* @__PURE__ */ jsx(SendGlyph, {})
72
245
  }
73
- )
246
+ ) : null
74
247
  ] }),
75
- /* @__PURE__ */ jsxs(Group, { gap: 4, children: [
76
- /* @__PURE__ */ jsx(Kbd, { size: "xs", children: "Enter" }),
77
- /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", style: { fontFamily: "var(--basalt-font-mono)" }, children: "to send" }),
78
- /* @__PURE__ */ jsx(Kbd, { size: "xs", children: "Shift" }),
79
- /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", style: { fontFamily: "var(--basalt-font-mono)" }, children: "+" }),
80
- /* @__PURE__ */ jsx(Kbd, { size: "xs", children: "Enter" }),
81
- /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", style: { fontFamily: "var(--basalt-font-mono)" }, children: "for a new line" })
82
- ] })
248
+ hint === void 0 ? /* @__PURE__ */ jsx(DefaultHint, {}) : hint
83
249
  ] });
84
250
  }
85
251
  export {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=composer.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"composer.test.d.ts","sourceRoot":"","sources":["../../src/agent-chat/composer.test.tsx"],"names":[],"mappings":""}
@@ -19,7 +19,9 @@ export type { ThreadOutcomeCardProps } from './thread-outcome-card.js';
19
19
  export { ThreadDetailPanel } from './thread-detail-panel.js';
20
20
  export type { ThreadDetailPanelProps } from './thread-detail-panel.js';
21
21
  export { Composer } from './composer.js';
22
- export type { ComposerProps } from './composer.js';
22
+ export type { ComposerAttachment, ComposerHandle, ComposerProps, ComposerSubmit } from './composer.js';
23
23
  export { threadPartRenderers, ThreadTranscript } from './thread-message.js';
24
24
  export type { ThreadTranscriptProps } from './thread-message.js';
25
+ export { ToolChip } from './tool-chip.js';
26
+ export type { ToolChipProps } from './tool-chip.js';
25
27
  //# sourceMappingURL=index.d.ts.map