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.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +17 -0
- package/README.md +38 -35
- package/agent/rules/basalt-agent.md +14 -0
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +5 -1
- package/dist/agent/adapter.d.ts.map +1 -1
- package/dist/agent/adapter.js +22 -13
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/id.d.ts +67 -19
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +21 -7
- package/dist/agent/thread.d.ts +4 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- package/dist/agent/use-agent-thread-runs.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent-chat/index.d.ts +4 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +315 -34
- package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +29 -3
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +3 -2
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/llms.txt +6 -6
- package/package.json +2 -2
- package/src/agent/adapter.test.ts +237 -0
- package/src/agent/adapter.ts +72 -14
- package/src/agent/ai-sdk-transport.test.ts +64 -0
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/id.test.ts +95 -1
- package/src/agent/id.ts +98 -28
- package/src/agent/thread.ts +4 -0
- package/src/agent/use-agent-stream.test.tsx +22 -0
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.test.tsx +326 -0
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
- package/src/agent-chat/index.ts +12 -0
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +937 -3
- package/src/agent-chat/thread-message.tsx +766 -38
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +69 -5
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +5 -0
- package/src/surfaces.ts +2 -1
- 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
|
-
/**
|
|
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
|
|
115
|
-
*
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
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
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
})
|
package/src/agent-chat/index.ts
CHANGED
|
@@ -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
|
+
}
|