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