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.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +32 -0
- package/README.md +63 -51
- package/agent/rules/basalt-agent.md +14 -0
- package/agent/rules/basalt-content.md +16 -3
- 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 +150 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +482 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +78 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +30 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +42 -6
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +18 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- 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-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- 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.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +5 -1
- 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-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- 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 +329 -37
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- 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 +30 -4
- 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/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.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/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.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/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +14 -4
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +9 -9
- package/package.json +7 -2
- package/src/agent/adapter.test.ts +1640 -0
- package/src/agent/adapter.ts +945 -0
- package/src/agent/ai-sdk-transport.test.ts +117 -26
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +101 -3
- package/src/agent/id.ts +118 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +25 -1
- package/src/agent/use-agent-stream.test.tsx +31 -5
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +356 -17
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +13 -1
- 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-detail-panel.tsx +3 -1
- 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 +1078 -4
- package/src/agent-chat/thread-message.tsx +795 -42
- 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 +70 -6
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- 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 +18 -1
- package/src/surfaces.ts +13 -3
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +21 -6
- 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
|
-
|
|
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
|