basalt-ui 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +63 -51
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/agent/rules/basalt-content.md +16 -3
  6. package/configs/oxlint-plugin.js +7 -3
  7. package/configs/oxlint-plugin.test.ts +34 -5
  8. package/configs/oxlint.json +4 -4
  9. package/dist/agent/adapter.d.ts +150 -0
  10. package/dist/agent/adapter.d.ts.map +1 -0
  11. package/dist/agent/adapter.js +482 -0
  12. package/dist/agent/adapter.test.d.ts +2 -0
  13. package/dist/agent/adapter.test.d.ts.map +1 -0
  14. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  15. package/dist/agent/ai-sdk-transport.js +6 -2
  16. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  17. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  18. package/dist/agent/coalesce.d.ts +20 -4
  19. package/dist/agent/coalesce.d.ts.map +1 -1
  20. package/dist/agent/coalesce.js +76 -8
  21. package/dist/agent/coalesce.test.d.ts +2 -0
  22. package/dist/agent/coalesce.test.d.ts.map +1 -0
  23. package/dist/agent/foreign.test.d.ts +2 -0
  24. package/dist/agent/foreign.test.d.ts.map +1 -0
  25. package/dist/agent/id.d.ts +78 -0
  26. package/dist/agent/id.d.ts.map +1 -1
  27. package/dist/agent/id.js +30 -0
  28. package/dist/agent/id.test.d.ts +2 -0
  29. package/dist/agent/id.test.d.ts.map +1 -0
  30. package/dist/agent/index.d.ts +2 -0
  31. package/dist/agent/index.d.ts.map +1 -1
  32. package/dist/agent/index.js +3 -0
  33. package/dist/agent/merge.d.ts.map +1 -1
  34. package/dist/agent/merge.js +19 -7
  35. package/dist/agent/merge.test.d.ts +2 -0
  36. package/dist/agent/merge.test.d.ts.map +1 -0
  37. package/dist/agent/part-list.d.ts +49 -34
  38. package/dist/agent/part-list.d.ts.map +1 -1
  39. package/dist/agent/part-list.js +42 -6
  40. package/dist/agent/part-list.test.d.ts +2 -0
  41. package/dist/agent/part-list.test.d.ts.map +1 -0
  42. package/dist/agent/parts.d.ts +46 -10
  43. package/dist/agent/parts.d.ts.map +1 -1
  44. package/dist/agent/parts.js +16 -6
  45. package/dist/agent/parts.test.d.ts +2 -0
  46. package/dist/agent/parts.test.d.ts.map +1 -0
  47. package/dist/agent/thread.d.ts +18 -0
  48. package/dist/agent/thread.d.ts.map +1 -1
  49. package/dist/agent/thread.js +8 -2
  50. package/dist/agent/thread.test.d.ts +2 -0
  51. package/dist/agent/thread.test.d.ts.map +1 -0
  52. package/dist/agent/transport.test.d.ts +2 -0
  53. package/dist/agent/transport.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  55. package/dist/agent/use-agent-stream.js +2 -2
  56. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  57. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  58. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  59. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  60. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  61. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  62. package/dist/agent/use-agent-thread-runs.js +55 -15
  63. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  64. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  66. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  67. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  69. package/dist/agent-chat/composer.d.ts +130 -8
  70. package/dist/agent-chat/composer.d.ts.map +1 -1
  71. package/dist/agent-chat/composer.js +189 -23
  72. package/dist/agent-chat/composer.test.d.ts +2 -0
  73. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  74. package/dist/agent-chat/index.d.ts +5 -1
  75. package/dist/agent-chat/index.d.ts.map +1 -1
  76. package/dist/agent-chat/index.js +2 -0
  77. package/dist/agent-chat/message-affordances.d.ts +59 -0
  78. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  79. package/dist/agent-chat/message-affordances.js +7 -0
  80. package/dist/agent-chat/relative-time.d.ts +27 -0
  81. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  82. package/dist/agent-chat/relative-time.js +27 -0
  83. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  84. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  85. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  86. package/dist/agent-chat/thread-detail-panel.js +1 -1
  87. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  88. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  89. package/dist/agent-chat/thread-feed-row.js +156 -0
  90. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  91. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  92. package/dist/agent-chat/thread-feed.d.ts +34 -5
  93. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  94. package/dist/agent-chat/thread-feed.js +52 -19
  95. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  96. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  97. package/dist/agent-chat/thread-message.d.ts +183 -2
  98. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  99. package/dist/agent-chat/thread-message.js +329 -37
  100. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  101. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  102. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  103. package/dist/agent-chat/thread-outcome-card.js +1 -16
  104. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  105. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  106. package/dist/agent-chat/thread-workspace.js +30 -4
  107. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  108. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  109. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  110. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  111. package/dist/agent-chat/virtualize.d.ts +123 -0
  112. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  113. package/dist/agent-chat/virtualize.js +9 -0
  114. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  115. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  116. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  117. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  118. package/dist/charts/utils/domain.test.d.ts +2 -0
  119. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  120. package/dist/cli/check-theme.test.d.ts +2 -0
  121. package/dist/cli/check-theme.test.d.ts.map +1 -0
  122. package/dist/cli/help.test.d.ts +2 -0
  123. package/dist/cli/help.test.d.ts.map +1 -0
  124. package/dist/cli/index.d.ts.map +1 -1
  125. package/dist/cli/init-sync.test.d.ts +2 -0
  126. package/dist/cli/init-sync.test.d.ts.map +1 -0
  127. package/dist/cli/placement-engine.test.d.ts +2 -0
  128. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  129. package/dist/cli/tarball-parity.test.d.ts +2 -0
  130. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  131. package/dist/cli/template-format.test.d.ts +2 -0
  132. package/dist/cli/template-format.test.d.ts.map +1 -0
  133. package/dist/cli/template-ledger.test.d.ts +2 -0
  134. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  135. package/dist/cli/tokens-css.test.d.ts +2 -0
  136. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  137. package/dist/content/article-actions.test.d.ts +2 -0
  138. package/dist/content/article-actions.test.d.ts.map +1 -0
  139. package/dist/content/article-model.test.d.ts +2 -0
  140. package/dist/content/article-model.test.d.ts.map +1 -0
  141. package/dist/content/block-split.test.d.ts +2 -0
  142. package/dist/content/block-split.test.d.ts.map +1 -0
  143. package/dist/content/fence-block.d.ts +37 -10
  144. package/dist/content/fence-block.d.ts.map +1 -1
  145. package/dist/content/fence-block.js +80 -14
  146. package/dist/content/heading-components.d.ts.map +1 -1
  147. package/dist/content/heading-components.js +7 -4
  148. package/dist/content/heading-components.test.d.ts +2 -0
  149. package/dist/content/heading-components.test.d.ts.map +1 -0
  150. package/dist/content/index.d.ts +6 -3
  151. package/dist/content/index.d.ts.map +1 -1
  152. package/dist/content/index.js +5 -1
  153. package/dist/content/markdown.d.ts +101 -14
  154. package/dist/content/markdown.d.ts.map +1 -1
  155. package/dist/content/markdown.js +141 -28
  156. package/dist/content/markdown.test.d.ts +2 -0
  157. package/dist/content/markdown.test.d.ts.map +1 -0
  158. package/dist/content/prose.module.css.test.d.ts +2 -0
  159. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  160. package/dist/content/sanitize.d.ts +170 -0
  161. package/dist/content/sanitize.d.ts.map +1 -0
  162. package/dist/content/sanitize.js +178 -0
  163. package/dist/content/sanitize.test.d.ts +2 -0
  164. package/dist/content/sanitize.test.d.ts.map +1 -0
  165. package/dist/content/slug.test.d.ts +2 -0
  166. package/dist/content/slug.test.d.ts.map +1 -0
  167. package/dist/dashboard/stat-card.test.d.ts +2 -0
  168. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  169. package/dist/data/index.d.ts +5 -1
  170. package/dist/data/index.d.ts.map +1 -1
  171. package/dist/data/virtual-list.d.ts.map +1 -1
  172. package/dist/data/virtual.d.ts +2 -1
  173. package/dist/data/virtual.d.ts.map +1 -1
  174. package/dist/guard/check-source.test.d.ts +2 -0
  175. package/dist/guard/check-source.test.d.ts.map +1 -0
  176. package/dist/guard/guard-hook.test.d.ts +2 -0
  177. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  178. package/dist/index.d.ts +4 -4
  179. package/dist/index.d.ts.map +1 -1
  180. package/dist/index.js +11 -1
  181. package/dist/provider/index.test.d.ts +2 -0
  182. package/dist/provider/index.test.d.ts.map +1 -0
  183. package/dist/provider/lab-theme.test.d.ts +2 -0
  184. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  186. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  187. package/dist/styles.floor.test.d.ts +2 -0
  188. package/dist/styles.floor.test.d.ts.map +1 -0
  189. package/dist/surfaces.d.ts +5 -5
  190. package/dist/surfaces.d.ts.map +1 -1
  191. package/dist/surfaces.js +14 -4
  192. package/dist/theme/border-coverage.test.d.ts +18 -0
  193. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  194. package/dist/theme/contrast.test.d.ts +2 -0
  195. package/dist/theme/contrast.test.d.ts.map +1 -0
  196. package/dist/theme/density-option.test.d.ts +2 -0
  197. package/dist/theme/density-option.test.d.ts.map +1 -0
  198. package/dist/theme/density-relations.test.d.ts +2 -0
  199. package/dist/theme/density-relations.test.d.ts.map +1 -0
  200. package/dist/theme/derive-theme.test.d.ts +2 -0
  201. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  202. package/dist/theme/fonts-option.test.d.ts +2 -0
  203. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  204. package/dist/theme/radius-option.test.d.ts +2 -0
  205. package/dist/theme/radius-option.test.d.ts.map +1 -0
  206. package/dist/theme/radius.test.d.ts +2 -0
  207. package/dist/theme/radius.test.d.ts.map +1 -0
  208. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  209. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  210. package/dist/theme/spacing.test.d.ts +2 -0
  211. package/dist/theme/spacing.test.d.ts.map +1 -0
  212. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  213. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  214. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  215. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  216. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  217. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  218. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  219. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  220. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  221. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  222. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  223. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  224. package/dist/tokens/density.test.d.ts +2 -0
  225. package/dist/tokens/density.test.d.ts.map +1 -0
  226. package/dist/tokens/derive-radius.test.d.ts +2 -0
  227. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  228. package/dist/tokens/derive.test.d.ts +2 -0
  229. package/dist/tokens/derive.test.d.ts.map +1 -0
  230. package/dist/tokens/palette.test.d.ts +2 -0
  231. package/dist/tokens/palette.test.d.ts.map +1 -0
  232. package/dist/vite-color-mix.test.d.ts +2 -0
  233. package/dist/vite-color-mix.test.d.ts.map +1 -0
  234. package/dist/vite.test.d.ts +2 -0
  235. package/dist/vite.test.d.ts.map +1 -0
  236. package/llms.txt +9 -9
  237. package/package.json +7 -2
  238. package/src/agent/adapter.test.ts +1640 -0
  239. package/src/agent/adapter.ts +945 -0
  240. package/src/agent/ai-sdk-transport.test.ts +117 -26
  241. package/src/agent/ai-sdk-transport.ts +8 -2
  242. package/src/agent/coalesce.test.ts +202 -9
  243. package/src/agent/coalesce.ts +126 -15
  244. package/src/agent/foreign.test.ts +5 -1
  245. package/src/agent/id.test.ts +101 -3
  246. package/src/agent/id.ts +118 -0
  247. package/src/agent/index.ts +8 -0
  248. package/src/agent/merge.test.ts +87 -0
  249. package/src/agent/merge.ts +57 -10
  250. package/src/agent/part-list.test.tsx +31 -1
  251. package/src/agent/part-list.tsx +77 -29
  252. package/src/agent/parts.test.ts +121 -4
  253. package/src/agent/parts.ts +78 -17
  254. package/src/agent/thread.test.ts +75 -2
  255. package/src/agent/thread.ts +25 -1
  256. package/src/agent/use-agent-stream.test.tsx +31 -5
  257. package/src/agent/use-agent-stream.ts +6 -3
  258. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  259. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  260. package/src/agent/use-agent-thread-runs.test.tsx +356 -17
  261. package/src/agent/use-agent-thread-runs.ts +159 -23
  262. package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
  263. package/src/agent-chat/composer.test.tsx +924 -0
  264. package/src/agent-chat/composer.tsx +521 -52
  265. package/src/agent-chat/index.ts +13 -1
  266. package/src/agent-chat/message-affordances.ts +61 -0
  267. package/src/agent-chat/relative-time.test.ts +95 -0
  268. package/src/agent-chat/relative-time.ts +55 -0
  269. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  270. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  271. package/src/agent-chat/thread-feed-row.tsx +274 -0
  272. package/src/agent-chat/thread-feed.test.tsx +241 -0
  273. package/src/agent-chat/thread-feed.tsx +119 -18
  274. package/src/agent-chat/thread-message.test.tsx +1078 -4
  275. package/src/agent-chat/thread-message.tsx +795 -42
  276. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  277. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  278. package/src/agent-chat/thread-workspace.tsx +70 -6
  279. package/src/agent-chat/virtualize.ts +132 -0
  280. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  281. package/src/cli/index.ts +0 -1
  282. package/src/cli/template-format.test.ts +2 -2
  283. package/src/content/fence-block.tsx +242 -15
  284. package/src/content/heading-components.test.tsx +65 -0
  285. package/src/content/heading-components.tsx +26 -5
  286. package/src/content/index.ts +19 -3
  287. package/src/content/markdown.test.tsx +603 -0
  288. package/src/content/markdown.tsx +355 -35
  289. package/src/content/sanitize.test.ts +659 -0
  290. package/src/content/sanitize.ts +510 -0
  291. package/src/dashboard/stat-card.test.tsx +6 -1
  292. package/src/data/index.ts +5 -1
  293. package/src/data/virtual-list.tsx +2 -1
  294. package/src/data/virtual.ts +2 -1
  295. package/src/index.ts +18 -1
  296. package/src/surfaces.ts +13 -3
  297. package/src/theme/contrast.test.ts +1 -4
  298. package/src/theme/density-relations.test.ts +4 -2
  299. package/src/theme/fonts-option.test.ts +7 -1
  300. package/src/theme/shadow-surfaces.test.ts +21 -6
  301. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -0,0 +1,61 @@
1
+ /**
2
+ * MessageAffordances — the per-message hover-row/action contract shared by `ThreadTranscript` and
3
+ * a virtualized `ThreadFeedRow` (both render `MessageBlock`, so the affordance surface lives here
4
+ * rather than being redeclared on each consumer).
5
+ *
6
+ * Type-only module — the rendering (the actual hover row, copy button, regenerate button) lives in
7
+ * `thread-message.tsx`. `MessageAffordances` itself IS public (re-exported, type-only, from the
8
+ * agent-chat barrel and the root): it is named in `ThreadTranscriptProps.affordances` and
9
+ * `ThreadFeedRowProps.affordances`, so a consumer has to be able to name it. `DEFAULT_AFFORDANCES`
10
+ * stays internal — resolving unset fields is the components' job, not the consumer's.
11
+ */
12
+ import type { ReactNode } from 'react'
13
+ import type { ChatMessage, TranscriptPart } from '../agent'
14
+
15
+ export type MessageAffordances = {
16
+ /**
17
+ * How the per-message timestamp renders.
18
+ * @default 'relative'
19
+ */
20
+ readonly timestamp?: 'relative' | 'absolute' | 'none'
21
+ /**
22
+ * Whether a copy action is offered on the hover row. Copies the message's COALESCED text (the
23
+ * same merged/de-duplicated text `coalesceParts` produces for display), not the raw `parts`
24
+ * array — a message can accumulate multiple adjacent/by-id text parts while streaming, and
25
+ * copying the raw parts would either duplicate text or copy fragments the user never saw
26
+ * assembled.
27
+ * @default true
28
+ */
29
+ readonly copy?: boolean
30
+ /**
31
+ * Regenerate action, shown on the LAST ASSISTANT message only (never on earlier turns, never on
32
+ * user messages). Receives that message's `id`. `useAgentThreadRuns`'s `retry` is
33
+ * THREAD-keyed (`retry: (threadId: string) => void`), replaying the last user input for the
34
+ * whole thread — the consumer bridges this messageId-keyed callback to that thread-keyed retry;
35
+ * the framework only ever hands back the messageId, it does not know about threads.
36
+ */
37
+ readonly onRegenerate?: (messageId: string) => void
38
+ /**
39
+ * Extra actions appended to the hover row, after the built-in copy/regenerate actions.
40
+ *
41
+ * This is a RENDER prop, not an event handler — it is called during render, once per message,
42
+ * and its return value is rendered. That means it is treated differently from `onRegenerate`:
43
+ * `onRegenerate` is identity-stabilized internally (a fresh inline literal every render costs
44
+ * nothing), while `actions` is compared by REFERENCE, because its reference is the only signal
45
+ * that its output may have changed. A fresh inline `actions={() => …}` literal therefore
46
+ * re-renders every message block on every render of the transcript. That is correct — an action
47
+ * whose output depends on consumer state (a pin/star toggle) must be allowed to update — but on
48
+ * a long thread it is worth avoiding: wrap `actions` in the consumer's own `useCallback`, keyed
49
+ * on whatever state it actually reads.
50
+ */
51
+ readonly actions?: (ctx: { readonly message: ChatMessage<TranscriptPart> }) => ReactNode
52
+ }
53
+
54
+ /**
55
+ * Resolved defaults for the optional {@link MessageAffordances} fields — one place, so
56
+ * `ThreadTranscript` and a virtualized `ThreadFeedRow` cannot drift on what "unset" means.
57
+ */
58
+ export const DEFAULT_AFFORDANCES: Required<Pick<MessageAffordances, 'timestamp' | 'copy'>> = {
59
+ timestamp: 'relative',
60
+ copy: true,
61
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { formatRelativeTime } from './relative-time'
3
+
4
+ // Unit boundaries (ms) mirrored from relative-time.ts — kept in sync deliberately rather than
5
+ // importing the private table, so a drift between the two shows up as a test failure.
6
+ const YEAR_MS = 31_536_000_000
7
+ const MONTH_MS = 2_628_000_000
8
+ const WEEK_MS = 604_800_000
9
+ const DAY_MS = 86_400_000
10
+ const HOUR_MS = 3_600_000
11
+ const MINUTE_MS = 60_000
12
+
13
+ describe('formatRelativeTime', () => {
14
+ test('sub-minute past → "just now"', () => {
15
+ expect(formatRelativeTime(Date.now() - 1)).toBe('just now')
16
+ expect(formatRelativeTime(Date.now() - 30_000)).toBe('just now')
17
+ expect(formatRelativeTime(Date.now() - (MINUTE_MS - 1_000))).toBe('just now')
18
+ })
19
+
20
+ test('sub-minute future → "just now" (the threshold is symmetric on |diff|)', () => {
21
+ expect(formatRelativeTime(Date.now() + 1)).toBe('just now')
22
+ expect(formatRelativeTime(Date.now() + (MINUTE_MS - 1))).toBe('just now')
23
+ })
24
+
25
+ test('minute boundary', () => {
26
+ expect(formatRelativeTime(Date.now() - MINUTE_MS)).toBe('1 minute ago')
27
+ })
28
+
29
+ test('hour boundary', () => {
30
+ expect(formatRelativeTime(Date.now() - HOUR_MS)).toBe('1 hour ago')
31
+ })
32
+
33
+ test('day boundary', () => {
34
+ expect(formatRelativeTime(Date.now() - DAY_MS)).toBe('yesterday')
35
+ })
36
+
37
+ test('week boundary', () => {
38
+ expect(formatRelativeTime(Date.now() - WEEK_MS)).toBe('last week')
39
+ })
40
+
41
+ test('month boundary', () => {
42
+ expect(formatRelativeTime(Date.now() - MONTH_MS)).toBe('last month')
43
+ })
44
+
45
+ test('year boundary', () => {
46
+ expect(formatRelativeTime(Date.now() - YEAR_MS)).toBe('last year')
47
+ })
48
+
49
+ test('future timestamp: the function handles negative diffMs (diff = timestamp - Date.now())', () => {
50
+ // diffMs is positive here (timestamp is ahead of "now"), so Math.round(diffMs / unit.ms) is
51
+ // positive too, and Intl.RelativeTimeFormat's 'auto' numeric mode renders the "in X" / "next
52
+ // X" / "tomorrow" forms rather than the "X ago" / "last X" forms used for the past.
53
+ expect(formatRelativeTime(Date.now() + HOUR_MS)).toBe('in 1 hour')
54
+ expect(formatRelativeTime(Date.now() + DAY_MS)).toBe('tomorrow')
55
+ expect(formatRelativeTime(Date.now() + WEEK_MS)).toBe('next week')
56
+ })
57
+
58
+ test('non-finite/wrong-type inputs degrade to empty string instead of throwing', () => {
59
+ // Intl.RelativeTimeFormat.format throws a RangeError on any of these. The pre-fix code called
60
+ // it unguarded, so each of the following would have thrown out of formatRelativeTime — except
61
+ // `null`, which coerces to 0 via `timestamp - Date.now()` and produced a nonsense-but-non-
62
+ // throwing "X years ago" string. The guard now normalizes all six to the same '' result.
63
+ expect(formatRelativeTime(NaN)).toBe('')
64
+ expect(formatRelativeTime(Infinity)).toBe('')
65
+ expect(formatRelativeTime(-Infinity)).toBe('')
66
+ // @ts-expect-error — runtime type violation (undefined at a `number`-typed call site), the exact
67
+ // shape a `ThreadsStoreAdapter` bug or corrupted localStorage JSON would produce.
68
+ expect(formatRelativeTime(undefined)).toBe('')
69
+ // @ts-expect-error — runtime type violation (ISO string), the single most likely real-world
70
+ // adapter mistake (a server returning `created_at` as a string).
71
+ expect(formatRelativeTime('2024-01-01T00:00:00Z')).toBe('')
72
+ // @ts-expect-error — runtime type violation (null); previously coerced to 0 rather than
73
+ // throwing, now normalized to the same '' as every other non-finite input.
74
+ expect(formatRelativeTime(null)).toBe('')
75
+ })
76
+
77
+ test('finite-but-absurd timestamps still format instead of throwing', () => {
78
+ // Number.isFinite is true for both, so these fall through the new guard unchanged — the guard
79
+ // must not over-trigger on merely-extreme (but valid) input.
80
+ expect(() => formatRelativeTime(8_640_000_000_000_000)).not.toThrow() // year 275760, JS Date max
81
+ expect(() => formatRelativeTime(-8_640_000_000_000_000)).not.toThrow() // symmetric min
82
+ expect(() => formatRelativeTime(-1_000_000_000_000)).not.toThrow() // pre-1970 epoch
83
+ })
84
+
85
+ test('"at(-1)!" fallback path — documented as effectively unreachable', () => {
86
+ // RELATIVE_TIME_UNITS.find(...) ?? RELATIVE_TIME_UNITS.at(-1)! only falls through to the
87
+ // fallback when .find returns undefined. The array's last (smallest) entry is 'minute' at
88
+ // 60_000ms, exactly the threshold the "just now" early-return already guards below — so for
89
+ // every absMs that reaches the .find call (absMs >= 60_000), 'minute' itself always matches
90
+ // first-or-later, and the ?? branch is defensive dead code under the current threshold table.
91
+ // This test exercises the boundary that the fallback would produce if it were ever reachable
92
+ // (it currently is not, verified by construction): the same 'minute' unit as the last table row.
93
+ expect(formatRelativeTime(Date.now() - MINUTE_MS)).toBe('1 minute ago')
94
+ })
95
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * formatRelativeTime — dependency-free relative-time formatting (no date-fns).
3
+ *
4
+ * Shared between `ThreadOutcomeCard` (inbox row timestamps) and `ThreadTranscript` (per-message
5
+ * timestamps). 'en'-hardcoded for now — see the module-level note below for the deferred locale
6
+ * seam.
7
+ *
8
+ * Internal to `agent-chat/` — not part of the public barrel.
9
+ */
10
+
11
+ const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
12
+
13
+ const RELATIVE_TIME_UNITS: readonly {
14
+ readonly unit: Intl.RelativeTimeFormatUnit
15
+ readonly ms: number
16
+ }[] = [
17
+ { unit: 'year', ms: 31_536_000_000 },
18
+ { unit: 'month', ms: 2_628_000_000 },
19
+ { unit: 'week', ms: 604_800_000 },
20
+ { unit: 'day', ms: 86_400_000 },
21
+ { unit: 'hour', ms: 3_600_000 },
22
+ { unit: 'minute', ms: 60_000 },
23
+ ]
24
+
25
+ /**
26
+ * Formats an epoch-ms timestamp as a short relative string ("3 hours ago", "just now").
27
+ *
28
+ * A non-finite `timestamp` — `NaN`, `±Infinity`, or a non-number value that slipped past the
29
+ * `ChatMessage.createdAt: number` type at runtime (an ISO string or `undefined`/`null` from a
30
+ * hand-rolled `ThreadsStoreAdapter`, or from JSON-deserialized `localStorage` state) renders as an
31
+ * empty string rather than reaching `Intl.RelativeTimeFormat.format`, which throws a `RangeError`
32
+ * on any non-finite input. This is render-path code — one bad `createdAt` on one message must not
33
+ * blank the whole transcript (the standing rule `spliceText`/`coalesceParts` already hold) — so it
34
+ * degrades instead of throwing, and warns in dev, matching `mergePart`'s clamp-and-warn precedent
35
+ * (`clampOffset` in `../agent/merge.ts`). The warning is NOT deduplicated, also matching that
36
+ * precedent — but note the call sites differ: `clampOffset` runs once per wire event, whereas this
37
+ * runs during render, once per message, so a single bad `createdAt` in a long transcript warns
38
+ * once per message per render. That is loud on purpose (a `createdAt` that isn't a number is an
39
+ * adapter bug worth fixing, not worth muting), and it costs nothing in production.
40
+ */
41
+ export function formatRelativeTime(timestamp: number): string {
42
+ if (!Number.isFinite(timestamp)) {
43
+ if (process.env['NODE_ENV'] !== 'production') {
44
+ console.warn(
45
+ `[basalt] formatRelativeTime: non-finite timestamp ${String(timestamp)} — rendering empty string`,
46
+ )
47
+ }
48
+ return ''
49
+ }
50
+ const diffMs = timestamp - Date.now()
51
+ const absMs = Math.abs(diffMs)
52
+ if (absMs < 60_000) return 'just now'
53
+ const unit = RELATIVE_TIME_UNITS.find(({ ms }) => absMs >= ms) ?? RELATIVE_TIME_UNITS.at(-1)!
54
+ return RELATIVE_TIME_FORMAT.format(Math.round(diffMs / unit.ms), unit.unit)
55
+ }
@@ -167,7 +167,9 @@ export function ThreadDetailPanel({
167
167
  <Divider color="var(--vx-divider)" />
168
168
  <Box p="sm">
169
169
  <Stack gap="xs">
170
- <Composer onSubmit={onSend} disabled={streaming} />
170
+ {/* `onSend` stays a plain string at this component's public seam — the payload shape is
171
+ unwrapped here rather than pushed onto every caller. */}
172
+ <Composer onSubmit={({ text }) => onSend(text)} streaming={streaming} />
171
173
  {streaming && (
172
174
  <Button size="compact-sm" variant="subtle" color="red" onClick={onStop}>
173
175
  Stop