From a15d163eae77e0a32c64e867eb2e23994feebd0f Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 10:19:13 +0200 Subject: [PATCH 01/11] feat: give the transcript a Slack shape, affordances and virtualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThreadFeedRow is the inline-expanding variant alongside the inbox-shaped ThreadOutcomeCard, which is unchanged — a second component, not a mode on the first. ThreadFeed gains variant and renderRow. Both its reduced-motion and animated branches carry the new path. The row mounts its transcript lazily and keeps it mounted, hiding it with CSS on collapse. That guarantee is owned here rather than delegated to Mantine Collapse: on the installed 9.3.0 a bare Collapse happens to keep children mounted, but Mantine master has flipped its defaults to keepMounted plus keepMountedMode activity, which wraps children in React 19 Activity — and a hidden Activity destroys the subtree's effects. Delegating would silently reintroduce a double stream replay on the next Mantine bump. Per-message affordances add a relative timestamp, copy, and regenerate on the last assistant message only, plus consumer actions. groupConsecutive suppresses the role label and chrome for a same-role message within five minutes, so ChatMessage.createdAt is finally rendered. It defaults on, so an existing transcript loses those labels on upgrade; pass groupConsecutive false to keep them. ThreadTranscript can virtualize. The props union is the guard: virtualize requires height and forbids it otherwise, because a virtualizer needs a measured scroll container — and a virtualized transcript owns that node, so it must not be nested inside BasaltStickToBottom. Rows are variable height and measured dynamically; a zero measurement is treated as unreliable and the last good size kept, because a row inside a display:none ancestor measures zero and would otherwise poison the cache permanently. initialScroll defaults to end, so a transcript opens at the newest message. Firing the jump is not enough: virtual-core writes a stale anchor offset back over it on the following commit, so the jump is confirmed by a real scrollTop read and re-fired if clobbered, bounded by an attempt cap. estimateSize defaults to 160 rather than 96, against a measured mean row height of about 145. The old value grew total size by 46 percent across one descent and shrank the scrollbar thumb the whole way down. --- packages/basalt-ui/AGENTS.md | 42 +- packages/basalt-ui/README.md | 2 +- .../basalt-ui/agent/rules/basalt-agent.md | 14 + packages/basalt-ui/llms.txt | 4 +- .../basalt-ui/scripts/export-surface.json | 2 + packages/basalt-ui/src/agent-chat/index.ts | 12 + .../src/agent-chat/message-affordances.ts | 61 ++ .../src/agent-chat/relative-time.test.ts | 95 ++ .../basalt-ui/src/agent-chat/relative-time.ts | 55 + .../src/agent-chat/thread-feed-row.test.tsx | 510 ++++++++++ .../src/agent-chat/thread-feed-row.tsx | 274 +++++ .../src/agent-chat/thread-feed.test.tsx | 241 +++++ .../basalt-ui/src/agent-chat/thread-feed.tsx | 135 ++- .../src/agent-chat/thread-message.test.tsx | 940 +++++++++++++++++- .../src/agent-chat/thread-message.tsx | 804 ++++++++++++++- .../src/agent-chat/thread-outcome-card.tsx | 26 +- .../basalt-ui/src/agent-chat/virtualize.ts | 132 +++ .../agent-chat/virtualize.type-guard.test.ts | 49 + packages/basalt-ui/src/index.ts | 5 + packages/basalt-ui/src/surfaces.ts | 3 +- .../src/theme/shadow-surfaces.test.ts | 9 + 21 files changed, 3306 insertions(+), 109 deletions(-) create mode 100644 packages/basalt-ui/src/agent-chat/message-affordances.ts create mode 100644 packages/basalt-ui/src/agent-chat/relative-time.test.ts create mode 100644 packages/basalt-ui/src/agent-chat/relative-time.ts create mode 100644 packages/basalt-ui/src/agent-chat/thread-feed-row.test.tsx create mode 100644 packages/basalt-ui/src/agent-chat/thread-feed-row.tsx create mode 100644 packages/basalt-ui/src/agent-chat/thread-feed.test.tsx create mode 100644 packages/basalt-ui/src/agent-chat/virtualize.ts create mode 100644 packages/basalt-ui/src/agent-chat/virtualize.type-guard.test.ts diff --git a/packages/basalt-ui/AGENTS.md b/packages/basalt-ui/AGENTS.md index c5a8ddb..1b880b6 100644 --- a/packages/basalt-ui/AGENTS.md +++ b/packages/basalt-ui/AGENTS.md @@ -6,27 +6,27 @@ Toolchain: Bun runtime, oxlint + oxfmt, conventional commits (empty scope), `mas ## Subpath ownership -| Subpath | Layer | Purpose | -| --------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `basalt-ui` | mantine-coupled | BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone) | -| `basalt-ui/charts` | headless | visx chart primitives, sparklines, hooks, and token re-exports (Mantine-free) | -| `basalt-ui/tokens` | headless | VX token refs, buildPaletteCss, defineSeries, seriesTokens, groupTokens, alpha (Mantine-free) | -| `basalt-ui/theme-lab` | mantine-coupled | ThemeLabControls, applyOverrides, COLOR_GROUPS — a low-level inspector for the non-derived structural tokens; identity/color tuning lives in DeriveControls | -| `basalt-ui/vite` | mantine-coupled | basaltViteConfig(opts) — Vite preset for basalt-ui consumer apps; basaltAppPlugin(opts) — PWA head, manifest, and icon metadata derived from the token palette | -| `basalt-ui/guard` | headless | checkSource, GUARD_RULES, Finding types — the headless theme-guard core | -| `basalt-ui/query` | headless | createBasaltQueryClient, transport-agnostic unwrap, lazy BasaltQueryDevtools | -| `basalt-ui/router-tanstack` | headless | TanStack Router bridge: useBasaltNav (active route) + useRouterBreadcrumbs + createSearchParamStore (single-select URL-state store) + createMultiSearchParamStore (multi-select URL-state store) | -| `basalt-ui/forms` | mantine-coupled | Mantine form adapter: useBasaltForm, field, FormErrorSummary, useFormDraft (Standard Schema) | -| `basalt-ui/notifications` | mantine-coupled | Mantine notifications: notify helpers, typed registry, persisted history, NotificationBell, NotificationCenter | -| `basalt-ui/commands` | mantine-coupled | typed command bus + overlay controller, toSpotlightActions, ShortcutsHelp, BasaltOverlays | -| `basalt-ui/data` | mantine-coupled | Convenience barrel pulling both TanStack Table + Virtual peer groups: BasaltDataTable, BasaltVirtualList (Mantine-rendered) — prefer ./data/table or ./data/virtual for per-feature opt-in | -| `basalt-ui/data/table` | mantine-coupled | BasaltDataTable: a sortable data table over TanStack Table, rendered with Mantine (Mantine-rendered) | -| `basalt-ui/data/virtual` | mantine-coupled | BasaltVirtualList: a windowed virtual list over TanStack Virtual, rendered with Mantine (Mantine-rendered) | -| `basalt-ui/agent` | headless | Headless streaming-chat layer: useAgentStream, aiSdkTransport (recommended default) + edenTransport, isResumable/ResumableAgentTransport (stream-resumption seam), PartList, coalesceParts, the ForeignPart/definePartRenderers/narrowAgentPart open part-registry seam, plus the multi-thread createThreadsStore + useAgentThreadRuns + outcome-resolver seam (Mantine-free) | -| `basalt-ui/agent-chat` | mantine-coupled | Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx. | -| `basalt-ui/content` | mantine-coupled | Prose (article/chat typography), CodeBlock (shiki, optional peer), Callout, TableOfContents, ReadingProgress, Markdown (react-markdown + remark-gfm, optional peers; `streaming` is a rendering mode ONLY — `contentTrust` is the independent security input, and any surface rendering agent/model output must pin `contentTrust="untrusted"`, the sole input to the image-origin allowlist; a `fenceRenderers` registry — settledOnly/FenceRenderer/FenceRenderers/FenceRenderContext; `sanitizeSchema`, an additions-only SanitizeSchemaExtension merged over BASALT_SANITIZE_SCHEMA via mergeSanitizeSchema; the remend streaming-repair pass is now a lazy optional peer), MermaidDiagram (beautiful-mermaid, optional peer), mdxComponents/createMdxComponents, ArticleLayout (docs-page frame), ArticleCard/ArticleGrid (overview cards), Article model (sortArticles/filterArticles/formatArticleDate), ArticleFilterBar (category/tags filter UI), toArticleActions (Spotlight projector, @mantine/spotlight type-only), GuideLink/GuideDrawer (contextual-help drawer) — the content/prose surface | -| `basalt-ui/state` | headless | createPersistedState (versioned localStorage) + useOnlineStatus — Mantine-free state primitives | -| `basalt-ui/connectivity` | mantine-coupled | ConnectivityProvider (aggregates browser online/offline, React Query onlineManager, SSE, and health-check pings into one status), useConnectivity, and ConnectivityIndicator — auto-mounted by BasaltProvider | +| Subpath | Layer | Purpose | +| --------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `basalt-ui` | mantine-coupled | BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone) | +| `basalt-ui/charts` | headless | visx chart primitives, sparklines, hooks, and token re-exports (Mantine-free) | +| `basalt-ui/tokens` | headless | VX token refs, buildPaletteCss, defineSeries, seriesTokens, groupTokens, alpha (Mantine-free) | +| `basalt-ui/theme-lab` | mantine-coupled | ThemeLabControls, applyOverrides, COLOR_GROUPS — a low-level inspector for the non-derived structural tokens; identity/color tuning lives in DeriveControls | +| `basalt-ui/vite` | mantine-coupled | basaltViteConfig(opts) — Vite preset for basalt-ui consumer apps; basaltAppPlugin(opts) — PWA head, manifest, and icon metadata derived from the token palette | +| `basalt-ui/guard` | headless | checkSource, GUARD_RULES, Finding types — the headless theme-guard core | +| `basalt-ui/query` | headless | createBasaltQueryClient, transport-agnostic unwrap, lazy BasaltQueryDevtools | +| `basalt-ui/router-tanstack` | headless | TanStack Router bridge: useBasaltNav (active route) + useRouterBreadcrumbs + createSearchParamStore (single-select URL-state store) + createMultiSearchParamStore (multi-select URL-state store) | +| `basalt-ui/forms` | mantine-coupled | Mantine form adapter: useBasaltForm, field, FormErrorSummary, useFormDraft (Standard Schema) | +| `basalt-ui/notifications` | mantine-coupled | Mantine notifications: notify helpers, typed registry, persisted history, NotificationBell, NotificationCenter | +| `basalt-ui/commands` | mantine-coupled | typed command bus + overlay controller, toSpotlightActions, ShortcutsHelp, BasaltOverlays | +| `basalt-ui/data` | mantine-coupled | Convenience barrel pulling both TanStack Table + Virtual peer groups: BasaltDataTable, BasaltVirtualList (Mantine-rendered) — prefer ./data/table or ./data/virtual for per-feature opt-in | +| `basalt-ui/data/table` | mantine-coupled | BasaltDataTable: a sortable data table over TanStack Table, rendered with Mantine (Mantine-rendered) | +| `basalt-ui/data/virtual` | mantine-coupled | BasaltVirtualList: a windowed virtual list over TanStack Virtual, rendered with Mantine (Mantine-rendered) | +| `basalt-ui/agent` | headless | Headless streaming-chat layer: useAgentStream, aiSdkTransport (recommended default) + edenTransport, isResumable/ResumableAgentTransport (stream-resumption seam), PartList, coalesceParts, the ForeignPart/definePartRenderers/narrowAgentPart open part-registry seam, plus the multi-thread createThreadsStore + useAgentThreadRuns + outcome-resolver seam (Mantine-free) | +| `basalt-ui/agent-chat` | mantine-coupled | Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed (variant/renderRow), ThreadFeedRow (inline-expanding Slack row, lazily mounted + kept mounted), ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props, per-message MessageAffordances, groupConsecutive, and an optional virtualize/height windowing mode whose VirtualizeOptions carry overscan/estimateSize/initialScroll — a virtualized transcript opens scrolled to the newest message unless initialScroll is "start"), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx, and @tanstack/react-virtual the same way through the lazy import() behind ThreadTranscript virtualize (absent peer degrades to an unwindowed, height-bound pane). | +| `basalt-ui/content` | mantine-coupled | Prose (article/chat typography), CodeBlock (shiki, optional peer), Callout, TableOfContents, ReadingProgress, Markdown (react-markdown + remark-gfm, optional peers; `streaming` is a rendering mode ONLY — `contentTrust` is the independent security input, and any surface rendering agent/model output must pin `contentTrust="untrusted"`, the sole input to the image-origin allowlist; a `fenceRenderers` registry — settledOnly/FenceRenderer/FenceRenderers/FenceRenderContext; `sanitizeSchema`, an additions-only SanitizeSchemaExtension merged over BASALT_SANITIZE_SCHEMA via mergeSanitizeSchema; the remend streaming-repair pass is now a lazy optional peer), MermaidDiagram (beautiful-mermaid, optional peer), mdxComponents/createMdxComponents, ArticleLayout (docs-page frame), ArticleCard/ArticleGrid (overview cards), Article model (sortArticles/filterArticles/formatArticleDate), ArticleFilterBar (category/tags filter UI), toArticleActions (Spotlight projector, @mantine/spotlight type-only), GuideLink/GuideDrawer (contextual-help drawer) — the content/prose surface | +| `basalt-ui/state` | headless | createPersistedState (versioned localStorage) + useOnlineStatus — Mantine-free state primitives | +| `basalt-ui/connectivity` | mantine-coupled | ConnectivityProvider (aggregates browser online/offline, React Query onlineManager, SSE, and health-check pings into one status), useConnectivity, and ConnectivityIndicator — auto-mounted by BasaltProvider | ## Hard rules diff --git a/packages/basalt-ui/README.md b/packages/basalt-ui/README.md index 3897a9d..bd480b2 100644 --- a/packages/basalt-ui/README.md +++ b/packages/basalt-ui/README.md @@ -236,7 +236,7 @@ deleted rather than silently surviving into the next real skew. | `./data/table` | coupled | `BasaltDataTable` — sortable data table over TanStack Table, rendered with Mantine | | `./data/virtual` | coupled | `BasaltVirtualList` — windowed virtual list over TanStack Virtual, rendered with Mantine | | `./agent` | **free** | Headless streaming layer (`useAgentStream`, `PartList`) + multi-thread `createThreadsStore` / `useAgentThreadRuns` / outcome seam | -| `./agent-chat` | coupled | Mantine-styled thread-chat components over `./agent`: `ThreadWorkspace`, `ThreadFeed`, `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript`, `threadPartRenderers` — **requires `motion`**, not merely an optional peer; see [Requirements](#requirements) | +| `./agent-chat` | coupled | Mantine-styled thread-chat components over `./agent`: `ThreadWorkspace`, `ThreadFeed` (`variant`/`renderRow`), `ThreadFeedRow` (inline-expanding Slack row, lazily mounted + kept mounted), `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript` (`groupConsecutive`/`affordances`/`virtualize` — windowed transcripts open at the newest message unless `initialScroll: 'start'`), `threadPartRenderers` — **requires `motion`**, not merely an optional peer; see [Requirements](#requirements) | | `./state` | **free** | `createPersistedState` (versioned localStorage) + `useOnlineStatus` — Mantine-free state primitives | | `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` | | `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` | diff --git a/packages/basalt-ui/agent/rules/basalt-agent.md b/packages/basalt-ui/agent/rules/basalt-agent.md index 53b6a07..96902eb 100644 --- a/packages/basalt-ui/agent/rules/basalt-agent.md +++ b/packages/basalt-ui/agent/rules/basalt-agent.md @@ -257,6 +257,20 @@ composer) on the right, collapsing to a single pane below 768px. The lower-level `threadPartRenderers`) are exported too for bespoke layouts. Motion (feed insert, panel slide) runs on the shared `MOTION_*` tokens and honours `useReducedMotion`. +`ThreadFeed` also takes a `variant` (`'outcome'`, the default above, or `'inline'` for +`ThreadFeedRow`, the Slack-style row that expands in place instead of opening a separate detail +panel — lazily mounted on first expand, then kept mounted and hidden via CSS on every collapse +after that) and a `renderRow` override that takes priority over `variant` entirely, for full control +over live-run wiring (`onSend`/`onStop`/`liveParts`/`liveStatus`) the built-in `'inline'` row +doesn't expose on its own; wire a real `onSend` on `ThreadFeed` itself to make the built-in row's +composer usable, or omit it and the row's composer renders disabled rather than a live control that +silently discards input. `ThreadTranscript` also takes `groupConsecutive` (suppresses role +label/chrome on same-speaker runs), a per-message `affordances` contract (timestamp/copy/regenerate/ +custom actions), and an optional `virtualize`/`height` windowing mode for very long threads. A +windowed transcript owns its own scroll node (never nest it in `BasaltStickToBottom`) and scrolls +itself to the newest message once on mount — `virtualize={{ initialScroll: 'start' }}` opts out, and +is what a consumer restoring its own saved scroll position wants so the two don't fight. + **Boundary:** the headless layer (`createThreadsStore`, `useAgentThreadRuns`, the outcome types) stays Mantine-free in `./agent`; the components are Mantine-coupled and ship from `./agent-chat` (also re-exported from the root entry). Never add `@mantine/*` under `src/agent/**` — it is diff --git a/packages/basalt-ui/llms.txt b/packages/basalt-ui/llms.txt index 4b35aec..4fcebc5 100644 --- a/packages/basalt-ui/llms.txt +++ b/packages/basalt-ui/llms.txt @@ -121,10 +121,10 @@ OptionalPeers: ai@^7.0.15, use-stick-to-bottom@^1.1.6 ## basalt-ui/agent-chat Import: import { ... } from 'basalt-ui/agent-chat' -Description: Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx. +Description: Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed (variant/renderRow), ThreadFeedRow (inline-expanding Slack row, lazily mounted + kept mounted), ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props, per-message MessageAffordances, groupConsecutive, and an optional virtualize/height windowing mode whose VirtualizeOptions carry overscan/estimateSize/initialScroll — a virtualized transcript opens scrolled to the newest message unless initialScroll is "start"), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx, and @tanstack/react-virtual the same way through the lazy import() behind ThreadTranscript virtualize (absent peer degrades to an unwindowed, height-bound pane). Layer: mantine-coupled Rule: basalt-agent -OptionalPeers: ai@^7.0.15, motion@12.42.0, remend@1.3.0, use-stick-to-bottom@^1.1.6, react-markdown@^10.1.0, remark-gfm@^4.0.1, shiki@^4.3.0, @shikijs/langs@^4.3.1, @shikijs/themes@^4.3.1, beautiful-mermaid@^1.1.0 +OptionalPeers: ai@^7.0.15, motion@12.42.0, remend@1.3.0, use-stick-to-bottom@^1.1.6, react-markdown@^10.1.0, remark-gfm@^4.0.1, shiki@^4.3.0, @shikijs/langs@^4.3.1, @shikijs/themes@^4.3.1, beautiful-mermaid@^1.1.0, @tanstack/react-virtual@>=3 <4 ## basalt-ui/content Import: import { ... } from 'basalt-ui/content' diff --git a/packages/basalt-ui/scripts/export-surface.json b/packages/basalt-ui/scripts/export-surface.json index 97e69dc..1a6693d 100644 --- a/packages/basalt-ui/scripts/export-surface.json +++ b/packages/basalt-ui/scripts/export-surface.json @@ -27,6 +27,7 @@ "ThemeToggle", "ThreadDetailPanel", "ThreadFeed", + "ThreadFeedRow", "ThreadOutcomeCard", "ThreadTranscript", "ThreadWorkspace", @@ -272,6 +273,7 @@ "Composer", "ThreadDetailPanel", "ThreadFeed", + "ThreadFeedRow", "ThreadOutcomeCard", "ThreadTranscript", "ThreadWorkspace", diff --git a/packages/basalt-ui/src/agent-chat/index.ts b/packages/basalt-ui/src/agent-chat/index.ts index 8d8a86a..9de4a19 100644 --- a/packages/basalt-ui/src/agent-chat/index.ts +++ b/packages/basalt-ui/src/agent-chat/index.ts @@ -19,6 +19,18 @@ export type { ThreadWorkspaceProps } from './thread-workspace' export { ThreadFeed } from './thread-feed' export type { ThreadFeedProps } from './thread-feed' +// ── ThreadFeedRow ───────────────────────────────────────────────────────────── +export { ThreadFeedRow } from './thread-feed-row' +export type { ThreadFeedRowProps } from './thread-feed-row' + +// ── Shared transcript/row contracts ─────────────────────────────────────────── +// Type-only. Both are named in the PUBLIC props of components exported above +// (`ThreadTranscriptProps.affordances`, `ThreadFeedRowProps`'s virtualize union), so a consumer +// that wants to hold one in a typed variable needs to be able to name it. No runtime export here — +// the resolution/defaults live inside the components. +export type { MessageAffordances } from './message-affordances' +export type { VirtualizeOptions, VirtualizeProps } from './virtualize' + // ── ThreadOutcomeCard ───────────────────────────────────────────────────────── export { ThreadOutcomeCard } from './thread-outcome-card' export type { ThreadOutcomeCardProps } from './thread-outcome-card' diff --git a/packages/basalt-ui/src/agent-chat/message-affordances.ts b/packages/basalt-ui/src/agent-chat/message-affordances.ts new file mode 100644 index 0000000..d6712c6 --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/message-affordances.ts @@ -0,0 +1,61 @@ +/** + * MessageAffordances — the per-message hover-row/action contract shared by `ThreadTranscript` and + * a virtualized `ThreadFeedRow` (both render `MessageBlock`, so the affordance surface lives here + * rather than being redeclared on each consumer). + * + * Type-only module — the rendering (the actual hover row, copy button, regenerate button) lives in + * `thread-message.tsx`. `MessageAffordances` itself IS public (re-exported, type-only, from the + * agent-chat barrel and the root): it is named in `ThreadTranscriptProps.affordances` and + * `ThreadFeedRowProps.affordances`, so a consumer has to be able to name it. `DEFAULT_AFFORDANCES` + * stays internal — resolving unset fields is the components' job, not the consumer's. + */ +import type { ReactNode } from 'react' +import type { ChatMessage, TranscriptPart } from '../agent' + +export type MessageAffordances = { + /** + * How the per-message timestamp renders. + * @default 'relative' + */ + readonly timestamp?: 'relative' | 'absolute' | 'none' + /** + * Whether a copy action is offered on the hover row. Copies the message's COALESCED text (the + * same merged/de-duplicated text `coalesceParts` produces for display), not the raw `parts` + * array — a message can accumulate multiple adjacent/by-id text parts while streaming, and + * copying the raw parts would either duplicate text or copy fragments the user never saw + * assembled. + * @default true + */ + readonly copy?: boolean + /** + * Regenerate action, shown on the LAST ASSISTANT message only (never on earlier turns, never on + * user messages). Receives that message's `id`. `useAgentThreadRuns`'s `retry` is + * THREAD-keyed (`retry: (threadId: string) => void`), replaying the last user input for the + * whole thread — the consumer bridges this messageId-keyed callback to that thread-keyed retry; + * the framework only ever hands back the messageId, it does not know about threads. + */ + readonly onRegenerate?: (messageId: string) => void + /** + * Extra actions appended to the hover row, after the built-in copy/regenerate actions. + * + * This is a RENDER prop, not an event handler — it is called during render, once per message, + * and its return value is rendered. That means it is treated differently from `onRegenerate`: + * `onRegenerate` is identity-stabilized internally (a fresh inline literal every render costs + * nothing), while `actions` is compared by REFERENCE, because its reference is the only signal + * that its output may have changed. A fresh inline `actions={() => …}` literal therefore + * re-renders every message block on every render of the transcript. That is correct — an action + * whose output depends on consumer state (a pin/star toggle) must be allowed to update — but on + * a long thread it is worth avoiding: wrap `actions` in the consumer's own `useCallback`, keyed + * on whatever state it actually reads. + */ + readonly actions?: (ctx: { readonly message: ChatMessage }) => ReactNode +} + +/** + * Resolved defaults for the optional {@link MessageAffordances} fields — one place, so + * `ThreadTranscript` and a virtualized `ThreadFeedRow` cannot drift on what "unset" means. + */ +export const DEFAULT_AFFORDANCES: Required> = { + timestamp: 'relative', + copy: true, +} diff --git a/packages/basalt-ui/src/agent-chat/relative-time.test.ts b/packages/basalt-ui/src/agent-chat/relative-time.test.ts new file mode 100644 index 0000000..c93f9f8 --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/relative-time.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test' +import { formatRelativeTime } from './relative-time' + +// Unit boundaries (ms) mirrored from relative-time.ts — kept in sync deliberately rather than +// importing the private table, so a drift between the two shows up as a test failure. +const YEAR_MS = 31_536_000_000 +const MONTH_MS = 2_628_000_000 +const WEEK_MS = 604_800_000 +const DAY_MS = 86_400_000 +const HOUR_MS = 3_600_000 +const MINUTE_MS = 60_000 + +describe('formatRelativeTime', () => { + test('sub-minute past → "just now"', () => { + expect(formatRelativeTime(Date.now() - 1)).toBe('just now') + expect(formatRelativeTime(Date.now() - 30_000)).toBe('just now') + expect(formatRelativeTime(Date.now() - (MINUTE_MS - 1))).toBe('just now') + }) + + test('sub-minute future → "just now" (the threshold is symmetric on |diff|)', () => { + expect(formatRelativeTime(Date.now() + 1)).toBe('just now') + expect(formatRelativeTime(Date.now() + (MINUTE_MS - 1))).toBe('just now') + }) + + test('minute boundary', () => { + expect(formatRelativeTime(Date.now() - MINUTE_MS)).toBe('1 minute ago') + }) + + test('hour boundary', () => { + expect(formatRelativeTime(Date.now() - HOUR_MS)).toBe('1 hour ago') + }) + + test('day boundary', () => { + expect(formatRelativeTime(Date.now() - DAY_MS)).toBe('yesterday') + }) + + test('week boundary', () => { + expect(formatRelativeTime(Date.now() - WEEK_MS)).toBe('last week') + }) + + test('month boundary', () => { + expect(formatRelativeTime(Date.now() - MONTH_MS)).toBe('last month') + }) + + test('year boundary', () => { + expect(formatRelativeTime(Date.now() - YEAR_MS)).toBe('last year') + }) + + test('future timestamp: the function handles negative diffMs (diff = timestamp - Date.now())', () => { + // diffMs is positive here (timestamp is ahead of "now"), so Math.round(diffMs / unit.ms) is + // positive too, and Intl.RelativeTimeFormat's 'auto' numeric mode renders the "in X" / "next + // X" / "tomorrow" forms rather than the "X ago" / "last X" forms used for the past. + expect(formatRelativeTime(Date.now() + HOUR_MS)).toBe('in 1 hour') + expect(formatRelativeTime(Date.now() + DAY_MS)).toBe('tomorrow') + expect(formatRelativeTime(Date.now() + WEEK_MS)).toBe('next week') + }) + + test('non-finite/wrong-type inputs degrade to empty string instead of throwing', () => { + // Intl.RelativeTimeFormat.format throws a RangeError on any of these. The pre-fix code called + // it unguarded, so each of the following would have thrown out of formatRelativeTime — except + // `null`, which coerces to 0 via `timestamp - Date.now()` and produced a nonsense-but-non- + // throwing "X years ago" string. The guard now normalizes all six to the same '' result. + expect(formatRelativeTime(NaN)).toBe('') + expect(formatRelativeTime(Infinity)).toBe('') + expect(formatRelativeTime(-Infinity)).toBe('') + // @ts-expect-error — runtime type violation (undefined at a `number`-typed call site), the exact + // shape a `ThreadsStoreAdapter` bug or corrupted localStorage JSON would produce. + expect(formatRelativeTime(undefined)).toBe('') + // @ts-expect-error — runtime type violation (ISO string), the single most likely real-world + // adapter mistake (a server returning `created_at` as a string). + expect(formatRelativeTime('2024-01-01T00:00:00Z')).toBe('') + // @ts-expect-error — runtime type violation (null); previously coerced to 0 rather than + // throwing, now normalized to the same '' as every other non-finite input. + expect(formatRelativeTime(null)).toBe('') + }) + + test('finite-but-absurd timestamps still format instead of throwing', () => { + // Number.isFinite is true for both, so these fall through the new guard unchanged — the guard + // must not over-trigger on merely-extreme (but valid) input. + expect(() => formatRelativeTime(8_640_000_000_000_000)).not.toThrow() // year 275760, JS Date max + expect(() => formatRelativeTime(-8_640_000_000_000_000)).not.toThrow() // symmetric min + expect(() => formatRelativeTime(-1_000_000_000_000)).not.toThrow() // pre-1970 epoch + }) + + test('"at(-1)!" fallback path — documented as effectively unreachable', () => { + // RELATIVE_TIME_UNITS.find(...) ?? RELATIVE_TIME_UNITS.at(-1)! only falls through to the + // fallback when .find returns undefined. The array's last (smallest) entry is 'minute' at + // 60_000ms, exactly the threshold the "just now" early-return already guards below — so for + // every absMs that reaches the .find call (absMs >= 60_000), 'minute' itself always matches + // first-or-later, and the ?? branch is defensive dead code under the current threshold table. + // This test exercises the boundary that the fallback would produce if it were ever reachable + // (it currently is not, verified by construction): the same 'minute' unit as the last table row. + expect(formatRelativeTime(Date.now() - MINUTE_MS)).toBe('1 minute ago') + }) +}) diff --git a/packages/basalt-ui/src/agent-chat/relative-time.ts b/packages/basalt-ui/src/agent-chat/relative-time.ts new file mode 100644 index 0000000..b239818 --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/relative-time.ts @@ -0,0 +1,55 @@ +/** + * formatRelativeTime — dependency-free relative-time formatting (no date-fns). + * + * Shared between `ThreadOutcomeCard` (inbox row timestamps) and `ThreadTranscript` (per-message + * timestamps). 'en'-hardcoded for now — see the module-level note below for the deferred locale + * seam. + * + * Internal to `agent-chat/` — not part of the public barrel. + */ + +const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + +const RELATIVE_TIME_UNITS: readonly { + readonly unit: Intl.RelativeTimeFormatUnit + readonly ms: number +}[] = [ + { unit: 'year', ms: 31_536_000_000 }, + { unit: 'month', ms: 2_628_000_000 }, + { unit: 'week', ms: 604_800_000 }, + { unit: 'day', ms: 86_400_000 }, + { unit: 'hour', ms: 3_600_000 }, + { unit: 'minute', ms: 60_000 }, +] + +/** + * Formats an epoch-ms timestamp as a short relative string ("3 hours ago", "just now"). + * + * A non-finite `timestamp` — `NaN`, `±Infinity`, or a non-number value that slipped past the + * `ChatMessage.createdAt: number` type at runtime (an ISO string or `undefined`/`null` from a + * hand-rolled `ThreadsStoreAdapter`, or from JSON-deserialized `localStorage` state) renders as an + * empty string rather than reaching `Intl.RelativeTimeFormat.format`, which throws a `RangeError` + * on any non-finite input. This is render-path code — one bad `createdAt` on one message must not + * blank the whole transcript (the standing rule `spliceText`/`coalesceParts` already hold) — so it + * degrades instead of throwing, and warns in dev, matching `mergePart`'s clamp-and-warn precedent + * (`clampOffset` in `../agent/merge.ts`). The warning is NOT deduplicated, also matching that + * precedent — but note the call sites differ: `clampOffset` runs once per wire event, whereas this + * runs during render, once per message, so a single bad `createdAt` in a long transcript warns + * once per message per render. That is loud on purpose (a `createdAt` that isn't a number is an + * adapter bug worth fixing, not worth muting), and it costs nothing in production. + */ +export function formatRelativeTime(timestamp: number): string { + if (!Number.isFinite(timestamp)) { + if (process.env['NODE_ENV'] !== 'production') { + console.warn( + `[basalt] formatRelativeTime: non-finite timestamp ${String(timestamp)} — rendering empty string`, + ) + } + return '' + } + const diffMs = timestamp - Date.now() + const absMs = Math.abs(diffMs) + if (absMs < 60_000) return 'just now' + const unit = RELATIVE_TIME_UNITS.find(({ ms }) => absMs >= ms) ?? RELATIVE_TIME_UNITS.at(-1)! + return RELATIVE_TIME_FORMAT.format(Math.round(diffMs / unit.ms), unit.unit) +} diff --git a/packages/basalt-ui/src/agent-chat/thread-feed-row.test.tsx b/packages/basalt-ui/src/agent-chat/thread-feed-row.test.tsx new file mode 100644 index 0000000..edede77 --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/thread-feed-row.test.tsx @@ -0,0 +1,510 @@ +/** + * ThreadFeedRow — the lazy-mount / keep-mounted invariant this component exists to guarantee + * (AGENT-CHAT-SPEC.md §12): never render the transcript before the first expand, never unmount it + * after. The `effectFireCount` probe is what makes the "no effect re-fire" half of that provable — + * a re-mount would bump it, a CSS-only hide/show never does. + */ +import { MantineProvider } from '@mantine/core' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { useEffect } from 'react' +import { definePartRenderers } from '../agent' +import type { + AgentThread, + ChatMessage, + ForeignPart, + PartRenderer, + PartRenderers, + TranscriptPart, +} from '../agent' +import { ThreadFeedRow } from './thread-feed-row' + +afterEach(cleanup) + +function buildThread(parts: TranscriptPart[]): AgentThread { + return { + id: 'thread-1', + messages: [{ id: 'm1', role: 'assistant', parts, createdAt: 0 }], + outcome: { title: 'A resolved thread', summary: 'Summary text', status: 'done' }, + status: 'done', + read: true, + createdAt: 0, + updatedAt: 0, + } +} + +let effectFireCount = 0 + +/** Mounted once per real mount of the subtree it lives in — proves whether a hide/show cycle + * re-mounted it (increments again) or merely hid it with CSS (stays put). */ +function EffectProbe(): null { + useEffect(() => { + effectFireCount += 1 + }, []) + return null +} + +const probeRenderers: PartRenderers = definePartRenderers({ + probe: () => , +}) + +/** Renders whatever `part.type` reached it — used to prove `fallbackRenderer` crossed the row's + * seam into `ThreadTranscript`, since a dropped prop falls back to the built-in dev-only chip + * instead (a different testid, not this one). */ +const rowFallbackRenderer: PartRenderer = ({ part }) => ( + {part.type} +) + +function renderRow(expanded: boolean) { + const thread = buildThread([{ id: 'p1', type: 'probe' }]) + return render( + + {}} + renderers={probeRenderers} + onSend={() => {}} + /> + , + ) +} + +describe('ThreadFeedRow — lazy mount, kept mounted', () => { + test('collapsed initially: the transcript body is not in the DOM', () => { + renderRow(false) + + expect(screen.queryByTestId('thread-feed-row-body')).toBeNull() + }) + + test('expanding mounts the transcript body', () => { + const { rerender } = render( + + {}} + renderers={probeRenderers} + onSend={() => {}} + /> + , + ) + expect(screen.queryByTestId('thread-feed-row-body')).toBeNull() + + rerender( + + {}} + renderers={probeRenderers} + onSend={() => {}} + /> + , + ) + + expect(screen.getByTestId('thread-feed-row-body')).toBeDefined() + }) + + test('collapsing again keeps the subtree mounted, hidden via CSS only', () => { + const thread = buildThread([{ id: 'p1', type: 'probe' }]) + const { rerender } = render( + + {}} + renderers={probeRenderers} + onSend={() => {}} + /> + , + ) + expect(screen.getByTestId('thread-feed-row-body')).toBeDefined() + + rerender( + + {}} + renderers={probeRenderers} + onSend={() => {}} + /> + , + ) + + // Still present — a `queryByTestId`-returns-null assertion here would be the "unmounted" + // behavior this test exists to rule out. + const body = screen.getByTestId('thread-feed-row-body') + expect(body).toBeDefined() + expect(body.style.display).toBe('none') + }) + + test('re-expanding after a collapse does NOT re-fire the subtree effect', () => { + effectFireCount = 0 + const thread = buildThread([{ id: 'p1', type: 'probe' }]) + const props = { + thread, + onToggle: () => {}, + renderers: probeRenderers, + onSend: () => {}, + } as const + + const { rerender } = render( + + + , + ) + expect(effectFireCount).toBe(1) + + rerender( + + + , + ) + rerender( + + + , + ) + + // A re-mount (the defect this invariant guards against) would bump this to 2. + expect(effectFireCount).toBe(1) + }) + + test('clicking the header calls onToggle with the thread id', () => { + // A plain `let` narrows to `null` at the assertion below (TS can't prove the callback ran) — + // a mutable holder object sidesteps that, since object property writes aren't narrowed the + // same way across the closure boundary. + const toggled: { id: string | null } = { id: null } + const thread: AgentThread = { + ...buildThread([{ id: 'p1', type: 'text', text: 'hi' }]), + outcome: null, + } + render( + + { + toggled.id = id + }} + onSend={() => {}} + /> + , + ) + + screen.getByText('Untitled thread') + screen.getByRole('button').click() + + expect(toggled.id).toBe('thread-1') + }) +}) + +// ── The composer half of `liveStatus` ──────────────────────────────────────────────────────── +// `onStop` was documented ("shown as the composer's Stop action while liveStatus === 'streaming'") +// but never forwarded together with the `streaming` flag `Composer` actually gates on +// (`composer.tsx`'s `showStop`/`inputDisabled`) — so Stop never appeared and a second turn could +// still be typed into a live thread. These assert the composer itself reflects `liveStatus`. + +describe('ThreadFeedRow — composer reflects liveStatus', () => { + test('liveStatus="streaming" with onStop shows Stop instead of Send, and calls onStop', () => { + let stops = 0 + render( + + {}} + liveStatus="streaming" + onStop={() => { + stops += 1 + }} + onSend={() => {}} + /> + , + ) + + expect(screen.queryByLabelText('Send message')).toBeNull() + fireEvent.click(screen.getByLabelText('Stop generating')) + expect(stops).toBe(1) + }) + + test('liveStatus="streaming" disables the composer textarea', () => { + render( + + {}} + liveStatus="streaming" + onStop={() => {}} + onSend={() => {}} + /> + , + ) + + expect((screen.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(true) + }) + + test('liveStatus="done" (or unset) shows Send, not Stop, and leaves the textarea enabled', () => { + render( + + {}} + liveStatus="done" + onStop={() => {}} + onSend={() => {}} + /> + , + ) + + expect(screen.getByLabelText('Send message')).toBeDefined() + expect(screen.queryByLabelText('Stop generating')).toBeNull() + expect((screen.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(false) + }) +}) + +// ── The row → transcript seam ──────────────────────────────────────────────────────────────── +// `ThreadFeedRow` and `ThreadTranscript` were built in parallel: the row declared the shared +// contract (`affordances`, and later `groupConsecutive`/`virtualize`) while the transcript grew the +// props that consume it. Each half was correct alone and the pair still compiled with the row +// silently DROPPING all three on the floor — a defect no type or single-component test can see. +// These assert the forwarding itself: every one of them goes red if a prop stops being passed +// through, which is exactly the failure that shipped. + +function threadWith(messages: ChatMessage[]): AgentThread { + return { ...buildThread([]), messages } +} + +const T0 = 1_000_000_000 + +function textMessage( + id: string, + role: ChatMessage['role'], + createdAt: number, +): ChatMessage { + return { id, role, parts: [{ id: `${id}-p1`, type: 'text', text: `body ${id}` }], createdAt } +} + +describe('ThreadFeedRow forwards the transcript contract it declares', () => { + test('affordances reach the row’s messages — a custom action renders inside the body', () => { + render( + + {}} + affordances={{ actions: () => extra }} + onSend={() => {}} + /> + , + ) + + // Dropping the prop leaves the DEFAULT affordance row (timestamp + copy) rendering perfectly — + // which is why the assertion is on the CONSUMER-supplied action, the one thing that cannot + // appear unless `affordances` actually crossed the seam. + expect(screen.getByTestId('row-custom-action')).toBeDefined() + }) + + test('affordances’ onRegenerate reaches the last assistant message', () => { + const regenerated: { id: string | null } = { id: null } + render( + + {}} + affordances={{ + onRegenerate: (id) => { + regenerated.id = id + }, + }} + onSend={() => {}} + /> + , + ) + + screen.getByText('Regenerate').click() + + expect(regenerated.id).toBe('fm2') + }) + + test('liveParts + liveStatus reach the transcript as an in-flight live message', () => { + render( + + {}} + liveParts={[{ id: 'live-p1', type: 'text', text: 'partial reply' }]} + liveStatus="streaming" + onSend={() => {}} + /> + , + ) + + // `ThreadTranscript` synthesizes a `__live__` message from `liveParts` only when it actually + // receives both props — dropping either leaves this testid absent. + expect(screen.getByTestId('agent-message-__live__')).toBeDefined() + expect(screen.getByText('partial reply')).toBeDefined() + }) + + test('renderers reaches the transcript — a registered part type renders the consumer renderer', () => { + const rowRenderers: PartRenderers = definePartRenderers({ + 'row-custom': () => custom, + }) + + render( + + {}} + renderers={rowRenderers} + onSend={() => {}} + /> + , + ) + + expect(screen.getByTestId('row-custom-part')).toBeDefined() + }) + + test('fallbackRenderer reaches the transcript for an unregistered foreign part', () => { + render( + + {}} + fallbackRenderer={rowFallbackRenderer} + onSend={() => {}} + /> + , + ) + + expect(screen.getByTestId('row-fallback-part').textContent).toBe('unregistered-foreign-type') + }) + + test('composerProps reaches the row’s Composer', () => { + render( + + {}} + onSend={() => {}} + composerProps={{ placeholder: 'row-composer-placeholder' }} + /> + , + ) + + expect(screen.getByPlaceholderText('row-composer-placeholder')).toBeDefined() + }) + + test('groupConsecutive={false} reaches the transcript', () => { + const messages = [ + textMessage('fm1', 'assistant', T0), + textMessage('fm2', 'assistant', T0 + 60_000), + ] + + render( + + {}} + onSend={() => {}} + /> + , + ) + // Default (`true`, forwarded as `undefined` and defaulted inside the transcript): the second + // same-role message inside the 5-minute window groups. + expect(screen.getByTestId('agent-message-fm2').getAttribute('data-grouped')).toBe('true') + + // A fresh mount, not a `rerender` — the first tree is torn down here, so the second render + // starts from a clean DOM and `getByTestId` can't match the stale row. + cleanup() + render( + + {}} + groupConsecutive={false} + onSend={() => {}} + /> + , + ) + + expect(screen.getByTestId('agent-message-fm2').getAttribute('data-grouped')).toBe('false') + }) +}) + +describe('ThreadFeedRow forwards virtualize/height to the transcript', () => { + let originalOffsetHeight: PropertyDescriptor | undefined + + beforeEach(() => { + originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight') + // Same reason as thread-message.test.tsx's virtualization block: happy-dom has no layout + // engine, so an unpatched offsetHeight of 0 makes TanStack Virtual compute an empty viewport. + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 300 }) + }) + + afterEach(() => { + if (originalOffsetHeight !== undefined) { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight) + } else { + delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight + } + }) + + test('an expanded row with virtualize windows its thread instead of rendering every message', async () => { + const messages = Array.from({ length: 120 }, (_, i) => + // 10 minutes apart — never groups, keeping this orthogonal to the grouping seam above. + textMessage(`vfm${i}`, i % 2 === 0 ? 'user' : 'assistant', T0 + i * 10 * 60_000), + ) + + const { container } = render( + + {}} + virtualize + height={300} + onSend={() => {}} + /> + , + ) + + // Deliberately asserts WINDOWING, not which end is anchored. `waitFor` also rides out the + // lazy `@tanstack/react-virtual` import: until it resolves, the Suspense fallback renders all + // 120 rows unwindowed — the same count a row that DROPPED `virtualize` would render forever, + // which is what makes the timeout a real failure rather than a slow pass. + await waitFor(() => { + const rendered = container.querySelectorAll('[data-testid^="agent-message-"]').length + expect(rendered).toBeGreaterThan(0) + expect(rendered).toBeLessThan(120) + }) + }) +}) diff --git a/packages/basalt-ui/src/agent-chat/thread-feed-row.tsx b/packages/basalt-ui/src/agent-chat/thread-feed-row.tsx new file mode 100644 index 0000000..fdf77cf --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/thread-feed-row.tsx @@ -0,0 +1,274 @@ +/** + * ThreadFeedRow — the inline-expanding "Slack shape" projection of one `AgentThread`: a header row + * that expands IN PLACE to reveal the thread's live transcript and a composer, instead of opening a + * separate detail pane (compare `ThreadOutcomeCard` + `ThreadDetailPanel`, the inbox variant). + * `ThreadOutcomeCard` is untouched by this — it stays inbox-shaped by design and deliberately never + * renders live text (its module doc, and its `isPreviewing` branch, which shows a skeleton rather + * than the in-flight turn); this is a second, sibling component, not a mode on the first. + * + * LOAD-BEARING INVARIANT — read before touching the expand/collapse mechanics: once a row has been + * expanded for the first time, its transcript+composer subtree mounts LAZILY (nothing renders before + * that first expand) and then STAYS MOUNTED for the rest of the row's lifetime. Collapsing hides it + * with CSS only (`display: none`); it never unmounts. + * + * Do NOT delegate this to Mantine `Collapse`. On the installed `@mantine/core@9.3.0` a bare + * `` happens to do the right thing (children stay mounted, hidden via + * `display: none` from `useCollapse`) — but Mantine master has already flipped the defaults to + * `keepMounted: true` + `keepMountedMode: 'activity'`, which wraps children in React 19 ``. + * `` DESTROYS the subtree's effects and RE-CREATES them on show, so the day + * this repo bumps Mantine, a bare `Collapse` would silently start re-firing every effect in the + * transcript on every re-open — for a streaming transport's subscription/resume effect, a literal + * duplicate stream replay. Own the show/hide here, in plain CSS, so no upstream default change can + * flip the semantics out from under this component. There are two separate guarantees and both are + * required: never render before the first expand (lazy), and never unmount after (kept). + * + * @example + * import { ThreadFeedRow } from 'basalt-ui' + * + * setOpenId((current) => (current === id ? null : id))} + * onSend={(payload) => send(thread.id, payload)} + * /> + */ +import { Box, Group, Stack, Text, UnstyledButton } from '@mantine/core' +import type { JSX } from 'react' +import { useState } from 'react' +import type { + AgentThread, + ForeignPart, + PartRenderer, + PartRenderers, + StreamStatus, + TranscriptPart, +} from '../agent' +import { VX } from '../tokens' +import type { ComposerProps, ComposerSubmit } from './composer' +import { Composer } from './composer' +import type { MessageAffordances } from './message-affordances' +import { formatRelativeTime } from './relative-time' +import { ThreadTranscript } from './thread-message' +import { resolveVirtualize } from './virtualize' +import type { VirtualizeProps } from './virtualize' + +/** A minimal, dependency-free chevron — rotates 90deg when expanded (same inline-svg idiom as + * `composer.tsx`'s `SendGlyph`/`StopGlyph`: no icon-library dependency for a one-off glyph). */ +function ChevronGlyph({ expanded }: { expanded: boolean }): JSX.Element { + return ( + + + + ) +} + +type ThreadFeedRowBase = { + /** + * The thread this row projects. Typed against `AgentThread` — not the plain, + * `AgentPart`-defaulted `AgentThread` `ThreadFeed` uses, and not a type parameter. A consumer + * with a registered `ForeignPart` union passes its own threads straight through with no generic + * argument and no cast, because `TranscriptPart` is the widest part type and arrays are + * structurally covariant: `AgentPart[]` and `ConsumerPart[]` both widen into `TranscriptPart[]`. + * Widening the row is what lets both `ThreadFeed` (non-generic, `AgentThread`) and a + * part-registry consumer feed the same component. + */ + readonly thread: AgentThread + /** Whether this row's transcript+composer body is currently visible. */ + readonly expanded: boolean + /** Called with the thread's id when the header is clicked/activated. */ + readonly onToggle: (id: string) => void + /** The live (in-flight) assistant turn's parts, when a run is streaming for this thread. */ + readonly liveParts?: readonly TranscriptPart[] + /** The live run's status — drives the in-progress indicator on the live block. */ + readonly liveStatus?: StreamStatus + /** Consumer renderers keyed by part.type, forwarded to `ThreadTranscript`. */ + readonly renderers?: PartRenderers + /** Forwarded to `ThreadTranscript` — see its own prop doc. */ + readonly fallbackRenderer?: PartRenderer + /** + * Per-message hover-row contract (timestamp/copy/regenerate/actions), forwarded verbatim to the + * row's `ThreadTranscript`. Shared with it via `message-affordances.ts` so the two cannot drift + * on what an unset field means. + */ + readonly affordances?: MessageAffordances + /** Forwarded to `ThreadTranscript` — suppresses role label + chrome on same-speaker runs. + * @default true */ + readonly groupConsecutive?: boolean + /** Called with the composer's submit payload. */ + readonly onSend: (payload: ComposerSubmit) => void + /** Shown as the composer's Stop action while `liveStatus === 'streaming'`. */ + readonly onStop?: () => void + /** Forwarded to the row's `Composer`, minus the three props this component always wires itself + * (`onSubmit`, `onStop`, and `streaming` — the last derived from `liveStatus` so a consumer can't + * pass one that disagrees with the row's own live status). */ + readonly composerProps?: Omit +} + +/** + * `virtualize`/`height` are forwarded straight to the row's `ThreadTranscript`, carrying the same + * union guard: an inline row holding a very long thread can window it, and doing so REQUIRES a + * `height` (the transcript then owns a fixed-height scroll node inside the expanded body, instead + * of the body growing to the thread's full length). Omit both and the row's transcript is + * content-sized, as before. + */ +export type ThreadFeedRowProps = ThreadFeedRowBase & VirtualizeProps + +/** The row header's title: the resolved outcome title, else a plain placeholder — this row never + * falls back to scanning the first user message the way `ThreadOutcomeCard`'s `promptOf` does, + * since an inline row's own expanded transcript already shows that prompt a scroll away. */ +function rowTitle(thread: AgentThread): string { + return thread.outcome?.title ?? 'Untitled thread' +} + +/** + * One inline-expanding thread row: a header (title + relative timestamp) that toggles a lazily + * mounted, kept-mounted transcript + composer body. See the module doc for the mount lifecycle + * invariant this component exists to guarantee. + * + * @example + * + */ +export function ThreadFeedRow(props: ThreadFeedRowProps): JSX.Element { + const { + thread, + expanded, + onToggle, + liveParts, + liveStatus, + renderers, + fallbackRenderer, + affordances, + groupConsecutive, + onSend, + onStop, + composerProps, + } = props + + // Resolved through the shared narrowing point rather than by destructuring `virtualize`/`height` + // apart — see `resolveVirtualize`'s doc for why the two must be read together. + const virtualized = resolveVirtualize(props) + const virtualizeProps: VirtualizeProps = + virtualized === null ? {} : { virtualize: virtualized.options, height: virtualized.height } + + // Lazy-mount + keep-mounted: flips true on the row's first expand and never resets. Everything + // below this line renders — once — the first time `expanded` becomes true, and stays in the tree + // (hidden via `display` only) for every collapse/expand after that. See the module doc. + // + // Set during render, not a `useEffect` — React's documented "adjusting state as props change" + // pattern (bails out via the `!hasOpened` guard, so this runs at most once per mount). A + // `useEffect` would flip `hasOpened` only AFTER the first expand's render had already committed + // with the body absent, so the header's first expand painted one tick before the body appeared. + // Setting it here makes React restart this render with `hasOpened` already true, so the header + // and the newly-lazy-mounted body commit in the same paint. + const [hasOpened, setHasOpened] = useState(expanded) + if (expanded && !hasOpened) { + setHasOpened(true) + } + + const summary = thread.outcome?.summary ?? '' + + // The composer half of `liveStatus`: `Composer` only shows its Stop action and gates the + // textarea when it is TOLD a run is streaming (`composer.tsx`'s `showStop`/`inputDisabled`) — it + // has no way to infer that from `onStop` alone. Forwarding `onStop` without this was the defect: + // Stop never rendered and a second turn could still be typed into a live thread. + const streaming = liveStatus === 'streaming' + + return ( + + onToggle(thread.id)} + w="100%" + aria-expanded={expanded} + style={{ + display: 'block', + padding: 'var(--mantine-spacing-xs) var(--mantine-spacing-sm)', + }} + > + + + + {rowTitle(thread)} + + {summary.length > 0 && ( + + {summary} + + )} + + + {formatRelativeTime(thread.updatedAt)} + + + + + {hasOpened && ( + + + + + + + )} + + ) +} diff --git a/packages/basalt-ui/src/agent-chat/thread-feed.test.tsx b/packages/basalt-ui/src/agent-chat/thread-feed.test.tsx new file mode 100644 index 0000000..f9deabc --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/thread-feed.test.tsx @@ -0,0 +1,241 @@ +/** + * ThreadFeed — `variant`/`renderRow` (AGENT-CHAT-SPEC.md §12, additive to the existing + * `ThreadOutcomeCard` inbox behaviour). Covers both the reduced-motion and the animated + * (`AnimatePresence`) render branches, since `variant`/`renderRow` have to work identically in + * both — see `thread-feed.tsx`'s module doc for why that split exists in the first place. + */ +import { MantineProvider } from '@mantine/core' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, test } from 'bun:test' +import type { ReactElement } from 'react' +import { useState } from 'react' +import type { AgentThread } from '../agent' +import type { ComposerSubmit } from './composer' +import { ThreadFeed } from './thread-feed' + +afterEach(cleanup) + +function buildThreads(): AgentThread[] { + return [ + { + id: 't1', + messages: [], + outcome: { title: 'First thread', summary: 'Summary one', status: 'done' }, + status: 'done', + read: true, + createdAt: 0, + updatedAt: 0, + }, + ] +} + +/** Temporarily forces `useReducedMotion()`'s result for the duration of `fn` — `@mantine/hooks`' + * `useMediaQuery` reads `window.matchMedia(query).matches` inside its mount effect, which RTL's + * `render` flushes synchronously (wrapped in `act`) before returning. See `use-media-query.mjs`. */ +function withReducedMotion(matches: boolean, fn: () => T): T { + const original = window.matchMedia + window.matchMedia = (query: string): MediaQueryList => + ({ + matches, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList + try { + return fn() + } finally { + window.matchMedia = original + } +} + +function renderFeed(ui: ReactElement) { + return render({ui}) +} + +describe.each([ + ['animated', false], + ['reduced motion', true], +])('ThreadFeed row selection (%s)', (_label, reducedMotion) => { + test('variant "outcome" (default) renders ThreadOutcomeCard unchanged', () => { + withReducedMotion(reducedMotion, () => { + renderFeed( {}} />) + }) + + expect(screen.getByText('First thread')).toBeDefined() + expect(screen.getByText('Summary one')).toBeDefined() + }) + + test('variant "inline" renders ThreadFeedRow instead of ThreadOutcomeCard', () => { + withReducedMotion(reducedMotion, () => { + renderFeed( + {}} + variant="inline" + />, + ) + }) + + // ThreadFeedRow renders the same title text, but as a toggle button (role=button) rather than + // ThreadOutcomeCard's selectable row — the observable difference between the two variants. + expect(screen.getByText('First thread')).toBeDefined() + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) + + test('renderRow overrides BOTH variants', () => { + withReducedMotion(reducedMotion, () => { + renderFeed( + {}} + renderRow={(thread) =>
{thread.id}
} + />, + ) + }) + + expect(screen.getByTestId('custom-t1')).toBeDefined() + // The default outcome-card content must NOT have rendered instead. + expect(screen.queryByText('First thread')).toBeNull() + }) + + test('renderRow overrides the "inline" variant too', () => { + withReducedMotion(reducedMotion, () => { + renderFeed( + {}} + variant="inline" + renderRow={(thread) =>
{thread.id}
} + />, + ) + }) + + expect(screen.getByTestId('custom-t1')).toBeDefined() + expect(screen.queryByRole('button')).toBeNull() + }) +}) + +// ── The inline row's own collapse tracking ─────────────────────────────────────────────────── +// Wiring `onToggle={onSelect}` directly (the previous shape) meant a plain, natural +// `onSelect={setActiveId}` consumer could never collapse a row: re-selecting the same id twice is +// a no-op state update, so `aria-expanded` got stuck at `true` forever. These use exactly that +// natural, non-toggling setter — the trap case — to prove the fix without leaning on a consumer +// that already special-cases re-selection. + +/** A minimal natural consumer: a plain `useState` setter passed straight through as `onSelect`, + * the exact shape the previous wiring broke. */ +function NaiveSingleSelectFeed({ threads }: { threads: AgentThread[] }): ReactElement { + const [activeId, setActiveId] = useState(null) + return ( + + ) +} + +describe('ThreadFeed inline variant — collapse decoupled from onSelect', () => { + test('clicking an unselected row expands it and reports the selection', () => { + render( + + + , + ) + + const header = screen.getByRole('button') + expect(header.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(header) + + expect(header.getAttribute('aria-expanded')).toBe('true') + }) + + test('clicking the already-selected row collapses it — the defect this fix targets', () => { + render( + + + , + ) + + const header = screen.getByRole('button') + fireEvent.click(header) + expect(header.getAttribute('aria-expanded')).toBe('true') + + // The second click, on the SAME row, with a naive `setActiveId` that turns this into a no-op + // state update — this is exactly the click the previous `onToggle={onSelect}` wiring couldn't + // collapse from. + fireEvent.click(header) + + expect(header.getAttribute('aria-expanded')).toBe('false') + }) + + test('re-clicking after a collapse re-expands the same row', () => { + render( + + + , + ) + + const header = screen.getByRole('button') + fireEvent.click(header) // expand + fireEvent.click(header) // collapse + fireEvent.click(header) // expand again + + expect(header.getAttribute('aria-expanded')).toBe('true') + }) +}) + +// ── The built-in inline row's composer: real send channel vs. visibly inert ───────────────── +// `variant='inline'` used to wire the row's composer to a hardcoded no-op — a live, enabled +// textarea + Send button that silently discarded whatever was typed into it. These assert both +// halves of the fix: `onSend` makes it actually work, and omitting `onSend` renders the composer +// disabled instead of a live dead end. + +describe('ThreadFeed inline variant — composer send channel', () => { + test('onSend is called with the thread and the composer payload on submit', () => { + const sent: { thread: AgentThread | null; payload: ComposerSubmit | null } = { + thread: null, + payload: null, + } + const threads = buildThreads() + + render( + + {}} + variant="inline" + onSend={(thread, payload) => { + sent.thread = thread + sent.payload = payload + }} + /> + , + ) + + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + expect(textarea.disabled).toBe(false) + + fireEvent.change(textarea, { target: { value: 'hello there' } }) + fireEvent.click(screen.getByLabelText('Send message')) + + expect(sent.thread?.id).toBe('t1') + expect(sent.payload?.text).toBe('hello there') + }) + + test('without onSend, the composer renders disabled rather than a live no-op', () => { + render( + + {}} variant="inline" /> + , + ) + + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + expect(textarea.disabled).toBe(true) + }) +}) diff --git a/packages/basalt-ui/src/agent-chat/thread-feed.tsx b/packages/basalt-ui/src/agent-chat/thread-feed.tsx index df1ec32..ed4375d 100644 --- a/packages/basalt-ui/src/agent-chat/thread-feed.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-feed.tsx @@ -1,53 +1,156 @@ /** - * ThreadFeed — a scrollable, animated list of `ThreadOutcomeCard` rows for a multi-thread inbox. + * ThreadFeed — a scrollable, animated list of thread rows for a multi-thread inbox. * * Wraps the list in `AnimatePresence` (`popLayout`) so removing/reordering threads reflows the * remaining rows smoothly; each row is a `motion.div` keyed by `thread.id` (never index — index * keys would corrupt the layout animation identity when threads are prepended/removed). Branches * on `useReducedMotion` for a plain, unanimated `Stack`. * + * Row content is picked by `variant` (default `'outcome'`, today's `ThreadOutcomeCard` inbox + * behaviour, unchanged) or `'inline'` (`ThreadFeedRow`, the Slack shape) — or by `renderRow`, which + * takes priority over `variant` entirely when supplied. The built-in `'inline'` row sends through + * `onSend` when supplied; omit it and the row's composer renders visibly disabled rather than + * silently discarding input — see `onSend`'s own doc. + * * @example * import { ThreadFeed } from 'basalt-ui' * * */ -import { ScrollArea, Stack } from '@mantine/core' +import { Box, ScrollArea, Stack } from '@mantine/core' import { useReducedMotion } from '@mantine/hooks' import { AnimatePresence, motion } from 'motion/react' -import type { JSX } from 'react' +import type { JSX, ReactNode } from 'react' +import { useEffect, useState } from 'react' import type { AgentThread } from '../agent' import { MOTION_SPRING } from '../motion' +import type { ComposerSubmit } from './composer' +import { ThreadFeedRow } from './thread-feed-row' import { ThreadOutcomeCard } from './thread-outcome-card' +/** A submit handler for the built-in `'inline'` row when the consumer supplies no `onSend` — paired + * with `composerProps={{ disabled: true }}` so it is unreachable, not silently reachable. See + * `onSend`'s doc for why an inert composer beats a live one that eats input. */ +function noopSend(): void {} + export type ThreadFeedProps = { readonly threads: AgentThread[] /** The currently open thread id, or null when none is selected. */ readonly activeId: string | null - /** Called with a thread's id when its row is selected. */ + /** Called with a thread's id when its row is selected. Never called to report a collapse — see + * `variant`'s doc for how the built-in `'inline'` row's expand/collapse is decoupled from this. */ readonly onSelect: (id: string) => void + /** + * Row shape: `'outcome'` (default) renders today's `ThreadOutcomeCard` inbox row, unchanged. + * `'inline'` renders `ThreadFeedRow`, the Slack-style inline-expanding row, wired to this feed's + * own `activeId` for which row is open (so at most one row is open at a time, mirroring the + * inbox's single-selection model) — with its OWN collapse tracking layered on top, not a raw + * pass-through of `onSelect` as `onToggle`. `onSelect` is documented as a selection event, and + * collapsing a row that remains the active thread isn't one; wiring `onToggle={onSelect}` + * directly (the previous shape) meant a plain `onSelect={setActiveId}` consumer could never + * collapse a row, since re-selecting the same id is a no-op state update. Collapsing here is + * local, UI-only state, reset whenever `activeId` moves to point at a different thread (or is + * re-driven to the same one from outside this component). + */ + readonly variant?: 'outcome' | 'inline' + /** + * Wires the built-in `'inline'` row's composer to a real send channel — called with the thread + * and the composer's submit payload. Omit it and the row's composer renders disabled (visibly + * inert) instead of a live control that silently discards whatever is typed into it; a consumer + * that needs the composer live must supply this (or `renderRow`, for full control over the row). + */ + readonly onSend?: (thread: AgentThread, payload: ComposerSubmit) => void + /** Overrides row rendering entirely, for BOTH variants — when supplied, this is called for every + * thread instead of either built-in row. Gives full control over live-run wiring + * (`onStop`/`liveParts`/`liveStatus`) the built-in `'inline'` row doesn't expose; see `variant` + * and `onSend`. */ + readonly renderRow?: (thread: AgentThread) => ReactNode +} + +function defaultRow( + thread: AgentThread, + variant: 'outcome' | 'inline', + activeId: string | null, + collapsedId: string | null, + onToggle: (id: string) => void, + onSelect: (id: string) => void, + onSend: ((thread: AgentThread, payload: ComposerSubmit) => void) | undefined, +): ReactNode { + if (variant === 'inline') { + const expanded = thread.id === activeId && thread.id !== collapsedId + return ( + onSend(thread, payload) : noopSend} + {...(onSend === undefined ? { composerProps: { disabled: true } } : {})} + /> + ) + } + return ( + onSelect(thread.id)} + /> + ) } /** - * A scrollable feed of thread rows, animated on add/remove/reorder. Each row renders via - * `ThreadOutcomeCard`; selection highlighting is derived from `activeId`. + * A scrollable feed of thread rows, animated on add/remove/reorder. Row content comes from + * `renderRow` when supplied, else from the built-in `variant` row (`ThreadOutcomeCard` or + * `ThreadFeedRow`) — see `ThreadFeedProps` for the full contract. * * @example * select(id)} /> */ -export function ThreadFeed({ threads, activeId, onSelect }: ThreadFeedProps): JSX.Element { +export function ThreadFeed({ + threads, + activeId, + onSelect, + variant = 'outcome', + onSend, + renderRow, +}: ThreadFeedProps): JSX.Element { const reduceMotion = useReducedMotion() + // The inline row's own manual-collapse override — see `ThreadFeedProps.variant`'s doc. Only ever + // meaningful for the id it names; a stale value left over for a since-deselected thread is inert + // (the per-row `expanded` check below always requires `thread.id === activeId` too). + const [collapsedId, setCollapsedId] = useState(null) + // Any externally-driven move of `activeId` — a different thread selected elsewhere, or the SAME + // thread re-selected after being manually collapsed — clears a stale override. A collapse + // triggered by this component's own header click does NOT touch `activeId` at all (see + // `handleInlineToggle`), so it does not re-trigger this effect. + useEffect(() => { + setCollapsedId(null) + }, [activeId]) + + function handleInlineToggle(id: string): void { + const isOpen = id === activeId && id !== collapsedId + if (isOpen) { + // Collapsing the already-selected row is a visual-only action. `onSelect` is documented as a + // SELECTION event; a row that remains the active thread while visually collapsed hasn't had + // its selection change, so this does not call it. + setCollapsedId(id) + return + } + setCollapsedId(null) + onSelect(id) + } + + const rowFor = (thread: AgentThread): ReactNode => + renderRow !== undefined + ? renderRow(thread) + : defaultRow(thread, variant, activeId, collapsedId, handleInlineToggle, onSelect, onSend) + if (reduceMotion) { return ( {threads.map((thread) => ( - onSelect(thread.id)} - /> + {rowFor(thread)} ))} @@ -67,11 +170,7 @@ export function ThreadFeed({ threads, activeId, onSelect }: ThreadFeedProps): JS exit={{ opacity: 0, scale: 0.97 }} transition={MOTION_SPRING} > - onSelect(thread.id)} - /> + {rowFor(thread)} ))} diff --git a/packages/basalt-ui/src/agent-chat/thread-message.test.tsx b/packages/basalt-ui/src/agent-chat/thread-message.test.tsx index 894a90d..30b7b33 100644 --- a/packages/basalt-ui/src/agent-chat/thread-message.test.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-message.test.tsx @@ -7,11 +7,26 @@ * boundary in a controlled render-count harness. */ import { MantineProvider } from '@mantine/core' -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' import { definePartRenderers } from '../agent' import type { ChatMessage, TranscriptPart } from '../agent' -import { messageBlockRenderCounter, ThreadTranscript } from './thread-message' +import { + applyInitialScroll, + MAX_INITIAL_SCROLL_ATTEMPTS, + messageBlockRenderCounter, + nonVirtualizedRowsFallback, + resolveGuardedMeasurement, + resolveInitialScrollAction, + ThreadTranscript, +} from './thread-message' +import type { InitialScrollState } from './thread-message' + +// `nonVirtualizedRowsFallback` is a plain function component but its export name (this module's +// test-only-escape-hatch convention, matching `messageBlockRenderCounter`) is lowercase-first, so +// JSX would treat `` as a native DOM tag rather than a component +// reference. Alias it to a capitalized local binding for JSX use below. +const VirtualizeFallback = nonVirtualizedRowsFallback afterEach(cleanup) @@ -40,6 +55,22 @@ function buildMessages(count: number): ChatMessage[] { }) } +/** Stubs `navigator.clipboard.writeText`, returning the array of copied strings. happy-dom does + * not implement the Clipboard API at all. */ +function stubClipboard(): string[] { + const calls: string[] = [] + Object.defineProperty(window.navigator, 'clipboard', { + configurable: true, + value: { + writeText: (text: string) => { + calls.push(text) + return Promise.resolve() + }, + }, + }) + return calls +} + describe('resolution order', () => { test('a registered consumer renderer wins over the built-in union', () => { const renderers = definePartRenderers({ @@ -348,4 +379,907 @@ describe('MessageBlock memoization (AGENT-CHAT-SPEC.md §9)', () => { expect(messageBlockRenderCounter.count).toBe(1) }) + + test('flipping `groupConsecutive` re-renders only the messages whose `grouped` value actually flips', () => { + const t0 = 1_000_000_000 + const messages: ChatMessage[] = [ + // No predecessor — `grouped` is false either way, this block must NOT re-render. + { id: 'g0', role: 'user', parts: [{ id: 'g0-p1', type: 'text', text: 'q' }], createdAt: t0 }, + // Different-role predecessor — `grouped` is false either way, must NOT re-render. + { + id: 'g1', + role: 'assistant', + parts: [{ id: 'g1-p1', type: 'text', text: 'a' }], + createdAt: t0 + 1_000, + }, + // Same-role, 1 minute after g1 — `grouped` flips true -> false. The ONLY block that must + // re-render. + { + id: 'g2', + role: 'assistant', + parts: [{ id: 'g2-p1', type: 'text', text: 'b' }], + createdAt: t0 + 60_000, + }, + ] + + const { rerender } = render( + + + , + ) + messageBlockRenderCounter.count = 0 + + // Same `messages` array + every message object reference unchanged — only the boolean prop + // flips. + rerender( + + + , + ) + + expect(messageBlockRenderCounter.count).toBe(1) + }) + + test('a consumer passing a fresh `affordances` object literal each render does not force a re-render', () => { + const messages = buildMessages(10) + const { rerender } = render( + + + , + ) + messageBlockRenderCounter.count = 0 + + // A BRAND NEW object literal, same field values, same `messages` reference — nothing has + // actually changed from any consumer's point of view. + rerender( + + + , + ) + + expect(messageBlockRenderCounter.count).toBe(0) + }) +}) + +function singleMessage(createdAt: number): ChatMessage[] { + return [ + { + id: 'aff1', + role: 'assistant', + parts: [{ id: 'aff1-p1', type: 'text', text: 'hello there' }], + createdAt, + }, + ] +} + +/** Stands in for a consumer's own `useCallback`-wrapped `actions` — see the memo-bail-out test. */ +const STABLE_ACTIONS = (): null => null + +describe('per-message affordances (AGENT-CHAT-SPEC.md §11)', () => { + test('timestamp: relative (the default) renders a relative label', () => { + render( + + + , + ) + + expect(within(screen.getByTestId('message-affordances-aff1')).getByText(/ago/)).toBeDefined() + }) + + test('timestamp: absolute renders a locale date/time string, not a relative one', () => { + const createdAt = new Date('2024-01-01T12:00:00Z').getTime() + render( + + + , + ) + + const row = within(screen.getByTestId('message-affordances-aff1')) + expect(row.getByText(new Date(createdAt).toLocaleString())).toBeDefined() + expect(row.queryByText(/ago/)).toBeNull() + }) + + test('timestamp: none renders no timestamp text at all', () => { + render( + + + , + ) + + expect(within(screen.getByTestId('message-affordances-aff1')).queryByText(/ago/)).toBeNull() + }) + + test('copy copies the message COALESCED text, not a naive join of the raw parts', async () => { + const calls = stubClipboard() + const messages: ChatMessage[] = [ + { + id: 'aff2', + role: 'assistant', + parts: [ + { id: 'p1', type: 'text', text: 'Hello' }, + // A source part splits the run — a non-text part sitting between two text parts. Naive + // `parts.map(p => p.text).join('')` would blow up on `.text` not existing here; the + // coalesced-segment walk skips it and joins the two text runs with a blank line. + { id: 'p2', type: 'source', url: 'https://example.com' }, + { id: 'p3', type: 'text', text: 'World' }, + ], + createdAt: Date.now(), + }, + ] + + render( + + + , + ) + + fireEvent.click(screen.getByLabelText('Copy message')) + + await waitFor(() => expect(calls).toEqual(['Hello\n\nWorld'])) + }) + + test('regenerate appears on the LAST assistant message only, and calls back with its id', () => { + const calls: string[] = [] + const now = Date.now() + const messages: ChatMessage[] = [ + { id: 'u1', role: 'user', parts: [{ id: 'u1-p1', type: 'text', text: 'q' }], createdAt: now }, + { + id: 'a1', + role: 'assistant', + parts: [{ id: 'a1-p1', type: 'text', text: 'first reply' }], + createdAt: now + 1_000, + }, + { + id: 'u2', + role: 'user', + parts: [{ id: 'u2-p1', type: 'text', text: 'q2' }], + createdAt: now + 2_000, + }, + { + id: 'a2', + role: 'assistant', + parts: [{ id: 'a2-p1', type: 'text', text: 'second reply' }], + createdAt: now + 3_000, + }, + ] + + render( + + calls.push(messageId) }} + /> + , + ) + + expect(screen.getAllByText('Regenerate')).toHaveLength(1) + const lastRow = within(screen.getByTestId('message-affordances-a2')) + fireEvent.click(lastRow.getByText('Regenerate')) + expect(calls).toEqual(['a2']) + }) + + test('custom actions render alongside the built-in affordances, on every message', () => { + render( + + , + }} + /> + , + ) + + expect(screen.getByText('pin-aff1')).toBeDefined() + }) + + test('the live/streaming message never renders an affordance row', () => { + render( + + + , + ) + + expect(screen.queryByTestId('message-affordances-__live__')).toBeNull() + expect(screen.queryByLabelText('Copy message')).toBeNull() + }) + + test('renders no affordance row at all when every affordance resolves to off', () => { + render( + + + , + ) + + // No onRegenerate (so no regenerate control, even though this IS the last assistant message) + // and no custom `actions` either — nothing for the row to show, so `MessageAffordanceRow` + // renders `null` rather than an empty strip. + expect(screen.queryByTestId('message-affordances-aff1')).toBeNull() + }) + + test('a fresh onRegenerate literal each render does not force a re-render either', () => { + const messages = buildMessages(10) + const { rerender } = render( + + id }} /> + , + ) + messageBlockRenderCounter.count = 0 + + // A brand-new handler literal every render (the realistic consumer shape this finding names) — + // `useStableCallback` must absorb this so `resolvedAffordances`'s reference stays put. Note + // this covers `onRegenerate` ONLY: `actions` is a render prop and is deliberately compared by + // reference (see the two tests below, and `MessageAffordances.actions`'s own doc). + rerender( + + id }} /> + , + ) + + expect(messageBlockRenderCounter.count).toBe(0) + }) + + test('a consumer `actions` render prop reflects the consumer state it closes over', () => { + // The counterweight to the test above, and the reason `actions` is exempt from + // `useStableCallback`: `actions` is invoked DURING render to produce nodes, so identity- + // stabilizing it would freeze `resolvedAffordances` → `settledRows` → the very JSX element + // objects React reconciles, and the consumer's control would render its first-render output + // for the transcript's whole lifetime. This is the shape any real pin/star/bookmark action + // takes. + const messages = singleMessage(Date.now()) + const { rerender } = render( + + pinned: no }} + /> + , + ) + + expect(screen.getByTestId('pin').textContent).toBe('pinned: no') + + rerender( + + pinned: yes }} + /> + , + ) + + expect(screen.getByTestId('pin').textContent).toBe('pinned: yes') + }) + + test('a REFERENCE-STABLE `actions` still gets the memo bail-out', () => { + // The documented opt-out: a consumer that wraps `actions` in its own `useCallback` (modelled + // here by a module-scope constant) keeps the whole `affordances`-by-reference bail-out, so + // exempting `actions` costs nothing for a consumer that cares about it. + const messages = buildMessages(10) + const { rerender } = render( + + + , + ) + messageBlockRenderCounter.count = 0 + + rerender( + + + , + ) + + expect(messageBlockRenderCounter.count).toBe(0) + }) +}) + +describe('affordance row visibility — hover OR focus-within (a11y)', () => { + test('a descendant holding focus reveals the row, not just mouse hover', () => { + render( + + + , + ) + + const row = screen.getByTestId('message-affordances-aff1') + expect(row.style.opacity).toBe('0') + + // The row stays mounted regardless of visibility (see `MessageAffordanceRowProps.visible`'s + // doc), so its Copy control is focusable even before hover/focus reveals it. `focusIn` (not + // `focus`, which does not bubble) is what `useFocusWithin`'s underlying `addEventListener` + // actually listens for — matching how a real Tab keypress reaches focus in the browser. + fireEvent.focusIn(screen.getByLabelText('Copy message')) + + expect(row.style.opacity).toBe('1') + + fireEvent.focusOut(screen.getByLabelText('Copy message')) + + expect(row.style.opacity).toBe('0') + }) +}) + +describe('groupConsecutive — the Slack rhythm (AGENT-CHAT-SPEC.md §11, default true)', () => { + const T0 = 1_000_000_000 + + function pair( + deltaMs: number, + roleA: ChatMessage['role'] = 'assistant', + roleB: ChatMessage['role'] = 'assistant', + ): ChatMessage[] { + return [ + { + id: 'gp1', + role: roleA, + parts: [{ id: 'gp1-p1', type: 'text', text: 'first' }], + createdAt: T0, + }, + { + id: 'gp2', + role: roleB, + parts: [{ id: 'gp2-p1', type: 'text', text: 'second' }], + createdAt: T0 + deltaMs, + }, + ] + } + + test('same role within 5 minutes groups — the second message drops its role label and chrome', () => { + render( + + + , + ) + + expect(screen.getAllByText('Assistant')).toHaveLength(1) + expect(screen.getByTestId('agent-message-gp2').getAttribute('data-grouped')).toBe('true') + }) + + test('same role at exactly the 5-minute boundary still groups', () => { + render( + + + , + ) + + expect(screen.getAllByText('Assistant')).toHaveLength(1) + }) + + test('same role at 5 minutes + 1ms does NOT group', () => { + render( + + + , + ) + + expect(screen.getAllByText('Assistant')).toHaveLength(2) + expect(screen.getByTestId('agent-message-gp2').getAttribute('data-grouped')).toBe('false') + }) + + test('different roles never group, regardless of how close in time', () => { + render( + + + , + ) + + expect(screen.getByText('You')).toBeDefined() + expect(screen.getByText('Assistant')).toBeDefined() + }) + + test('the first message never groups — there is no predecessor', () => { + render( + + + , + ) + + expect(screen.getByTestId('agent-message-gp1').getAttribute('data-grouped')).toBe('false') + }) + + test('groupConsecutive={false} disables grouping entirely', () => { + render( + + + , + ) + + expect(screen.getAllByText('Assistant')).toHaveLength(2) + }) +}) + +/** A minimal fake matching `MeasurementCacheHost`'s structural shape — no ResizeObserver, no + * layout engine, no `@tanstack/react-virtual` import at all needed to prove the guard branch. */ +function fakeMeasurementHost({ + cached, + estimate = 999, +}: { readonly cached?: number; readonly estimate?: number } = {}) { + const itemSizeCache = new Map() + if (cached !== undefined) itemSizeCache.set('row-key', cached) + return { + indexFromElement: () => 0, + itemSizeCache, + options: { + getItemKey: () => 'row-key', + estimateSize: () => estimate, + }, + } +} + +describe('resolveGuardedMeasurement — a 0 ResizeObserver reading is never a real row measurement', () => { + const element = document.createElement('div') + + test('a genuine (non-zero) measurement passes through unchanged', () => { + const host = fakeMeasurementHost({ cached: 40 }) + expect(resolveGuardedMeasurement(host, element, 145)).toBe(145) + }) + + test('a 0 reading with a cached size falls back to the LAST-KNOWN size, not 0 — this is the exact mechanism that stops a collapsed virtualized row from poisoning itemSizeCache', () => { + const host = fakeMeasurementHost({ cached: 220 }) + expect(resolveGuardedMeasurement(host, element, 0)).toBe(220) + }) + + test('a 0 reading with no prior measurement (first mount) falls back to estimateSize, not 0', () => { + const host = fakeMeasurementHost({ estimate: 160 }) + expect(resolveGuardedMeasurement(host, element, 0)).toBe(160) + }) +}) + +describe('resolveInitialScrollAction — the pure decision behind "a virtualized transcript opens at the newest message"', () => { + test('"start" resolves to skip-done regardless of row count or container height', () => { + expect(resolveInitialScrollAction('start', 50, 300)).toBe('skip-done') + expect(resolveInitialScrollAction('start', 0, 0)).toBe('skip-done') + }) + + test('an empty transcript resolves to skip-done even when targeting "end" — nothing to scroll to', () => { + expect(resolveInitialScrollAction('end', 0, 300)).toBe('skip-done') + }) + + test('a non-positive container height resolves to skip-retry, NOT skip-done — a hidden ancestor must not permanently strand the transcript at the top', () => { + expect(resolveInitialScrollAction('end', 50, 0)).toBe('skip-retry') + expect(resolveInitialScrollAction('end', 50, -1)).toBe('skip-retry') + }) + + test('a genuinely measured, non-empty, "end"-targeted mount resolves to scroll', () => { + expect(resolveInitialScrollAction('end', 50, 300)).toBe('scroll') + }) +}) + +describe('applyInitialScroll — the ACROSS-CALLS guard that makes the jump fire once, ever', () => { + // A fake `scrollToEnd` is the boundary here rather than a real DOM `Element.scrollTo` spy on + // purpose: a real `Virtualizer`'s own `followOnAppend` ALSO calls `scrollTo` on a later append + // (verified against the installed virtual-core 3.17.1 — `setOptions`'s edge-key-change branch), + // making that call indistinguishable from this effect's own at the DOM boundary. `applyInitialScroll` + // is the exact function `VirtualizedRowsInner`'s effect calls, so this proves the real guard, not + // a hand-mirrored copy of it. `isAtEnd` is likewise a fake rather than a real DOM `scrollTop` read + // — this describe block proves the STATE MACHINE (fire → verify → settle-or-retry, bounded), not + // the real browser race the fix exists for, which happy-dom cannot observe (no layout engine — + // see the `initialScroll` describe block below for what IS provable against the real component). + + test('fires once, stays unsettled until a later call confirms it via isAtEnd, then never fires again', () => { + const state: InitialScrollState = { hasApplied: false, attempts: 0 } + const scrollToEnd = mock(() => {}) + const isAtEnd = mock(() => true) + + applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(1) + // Not yet permanent: firing is not landing. Verification happens on the NEXT call. + expect(state.hasApplied).toBe(false) + + // Simulates the effect re-running on every later streamed append (rows.length keeps growing) + // — the exact scenario that must NOT re-trigger the jump once it has settled. + applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd) + expect(state.hasApplied).toBe(true) + + applyInitialScroll(state, 'end', 62, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(1) + }) + + test('re-fires when isAtEnd reports the jump was reverted before settling — the clobber-recovery path', () => { + const state: InitialScrollState = { hasApplied: false, attempts: 0 } + const scrollToEnd = mock(() => {}) + let atEnd = false + const isAtEnd = () => atEnd + + // First attempt: fires, unconfirmed. + applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(1) + + // Next commit: the DOM reports NOT at the end (something else clobbered `scrollTop` in the + // meantime, e.g. virtual-core's own `_willUpdate` anchor branch) — re-fires rather than + // accepting the reverted position as final. + applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(2) + expect(state.hasApplied).toBe(false) + + // The DOM now holds: settles on the next confirming call. + atEnd = true + applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(2) + expect(state.hasApplied).toBe(true) + + // A further call must not fire again. + applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(2) + }) + + test('gives up after MAX_INITIAL_SCROLL_ATTEMPTS rather than retrying forever against a DOM that never confirms', () => { + const state: InitialScrollState = { hasApplied: false, attempts: 0 } + const scrollToEnd = mock(() => {}) + // never confirms — the pathological case this bound exists for + const isAtEnd = mock(() => false) + + for (let i = 0; i < 20; i += 1) { + applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd) + } + + expect(state.hasApplied).toBe(true) + expect(scrollToEnd).toHaveBeenCalledTimes(MAX_INITIAL_SCROLL_ATTEMPTS) + + // Once given up, further calls are true no-ops. + applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(MAX_INITIAL_SCROLL_ATTEMPTS) + }) + + test('"start" marks done WITHOUT ever calling scrollToEnd — mutation-proof for the skip-done branch not silently becoming skip-retry', () => { + const state: InitialScrollState = { hasApplied: false, attempts: 0 } + const scrollToEnd = mock(() => {}) + const isAtEnd = mock(() => false) + + applyInitialScroll(state, 'start', 60, () => 300, scrollToEnd, isAtEnd) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(isAtEnd).not.toHaveBeenCalled() + expect(state.hasApplied).toBe(true) + }) + + test('an empty transcript marks done WITHOUT ever calling scrollToEnd', () => { + const state: InitialScrollState = { hasApplied: false, attempts: 0 } + const scrollToEnd = mock(() => {}) + const isAtEnd = mock(() => false) + + applyInitialScroll(state, 'end', 0, () => 300, scrollToEnd, isAtEnd) + + expect(scrollToEnd).not.toHaveBeenCalled() + expect(isAtEnd).not.toHaveBeenCalled() + expect(state.hasApplied).toBe(true) + }) + + test('a hidden (0px) container retries on a later call instead of being permanently stranded, then settles once confirmed', () => { + const state: InitialScrollState = { hasApplied: false, attempts: 0 } + const scrollToEnd = mock(() => {}) + const isAtEnd = mock(() => true) + + // First attempt: mounted behind a `display: none` ancestor — measures 0. + applyInitialScroll(state, 'end', 60, () => 0, scrollToEnd, isAtEnd) + expect(scrollToEnd).not.toHaveBeenCalled() + expect(state.hasApplied).toBe(false) + + // Second attempt — the row was re-expanded, so the SAME row count now measures. Deliberately + // NOT a changed count: the commit that un-hides a `ThreadFeedRow` body changes nothing the + // effect could key a dependency array on, which is why that effect has none. Fires, unconfirmed. + applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(1) + expect(state.hasApplied).toBe(false) + + // Third attempt (next commit): confirmed settled. + applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(1) + expect(state.hasApplied).toBe(true) + + // A fourth attempt must not fire again. + applyInitialScroll(state, 'end', 62, () => 300, scrollToEnd, isAtEnd) + expect(scrollToEnd).toHaveBeenCalledTimes(1) + }) + + test('the container-height read happens at most once per mount — verification never touches it, only isAtEnd', () => { + const state: InitialScrollState = { hasApplied: false, attempts: 0 } + const scrollToEnd = mock(() => {}) + const getContainerHeight = mock(() => 300) + const isAtEnd = mock(() => true) + + applyInitialScroll(state, 'end', 60, getContainerHeight, scrollToEnd, isAtEnd) + expect(getContainerHeight).toHaveBeenCalledTimes(1) + expect(isAtEnd).not.toHaveBeenCalled() + + // Verification call — reads `isAtEnd`, not `getContainerHeight` (no forced `offsetHeight` + // layout once the row count / container height have already done their one job). + applyInitialScroll(state, 'end', 61, getContainerHeight, scrollToEnd, isAtEnd) + expect(getContainerHeight).toHaveBeenCalledTimes(1) + expect(isAtEnd).toHaveBeenCalledTimes(1) + expect(state.hasApplied).toBe(true) + + // Every later commit (one per streamed chunk on a live thread) re-runs the dependency-free + // effect — a settled transcript must touch neither thunk. + applyInitialScroll(state, 'end', 62, getContainerHeight, scrollToEnd, isAtEnd) + expect(getContainerHeight).toHaveBeenCalledTimes(1) + expect(isAtEnd).toHaveBeenCalledTimes(1) + }) +}) + +function manyMessages(count: number): ChatMessage[] { + const t0 = 1_000_000_000 + return Array.from({ length: count }, (_, i) => ({ + id: `vm${i}`, + role: i % 2 === 0 ? 'user' : 'assistant', + // 10 minutes apart — never groups, keeping this describe block orthogonal to grouping. + parts: [{ id: `vm${i}-p1`, type: 'text', text: `message ${i}` }], + createdAt: t0 + i * 10 * 60_000, + })) +} + +// `virtual-core` itself unconditionally calls `scrollTo({ top: 0, behavior: undefined })` the +// moment ANY virtualizer first attaches its scroll element (`_willUpdate`'s +// `this.scrollElement !== scrollElement` branch, verified against the installed 3.17.1 source) — +// that call exists with or without this feature and is not evidence of anything. The initial-scroll +// effect's own call is `scrollToEnd({ behavior: 'auto' })`, so an explicit `behavior: 'auto'` +// argument is what's genuinely diagnostic — and, on the very FIRST mount (before any append has +// ever run `setOptions` again), it can only have come from that effect: `followOnAppend`'s own +// `behavior: 'auto'` re-scroll is gated on `prevOptions !== undefined`, i.e. it never fires on +// construction. (A later append's `followOnAppend` call is indistinguishable from the effect's own +// at this DOM boundary — the `applyInitialScroll` describe block above proves the once-ever +// guarantee with a fake `scrollToEnd` instead, precisely because of that ambiguity.) +// Typed structurally rather than as `ReturnType`: `spyOn` is generic over the object +// and key being spied on, so referencing its return type with the type arguments unresolved leaves +// `mock.calls` uninferrable and every callback parameter an implicit `any` (a tsc error under this +// package's strict config — `bun test` alone never sees it). +function autoBehaviorCalls(spy: { + readonly mock: { readonly calls: readonly unknown[][] } +}): number { + return spy.mock.calls.filter( + (call) => (call[0] as { behavior?: string } | undefined)?.behavior === 'auto', + ).length +} + +describe('virtualization (AGENT-CHAT-SPEC.md §9)', () => { + let originalOffsetHeight: PropertyDescriptor | undefined + + beforeEach(() => { + originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight') + // happy-dom has no layout engine — every element's offsetHeight is always 0, which makes + // TanStack Virtual compute a zero-height viewport and render nothing at all. A fixed value + // gives it a real, deterministic non-degenerate viewport to compute a visible window against. + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 300 }) + }) + + afterEach(() => { + if (originalOffsetHeight !== undefined) { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight) + } else { + delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight + } + }) + + test('virtualize renders a windowed subset of a large thread, once the lazy import resolves', async () => { + const messages = manyMessages(200) + const { container } = render( + + + , + ) + + // The Suspense fallback (`VirtualizeSuspenseFallback`) is an EMPTY placeholder with no row + // content at all, so waiting for ANY rendered row can only resolve once the lazy + // `@tanstack/react-virtual` import has settled and the real virtualizer has mounted — unlike + // the assertion this replaces (`await screen.findByText('message 199')`), which resolved from + // the OLD full-row-tree fallback at SYNCHRONOUS tick 0, before any `await`, regardless of + // whether the import ever resolved or the virtualizer ever ran. + // + // Which row lands first is deliberately NOT asserted: happy-dom has no layout/scroll engine + // (every element's `scrollHeight`/`offsetHeight` beyond the stubbed constant above is 0), so + // `anchorTo: 'end'` cannot be proven in this harness — observed locally, the virtualizer mounts + // starting at index 0, not the tail, here. That behaviour remains unverified until the browser + // gate; this test only proves the async mount + windowing, not the anchor direction. + await waitFor(() => { + const renderedCount = container.querySelectorAll('[data-testid^="agent-message-"]').length + expect(renderedCount).toBeGreaterThan(0) + expect(renderedCount).toBeLessThan(200) + }) + }) + + test('virtualize keeps windowing with an active live/streaming turn appended', async () => { + const messages = manyMessages(80) + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(container.querySelectorAll('[data-testid^="agent-message-"]').length).toBeGreaterThan( + 0, + ) + }) + + // `anchorTo: 'end'` cannot be relied on in happy-dom (no layout/scroll engine — see the + // previous test's comment), so the live block does not necessarily paint on its own. This + // proves the settled/live SPLIT (the `settledRows`/`liveRow`/`rows` memos from finding #2) still + // hands the virtualizer one COMBINED row set that genuinely includes the live block, by + // manually scrolling the pane to its reported end and confirming the live message is what + // renders there — not that a live message got dropped when it was pulled out of the settled + // memo. + const scrollElement = container.querySelector('div[style*="overflow: auto"]') + if (scrollElement === null) + throw new Error('expected the virtualizer scroll container to exist') + ;(scrollElement as HTMLElement).scrollTop = Number.MAX_SAFE_INTEGER + fireEvent.scroll(scrollElement) + + await waitFor(() => { + expect(screen.queryByText('streaming reply')).not.toBeNull() + }) + + // 80 settled + 1 live = 81 total; still windowed even scrolled to the tail. + const renderedCount = container.querySelectorAll('[data-testid^="agent-message-"]').length + expect(renderedCount).toBeGreaterThan(0) + expect(renderedCount).toBeLessThan(81) + }) + + test('the non-virtual path is unchanged — every message renders, unwindowed', () => { + const messages = manyMessages(12) + const { container } = render( + + + , + ) + + expect(container.querySelectorAll('[data-testid^="agent-message-"]').length).toBe(12) + }) + + test('the optional-peer-absent degrade target renders every row unwindowed, and never throws', () => { + const rows = Array.from({ length: 30 }, (_, i) => ({ + key: `fallback-row-${i}`, + node:
{`row ${i}`}
, + })) + + let container: HTMLElement | undefined + expect(() => { + ;({ container } = render( + + + , + )) + }).not.toThrow() + + expect(container).toBeDefined() + expect(container?.querySelectorAll('[data-testid^="fallback-row-"]').length).toBe(30) + expect(screen.getByTestId('fallback-row-0')).toBeDefined() + expect(screen.getByTestId('fallback-row-29')).toBeDefined() + }) + + // happy-dom has no scroll engine, so scroll POSITION can't be asserted (see the module-level + // comment on the first test above). What CAN be asserted, genuinely, is the SCROLL INTENT: a + // spy on `Element.prototype.scrollTo` — the exact DOM entry point `virtual-core`'s `elementScroll` + // calls at the bottom of `scrollToEnd()` (verified against the installed 3.17.1 source; happy-dom + // implements `scrollTo` as a real, synchronous `scrollTop` write for the `'auto'`/default + // behavior this effect uses, so the spy observes a genuine call, not a no-op the DOM silently + // swallows). This is the boundary the brief asks for: a fake/spy at the edge, not a pretended + // observation of scroll position. + describe('the default open-at-the-newest-message jump (VirtualizeOptions.initialScroll)', () => { + test('fires once the lazy virtualizer mounts', async () => { + const scrollToSpy = spyOn(Element.prototype, 'scrollTo') + const messages = manyMessages(60) + const { container } = render( + + + , + ) + + await waitFor(() => { + expect( + container.querySelectorAll('[data-testid^="agent-message-"]').length, + ).toBeGreaterThan(0) + }) + expect(autoBehaviorCalls(scrollToSpy)).toBeGreaterThan(0) + + scrollToSpy.mockRestore() + }) + + test('a virtualizer that mounted behind a hidden ancestor lands the jump on the commit that makes it measurable', async () => { + // The reachable version of `virtualize.ts`'s third+fourth composition rules meeting: the + // lazy `import('@tanstack/react-virtual')` settles ASYNCHRONOUSLY, so a `ThreadFeedRow` + // collapsed during that window mounts `VirtualizedRowsInner` behind `display: none` — every + // measurement reads 0, and the commit that later un-hides the body changes neither the row + // count nor the virtualizer identity. A dependency-keyed effect never re-runs for that + // commit, which stranded the transcript at message #0 for the rest of the row's life. + // happy-dom has no per-element layout to hide, so the describe block's 300px prototype stub + // is overridden to 0 to reach the same state, then restored to stand in for the re-expand. + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 0 }) + const scrollToSpy = spyOn(Element.prototype, 'scrollTo') + const messages = manyMessages(60) + // A FRESH element on every render pass: React bails out of re-rendering a subtree whose + // element object is reference-identical to the previous one, which would skip the very + // commit this test is about. + const tree = () => ( + + + + ) + const { container, rerender } = render(tree()) + + // A 0px viewport windows to no rows at all, so wait on the real virtualizer's sizer instead + // (same signal the empty-transcript test below uses). + await waitFor(() => { + expect(container.querySelector('div[style*="overflow: auto"] > div')).not.toBeNull() + }) + expect(autoBehaviorCalls(scrollToSpy)).toBe(0) + + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 300, + }) + rerender(tree()) + + expect(autoBehaviorCalls(scrollToSpy)).toBeGreaterThan(0) + + scrollToSpy.mockRestore() + }) + + test('initialScroll: "start" suppresses the jump entirely', async () => { + const scrollToSpy = spyOn(Element.prototype, 'scrollTo') + const messages = manyMessages(200) + const { container } = render( + + + , + ) + + await waitFor(() => { + expect( + container.querySelectorAll('[data-testid^="agent-message-"]').length, + ).toBeGreaterThan(0) + }) + expect(autoBehaviorCalls(scrollToSpy)).toBe(0) + + scrollToSpy.mockRestore() + }) + + test('an empty transcript never scrolls, and never throws', async () => { + const scrollToSpy = spyOn(Element.prototype, 'scrollTo') + + let container: HTMLElement | undefined + expect(() => { + ;({ container } = render( + + + , + )) + }).not.toThrow() + + // No message rows ever exist to wait on, so instead wait for the REAL virtualizer's nested + // sizer `Box` (present only once `LazyVirtualizedRows` — not `VirtualizeSuspenseFallback`, + // which renders a single childless pane — has mounted). + await waitFor(() => { + expect(container?.querySelector('div[style*="overflow: auto"] > div')).not.toBeNull() + }) + expect(autoBehaviorCalls(scrollToSpy)).toBe(0) + + scrollToSpy.mockRestore() + }) + }) }) diff --git a/packages/basalt-ui/src/agent-chat/thread-message.tsx b/packages/basalt-ui/src/agent-chat/thread-message.tsx index 81be880..e77ab14 100644 --- a/packages/basalt-ui/src/agent-chat/thread-message.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-message.tsx @@ -29,8 +29,8 @@ import { Text, UnstyledButton, } from '@mantine/core' -import { useDisclosure } from '@mantine/hooks' -import { Fragment, memo, useMemo } from 'react' +import { useDisclosure, useFocusWithin, useHover, useMergedRef } from '@mantine/hooks' +import { Fragment, lazy, memo, Suspense, useLayoutEffect, useMemo, useRef } from 'react' import type { JSX } from 'react' import type { AgentPart, @@ -49,8 +49,14 @@ import type { } from '../agent' import { coalesceParts, narrowAgentPart, PartList } from '../agent' import { Markdown } from '../content/markdown' +import { CopyAction } from '../content/copy-action' import { VX } from '../tokens' +import { DEFAULT_AFFORDANCES } from './message-affordances' +import type { MessageAffordances } from './message-affordances' +import { formatRelativeTime } from './relative-time' import { ToolChip } from './tool-chip' +import { resolveVirtualize } from './virtualize' +import type { VirtualizeOptions, VirtualizeProps } from './virtualize' /** The mono, uppercase, letter-spaced micro-label idiom (docs/DESIGN-SPEC.md §3) — shared by the * transcript's role labels and the reasoning/tool-call headers below. */ @@ -266,6 +272,99 @@ function resolveSegments( return segments } +/** Concatenates every resolved `text` part across a message's segments, in order, joined by a + * blank line between non-adjacent runs (adjacent text parts are already merged by `coalesceParts` + * inside `resolveSegments`). This is what the per-message copy affordance copies — the message's + * COALESCED text, not its raw `parts`, so a message that streamed in several by-id text chunks + * copies one clean string rather than the fragments the user never saw assembled. Foreign + * (consumer-registered) segments carry no `AgentPart`-typed text and are skipped. */ +function extractCoalescedText(segments: readonly ResolvedSegment[]): string { + const chunks: string[] = [] + for (const segment of segments) { + if (segment.kind !== 'agent') continue + for (const part of segment.parts) { + if (part.type === 'text') chunks.push(part.text) + } + } + return chunks.join('\n\n') +} + +// ── MessageAffordanceRow — the per-message hover row (AGENT-CHAT-SPEC.md §11) ───────────────── + +const GROUP_WINDOW_MS = 5 * 60_000 + +/** True when `message` continues the SAME speaker's immediately-preceding turn within the Slack + * grouping window — the predecessor is absent (first message), a different role, or more than + * `GROUP_WINDOW_MS` apart all resolve to false. */ +function isConsecutiveWithPredecessor( + message: ChatMessage, + predecessor: ChatMessage | undefined, +): boolean { + if (predecessor === undefined) return false + if (predecessor.role !== message.role) return false + return message.createdAt - predecessor.createdAt <= GROUP_WINDOW_MS +} + +type MessageAffordanceRowProps = { + readonly message: ChatMessage + readonly coalescedText: string + readonly affordances: MessageAffordances + readonly isLastAssistant: boolean + /** Driven by the parent block's hover state OR its focus-within state — a visual reveal-on-hover + * strip that must ALSO reveal for a sighted keyboard-only user tabbing a control into view (hover + * alone left a focused Copy/Regenerate control invisible, with no focus ring visible either). The + * row stays mounted (and clickable/focusable) at all times rather than being removed from the + * DOM — that part of "keyboard/AT users never lose the controls a mouse user gets for free" held + * even before this fix for screen-reader users (opacity keeps elements in the a11y tree); visible + * focus for SIGHTED keyboard users is the half that did not, until this `focused` term was added. */ + readonly visible: boolean +} + +/** The per-message hover row: relative/absolute/none timestamp, a copy-coalesced-text action, a + * regenerate action (last assistant message only), and any consumer `actions`. Renders `null` when + * every affordance resolves to off (nothing to show, no empty strip taking up rhythm). */ +function MessageAffordanceRow({ + message, + coalescedText, + affordances, + isLastAssistant, + visible, +}: MessageAffordanceRowProps): JSX.Element | null { + const timestampMode = affordances.timestamp ?? DEFAULT_AFFORDANCES.timestamp + const showTimestamp = timestampMode !== 'none' + const showCopy = affordances.copy ?? DEFAULT_AFFORDANCES.copy + const showRegenerate = isLastAssistant && affordances.onRegenerate !== undefined + const customActions = affordances.actions?.({ message }) + + if (!showTimestamp && !showCopy && !showRegenerate && customActions === undefined) return null + + const timestampLabel = + timestampMode === 'absolute' + ? new Date(message.createdAt).toLocaleString() + : formatRelativeTime(message.createdAt) + + return ( + + {showTimestamp && ( + + {timestampLabel} + + )} + {showCopy && } + {showRegenerate && ( + affordances.onRegenerate?.(message.id)}> + Regenerate + + )} + {customActions} + + ) +} + // ── MessageBlock (memoized) ──────────────────────────────────────────────────── const ROLE_LABEL: Record = { @@ -294,6 +393,20 @@ type MessageBlockProps = { readonly streaming?: boolean readonly renderers: PartRenderers readonly fallbackRenderer: PartRenderer + /** Suppresses the role label and surface chrome — this message continues the same speaker's + * immediately-preceding turn within the grouping window (the Slack rhythm, AGENT-CHAT-SPEC.md + * §_). Computed by `ThreadTranscript` (a property of the SEQUENCE, not of one message in + * isolation) and passed down. @default false */ + readonly grouped?: boolean + /** Per-message hover-row affordances. `undefined` renders NO affordance row at all — this is how + * `ThreadTranscript` opts the live/streaming message out (an epoch-0 `createdAt` and a + * half-streamed coalesced text would otherwise render a nonsensical "56 years ago" timestamp and + * a copy button that copies a fragment). Every settled message gets a resolved (defaults-merged) + * object instead. */ + readonly affordances?: MessageAffordances + /** True only for the single most-recent SETTLED assistant message — the sole message + * `affordances.onRegenerate` renders a control for. @default false */ + readonly isLastAssistant?: boolean } /** @@ -309,37 +422,71 @@ function MessageBlockImpl({ streaming = false, renderers, fallbackRenderer, + grouped = false, + affordances, + isLastAssistant = false, }: MessageBlockProps): JSX.Element { messageBlockRenderCounter.count += 1 const settled = !streaming + const { hovered, ref: hoverRef } = useHover() + // `focused` covers the sighted-keyboard-user gap hover alone leaves open (see + // `MessageAffordanceRowProps.visible`'s doc) — merged onto the SAME node as `hoverRef` via + // `useMergedRef` since both hooks need their own callback ref on this one element. + const { focused, ref: focusWithinRef } = useFocusWithin() + const ref = useMergedRef(hoverRef, focusWithinRef) const segments = useMemo( () => resolveSegments(message.parts, renderers, fallbackRenderer), [message.parts, renderers, fallbackRenderer], ) const finishIndicator = message.finish === undefined ? undefined : FINISH_INDICATOR[message.finish] + // Guarded on `affordances`: the live/streaming message is pushed into `rows` WITHOUT this prop + // (see `MessageBlockProps.affordances`'s doc), so `MessageAffordanceRow` never renders for it — + // computing `extractCoalescedText` for it anyway would be pure waste, and `segments` for a live + // message grows every streamed chunk, so unconditionally recomputing it scaled total work across + // one streamed reply with the SQUARE of the response length. + const coalescedText = useMemo( + () => (affordances === undefined ? '' : extractCoalescedText(segments)), + [affordances, segments], + ) + + // Grouped continuations drop the role label — but a streaming loader or a finish badge is + // per-message state (not sequence rhythm) and must still surface even mid-group, so the header + // row itself only disappears when there is truly nothing left for it to show. + const showHeader = !grouped || streaming || finishIndicator !== undefined // Assistant/user surfaces are differentiated by subtle vs panel tokens, radius 7 - // (VX.radiusCard, docs/DESIGN-SPEC.md §5) — the user's own turn sits a shade quieter than the reply. + // (VX.radiusCard, docs/DESIGN-SPEC.md §5) — the user's own turn sits a shade quieter than the + // reply. A grouped continuation drops this chrome entirely (transparent, no shadow, no radius) + // — that absence, alongside the suppressed role label, IS the Slack rhythm. return ( - - {ROLE_LABEL[message.role]} - {streaming && } - {finishIndicator !== undefined && ( - - {finishIndicator.label} - - )} - + {showHeader && ( + + {!grouped && {ROLE_LABEL[message.role]}} + {streaming && } + {finishIndicator !== undefined && ( + + {finishIndicator.label} + + )} + + )} {segments.map((segment) => segment.kind === 'agent' ? ( ), )} + {affordances !== undefined && ( + + )} ) @@ -369,18 +525,431 @@ function MessageBlockImpl({ * re-render must not force through every settled message's `MessageBlock`. `renderers`/ * `fallbackRenderer` are included too: both are memoized once at the `ThreadTranscript` level (see * below), so in practice they're stable across renders, but a genuine change to either (a consumer - * swapping its renderer map) must still invalidate the memo. */ + * swapping its renderer map) must still invalidate the memo. `grouped` and `isLastAssistant` are + * cheap booleans recomputed every `ThreadTranscript` render — including them keeps a message from + * rendering yesterday's grouping/regenerate-eligibility when a NEIGHBOUR changes, at negligible + * comparator cost. `affordances` is a single object `ThreadTranscript` re-resolves only when its + * OWN fields change (see `resolvedAffordances` below) — comparing it by reference here is what lets + * a consumer's fresh-object-literal `affordances` prop NOT force every block to re-render. */ function areMessageBlockPropsEqual(prev: MessageBlockProps, next: MessageBlockProps): boolean { return ( prev.message === next.message && prev.streaming === next.streaming && prev.renderers === next.renderers && - prev.fallbackRenderer === next.fallbackRenderer + prev.fallbackRenderer === next.fallbackRenderer && + prev.grouped === next.grouped && + prev.affordances === next.affordances && + prev.isLastAssistant === next.isLastAssistant ) } const MessageBlock = memo(MessageBlockImpl, areMessageBlockPropsEqual) +// ── Virtualization (AGENT-CHAT-SPEC.md §9) ───────────────────────────────────── +// +// `@tanstack/react-virtual` is an OPTIONAL peer (see `./virtualize`'s module doc). `agent-chat` is +// ONE package.json export subpath — unlike `basalt-ui/data/virtual`, which gets to statically +// import the peer because it lives behind its OWN subpath (a consumer who never imports it never +// resolves the peer either) — so a static top-level import here would make every `ThreadTranscript` +// consumer require the peer, even ones that never pass `virtualize`. Lazy + dynamic `import()` + +// `.catch()` degrade, mirroring `../agent/stick-to-bottom.tsx`'s established pattern exactly, keeps +// the non-virtualized path working with the package absent. + +/** Default extra rows rendered beyond the viewport, each side. Higher than + * `BasaltVirtualList`'s generic-list default (5) — a streaming transcript's scroll bursts benefit + * from more pre-rendered neighbours above/below the fold. */ +const DEFAULT_VIRTUALIZE_OVERSCAN = 6 + +/** Default estimated row height in px, used before a row has been measured. Chat rows run taller + * than a generic list row (role header + multi-line prose), hence higher than + * `BasaltVirtualList`'s default of 40. Measured mean row height on the shipped 500-message demo + * thread is ~145px (total size climbed 49,486 → 72,448px over one descent, ~934px/screenful) — an + * estimate below the mean shrinks the scrollbar thumb continuously for the whole first scroll + * down. TanStack's own guidance for dynamically measured lists is to estimate toward the LARGER + * end: overestimating settles the thumb downward once real measurements land, instead of + * shrinking it. 160 rounds up from the measured mean with headroom for the estimate to still be + * defensible on a shorter/plainer demo thread. */ +const DEFAULT_VIRTUALIZE_ESTIMATE_SIZE = 160 + +/** Default `scrollEndThreshold` — how close (px) to the end still counts as "at the end" for + * `followOnAppend`/`anchorTo: 'end'` to keep tracking rather than freezing the scroll position. */ +const DEFAULT_VIRTUALIZE_SCROLL_END_THRESHOLD = 64 + +/** Default `VirtualizeOptions.initialScroll` — see `./virtualize`'s fourth composition rule for + * why a chat transcript opens at the newest message rather than the oldest. */ +const DEFAULT_VIRTUALIZE_INITIAL_SCROLL: NonNullable = 'end' + +/** Caps `applyInitialScroll`'s clobber-recovery retries (see `./virtualize`'s fourth composition + * rule for the full mechanism this guards). A small fixed budget, not a time/frame-based deadline: + * every observed case of virtual-core's `_willUpdate` clobbering this effect's jump resolves within + * one extra commit, so this exists purely as a backstop against a pathological DOM that never + * reports settled — not as the expected path. Once exhausted, the effect gives up and marks the + * jump permanently done wherever the last attempt landed, rather than fighting the DOM (or a user + * who has since scrolled away) forever. Exported (like `messageBlockRenderCounter`) purely so its + * test can assert the exact bound without duplicating the number; not part of the public surface. */ +export const MAX_INITIAL_SCROLL_ATTEMPTS = 5 + +type TranscriptRow = { + readonly key: string + readonly node: JSX.Element +} + +type VirtualizedTranscriptProps = { + readonly rows: readonly TranscriptRow[] + readonly height: number | string + readonly overscan: number + readonly estimateSize: number + readonly initialScroll: NonNullable +} + +/** + * The degrade target: every row rendered unwindowed inside a fixed-height scroll container. + * This is what a `virtualize: true` transcript falls back to when `@tanstack/react-virtual` fails + * to resolve (peer absent) — same visual shape (a scrollable `height`-bound pane), just without + * windowing. + * + * NOT used as the `Suspense` fallback (see `VirtualizeSuspenseFallback` below) — mounting every + * real `MessageBlock` subtree for the one tick the lazy import takes to settle would make that + * settle a genuine unmount/remount of every rendered row, re-firing each row's effects once and + * risking hitting a message mid-stream during the resolve window. + */ +function NonVirtualizedRows({ rows, height }: VirtualizedTranscriptProps): JSX.Element { + return ( + // theme-allow — degrade target owns its own scroll node, same as the real virtualizer below. + + + {rows.map((row) => ( + {row.node} + ))} + + + ) +} + +/** Test-only escape hatch (mirrors `messageBlockRenderCounter` above) — proves the peer-absent + * degrade target itself renders every row without windowing and without throwing, without having + * to fight `React.lazy`'s permanent once-per-module-instance resolution cache to simulate the + * peer's absence at the `import()` layer (see thread-message.test.tsx's virtualization describe + * block for why that approach was rejected). Not exported from `agent-chat/index.ts`. */ +export const nonVirtualizedRowsFallback = NonVirtualizedRows + +/** + * Minimal structural slice of `Virtualizer` (virtual-core 3.17.1) that + * {@link resolveGuardedMeasurement} needs — `indexFromElement`/`itemSizeCache`/`options` are all + * public instance members, but typing against the real (generic, peer-only) `Virtualizer` class + * would force this test-only-exported helper to import the peer's runtime types at module scope, + * the exact static-import-forces-the-peer problem the surrounding `lazy(() => import(...))` exists + * to avoid. A structural type sidesteps that without loosening to `any`. Generic over the item + * element type (rather than fixed at `Element`) so it stays exactly assignable FROM a real + * `Virtualizer` at the call site below — `indexFromElement` is + * declared with arrow-property (not method) syntax upstream, so it is checked contravariantly, and + * a fixed `Element` parameter there does not structurally match a real instance's narrower + * `TItemElement` parameter. + */ +type MeasurementCacheHost = { + readonly indexFromElement: (node: TItemElement) => number + readonly itemSizeCache: Map + readonly options: { + readonly getItemKey: (index: number) => unknown + readonly estimateSize: (index: number) => number + } +} + +/** + * Resolves the size a `measureElement` implementation should report, given what the underlying + * (default or custom) measurement already produced: that value unchanged when it is a genuine + * measurement, or the row's own last-known size when it is `0` — never a real reading for a mounted + * row, so a `0` only ever means the ResizeObserver measured through a `display: none` (or otherwise + * unlaid-out) ancestor, e.g. a virtualized transcript sitting inside a collapsed `ThreadFeedRow`. + * Falling through to it unguarded is what corrupted `itemSizeCache`/`measurementsCache`: virtual- + * core's default `resizeItem` writes whatever `measureElement` returns with no floor, so every + * mounted row remeasures at 0px the instant an ancestor hides, `getTotalSize()` collapses within + * the same tick, and the collapse is never undone — a later real remeasurement only OVERWRITES + * entries the ResizeObserver happens to re-fire for, it does not restore ones that silently kept + * their poisoned 0 (see `virtualize.ts`'s composition-rule doc for the full mechanism and the + * measured numbers). + * + * Takes the already-computed `size` plus the {@link MeasurementCacheHost} slice, rather than + * wrapping the `measureElement` function itself, so this stays a plain, non-generic-over-a- + * generic-function call at its one call site below (`instance` there is `Virtualizer` for THAT call's concrete type arguments, not the peer's own generic + * `measureElement` export — wrapping the latter directly hits TS's higher-rank inference limits on + * a doubly-generic HOF). That shape is also what makes this unit-testable with a fake host object + * and no ResizeObserver/layout engine at all (happy-dom has neither). Not exported from + * `agent-chat/index.ts`: a test-only escape hatch, matching + * `messageBlockRenderCounter`/`nonVirtualizedRowsFallback` above. + */ +export function resolveGuardedMeasurement( + host: MeasurementCacheHost, + element: TItemElement, + size: number, +): number { + if (size > 0) return size + // Same key derivation `resizeItem` itself uses (index → getItemKey), read BEFORE `resizeItem` + // writes the new (bad) size — `itemSizeCache` still holds the last-good measurement here. + const index = host.indexFromElement(element) + const key = host.options.getItemKey(index) + return host.itemSizeCache.get(key) ?? host.options.estimateSize(index) +} + +/** + * Decides what `VirtualizedRowsInner`'s one-shot initial-scroll effect should do this render, + * given the inputs the effect closes over. Pure and peer/DOM-free — pulled out of the lazy-loaded + * closure for the same reason `resolveGuardedMeasurement` is: it stays unit-testable without a + * real `Virtualizer` instance or `@tanstack/react-virtual` itself. + * + * `'start'` and an empty transcript resolve to `'skip-done'` — there is nothing to scroll to, and + * DONE is permanent (see `virtualize.ts`'s fourth composition rule: the real scroll must fire at + * most once, ever, so a later append never yanks a user who has scrolled up). A non-positive + * `containerHeight` — the transcript's own scroll node measuring `0`, e.g. mounted behind a + * `display: none` ancestor per `virtualize.ts`'s third composition rule — resolves to + * `'skip-retry'`: NOT done, so the caller's effect (which re-runs after EVERY commit until the jump + * lands — see {@link applyInitialScroll}) gets another chance the next time this subtree renders, + * instead of permanently stranding the transcript at the top the moment it becomes visible. Only a + * genuinely measured, non-empty, `'end'`-targeted mount resolves to `'scroll'`. + */ +export function resolveInitialScrollAction( + initialScroll: NonNullable, + rowCount: number, + containerHeight: number, +): 'scroll' | 'skip-done' | 'skip-retry' { + if (initialScroll === 'start') return 'skip-done' + if (rowCount === 0) return 'skip-done' + if (containerHeight <= 0) return 'skip-retry' + return 'scroll' +} + +/** The mutable half of the one-shot initial-scroll contract. `hasApplied` is whether it has + * PERMANENTLY finished — either there was nothing to do (`'start'`/empty transcript), or + * `scrollToEnd` fired and was subsequently CONFIRMED (via a real DOM read — see + * {@link applyInitialScroll}'s doc for why virtual-core's own tracked offset can't be trusted for + * this) to have survived whatever else committed since. `attempts` counts how many times + * `scrollToEnd` has been (re-)fired this mount; 0 means never attempted, and also gates whether a + * call re-derives {@link resolveInitialScrollAction} at all. One instance lives for the lifetime of + * a `VirtualizedRowsInner` mount, held in a `useRef` so it survives re-renders without itself + * triggering one. */ +export type InitialScrollState = { hasApplied: boolean; attempts: number } + +/** + * The stateful half of "fires once, on first mount, and never again" (`virtualize.ts`'s fourth + * composition rule): wraps {@link resolveInitialScrollAction}'s per-call decision with the + * ACROSS-CALLS memory that decision alone can't carry. Exported (like `resolveGuardedMeasurement`) + * so `VirtualizedRowsInner`'s effect below calls this SAME function rather than a hand-mirrored + * copy for tests — a fake `scrollToEnd` is the only DOM/peer-free way to prove the guard, since a + * real `Virtualizer`'s own `followOnAppend` also calls the real DOM `scrollTo` on a later append, + * making that specific call indistinguishable from this one at the DOM boundary (see this + * function's test suite for the measurement that ruled that boundary out). + * + * Firing `scrollToEnd()` once is NOT the same as landing there: virtual-core 3.17.1's own + * `_willUpdate` (verified against the installed dist) writes to the SAME scroll container from + * `this.scrollOffset`, which updates only from the ASYNCHRONOUS native `scroll` event — never from + * the synchronous call that requested a scroll. A commit that runs `_willUpdate` before this + * effect gets a turn (element-attach) or that re-derives an anchor while `scrollOffset` still holds + * the pre-jump value writes that STALE offset straight back to `scrollTop`, undoing the jump within + * about one extra commit of it landing (see `./virtualize`'s fourth composition rule for the full + * trace this was diagnosed from). So `resolveInitialScrollAction`'s `'scroll'` outcome does not, by + * itself, set `hasApplied`: `isAtEnd` — a real `scrollTop`/`scrollHeight`/`clientHeight` read, the + * one piece of ground truth that is NEVER stale, unlike virtual-core's own tracked offset — is + * consulted on the NEXT call (i.e. the next commit, exactly the cadence this dependency-free effect + * already runs on) to confirm the jump survived. If it didn't, `scrollToEnd` fires again; if it did, + * `hasApplied` is set and the effect is inert for the rest of the mount. `MAX_INITIAL_SCROLL_ATTEMPTS` + * bounds this so a DOM that pathologically never reports settled — or a user who scrolls away mid- + * measurement — can never be fought forever; once exhausted, `hasApplied` is set regardless, wherever + * the last attempt left the container. + * + * `state.hasApplied` is the ONLY thing that can permanently stop this from calling `scrollToEnd` + * again: `'start'` and an empty transcript set it immediately (nothing to verify), and `'skip-retry'` + * deliberately leaves it `false` so a transcript that mounted behind a hidden ancestor gets another + * attempt on the caller's next commit, rather than being silently abandoned the moment it becomes + * visible. That retry — and the clobber-recovery above — is why the caller's effect carries NO + * dependency array: the commit that matters (a collapsed `ThreadFeedRow` flipping its body back to + * `display: block`, or virtual-core's own follow-up commit after this effect's own jump) changes + * neither the row count nor the virtualizer identity, so a dependency-keyed effect would never + * re-run for it. + * + * `getContainerHeight` is a THUNK, not a number, for the same reason: reading `offsetHeight` forces + * a synchronous layout, and it is read at most ONCE per mount — only on the very first call + * (`attempts === 0`) — never again while verifying or retrying, so a landed transcript still pays + * nothing for the effect it keeps re-running (a streaming transcript commits on every chunk). + * `isAtEnd` is cheaper (a plain property read, no forced reflow beyond what the browser already + * tracks) and is exactly what the verification/retry loop above consults instead. + */ +export function applyInitialScroll( + state: InitialScrollState, + initialScroll: NonNullable, + rowCount: number, + getContainerHeight: () => number, + scrollToEnd: () => void, + isAtEnd: () => boolean, +): void { + if (state.hasApplied) return + + // Already fired at least once this mount — verify it survived rather than re-deriving + // `resolveInitialScrollAction` (row count / container height are irrelevant to "did the DOM + // keep the scroll we already decided on"). + if (state.attempts > 0) { + if (isAtEnd() || state.attempts >= MAX_INITIAL_SCROLL_ATTEMPTS) { + state.hasApplied = true + return + } + scrollToEnd() + state.attempts += 1 + return + } + + const action = resolveInitialScrollAction(initialScroll, rowCount, getContainerHeight()) + if (action === 'skip-retry') return + if (action === 'skip-done') { + state.hasApplied = true + return + } + scrollToEnd() + state.attempts += 1 +} + +/** Reads the transcript's own scroll container to answer "is it currently at (or within + * `threshold`px of) the end" — the ground-truth check {@link applyInitialScroll} verifies its jump + * against. Deliberately NOT `virtualizer.isAtEnd()`/`getScrollOffset()`: both are backed by + * `this.scrollOffset`, the exact value that lags a real scroll until the next native `scroll` + * event fires — using it here would make the verification vulnerable to the identical staleness + * that causes the clobber in the first place. `scrollTop`/`scrollHeight`/`clientHeight` are plain + * DOM properties with no such lag: `Element.scrollTo` with `'auto'` (this module's own behavior) + * updates `scrollTop` synchronously, so a read immediately after — or, as here, on the next + * commit — reflects reality. `null` (container not yet attached) reads as "not at end" rather than + * throwing, matching every other guard in this file's tolerance for a not-yet-mounted ref. */ +function isContainerAtEnd(container: HTMLDivElement | null, threshold: number): boolean { + if (container === null) return false + const distanceFromEnd = container.scrollHeight - container.clientHeight - container.scrollTop + return distanceFromEnd <= threshold +} + +/** Lazy-loaded real virtualizer. Resolves once, permanently, for the lifetime of this module + * instance — identical caching behaviour to `../agent/stick-to-bottom.tsx`'s `LazyStickToBottom`. */ +const LazyVirtualizedRows = lazy(() => + import('@tanstack/react-virtual') + .then(({ useVirtualizer, measureElement: defaultMeasureElement }) => { + function VirtualizedRowsInner({ + rows, + height, + overscan, + estimateSize, + initialScroll, + }: VirtualizedTranscriptProps): JSX.Element { + const parentRef = useRef(null) + const initialScrollStateRef = useRef({ + hasApplied: false, + attempts: 0, + }) + + // Resolved research facts (virtual-core 3.17.1, carried by react-virtual 3.14.3): + // `getItemKey` returning `message.id` (NOT the default index) so prepending/streaming + // doesn't scramble anchoring; `anchorTo: 'end'` + `followOnAppend` for chat-scroll rhythm; + // `useFlushSync: false` per the same React-19 opt-out `BasaltVirtualList` documents. + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => estimateSize, + overscan, + getItemKey: (index) => rows[index]?.key ?? index, + anchorTo: 'end', + followOnAppend: true, + scrollEndThreshold: DEFAULT_VIRTUALIZE_SCROLL_END_THRESHOLD, + useFlushSync: false, + // Guards against the hidden-ancestor cache-poisoning mechanism + // `resolveGuardedMeasurement`'s doc and `virtualize.ts`'s composition rules describe — + // NOT `enabled: false`, which virtual-core 3.17.1 wires to WIPE + // `itemSizeCache`/`measurementsCache` entirely (`getMeasurements`'s `!enabled` branch) + // rather than pause measurement, so toggling it off/on around a collapse would be + // strictly worse than the bug it was meant to fix. + measureElement: (element, entry, instance) => + resolveGuardedMeasurement( + instance, + element, + defaultMeasureElement(element, entry, instance), + ), + }) + + // The one-shot "open at the newest message" jump (`virtualize.ts`'s fourth composition + // rule) — see `applyInitialScroll`'s doc for the guard mechanics, including why this effect + // deliberately carries NO dependency array. Short version: this subtree can mount while + // INVISIBLE (the lazy `import()` above settles asynchronously, so a `ThreadFeedRow` + // collapsed during that window mounts it behind `display: none`), and the commit that makes + // it visible again changes no dependency a keyed array could watch. That SAME dependency- + // free cadence is also what lets this effect recover from virtual-core's own `_willUpdate` + // clobbering the jump on the commit right after it lands (see `applyInitialScroll`'s doc) — + // `state.hasApplied` only becomes permanent once `isContainerAtEnd` confirms it on a later + // call, not the instant `scrollToEnd` is invoked. `parentRef.current.offsetHeight` (not + // `getBoundingClientRect`) matches what virtual-core's own `getRect` reads for the scroll + // container, so a hidden ancestor is treated as unmeasured in exactly the cases virtual-core + // itself would also treat that way; it is passed as a thunk (like the `isContainerAtEnd` + // read below) so nothing is forced once the jump has landed and been confirmed. + useLayoutEffect(() => { + applyInitialScroll( + initialScrollStateRef.current, + initialScroll, + rows.length, + () => parentRef.current?.offsetHeight ?? 0, + () => virtualizer.scrollToEnd({ behavior: 'auto' }), + () => isContainerAtEnd(parentRef.current, DEFAULT_VIRTUALIZE_SCROLL_END_THRESHOLD), + ) + }) + + return ( + // theme-allow — TanStack Virtual measures/scrolls this element (never nest in BasaltStickToBottom, see ./virtualize). + + + {virtualizer.getVirtualItems().map((virtualItem) => { + const row = rows[virtualItem.index] + if (row === undefined) return null + return ( + + {row.node} + + ) + })} + + + ) + } + return { default: VirtualizedRowsInner } + }) + .catch(() => ({ default: NonVirtualizedRows })), +) + +/** + * Lightweight `Suspense` fallback for `VirtualizedTranscript` — shown only for the one tick the + * dynamic `@tanstack/react-virtual` import takes to resolve. Deliberately NOT `NonVirtualizedRows`: + * see that component's doc for why mounting every real row here would be a genuine (if one-time) + * unmount/remount of the whole transcript. An empty, correctly-sized scroll pane avoids a layout + * flash without paying that cost — it settles state before any row has meaningfully mounted. + */ +function VirtualizeSuspenseFallback({ height }: { readonly height: number | string }): JSX.Element { + // theme-allow — placeholder owns its own scroll node, matching the real virtualizer's shape. + return +} + +function VirtualizedTranscript(props: VirtualizedTranscriptProps): JSX.Element { + return ( + }> + + + ) +} + // ── ThreadTranscript ────────────────────────────────────────────────────────── const EMPTY_RENDERERS: PartRenderers = {} @@ -397,9 +966,58 @@ type ThreadTranscriptBase = { /** Called for a part whose type is neither an AgentPart variant nor a registered key. Defaults * to a visible `UnknownPartChip` outside production, `null` in production — never throws. */ readonly fallbackRenderer?: PartRenderer + /** Per-message hover-row affordances (timestamp/copy/regenerate/custom actions). Unset fields + * fall back to `DEFAULT_AFFORDANCES`. Never shown on the live/streaming message — see + * `MessageBlockProps.affordances`. */ + readonly affordances?: MessageAffordances + /** Suppresses the role label and surface chrome on a message whose predecessor shares its role + * and landed within 5 minutes — the Slack rhythm. + * @default true */ + readonly groupConsecutive?: boolean } -export type ThreadTranscriptProps = ThreadTranscriptBase +export type ThreadTranscriptProps = ThreadTranscriptBase & VirtualizeProps + +/** + * Wraps a possibly-fresh-every-render EVENT HANDLER in a wrapper function whose IDENTITY never + * changes across renders (while it is defined), always calling through to the LATEST version via a + * ref — the same "mirror the latest value every render" idiom `use-agent-thread-runs.ts` uses for + * `storeRef`/`transportRef`, applied to a callback instead of a value. + * + * This is what lets `resolvedAffordances` below key its memo on the handler's PRESENCE (defined vs + * not) rather than on ITS reference — a consumer passing a fresh inline `onRegenerate={(id) => …}` + * literal every render (the common case; see `MessageAffordances`'s own doc) no longer defeats + * `resolvedAffordances`'s memo, and transitively `MessageBlock`'s `affordances`-by-reference + * bail-out. Only a genuine defined-to-undefined (or back) transition changes the returned wrapper's + * presence. + * + * ONLY EVER APPLY THIS TO AN EVENT HANDLER — never to a render prop. The trick works precisely + * because the wrapper is invoked AFTER render (from a click), so freezing its identity costs + * nothing: React re-reads the ref at call time. A render prop (`MessageAffordances.actions`, which + * `MessageAffordanceRow` invokes DURING render to produce nodes) is the opposite case: its identity + * is the only signal React has that its OUTPUT might differ, so freezing it makes every memo above + * it bail out and the consumer's actions render permanently stale — a `pin`/`star` control wired to + * consumer state would never update again for the transcript's whole lifetime. `actions` is + * therefore deliberately keyed by reference in `resolvedAffordances`; a consumer that wants the + * memo win there wraps its own `actions` in `useCallback`, which is a choice only the consumer can + * make because only it knows what the closure reads. + */ +function useStableCallback( + callback: ((...args: Args) => R) | undefined, +): ((...args: Args) => R) | undefined { + const callbackRef = useRef(callback) + callbackRef.current = callback + // Deliberately empty deps: this is what keeps `stable`'s identity constant across renders. It + // reads `callbackRef.current` at CALL time (not at creation time), so it always invokes whatever + // the consumer most recently passed — never a closure captured at first mount. + const stable = useMemo<(...args: Args) => R>( + () => + (...args: Args) => + callbackRef.current?.(...args) as R, + [], + ) + return callback === undefined ? undefined : stable +} /** * Renders a thread's settled messages, each role-labelled ("You" / "Assistant"). Each part @@ -407,18 +1025,35 @@ export type ThreadTranscriptProps = ThreadTranscriptBase * the built-in six, then `fallbackRenderer`. When `liveParts` is non-empty, an extra in-progress * assistant block is appended at the tail. * + * Set `virtualize` (with a required `height`) to window a long thread over + * `@tanstack/react-virtual` (an optional peer) instead of rendering every message. The virtualized + * transcript owns its own fixed-height scroll container and must NOT be nested inside + * `BasaltStickToBottom` — see `./virtualize`'s module doc. With the peer absent, `virtualize` still + * renders (unwindowed, inside the same fixed-height pane) rather than throwing. A virtualized + * transcript scrolls itself to the newest message once, on mount (`initialScroll: 'start'` opts + * out) — see `./virtualize`'s fourth composition rule. + * * @example * import { ThreadTranscript } from 'basalt-ui' * + * + * @example + * // Windowed, for a long thread: + * */ -export function ThreadTranscript({ - messages, - liveParts, - liveStatus, - renderers: renderersProp, - fallbackRenderer = DEFAULT_FALLBACK_RENDERER, -}: ThreadTranscriptProps): JSX.Element { - // Mirrors PartList's own `useMemo` (part-list.tsx:237-240) — avoids rebuilding the renderer +export function ThreadTranscript(props: ThreadTranscriptProps): JSX.Element { + const { + messages, + liveParts, + liveStatus, + renderers: renderersProp, + fallbackRenderer = DEFAULT_FALLBACK_RENDERER, + affordances: affordancesProp, + groupConsecutive = true, + } = props + const virtualized = resolveVirtualize(props) + + // Mirrors PartList's own `useMemo` (part-list.tsx:290) — avoids rebuilding the renderer // lookup on every streaming re-render. const renderers = useMemo(() => renderersProp ?? EMPTY_RENDERERS, [renderersProp]) @@ -427,17 +1062,85 @@ export function ThreadTranscript({ return { id: '__live__', role: 'assistant', parts: liveParts as TranscriptPart[], createdAt: 0 } }, [liveParts]) - return ( - - {messages.map((message) => ( - - ))} - {liveMessage !== null && ( + const lastAssistantMessageId = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const candidate = messages[i] + if (candidate?.role === 'assistant') return candidate.id + } + return null + }, [messages]) + + // `onRegenerate` is an EVENT HANDLER, so it goes through `useStableCallback` before this memo + // sees it — without that, a consumer's fresh inline `{ onRegenerate: (id) => … }` literal (the + // common case) changes every render, so keying the deps array on the raw field defeated this memo + // for exactly that case: `resolvedAffordances` (and transitively every `MessageBlock`'s + // `affordances` reference-equality bail-out) recomputed every render regardless of the + // timestamp/copy fields actually being stable. Keying on the STABLE wrapper's presence fixes + // that; only a genuine defined-to-undefined transition on the underlying handler invalidates. + // + // `actions` is deliberately NOT given the same treatment: it is a RENDER prop (invoked during + // render to produce nodes), so its reference is the only signal that its OUTPUT may have changed. + // Stabilizing it froze every consumer action at its first-render output for the transcript's + // whole lifetime. It is keyed by reference here, which means a fresh inline `actions` literal + // legitimately re-renders the blocks — the correct trade, and one a consumer can opt out of with + // its own `useCallback`. See `useStableCallback`'s doc. + const stableOnRegenerate = useStableCallback(affordancesProp?.onRegenerate) + const actions = affordancesProp?.actions + + const resolvedAffordances = useMemo( + () => ({ + timestamp: affordancesProp?.timestamp ?? DEFAULT_AFFORDANCES.timestamp, + copy: affordancesProp?.copy ?? DEFAULT_AFFORDANCES.copy, + // `exactOptionalPropertyTypes` forbids assigning a possibly-`undefined` value to an optional + // property directly — spread each field in only when it is actually present, rather than + // widening `MessageAffordances` itself to accept an explicit `undefined`. + ...(stableOnRegenerate !== undefined && { onRegenerate: stableOnRegenerate }), + ...(actions !== undefined && { actions }), + }), + [affordancesProp?.timestamp, affordancesProp?.copy, stableOnRegenerate, actions], + ) + + // Settled rows are memoized SEPARATELY from the live block: `liveMessage`/`liveStatus` change on + // every streamed chunk (a fresh `liveParts` array produces a fresh `liveMessage` object each + // time), so keeping them in the SAME memo as the settled rows recomputed every settled message's + // JSX descriptor on every chunk — O(n) allocation per token on a long thread, and it handed + // `useVirtualizer` a new `rows`/`count`/`getItemKey` identity every chunk too. Appending the live + // row OUTSIDE this memo means a streamed chunk only ever re-allocates the one live row plus a + // cheap wrapping array — the settled `MessageBlock` elements are the SAME object references + // React saw last render, so it bails out of that whole subtree without even reaching each + // `MessageBlock`'s memo comparator. + const settledRows = useMemo(() => { + const out: TranscriptRow[] = [] + messages.forEach((message, index) => { + out.push({ + key: message.id, + node: ( + + ), + }) + }) + return out + }, [ + messages, + renderers, + fallbackRenderer, + groupConsecutive, + lastAssistantMessageId, + resolvedAffordances, + ]) + + const liveRow = useMemo(() => { + if (liveMessage === null) return null + return { + key: liveMessage.id, + node: (
- )} + ), + } + }, [liveMessage, liveStatus, renderers, fallbackRenderer]) + + const rows = useMemo( + () => (liveRow === null ? settledRows : [...settledRows, liveRow]), + [settledRows, liveRow], + ) + + if (virtualized !== null) { + return ( + + ) + } + + return ( + + {rows.map((row) => ( + {row.node} + ))} ) } diff --git a/packages/basalt-ui/src/agent-chat/thread-outcome-card.tsx b/packages/basalt-ui/src/agent-chat/thread-outcome-card.tsx index 20644b7..553e26a 100644 --- a/packages/basalt-ui/src/agent-chat/thread-outcome-card.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-outcome-card.tsx @@ -23,6 +23,7 @@ import { useHover } from '@mantine/hooks' import type { JSX } from 'react' import type { AgentThread, ThreadStatus } from '../agent' import { alpha, VX } from '../tokens' +import { formatRelativeTime } from './relative-time' // ── Status badge — shown ONLY for states that need a glance (attention/error). ──── // done/pending/streaming stay badge-free: a settled feed shouldn't be a wall of green chips — @@ -35,31 +36,6 @@ const STATUS_BADGE: Partial< error: { label: 'Failed', statusToken: VX.status.bad }, } -// ── Dependency-free relative-time helper (no date-fns) ──────────────────────── - -const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) - -const RELATIVE_TIME_UNITS: readonly { - readonly unit: Intl.RelativeTimeFormatUnit - readonly ms: number -}[] = [ - { unit: 'year', ms: 31_536_000_000 }, - { unit: 'month', ms: 2_628_000_000 }, - { unit: 'week', ms: 604_800_000 }, - { unit: 'day', ms: 86_400_000 }, - { unit: 'hour', ms: 3_600_000 }, - { unit: 'minute', ms: 60_000 }, -] - -/** Formats an epoch-ms timestamp as a short relative string ("3 hours ago", "just now"). */ -function formatRelativeTime(timestamp: number): string { - const diffMs = timestamp - Date.now() - const absMs = Math.abs(diffMs) - if (absMs < 60_000) return 'just now' - const unit = RELATIVE_TIME_UNITS.find(({ ms }) => absMs >= ms) ?? RELATIVE_TIME_UNITS.at(-1)! - return RELATIVE_TIME_FORMAT.format(Math.round(diffMs / unit.ms), unit.unit) -} - // ── Row bodies ───────────────────────────────────────────────────────────────── /** Preview skeleton shown while a thread has no resolved outcome yet (never raw prompt/live text). */ diff --git a/packages/basalt-ui/src/agent-chat/virtualize.ts b/packages/basalt-ui/src/agent-chat/virtualize.ts new file mode 100644 index 0000000..c6e0de1 --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/virtualize.ts @@ -0,0 +1,132 @@ +/** + * Virtualization contract shared by the transcript/feed components that can opt into + * `@tanstack/react-virtual` for long lists. + * + * The `VirtualizeProps` union IS the guard: a virtualizer needs a measured scroll container, so + * turning `virtualize` on REQUIRES a `height` and turning it off (or omitting it) FORBIDS one. See + * `virtualize.type-guard.test.ts` beside this file for a compile-time proof that the union actually + * rejects the invalid combinations. + * + * The one piece of runtime here is {@link resolveVirtualize} — the single narrowing point every + * component that accepts `VirtualizeProps` goes through, so no call site has to re-derive (or + * assert its way past) the union's `virtualize`-implies-`height` link. + * + * The other half of the contract, not encoded in the types (it's a runtime/composition rule, not + * a shape): a virtualized transcript OWNS ITS OWN SCROLL NODE (the element `getScrollElement` + * measures) and must NOT be nested inside `BasaltStickToBottom` — unlike the non-virtual path, + * which `ThreadDetailPanel` nests inside `BasaltStickToBottom` today (its transcript body). + * Stacking a virtualizer's own scroll container inside `BasaltStickToBottom`'s would give the + * transcript two competing owners of "which element scrolls" and break both the virtualizer's + * `scrollToEnd` and stick-to-bottom's anchor tracking. + * + * A third composition rule, same category (runtime, not a shape): a virtualized transcript + * tolerates being hidden via `display: none` on an ANCESTOR (e.g. sitting inside a collapsible + * `ThreadFeedRow`) — collapsing does not corrupt its measurement cache, and re-expanding restores + * the same scroll position (same top-visible row) it had before the collapse. This is NOT free of + * virtual-core: hiding an ancestor makes every mounted row's ResizeObserver box report `0` (a + * `display: none` ancestor un-lays-out its descendants), and virtual-core 3.17.1's default + * `measureElement`/`resizeItem` write that `0` into `itemSizeCache` with no floor — unguarded, a + * few collapse/expand cycles permanently shrink `getTotalSize()` and silently scroll the visible + * window to a DIFFERENT part of the transcript (`scrollTop` stays put; the content under it moves). + * `thread-message.tsx` closes this with a `measureElement` override (`resolveGuardedMeasurement`) + * that treats a `0` reading as unreliable and keeps the last-known size instead of committing it — + * see that function's doc for the full mechanism, including why `useVirtualizer`'s `enabled` option + * is NOT the fix (it wipes the measurement cache outright, worse than the bug). A consumer combining a + * virtualized transcript with its own hide-via-CSS container gets this for free; a consumer who + * unmounts/remounts the transcript instead of hiding it is unaffected either way (a fresh + * `useVirtualizer` call has no stale cache to poison). + * + * A fourth composition rule: a virtualized transcript SCROLLS ITSELF on mount. `initialScroll` + * defaults to `'end'` — `anchorTo: 'end'` and `followOnAppend` (both wired in `thread-message.tsx`) + * only keep an ALREADY-at-the-end transcript pinned as content changes; neither performs the initial + * jump, so left alone a freshly mounted virtualized transcript renders at message #0, not the + * newest. The fix fires exactly once, on first mount, via `scrollToEnd()` — never again, so a user + * who has scrolled up is never yanked back down by a later append (that is precisely the failure + * `followOnAppend` exists to avoid, and re-introducing it here would be worse than the defect this + * closes). A consumer wiring its OWN scroll-restoration (e.g. `initialOffset` / + * `initialMeasurementsCache`, restoring a prior session's position) wants `initialScroll: 'start'` — + * otherwise this default jump fires first and the restoration has to fight it. `initialScroll` is + * ignored (no scroll, no throw) for an empty transcript, and is deferred rather than treated as done + * if the transcript's own scroll container measures 0px on mount (e.g. mounted behind a hidden + * ancestor) — it then lands on the first commit where the container is genuinely measurable, which + * is what makes "opens at the newest message" hold for a transcript whose lazy virtualizer settled + * while its `ThreadFeedRow` was collapsed. See `thread-message.tsx`'s `applyInitialScroll` for the + * guard, and `VirtualizedRowsInner` for why its effect is deliberately dependency-free. + * + * Firing `scrollToEnd()` once is not sufficient on its own to land there, because virtual-core + * 3.17.1's own `_willUpdate` writes to the SAME scroll container on the very next commit it runs + * on, from state that lags behind the DOM: `_scrollToOffset` only ever records the write's + * intent (`_intendedScrollOffset`) — `this.scrollOffset` itself updates ASYNCHRONOUSLY, from the + * native `scroll` event, not from the call that requested it. So a commit that runs `_willUpdate` + * before this effect had a chance to fire (element-attach) or that re-derives an anchor from + * `getScrollOffset()` while `this.scrollOffset` is still the pre-jump value (the anchor branch, + * gated on `pendingScrollAnchor`) writes that stale offset — 0, on first mount — straight back to + * `scrollTop`, clobbering this effect's jump within roughly one extra commit of it landing. + * `applyInitialScroll` survives this the same way the hidden-ancestor case above already + * (accidentally) does: by not treating one successful call as permanent. It fires, then — on a + * LATER call, i.e. a later commit, exactly the cadence this dependency-free effect already runs on + * — reads the container's REAL `scrollTop`/`scrollHeight`/`clientHeight` (never virtual-core's own + * `scrollOffset`, which is exactly the stale value that caused the clobber) to check whether the + * jump survived; if it didn't, it re-fires, bounded by `MAX_INITIAL_SCROLL_ATTEMPTS` so a DOM that + * pathologically never reports settled can never be fought forever. `resolveInitialScrollAction` + * itself is untouched by any of this — it is still the one-shot "should I even attempt this" + * decision; `applyInitialScroll` layers the across-commits verification on top. + * + * `initialOffset` (`number | (() => number)`, `virtual-core`'s own construction-time seed for + * `scrollOffset`) was investigated as a way to avoid the clobber altogether and rejected: it takes + * a concrete pixel number, not an `'end'` sentinel, and the only number available at the point + * it would need to resolve — before any row has ever been measured — is an ESTIMATE + * (`estimateSize` × count), not the real total. It would relocate the transcript's initial paint + * from message #0 to an approximately-right position, but the moment real measurements replace + * those estimates the total size (and therefore the true end offset) changes, which still needs a + * corrective `scrollToEnd()` once rows have actually measured — the exact write this whole + * mechanism exists to protect. It also can't reference the `virtualizer` instance it would be + * computing an offset for without a separate ref indirection, since it is itself one of the options + * passed to construct that instance. Not a fix for this defect, just a smaller wrong first paint. + */ + +/** Tuning knobs passed through to `@tanstack/react-virtual`'s `useVirtualizer`. */ +export type VirtualizeOptions = { + /** Extra rows rendered beyond the visible viewport, each side. Library default: 1. */ + readonly overscan?: number + /** Estimated row height in px, used before a row has been measured. */ + readonly estimateSize?: number + /** Where the transcript sits on first mount. Default 'end' — a chat transcript opens at the + newest message. 'start' opens at the oldest. See this module's fourth composition rule + above for the mechanism and why a consumer restoring its own scroll position wants 'start'. */ + readonly initialScroll?: 'end' | 'start' +} + +/** + * `virtualize: false` (or omitted) → no `height` prop (content-sized, non-scrolling-owner layout). + * `virtualize: true | VirtualizeOptions` → `height` is REQUIRED — the virtualizer measures a fixed + * scroll container, so there is no valid virtualized layout without one. + */ +export type VirtualizeProps = + | { readonly virtualize?: false; readonly height?: never } + | { readonly virtualize: true | VirtualizeOptions; readonly height: number | string } + +/** The enabled branch of {@link VirtualizeProps}, with `height` no longer optional. */ +export type ResolvedVirtualize = { + readonly options: VirtualizeOptions + readonly height: number | string +} + +/** + * Narrows a `VirtualizeProps` pair once, centrally: `null` when virtualization is off, otherwise + * the options object (normalizing the bare `virtualize: true` shorthand to `{}`) alongside the + * `height` the union guarantees is present. Every component accepting `VirtualizeProps` resolves + * through here rather than destructuring the two fields apart — destructuring severs the link TS + * needs, which is what forces a non-null assertion on `height` at each call site. + */ +export function resolveVirtualize(props: VirtualizeProps): ResolvedVirtualize | null { + const { virtualize } = props + if (virtualize === undefined || virtualize === false) return null + // The union already REQUIRES `height` on this branch and rejects it on the other — that guarantee + // is enforced where it matters, at every call site (see virtualize.type-guard.test.ts). What TS + // won't do is propagate the narrowing FROM `virtualize` TO `height`: `virtualize`'s enabled branch + // is `true | VirtualizeOptions`, and the object constituent disqualifies it as a discriminant + // property. So the pairing is re-stated once, here, instead of a `height!` at each consumer. + const height = props.height as number | string + return { options: virtualize === true ? {} : virtualize, height } +} diff --git a/packages/basalt-ui/src/agent-chat/virtualize.type-guard.test.ts b/packages/basalt-ui/src/agent-chat/virtualize.type-guard.test.ts new file mode 100644 index 0000000..31fcf4b --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/virtualize.type-guard.test.ts @@ -0,0 +1,49 @@ +/** + * Compile-time proof that `VirtualizeProps` actually rejects the invalid `virtualize`/`height` + * combinations (not just documentation). + * + * This mirrors the `apps/playground/src/*.type-guard.ts` convention (one `@ts-expect-error` per bad + * line, proven by CI's `tsc --noEmit`) but lives HERE, beside the type, rather than in the + * playground app. `VirtualizeProps` IS public now (re-exported type-only from the agent-chat barrel + * and the root), so a playground fixture would also work — this stays put because a source-relative + * import proves the union at its definition, before the build, and needs no rebuild to stay honest. + * A co-located `.type-guard.ts` file under `src/` would get picked up by tsup's + * `src/**\/*.{ts,tsx}` build glob and ship an unused fixture in the published package; naming it + * `*.test.ts` excludes it from that glob (tsup.config.ts excludes `src/**\/*.test.{ts,tsx}`) while + * this package's own `tsc --noEmit` (and `bun test`, which type-checks test files per the repo + * convention) still verifies it. + */ +import { describe, expect, test } from 'bun:test' +import type { VirtualizeProps } from './virtualize' + +function accept(props: VirtualizeProps): VirtualizeProps { + return props +} + +// ── Valid combinations — must type-check with no error ──────────────────────── + +accept({}) +accept({ virtualize: false }) +accept({ virtualize: true, height: 400 }) +accept({ virtualize: true, height: '100%' }) +accept({ virtualize: { overscan: 3 }, height: 400 }) +accept({ virtualize: { estimateSize: 72 }, height: '50vh' }) + +// ── Invalid combinations — each MUST be a tsc error, one directive per bad line ─ + +// @ts-expect-error `height` is forbidden when `virtualize` is false +accept({ virtualize: false, height: 400 }) +// @ts-expect-error `height` is required when `virtualize: true` +accept({ virtualize: true }) +// @ts-expect-error `height` is required when `virtualize` is a VirtualizeOptions object +accept({ virtualize: { overscan: 2 } }) +// @ts-expect-error `height` is forbidden when `virtualize` is omitted (defaults to the `false` branch) +accept({ height: 400 }) + +describe('VirtualizeProps (type-guard)', () => { + test('is a compile-time-only fixture — see the @ts-expect-error directives above', () => { + // No runtime behavior to assert: the proof is that this file type-checks at all. A single + // trivial assertion keeps this a normal bun:test file rather than an empty-suite oddity. + expect(true).toBe(true) + }) +}) diff --git a/packages/basalt-ui/src/index.ts b/packages/basalt-ui/src/index.ts index ca5dc72..c797d14 100644 --- a/packages/basalt-ui/src/index.ts +++ b/packages/basalt-ui/src/index.ts @@ -113,6 +113,7 @@ export type { GuardKind } from './guard/types' export { ThreadWorkspace, ThreadFeed, + ThreadFeedRow, ThreadOutcomeCard, ThreadDetailPanel, Composer, @@ -123,6 +124,7 @@ export { export type { ThreadWorkspaceProps, ThreadFeedProps, + ThreadFeedRowProps, ThreadOutcomeCardProps, ThreadDetailPanelProps, ComposerProps, @@ -130,6 +132,9 @@ export type { ComposerAttachment, ComposerHandle, ThreadTranscriptProps, + MessageAffordances, + VirtualizeOptions, + VirtualizeProps, ToolChipProps, } from './agent-chat' export { diff --git a/packages/basalt-ui/src/surfaces.ts b/packages/basalt-ui/src/surfaces.ts index 2e5f381..a5b4300 100644 --- a/packages/basalt-ui/src/surfaces.ts +++ b/packages/basalt-ui/src/surfaces.ts @@ -385,7 +385,7 @@ export const SURFACES = { skill: ['basalt-app'], guardKinds: [], description: - 'Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx.', + 'Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed (variant/renderRow), ThreadFeedRow (inline-expanding Slack row, lazily mounted + kept mounted), ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props, per-message MessageAffordances, groupConsecutive, and an optional virtualize/height windowing mode whose VirtualizeOptions carry overscan/estimateSize/initialScroll — a virtualized transcript opens scrolled to the newest message unless initialScroll is "start"), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx, and @tanstack/react-virtual the same way through the lazy import() behind ThreadTranscript virtualize (absent peer degrades to an unwindowed, height-bound pane).', optionalPeers: [ 'ai', 'motion', @@ -397,6 +397,7 @@ export const SURFACES = { '@shikijs/langs', '@shikijs/themes', 'beautiful-mermaid', + '@tanstack/react-virtual', ], forbiddenImports: [], }, diff --git a/packages/basalt-ui/src/theme/shadow-surfaces.test.ts b/packages/basalt-ui/src/theme/shadow-surfaces.test.ts index 4fb09a1..3e41707 100644 --- a/packages/basalt-ui/src/theme/shadow-surfaces.test.ts +++ b/packages/basalt-ui/src/theme/shadow-surfaces.test.ts @@ -386,6 +386,15 @@ export const SHADOW_SURFACES: readonly ShadowSurfaceEntry[] = [ site: 'default', roundedBy: 'borderRadius: VX.radiusCard (co-declared in the same inline style object).', }, + { + file: 'agent-chat/thread-feed-row.tsx', + site: 'default', + roundedBy: + 'borderRadius: VX.radiusCard (co-declared in the same inline style object as the shadow). ' + + 'This box also carries overflow: hidden, which is what lets the header button and the ' + + 'expanded body square off against the row’s rounded corners; an element’s own overflow ' + + 'never clips its own ring, so the shadow is unaffected.', + }, // ── agent ──────────────────────────────────────────────────────────────────────────────────── { file: 'agent/stick-to-bottom.tsx', From f00bf6d505455f75a9d918396b25e95e49b476cd Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 10:19:38 +0200 Subject: [PATCH 02/11] fix: stop a hydrating store flashing empty and a cascade burying its cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThreadWorkspace rendered its "no threads yet" empty state while the store was still hydrating. ThreadsStore exposes hydrated for exactly this and nothing in the Mantine layer read it, so a server-backed store with real threads showed an empty inbox and then swapped. It held only because the playground's in-memory adapter seeds an empty map, so there was nothing to flash to. A hydrating store that already carries threads still renders them — hydrating is not empty — and the synchronous store pins hydrated true, so it never sees the new branch. The adapter's write queue drained dependent writes after their thread had rolled away, and each failure overwrote the error before it. A failed create surfaced as "setStatus: unknown thread" more than a second later, with the real cause gone. Writes for a rolled-back thread are now dropped rather than sent; independent failures still surface, and other threads' queues are untouched. That record is kept for the store's lifetime rather than cleared when the queue drains. The cascade is not confined to the synchronous send path — the completion path fires when a stream ends, long after the chain has drained, and reproduced the same overwrite. It is bounded because create mints its own id, so an id in the set can never be created again. --- .../src/agent-chat/thread-workspace.test.tsx | 156 ++++++++++++ .../src/agent-chat/thread-workspace.tsx | 36 ++- packages/basalt-ui/src/agent/adapter.test.ts | 237 ++++++++++++++++++ packages/basalt-ui/src/agent/adapter.ts | 86 +++++-- packages/basalt-ui/src/agent/thread.ts | 4 + 5 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx diff --git a/packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx b/packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx new file mode 100644 index 0000000..2a4ea91 --- /dev/null +++ b/packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx @@ -0,0 +1,156 @@ +/** + * ThreadWorkspace — hydration gating (D1). + * + * A store built with `createAdapterThreadsStore` starts `!hydrated` until its first `listThreads` + * succeeds. Before this fix, `ThreadWorkspace` read only `threads.length` to decide between the + * feed and the empty state, so a server-backed store with real threads (a database, a real API) + * would render "no threads yet" for the whole initial round trip, then swap to the populated feed + * once it landed — a defect invisible in the playground's demo only because that demo's in-memory + * adapter seeds an empty Map, so there is nothing to flash to. + * + * These tests drive `ThreadWorkspace` directly against a hand-built `ThreadsStore` (not a real + * `createAdapterThreadsStore` instance) so each case can assert one exact `{ hydrated, threads }` + * combination without racing a real async adapter. + */ +import { MantineProvider } from '@mantine/core' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, test } from 'bun:test' +import type { ReactElement } from 'react' +import type { AgentThread, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent' +import { createThreadsStore } from '../agent' +import { ThreadWorkspace } from './thread-workspace' + +afterEach(cleanup) + +// `useMediaQuery` reads `window.matchMedia` inside a mount effect RTL flushes synchronously — +// force the wide-viewport branch so every case renders the feed pane (same idiom as +// thread-feed.test.tsx's `withReducedMotion`). +function withWideViewport(fn: () => T): T { + const original = window.matchMedia + window.matchMedia = (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList + try { + return fn() + } finally { + window.matchMedia = original + } +} + +function noop(): void {} + +/** A transport that never resolves — no case here calls `start()`, so `stream` is never invoked. */ +const stubTransport: AgentTransport = { + stream: (): AsyncGenerator => { + throw new Error('stubTransport.stream must not be called by these tests') + }, +} + +const stubResolveOutcome: OutcomeResolver = async (thread) => ({ + title: thread.id, + summary: '', + status: 'done', +}) + +function buildThread(id: string): AgentThread { + return { + id, + messages: [], + outcome: { title: id, summary: '', status: 'done' }, + status: 'done', + read: true, + createdAt: 0, + updatedAt: 0, + } +} + +/** A minimal fixed `ThreadsStore` — every mutator is a no-op since no case here calls one. */ +function makeStore(overrides: Partial): ThreadsStore { + return { + threads: [], + activeId: null, + hydrated: true, + error: undefined, + select: noop, + create: () => 'unused', + appendMessage: noop, + setOutcome: noop, + setStatus: noop, + setResumeToken: noop, + markRead: noop, + remove: noop, + clear: noop, + ...overrides, + } +} + +function renderWorkspace(store: ThreadsStore): ReactElement { + return ( + + store} + transport={stubTransport} + resolveOutcome={stubResolveOutcome} + /> + + ) +} + +describe('ThreadWorkspace hydration gating', () => { + test('hydrating + empty: renders the hydrating hold, never the empty-state copy', () => { + withWideViewport(() => { + render(renderWorkspace(makeStore({ hydrated: false, threads: [] }))) + }) + + expect(screen.getByTestId('thread-workspace-hydrating')).toBeDefined() + expect(screen.queryByText('No threads yet')).toBeNull() + }) + + test('hydrating + already-populated: renders the feed content, not the hydrating hold', () => { + withWideViewport(() => { + render(renderWorkspace(makeStore({ hydrated: false, threads: [buildThread('cached-1')] }))) + }) + + expect(screen.getByText('cached-1')).toBeDefined() + expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull() + }) + + test('hydrated + empty: renders the empty state (no threads really is the truth)', () => { + withWideViewport(() => { + render(renderWorkspace(makeStore({ hydrated: true, threads: [] }))) + }) + + expect(screen.getByText('No threads yet')).toBeDefined() + expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull() + }) + + test('real synchronous createThreadsStore: never shows the hydrating hold, even on first render', () => { + // The real localStorage-backed store (hydrated pinned `true` — see thread.test.ts), not the + // hand-built stub above, so this proves the gate reads a genuine ThreadsStore correctly and + // does not regress the synchronous path with a permanent skeleton. + const useThreads = createThreadsStore({ key: 'thread-workspace-sync-empty', version: 1 }) + + withWideViewport(() => { + render( + + + , + ) + }) + + expect(screen.getByText('No threads yet')).toBeDefined() + expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull() + }) +}) diff --git a/packages/basalt-ui/src/agent-chat/thread-workspace.tsx b/packages/basalt-ui/src/agent-chat/thread-workspace.tsx index ec44f89..cfa56f1 100644 --- a/packages/basalt-ui/src/agent-chat/thread-workspace.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-workspace.tsx @@ -32,7 +32,7 @@ * ) * } */ -import { Box, Divider, Flex, Stack, Text } from '@mantine/core' +import { Box, Divider, Flex, Skeleton, Stack, Text } from '@mantine/core' import { useMediaQuery } from '@mantine/hooks' import type { JSX, ReactNode } from 'react' import type { AgentPart, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent' @@ -56,11 +56,16 @@ export type ThreadWorkspaceProps = { readonly resolveOutcome: OutcomeResolver /** Placeholder for the new-thread composer pinned under the feed. */ readonly newThreadPlaceholder?: string - /** Rendered in place of the feed while there are no threads yet. */ + /** + * Rendered in place of the feed once the store is hydrated and there really are no threads. + * Shown ONLY when `store.hydrated` is true — while an async store's initial load is still in + * flight, `ThreadWorkspace` holds a neutral loading state instead (see `FeedHydratingState`), + * so a server-backed workspace never asserts "no threads yet" before it actually knows that. + */ readonly emptyState?: ReactNode } -/** Default hint shown in the feed pane before any thread exists (overridable via `emptyState`). */ +/** Default hint shown in the feed pane once hydrated with no threads (overridable via `emptyState`). */ function FeedEmptyState(): JSX.Element { return ( @@ -74,6 +79,23 @@ function FeedEmptyState(): JSX.Element { ) } +/** + * Neutral hold shown in the feed pane while an async store's initial `listThreads` is still in + * flight AND it has no threads to show yet (`!store.hydrated && threads.length === 0`). Never + * shown for the synchronous `createThreadsStore`, whose `hydrated` is always `true` — see + * `ThreadsStore.hydrated`'s doc. A store that already holds cached threads while revalidating + * (`!hydrated` with a non-empty `threads`) skips this entirely and renders the feed normally. + */ +function FeedHydratingState(): JSX.Element { + return ( + + + + + + ) +} + /** * A two-pane master-detail thread workspace: a scrollable feed of threads with an anchored * new-thread composer, and a detail panel for the open thread's live transcript. Collapses to a @@ -130,8 +152,12 @@ export function ThreadWorkspace({ const feed = ( - {store.threads.length === 0 ? ( - (emptyState ?? ) + {threads.length === 0 ? ( + store.hydrated ? ( + (emptyState ?? ) + ) : ( + + ) ) : ( )} diff --git a/packages/basalt-ui/src/agent/adapter.test.ts b/packages/basalt-ui/src/agent/adapter.test.ts index a90bf9f..f163bbb 100644 --- a/packages/basalt-ui/src/agent/adapter.test.ts +++ b/packages/basalt-ui/src/agent/adapter.test.ts @@ -134,6 +134,21 @@ describe('threadsStoreAdapterContract', () => { expect(new Set(cases.map((c) => c.name)).size).toBe(cases.length) }) + test("the contract suite itself does not depend on crypto.randomUUID — it still runs to completion via the getRandomValues fallback (mintThreadId, since every id it mints here is a THREAD id, never appendMessage's idempotency key)", async () => { + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { + value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }, + configurable: true, + }) + try { + for (const conformanceCase of threadsStoreAdapterContract(() => createMemoryAdapter())) { + await conformanceCase.run() + } + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + }) + test('FAILS an adapter whose appendMessage is not idempotent on message.id', async () => { const cases = threadsStoreAdapterContract(() => { const memory = createMemoryAdapter() @@ -417,6 +432,228 @@ describe('createAdapterThreadsStore', () => { expect(result.current.threads[0]?.read).toBe(true) }) + // ── rolled-back create() and its dependent writes ─────────────────────────── + + test('a rolled-back create surfaces its own error, not a cascade from the writes queued behind it', async () => { + const calls: string[] = [] + const memory = createMemoryAdapter() + const useThreads = createAdapterThreadsStore({ + ...memory, + createThread: async () => { + calls.push('createThread') + throw new Error('create-boom') + }, + // Mirrors the real finding: a real backend REJECTS a write against a row that was never + // created, it does not silently no-op the way the permissive reference `memory` adapter + // does — so these reproduce the exact "unknown thread" cascade from the console trace. + markRead: async (id) => { + calls.push('markRead') + if ((await memory.loadThread(id)) === null) + throw new Error(`markRead: unknown thread ${id}`) + await memory.markRead(id) + }, + appendMessage: async (i) => { + calls.push('appendMessage') + if ((await memory.loadThread(i.threadId)) === null) { + throw new Error(`appendMessage: unknown thread ${i.threadId}`) + } + await memory.appendMessage(i) + }, + setStatus: async (i) => { + calls.push('setStatus') + if ((await memory.loadThread(i.threadId)) === null) { + throw new Error(`setStatus: unknown thread ${i.threadId}`) + } + await memory.setStatus(i) + }, + }) + + const { result } = renderHook(() => useThreads()) + await waitFor(() => { + expect(result.current.hydrated).toBe(true) + }) + + // Exactly the observed sequence: create() -> markRead() -> appendMessage() -> setStatus() in + // one synchronous block, every dependent write chained behind the same rejected createThread. + let id = '' + act(() => { + id = result.current.create() + result.current.markRead(id) + result.current.appendMessage(id, makeMessage('m-first')) + result.current.setStatus(id, 'streaming') + }) + + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error) + }) + await settle() + + // The surfaced error names the create, not one of the three cascade rejections that would + // otherwise overwrite it in issue order. + expect((result.current.error as Error).message).toBe('create-boom') + // The dependent writes never reached the adapter at all — dropped, not run-and-failed. + expect(calls).toEqual(['createThread']) + // Rollback semantics are untouched: the thread is gone, both locally and server-side. + expect(result.current.threads).toHaveLength(0) + expect(await memory.loadThread(id)).toBeNull() + }) + + test('a rolled-back create does not stall or spoil the write queue for a different thread', async () => { + const memory = createMemoryAdapter() + const calls: string[] = [] + let createCalls = 0 + const useThreads = createAdapterThreadsStore({ + ...memory, + createThread: async (i) => { + createCalls += 1 + if (createCalls === 1) { + calls.push('createThread:doomed') + throw new Error('create-boom') + } + calls.push('createThread:healthy') + await memory.createThread(i) + }, + markRead: async (id) => { + calls.push(`markRead:${id}`) + await memory.markRead(id) + }, + }) + + const { result } = renderHook(() => useThreads()) + await waitFor(() => { + expect(result.current.hydrated).toBe(true) + }) + + let doomed = '' + let healthy = '' + act(() => { + doomed = result.current.create() + result.current.markRead(doomed) + healthy = result.current.create() + result.current.markRead(healthy) + }) + + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error) + }) + await waitFor(async () => { + expect(await memory.loadThread(healthy)).not.toBeNull() + }) + await settle() + + expect((result.current.error as Error).message).toBe('create-boom') + // doomed's markRead was dropped; healthy's own write for a DIFFERENT thread ran normally. + expect(calls).toEqual(['createThread:doomed', 'createThread:healthy', `markRead:${healthy}`]) + expect((await memory.loadThread(healthy))?.read).toBe(true) + expect(await memory.loadThread(doomed)).toBeNull() + }) + + test('a genuine failure that arrives after a rolled-back create still surfaces', async () => { + const memory = createMemoryAdapter() + let createCalls = 0 + const useThreads = createAdapterThreadsStore({ + ...memory, + createThread: async (i) => { + createCalls += 1 + if (createCalls === 1) throw new Error('create-boom') + await memory.createThread(i) + }, + markRead: async () => { + // A genuine, unrelated failure against a HEALTHY thread — not a cascade of the rollback. + throw new Error('markRead-boom') + }, + }) + + const { result } = renderHook(() => useThreads()) + await waitFor(() => { + expect(result.current.hydrated).toBe(true) + }) + + act(() => { + result.current.create() + }) + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error) + }) + expect((result.current.error as Error).message).toBe('create-boom') + + let healthy = '' + act(() => { + healthy = result.current.create() + }) + await waitFor(async () => { + expect(await memory.loadThread(healthy)).not.toBeNull() + }) + + act(() => { + result.current.markRead(healthy) + }) + + await waitFor(() => { + expect((result.current.error as Error).message).toBe('markRead-boom') + }) + }) + + test('a rolled-back create keeps dropping its thread’s writes after the queue drains', async () => { + // The run's COMPLETION path (useAgentThreadRuns: appendMessage(assistant) -> setOutcome -> + // setStatus -> setResumeToken) lands when the stream ends, long after the create's own + // rollback has drained the per-thread chain. Those writes target the same rolled-back row, so + // they are the same cascade as the synchronous send-path trio — just later. If the failed-id + // record is cleared when the chain drains, the fix above covers the first ~200ms and then the + // cascade returns at stream end, which is precisely when the user is looking at the thread. + const calls: string[] = [] + const memory = createMemoryAdapter() + const useThreads = createAdapterThreadsStore({ + ...memory, + createThread: async () => { + calls.push('createThread') + throw new Error('create-boom') + }, + appendMessage: async (i) => { + calls.push('appendMessage') + if ((await memory.loadThread(i.threadId)) === null) { + throw new Error(`appendMessage: unknown thread ${i.threadId}`) + } + await memory.appendMessage(i) + }, + setStatus: async (i) => { + calls.push('setStatus') + if ((await memory.loadThread(i.threadId)) === null) { + throw new Error(`setStatus: unknown thread ${i.threadId}`) + } + await memory.setStatus(i) + }, + }) + + const { result } = renderHook(() => useThreads()) + await waitFor(() => { + expect(result.current.hydrated).toBe(true) + }) + + let id = '' + act(() => { + id = result.current.create() + }) + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error) + }) + // Drain fully: the create's chain entry is gone by the time the completion writes arrive. + await settle() + await settle() + expect((result.current.error as Error).message).toBe('create-boom') + + // The completion path fires now, against the row that was never materialized. + act(() => { + result.current.appendMessage(id, makeMessage('m-assistant')) + result.current.setStatus(id, 'done') + }) + await settle() + await settle() + + expect(calls).toEqual(['createThread']) + expect((result.current.error as Error).message).toBe('create-boom') + }) + test('setResumeToken(undefined) clears the key through the adapter', async () => { const memory = createMemoryAdapter() const useThreads = createAdapterThreadsStore(memory) diff --git a/packages/basalt-ui/src/agent/adapter.ts b/packages/basalt-ui/src/agent/adapter.ts index 93d44e5..c2426c1 100644 --- a/packages/basalt-ui/src/agent/adapter.ts +++ b/packages/basalt-ui/src/agent/adapter.ts @@ -168,7 +168,11 @@ type ThreadPatch = { * - **Roll back on rejection.** A rejected write's patch is discarded, the rejection is surfaced * on `error`, and the store re-lists to converge on whatever the server really did. Patches are * tracked individually, so a failing write rolls back only itself and not whatever else was in - * flight beside it. + * flight beside it. A rejected `create()` additionally drops every later single-thread write for + * that id (`markRead`/`appendMessage`/`setStatus`/… — both the ones chained directly behind it and + * the run's completion writes at stream end) instead of letting each fail independently against a + * row that no longer exists — see `failedCreateIds`. Without that, the create's own error is + * overwritten by a run of "unknown thread" rejections that name nothing a caller can act on. * - **`hydrated` means "a `listThreads` has succeeded"**, not "a load has been attempted". A * store that is `!hydrated` with a non-undefined `error` failed to load; check both. * - **`error` latches the most recent failure until something disproves it.** A successful @@ -412,6 +416,31 @@ export function createAdapterThreadsStore( */ const chains = new Map>() + /** + * Thread ids whose `createThread` has rejected and been rolled back. The send path issues + * `create()` → `markRead()` → `appendMessage()` → `setStatus()` in one synchronous block, all + * chained behind the same per-thread queue above — so when `createThread` rejects, the three + * dependent writes queued right behind it are guaranteed to reach the adapter next, each against + * a row that was never materialized. Left alone, each of those independently rejects with its own + * "unknown thread" error and overwrites the one that actually explains what happened (the + * create's), so the user ends up staring at the last cascade error instead of the root cause. + * + * Recording the id here lets `mutate` recognize a single-thread write against a rolled-back + * create and drop it silently instead of running a doomed commit — see `mutate`'s `isCreate` + * guard. + * + * Entries are NEVER removed, deliberately. The cascade is not confined to the synchronous block: + * `useAgentThreadRuns`' COMPLETION path (`appendMessage(assistant)` → `setOutcome` → `setStatus` + * → `setResumeToken`) fires when the stream ends, long after this thread's write chain has + * drained — same rolled-back row, same "unknown thread" rejections, and landing at exactly the + * moment the user is watching the thread. Clearing the entry when the chain drains would fix the + * first few hundred milliseconds and hand the cascade straight back at stream end. Retaining it + * is safe and bounded: `create()` always mints its own id (`mintThreadId`, never caller-supplied), + * so an id in here can never be re-created and the set only grows by one per FAILED create in a + * session. + */ + const failedCreateIds = new Set() + /** `task` must never reject, so a failed write cannot poison the chain behind it. */ function enqueue(threadIds: readonly string[], task: () => Promise): void { const ids = [...new Set(threadIds)] @@ -421,6 +450,7 @@ export function createAdapterThreadsStore( for (const id of ids) chains.set(id, run) void run.finally(() => { // Only the tail clears its own entry, so a later write chained behind it is not orphaned. + // `failedCreateIds` is deliberately NOT cleared here — see its doc. for (const id of ids) if (chains.get(id) === run) chains.delete(id) }) } @@ -438,6 +468,12 @@ export function createAdapterThreadsStore( readonly apply: ThreadPatch['apply'] readonly applyActive?: (activeId: string | null) => string | null readonly commit: () => Promise + /** + * True only for `create()`'s own write. On rejection, marks its thread id as failed so the + * dependent writes chained behind it in the same synchronous send-path block recognize the row + * was rolled back — see `failedCreateIds`. + */ + readonly isCreate?: boolean }): void { const patch: ThreadPatch = { apply: memoizeApply(i.apply), @@ -448,9 +484,30 @@ export function createAdapterThreadsStore( const tokenAtIssue = errorToken recompute() enqueue(i.threadIds, async (): Promise => { + // A single-thread write against a create that already rolled back cannot succeed — the row + // was never materialized, and running it only manufactures a second "unknown thread" error + // that would bury the create's own. This covers both the writes chained directly behind the + // create and the run's later completion writes (see `failedCreateIds`). Scoped to + // single-thread writes deliberately: + // `clear()`'s multi-id commit already has its own per-id fan-out (`Promise.allSettled`) and + // `removeThread` is contractually a no-op on an unknown id anyway, so folding this check into + // a multi-id write would risk dropping legitimate deletes for OTHER, healthy threads in the + // same batch just because one id in it happened to be orphaned. + const [soleThreadId] = i.threadIds + if ( + !i.isCreate && + i.threadIds.length === 1 && + soleThreadId !== undefined && + failedCreateIds.has(soleThreadId) + ) { + drop(patch) + recompute() + return + } try { await i.commit() } catch (cause) { + if (i.isCreate) for (const threadId of i.threadIds) failedCreateIds.add(threadId) // Roll back: discard THIS patch only, leaving any other in-flight patch applied. drop(patch) error = cause @@ -540,6 +597,7 @@ export function createAdapterThreadsStore( mutate({ threadIds: [id], apply: (threads) => (threads.some((t) => t.id === id) ? [...threads] : [thread, ...threads]), + isCreate: true, commit: () => adapter.createThread({ id, @@ -738,7 +796,7 @@ export function threadsStoreAdapterContract( return [ define('createThread then listThreads returns the thread with empty defaults', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) const threads = await a.listThreads() const found = threads.filter((t) => t.id === id) @@ -754,19 +812,19 @@ export function threadsStoreAdapterContract( }), define('createThread persists meta', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id, meta: { source: 'contract' } }) const thread = await loadOrThrow(a, id) assert(thread.meta?.['source'] === 'contract', 'meta did not round-trip') }), define('loadThread returns null for an unknown id', async (a) => { - const thread = await a.loadThread(crypto.randomUUID()) + const thread = await a.loadThread(mintThreadId()) assert(thread === null, 'loadThread must return null (not throw, not undefined) when absent') }), define('appendMessage is idempotent on message.id', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) const msg = message('contract-msg-1') await a.appendMessage({ threadId: id, message: msg }) @@ -780,7 +838,7 @@ export function threadsStoreAdapterContract( }), define('appendMessage preserves order for distinct ids', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) await a.appendMessage({ threadId: id, message: message('contract-msg-1') }) await a.appendMessage({ threadId: id, message: message('contract-msg-2') }) @@ -792,7 +850,7 @@ export function threadsStoreAdapterContract( }), define('setStatus round-trips', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) await a.setStatus({ threadId: id, status: 'streaming' }) assert((await loadOrThrow(a, id)).status === 'streaming', "status did not become 'streaming'") @@ -801,7 +859,7 @@ export function threadsStoreAdapterContract( }), define('setOutcome round-trips', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) const outcome: AgentOutcome = { title: 'Title', summary: 'Summary', status: 'done' } await a.setOutcome({ threadId: id, outcome }) @@ -812,7 +870,7 @@ export function threadsStoreAdapterContract( }), define('setResumeToken round-trips and clears with undefined', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) await a.setResumeToken({ threadId: id, token: 'tok-1' }) assert((await loadOrThrow(a, id)).resumeToken === 'tok-1', 'resumeToken did not round-trip') @@ -824,7 +882,7 @@ export function threadsStoreAdapterContract( }), define('markRead marks the thread read', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) assert((await loadOrThrow(a, id)).read === false, 'a new thread must start unread') await a.markRead(id) @@ -835,7 +893,7 @@ export function threadsStoreAdapterContract( // The store keeps a write's optimistic patch applied until a listThreads STARTED after that // write resolved lands in the confirmed base. An eventually-consistent list (a stale read // replica, a cached response) breaks that and flashes the pre-write state back into the UI. - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) await a.appendMessage({ threadId: id, message: message('contract-msg-raw') }) await a.setStatus({ threadId: id, status: 'streaming' }) @@ -856,7 +914,7 @@ export function threadsStoreAdapterContract( async (a) => { // The store serializes writes per thread id, so this exact sequence — the send path's // create-then-first-message — is what a real adapter receives. Each write must find the row. - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) await a.markRead(id) await a.appendMessage({ threadId: id, message: message('contract-msg-dep') }) @@ -872,7 +930,7 @@ export function threadsStoreAdapterContract( ), define('removeThread removes the thread', async (a) => { - const id = crypto.randomUUID() + const id = mintThreadId() await a.createThread({ id }) await a.removeThread(id) assert(await a.loadThread(id).then((t) => t === null), 'loadThread must be null after remove') @@ -881,7 +939,7 @@ export function threadsStoreAdapterContract( }), define('removeThread of an unknown id does not throw', async (a) => { - await a.removeThread(crypto.randomUUID()) + await a.removeThread(mintThreadId()) }), ] } diff --git a/packages/basalt-ui/src/agent/thread.ts b/packages/basalt-ui/src/agent/thread.ts index e7e5bcf..1775f42 100644 --- a/packages/basalt-ui/src/agent/thread.ts +++ b/packages/basalt-ui/src/agent/thread.ts @@ -145,6 +145,10 @@ export type ThreadsStore = { * store built with `createAdapterThreadsStore` (./adapter), where it stays `false` until the * first `listThreads` SUCCEEDS. Pair it with `error` — `!hydrated && error !== undefined` is a * failed load, `!hydrated && error === undefined` is still loading. + * + * Gating an empty state on this is the CALLER's job when hand-assembling `ThreadFeed` / + * `ThreadDetailPanel`; the shipped `ThreadWorkspace` already does it internally, so an + * async store never flashes "no threads yet" through it before it actually knows. */ readonly hydrated: boolean /** From 55838a2bbafc5f00d7fd2896d108530d84458f3d Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 10:19:55 +0200 Subject: [PATCH 03/11] fix: mint ids safely off a secure context and never wedge a stopped turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crypto.randomUUID exists only in secure contexts, so a consumer served over plain HTTP on a LAN hostname threw a TypeError on ordinary actions. mintThreadId covered the two store create sites; the rest of the agent layer did not. Ids are classified rather than blanket-replaced, because the two cases differ in kind. A thread id may fall back to a non-random rung: a collision costs a locally duplicated thread, which is visible and recoverable. A message id may not. appendMessage is idempotent on it, so a collision is not two rows but one — the second write silently no-ops and its content is gone with nothing downstream able to tell. mintMessageId therefore throws rather than degrading, which is the one place in this layer where a loud failure beats a quiet one. That throw is a write-path decision, not a render-path one, and the render path still degrades: spliceText clamps, coalesceParts degrades, a fence renderer that throws does not take the message down. finalizeStop could leave a thread streaming forever. It appended before setting status, and any throw in that window — the new mint, or the consumer's own store call — skipped the teardown. Since stop had already dropped the controller, a second stop was a silent no-op and the run was unrecoverable: a thread that claims to be streaming, cannot be stopped, and cannot be typed into. Each store call is guarded individually and the teardown is unconditional. The unmount cleanup aborted every controller without clearing the runs it had just killed. On a fiber that survives its effects re-running — StrictMode's double invoke, or an Activity hide and show — that left the same phantom. It now tears down exactly the threads it aborts, and stop settles a thread whose controller is already gone instead of returning. consumeAndFinalize's abort guard is deliberately untouched: it cannot tell an abort from a supersede, so a teardown there would clobber a resumed run. --- .../src/agent/ai-sdk-transport.test.ts | 64 ++++ .../basalt-ui/src/agent/ai-sdk-transport.ts | 10 +- packages/basalt-ui/src/agent/id.test.ts | 96 +++++- packages/basalt-ui/src/agent/id.ts | 126 +++++-- .../src/agent/use-agent-stream.test.tsx | 22 ++ .../basalt-ui/src/agent/use-agent-stream.ts | 9 +- .../src/agent/use-agent-thread-runs.test.tsx | 326 ++++++++++++++++++ .../src/agent/use-agent-thread-runs.ts | 182 ++++++++-- .../use-agent-thread-runs.wedge.test.tsx | 80 +++++ 9 files changed, 858 insertions(+), 57 deletions(-) diff --git a/packages/basalt-ui/src/agent/ai-sdk-transport.test.ts b/packages/basalt-ui/src/agent/ai-sdk-transport.test.ts index ca9124a..f14f21d 100644 --- a/packages/basalt-ui/src/agent/ai-sdk-transport.test.ts +++ b/packages/basalt-ui/src/agent/ai-sdk-transport.test.ts @@ -592,3 +592,67 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => { ) }) }) + +describe('aiSdkTransport — id minting under degraded crypto', () => { + test('the fixed chatId (mintThreadId, low collision cost) mints fine with no usable crypto at all — construction never throws', () => { + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + expect(() => + aiSdkTransport({ + api: '/api/chat', + fetch: mockFetch(() => { + throw new Error('must not be called') + }), + }), + ).not.toThrow() + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + }) + + test('the outbound user message id (mintMessageId, an idempotency key on the AI SDK backend) works via getRandomValues when randomUUID is unavailable', async () => { + const chunks: UIMessageChunk[] = [ + { type: 'text-start', id: 't1' }, + { type: 'text-end', id: 't1' }, + ] + const transport = aiSdkTransport({ + api: '/api/chat', + fetch: mockFetch(async () => scriptedResponse(chunks)), + }) + + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { + value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }, + configurable: true, + }) + try { + const parts = await collect(transport.stream('hi')) + expect(parts[0]?.type).toBe('start') + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + }) + + test('the outbound user message id THROWS on a host with no usable crypto at all, rather than silently colliding', async () => { + const transport = aiSdkTransport({ + api: '/api/chat', + fetch: mockFetch(() => { + throw new Error('must not be called — must throw before any network call') + }), + }) + const gen = transport.stream('hi') + + // The synthesized StartPart is yielded first, before mintMessageId is ever reached. + const start = await gen.next() + expect(start.value).toMatchObject({ type: 'start' }) + + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + await expect(gen.next()).rejects.toThrow(/idempotency key/) + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + }) +}) diff --git a/packages/basalt-ui/src/agent/ai-sdk-transport.ts b/packages/basalt-ui/src/agent/ai-sdk-transport.ts index cd5ce42..f510aef 100644 --- a/packages/basalt-ui/src/agent/ai-sdk-transport.ts +++ b/packages/basalt-ui/src/agent/ai-sdk-transport.ts @@ -48,6 +48,7 @@ * }) */ import { assertNever } from '../register' +import { mintMessageId, mintThreadId } from './id' import { TERMINAL_TOOL_STATES } from './parts' import type { AgentPart, AgentPartDraft } from './parts' import type { ResumableAgentTransport } from './transport' @@ -401,7 +402,10 @@ export function aiSdkTransport( const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk() const userMessage: UIMessage = { - id: crypto.randomUUID(), + // This id is what AI SDK's own backend persistence/dedup keys turns hang off — the same + // idempotency-key cost as basalt's own ChatMessage.id (see mintMessageId's doc), so + // mintMessageId, not mintThreadId, is the right helper here. + id: mintMessageId(), role: 'user', parts: [{ type: 'text', text: input }], } @@ -425,7 +429,9 @@ export function aiSdkTransport( } } - const fixedChatId = crypto.randomUUID() + // Client-side conversation namespace, not an idempotency key — a collision here would merge two + // conversations' index-addressed part ids, the same low collision cost `mintThreadId` covers. + const fixedChatId = mintThreadId() return { ...makeTransport(fixedChatId), forThread: (chatId: string) => makeTransport(chatId), diff --git a/packages/basalt-ui/src/agent/id.test.ts b/packages/basalt-ui/src/agent/id.test.ts index 4d27b28..5dbd94d 100644 --- a/packages/basalt-ui/src/agent/id.test.ts +++ b/packages/basalt-ui/src/agent/id.test.ts @@ -1,9 +1,15 @@ /** * withPartIds — stamps `${runId}#${n}` onto any draft part arriving without an id; idempotent for * drafts that already have one (untouched, and doesn't advance the counter). + * + * mintThreadId/mintMessageId — the two id-minting helpers behind every unguarded + * `crypto.randomUUID()` call site this file's siblings used to have. Both share rungs 1 + * (`crypto.randomUUID`) and 2 (`crypto.getRandomValues`, hand-assembled into a UUIDv4); they + * deliberately diverge on rung 3 (no crypto at all) — `mintThreadId` degrades, `mintMessageId` + * throws. See each function's own doc for why. */ import { describe, expect, test } from 'bun:test' -import { withPartIds } from './id' +import { mintMessageId, mintThreadId, withPartIds } from './id' import type { AgentPartDraft } from './parts' async function* gen(values: T[]): AsyncGenerator { @@ -16,6 +22,33 @@ async function collect(source: AsyncGenerator): Promise { return out } +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +/** Rung 2: randomUUID absent, getRandomValues present (a real, non-secure-context host). */ +function withRung2Crypto(run: () => T): T { + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { + value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }, + configurable: true, + }) + try { + return run() + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } +} + +/** Rung 3: no usable crypto at all. */ +function withRung3Crypto(run: () => T): T { + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + return run() + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } +} + describe('withPartIds', () => { test('stamps a sequential id on every draft missing one', async () => { // Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less @@ -73,3 +106,64 @@ describe('withPartIds', () => { ]) }) }) + +describe('mintThreadId', () => { + test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => { + const id = mintThreadId() + expect(id).toMatch(UUID_V4_RE) + }) + + test('rung 1: many mints are distinct', () => { + const ids = Array.from({ length: 1000 }, () => mintThreadId()) + expect(new Set(ids).size).toBe(1000) + }) + + test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => { + const id = withRung2Crypto(() => mintThreadId()) + expect(id).toMatch(UUID_V4_RE) + }) + + test('rung 2: many mints are distinct', () => { + const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintThreadId())) + expect(new Set(ids).size).toBe(1000) + }) + + test('rung 3 (no usable crypto at all): does NOT throw, still returns distinct non-empty strings', () => { + const ids = withRung3Crypto(() => Array.from({ length: 50 }, () => mintThreadId())) + for (const id of ids) { + expect(typeof id).toBe('string') + expect(id.length).toBeGreaterThan(0) + } + // Weak (Date.now() + Math.random()), not cryptographically random — but still distinct across + // this many mints in one tick, since Math.random() varies even when Date.now() doesn't. + expect(new Set(ids).size).toBe(50) + }) +}) + +describe('mintMessageId', () => { + test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => { + const id = mintMessageId() + expect(id).toMatch(UUID_V4_RE) + }) + + test('rung 1: many mints are distinct', () => { + const ids = Array.from({ length: 1000 }, () => mintMessageId()) + expect(new Set(ids).size).toBe(1000) + }) + + test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => { + const id = withRung2Crypto(() => mintMessageId()) + expect(id).toMatch(UUID_V4_RE) + }) + + test('rung 2: many mints are distinct', () => { + const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintMessageId())) + expect(new Set(ids).size).toBe(1000) + }) + + test('rung 3 (no usable crypto at all): THROWS rather than minting a non-random id', () => { + // Diverges from mintThreadId's rung 3 on purpose — appendMessage's idempotency key must never + // silently degrade to a non-collision-resistant id. See mintMessageId's own doc. + expect(() => withRung3Crypto(() => mintMessageId())).toThrow(/idempotency key/) + }) +}) diff --git a/packages/basalt-ui/src/agent/id.ts b/packages/basalt-ui/src/agent/id.ts index 3335642..064da67 100644 --- a/packages/basalt-ui/src/agent/id.ts +++ b/packages/basalt-ui/src/agent/id.ts @@ -1,51 +1,121 @@ /** - * Id-minting helpers shared by the thread stores: `mintThreadId` (a client-side thread id, used by - * both `./thread`'s `createThreadsStore` and `./adapter`'s `createAdapterThreadsStore`) and - * `withPartIds` (stamps sequence ids onto streamed parts). Related in theme — both mint an id a - * store needs — unrelated in mechanism; grouped here because they're the two id-minting leaves the - * two thread stores both sit above. + * Id-minting helpers shared across the agent layer: `mintThreadId` (a low-stakes client-side id — + * threads, run/chat namespaces — used by `./thread`'s `createThreadsStore`, `./adapter`'s + * `createAdapterThreadsStore`/`threadsStoreAdapterContract`, `./use-agent-stream`'s per-send run + * id, and `aiSdkTransport`'s fixed chat id), `mintMessageId` (the higher-stakes id `appendMessage` + * treats as its only idempotency key), and `withPartIds` (stamps sequence ids onto streamed + * parts). Grouped here because they're the id-minting leaves every store/hook/transport in this + * layer sits above — not because they share a mechanism (`mintMessageId` deliberately diverges + * from `mintThreadId` on rung 3; see its own doc). */ /** - * Mints a client-side thread id. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or - * localhost) — a consumer served over plain HTTP on a LAN hostname (a homelab dashboard on a bare - * IP, a staging box) would otherwise have `create()`, about the most basic user action a thread - * store has, throw a plain TypeError. A store used from a browser must not throw on a user gesture - * — the same principle already applied twice this release (`spliceText` clamps rather than throws, - * `coalesceParts` degrades rather than throws). + * Hand-assembles a UUIDv4 string from 16 random bytes — the shared rung-2 mechanism both + * `mintThreadId` and `mintMessageId` use once `crypto.randomUUID` is unavailable but + * `crypto.getRandomValues` still is. The version/variant bits are set per RFC 4122, since this + * rung hands back raw bytes, not a formatted UUID. + */ +function assembleUuidV4(bytes: Uint8Array): string { + const hex = Array.from({ length: 16 }, (_, i) => { + const raw = bytes.at(i) ?? 0 + // Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of + // clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes. + const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw + return byte.toString(16).padStart(2, '0') + }).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +/** + * Mints a client-side id for a LOW-COLLISION-COST use: a thread id, a per-send run-id namespace, a + * fixed chat id — anything minted client-side that is NOT the idempotency key some write depends + * on. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or localhost) — a consumer served + * over plain HTTP on a LAN hostname (a homelab dashboard on a bare IP, a staging box) would + * otherwise have `create()`, about the most basic user action a thread store has, throw a plain + * TypeError. A store used from a browser must not throw on a user gesture — the same principle + * already applied twice this release (`spliceText` clamps rather than throws, `coalesceParts` + * degrades rather than throws). * * Fallback chain, each rung reached only when the one above it is unavailable: * 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path. * 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4 - * (the version/variant bits are set per RFC 4122, since this rung hands back raw bytes, not a - * formatted UUID). + * via `assembleUuidV4`. * 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView, * an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung - * exists solely so `create()` never throws on such a host. It is acceptable ONLY because this - * id is minted a handful of times per client session and is not the idempotency key any write - * here depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE id, - * not this thread id, so a collision on this rung costs a locally merged/duplicated thread, not - * silent data loss on the append path. Do not reuse this rung for anything with a higher - * collision cost than that. + * exists solely so a caller never throws on such a host. It is acceptable ONLY because an id + * minted here is minted a handful of times per client session and is not the idempotency key + * any write depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE + * id (see `mintMessageId`), not this one, so a collision on this rung costs a locally + * merged/duplicated thread (or two runs sharing a part-id namespace), not silent data loss on + * the append path. Do not reuse this rung for anything with a higher collision cost than that + * — see `mintMessageId` for the id that needs one. */ export function mintThreadId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { - const bytes = crypto.getRandomValues(new Uint8Array(16)) - const hex = Array.from({ length: 16 }, (_, i) => { - const raw = bytes.at(i) ?? 0 - // Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of - // clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes. - const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw - return byte.toString(16).padStart(2, '0') - }).join('') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16))) } return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` } +/** + * Mints a message id — the value `ThreadsStoreAdapter.appendMessage` treats as its ONLY + * idempotency key (see that method's contract doc in `./adapter`). Shares `mintThreadId`'s rungs + * 1-2 (`crypto.randomUUID()`, then `crypto.getRandomValues()` assembled into a UUIDv4) — both are + * cryptographically random and collision-resistant, and rung 2 is the fix that actually matters in + * practice (a plain-HTTP/LAN host still has `getRandomValues`; only `randomUUID` is secure-context- + * gated). + * + * Rung 3 deliberately DIVERGES from `mintThreadId`: it THROWS instead of degrading to a + * non-random fallback. A thread id's rung-3 collision costs a locally merged/duplicated thread — + * annoying, recoverable, visible. A message id's collision cost is different IN KIND, not degree: + * `appendMessage` is idempotent on this id, so two messages that collide are not two rows, they + * are ONE — the second write silently no-ops and its content is gone, with nothing downstream able + * to tell. Degrading here would trade a loud, debuggable failure for silent, permanent data loss. + * + * This deliberately diverges from this layer's other standing rule — render-path code degrades, + * never throws (`spliceText` clamps, `coalesceParts` degrades, a consumer fence renderer must not + * take the message down) — because minting a message id is NOT render-path code. It runs once, in + * the write path that constructs a message before handing it to a store (`start()`, + * `consumeAndFinalize`'s finalize step, `finalizeStop`, `aiSdkTransport`'s per-turn user message), + * never inside the per-chunk accumulation/render loop those functions guard. A host with no usable + * `crypto` at all (missing both `randomUUID` and `getRandomValues`) is vanishingly rare — no + * runtime with a DOM/fetch surface worth targeting ships without a Crypto object — and an uncaught + * throw here is a loud, debuggable failure rather than a message silently vanishing with no trace, + * but WHERE it surfaces differs by call site (audited, not assumed — this paragraph previously + * claimed a single shared catch handled all of them, which was false): + * - `consumeAndFinalize`'s own assistantMessage mint runs inside that function's try/catch, so a + * throw there is correctly turned into `onFailureStatus` ('error' from `start()`, 'interrupted' + * from the mount-time resume) — a visibly stuck-then-settled run, as originally claimed. + * - `useAgentThreadRuns.start()`'s userMessage mint runs BEFORE any state is touched + * (`appendMessage`/`setStatus`/the controller registration all come after it), so a throw there + * is a true no-op that propagates synchronously out of `start()` itself — not a stuck run at + * all, since nothing ever started. See `UseAgentThreadRunsReturn.start`'s `@throws` doc. + * - `finalizeStop`'s stoppedMessage mint is wrapped in its own try/catch precisely so a throw + * here cannot wedge the thread at 'streaming' forever (stop() has already torn down this run's + * bookkeeping by the time finalizeStop runs, so a second stop() is always a no-op) — see + * `finalizeStop`'s doc for the guarded behavior and the resulting 'error' status. + * A stuck spinner is debuggable; silently discarded chat history is not — that half of the + * original claim stands regardless of which of the three paths above catches it. + * + * @throws {Error} when neither `crypto.randomUUID` nor `crypto.getRandomValues` is available. + */ +export function mintMessageId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16))) + } + throw new Error( + 'mintMessageId: no usable crypto (both randomUUID and getRandomValues are unavailable) — ' + + 'refusing to mint a non-random message id, since ThreadsStoreAdapter.appendMessage treats ' + + 'this id as its only idempotency key and a collision would silently drop a message.', + ) +} + /** * withPartIds — stamps a stable id onto every draft part arriving without one. * diff --git a/packages/basalt-ui/src/agent/use-agent-stream.test.tsx b/packages/basalt-ui/src/agent/use-agent-stream.test.tsx index 9e85590..e25ed28 100644 --- a/packages/basalt-ui/src/agent/use-agent-stream.test.tsx +++ b/packages/basalt-ui/src/agent/use-agent-stream.test.tsx @@ -262,4 +262,26 @@ describe('useAgentStream', () => { // Only the SECOND (surviving) call's part made it into final state — the first is discarded. expect(capturedParts).toEqual([{ id: 'call-2', type: 'text', text: 'call-2' }]) }) + + test('send() still streams without throwing on a host with no usable crypto at all — its runId is a low-collision-cost mint (mintThreadId), never the message-id one', async () => { + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + const transport: AgentTransport = { + async *stream() { + yield { type: 'text', text: 'a' } as AgentPartDraft as AgentPart + }, + } + const { result } = renderHook(() => useAgentStream({ transport })) + + await act(async () => { + await result.current.send('hi') + }) + + expect(result.current.status).toBe('done') + expect(result.current.parts).toHaveLength(1) + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + }) }) diff --git a/packages/basalt-ui/src/agent/use-agent-stream.ts b/packages/basalt-ui/src/agent/use-agent-stream.ts index ede3168..7e883b0 100644 --- a/packages/basalt-ui/src/agent/use-agent-stream.ts +++ b/packages/basalt-ui/src/agent/use-agent-stream.ts @@ -27,7 +27,7 @@ * } */ import { useCallback, useEffect, useRef, useState } from 'react' -import { withPartIds } from './id' +import { mintThreadId, withPartIds } from './id' import { isStartPart } from './parts' import { mergePart } from './merge' import type { PartLike } from './merge' @@ -107,8 +107,11 @@ export function useAgentStream part.type)).toEqual(['text', 'tool', 'text']) }) }) + +describe('useAgentThreadRuns — message-id minting under degraded crypto', () => { + test('start() mints distinct message ids via crypto.getRandomValues when randomUUID is unavailable', async () => { + const store = createTestThreadsStore() + const threadA = store.create() + const threadB = store.create() + + const transport: AgentTransport = { + async *stream(input) { + yield { id: 'p1', type: 'text', text: `done:${input}` } + }, + } + + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { + value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }, + configurable: true, + }) + try { + const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome })) + + act(() => { + result.current.start(threadA, 'input-a') + result.current.start(threadB, 'input-b') + }) + + await waitFor(() => { + expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done') + expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done') + }) + + const allMessageIds = store.threads.flatMap((t) => t.messages.map((m) => m.id)) + expect(allMessageIds.length).toBeGreaterThan(0) + expect(new Set(allMessageIds).size).toBe(allMessageIds.length) + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + }) + + test('start() THROWS on a host with no usable crypto at all, rather than silently minting a colliding message id', () => { + const store = createTestThreadsStore() + const threadId = store.create() + + const transport: AgentTransport = { + async *stream() { + yield { id: 'p1', type: 'text', text: 'unreachable' } + }, + } + + const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome })) + + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + expect(() => { + act(() => { + result.current.start(threadId, 'hi') + }) + }).toThrow(/idempotency key/) + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + + // The deliberate divergence documented on mintMessageId: throwing means the write never + // happened at all (no half-appended message, no orphaned run entry) rather than silently + // dropping content behind a colliding id. + expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(0) + expect(result.current.runs.has(threadId)).toBe(false) + }) +}) + +// A ThreadsStore double that delegates everything to a real test store EXCEPT appendMessage, +// which throws for messages matching `shouldThrow` — lets a test simulate consumer-adapter code +// (appendMessage) throwing without touching the shared createTestThreadsStore factory used by +// every other test in this file. +function wrapStoreWithThrowingAppend( + store: ThreadsStore, + shouldThrow: (message: AgentThread['messages'][number]) => boolean, +): ThreadsStore { + return { + get threads() { + return store.threads + }, + get activeId() { + return store.activeId + }, + select: store.select, + create: store.create, + appendMessage(id, message) { + if (shouldThrow(message)) { + throw new Error('consumer appendMessage boom') + } + store.appendMessage(id, message) + }, + setOutcome: store.setOutcome, + setStatus: store.setStatus, + setResumeToken: store.setResumeToken, + markRead: store.markRead, + remove: store.remove, + clear: store.clear, + hydrated: store.hydrated, + error: store.error, + } +} + +/** As above, but for the settle half of `finalizeStop` — a consumer store whose `setStatus` throws + * (a remote-backed adapter rejecting a thread removed mid-stream is the realistic shape). Narrowed + * by `shouldThrow` because the hook ALSO calls `setStatus` from its mount-time orphan sweep, which + * is a different call site with a different (non-wedge) failure mode. */ +function wrapStoreWithThrowingSetStatus( + store: ThreadsStore, + shouldThrow: (status: AgentThread['status']) => boolean, +): ThreadsStore { + return { + get threads() { + return store.threads + }, + get activeId() { + return store.activeId + }, + select: store.select, + create: store.create, + appendMessage: store.appendMessage, + setOutcome: store.setOutcome, + setStatus(id, status) { + if (shouldThrow(status)) throw new Error('consumer setStatus boom') + store.setStatus(id, status) + }, + setResumeToken: store.setResumeToken, + markRead: store.markRead, + remove: store.remove, + clear: store.clear, + hydrated: store.hydrated, + error: store.error, + } +} + +describe('useAgentThreadRuns — finalizeStop must reach a terminal state no matter what', () => { + test('stop() on a host with no usable crypto still reaches a terminal status and clears the run entry (was: wedged at "streaming" forever)', async () => { + const store = createTestThreadsStore() + const threadId = store.create() + + const gate = deferred() + const transport: AgentTransport = { + async *stream() { + yield { id: 'p1', type: 'text', text: 'partial' } + await gate.promise + yield { id: 'p2', type: 'text', text: 'never arrives' } + }, + } + + const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome })) + + act(() => { + result.current.start(threadId, 'hi') + }) + + await waitFor(() => { + expect(result.current.runs.get(threadId)?.parts).toHaveLength(1) + }) + + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + act(() => { + result.current.stop(threadId) + }) + + const thread = store.threads.find((t) => t.id === threadId) + // Before the fix: mintMessageId's throw escaped finalizeStop entirely, so setStatus/setRuns + // never ran and this stayed 'streaming' forever. + expect(thread?.status).toBe('error') + // The stopped message never minted an id, so appendMessage was never even attempted — + // only the original user message is present. + expect(thread?.messages).toHaveLength(1) + expect(result.current.runs.has(threadId)).toBe(false) + + // A second stop() must not be the user's only recourse — and it isn't: controllersRef + // already had this threadId deleted before finalizeStop ever ran, so this is an ordinary + // no-op, not a retry of the failed append. + act(() => { + result.current.stop(threadId) + }) + expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error') + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + + gate.resolve(undefined) + }) + + test("stop() reaches a terminal status and clears the run entry even when the consumer's appendMessage throws (was: wedged at 'streaming' forever)", async () => { + const baseStore = createTestThreadsStore() + const threadId = baseStore.create() + const store = wrapStoreWithThrowingAppend( + baseStore, + (message) => message.role === 'assistant' && message.finish === 'stopped', + ) + + const gate = deferred() + const transport: AgentTransport = { + async *stream() { + yield { id: 'p1', type: 'text', text: 'partial' } + await gate.promise + yield { id: 'p2', type: 'text', text: 'never arrives' } + }, + } + + const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome })) + + act(() => { + result.current.start(threadId, 'hi') + }) + + await waitFor(() => { + expect(result.current.runs.get(threadId)?.parts).toHaveLength(1) + }) + + act(() => { + result.current.stop(threadId) + }) + + const thread = store.threads.find((t) => t.id === threadId) + expect(thread?.status).toBe('error') + // Only the user message — the consumer's appendMessage rejected the stopped message. + expect(thread?.messages).toHaveLength(1) + expect(result.current.runs.has(threadId)).toBe(false) + + // A second stop() remains a true no-op — not the user's only recourse. + act(() => { + result.current.stop(threadId) + }) + expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error') + + gate.resolve(undefined) + }) + + test("stop() clears the hook's own run entry even when the consumer's setStatus throws (the second half of the same wedge)", async () => { + const baseStore = createTestThreadsStore() + const threadId = baseStore.create() + const store = wrapStoreWithThrowingSetStatus(baseStore, (status) => status === 'done') + + const gate = deferred() + const transport: AgentTransport = { + async *stream() { + yield { id: 'p1', type: 'text', text: 'partial' } + await gate.promise + yield { id: 'p2', type: 'text', text: 'never arrives' } + }, + } + + const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome })) + + act(() => { + result.current.start(threadId, 'hi') + }) + + await waitFor(() => { + expect(result.current.runs.get(threadId)?.parts).toHaveLength(1) + }) + + act(() => { + result.current.stop(threadId) + }) + + // The store's own status is whatever the throwing adapter left it as — unrecoverable from + // here. What IS this hook's to guarantee is that `runs` no longer reports an in-flight turn: + // that entry is the hook's own state and nothing later can ever clear it (stop() already + // deleted the thread from controllersRef), so a throw above the teardown stranded it forever. + expect(result.current.runs.has(threadId)).toBe(false) + // The partial content still landed — the append runs before the settle. + expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(2) + + act(() => { + result.current.stop(threadId) + }) + expect(result.current.runs.has(threadId)).toBe(false) + + gate.resolve(undefined) + }) + + test('stop() still succeeds via crypto.getRandomValues when randomUUID is unavailable (rung 2 — the rung that matters in practice)', async () => { + const store = createTestThreadsStore() + const threadId = store.create() + + const gate = deferred() + const transport: AgentTransport = { + async *stream() { + yield { id: 'p1', type: 'text', text: 'partial' } + await gate.promise + yield { id: 'p2', type: 'text', text: 'never arrives' } + }, + } + + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { + value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }, + configurable: true, + }) + try { + const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome })) + + act(() => { + result.current.start(threadId, 'hi') + }) + + await waitFor(() => { + expect(result.current.runs.get(threadId)?.parts).toHaveLength(1) + }) + + act(() => { + result.current.stop(threadId) + }) + + const thread = store.threads.find((t) => t.id === threadId) + expect(thread?.status).toBe('done') + expect(thread?.messages).toHaveLength(2) + expect(thread?.messages[1]?.finish).toBe('stopped') + expect(result.current.runs.has(threadId)).toBe(false) + } finally { + Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true }) + } + + gate.resolve(undefined) + }) +}) diff --git a/packages/basalt-ui/src/agent/use-agent-thread-runs.ts b/packages/basalt-ui/src/agent/use-agent-thread-runs.ts index ceacae9..8289863 100644 --- a/packages/basalt-ui/src/agent/use-agent-thread-runs.ts +++ b/packages/basalt-ui/src/agent/use-agent-thread-runs.ts @@ -40,7 +40,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { ChatMessage } from './history' -import { withPartIds } from './id' +import { mintMessageId, mintThreadId, withPartIds } from './id' import { mergePart } from './merge' import type { PartLike } from './merge' import type { AgentOutcome, OutcomeResolver } from './outcome' @@ -103,18 +103,39 @@ export type UseAgentThreadRunsArgs = { export type UseAgentThreadRunsReturn = { /** Live stream state per thread id, for threads with a run in progress or just completed. */ readonly runs: ReadonlyMap> - /** Start a new turn on `threadId`. No-op if that thread already has a stream in flight. */ + /** + * Start a new turn on `threadId`. No-op if that thread already has a stream in flight. + * + * @throws {Error} synchronously, before any state is touched, on a host with no usable + * `crypto` at all (see `mintMessageId` in `./id.ts` — the id it mints here is the new user + * ChatMessage's idempotency key, so it degrades to a throw rather than a silent collision). + * This throw is a true no-op: it happens before `appendMessage`/`setStatus`/the run's + * controller are registered, so nothing is left half-started — the thread is exactly as it was + * before the call. A caller invoking `start` from an event handler on a target that must + * tolerate this (very old WebViews, non-DOM SSR shims) should wrap the call in its own + * try/catch; this rung is vanishingly rare in practice (see `mintMessageId`'s own doc). + */ readonly start: (threadId: string, input: string) => void /** * Replay the last user input sent on `threadId` (same code path as `start`). No-op if * `threadId` has never had a turn started, or already has one in flight. + * + * @throws {Error} same condition and same true-no-op guarantee as `start` — see its doc. */ readonly retry: (threadId: string) => void /** - * Abort the in-flight stream for `threadId` (no-op if idle). Preserves whatever content had - * already arrived: if the run had accumulated any parts, they're persisted as an assistant + * Abort the in-flight stream for `threadId` (true no-op only if BOTH `controllersRef` and + * `runs` have nothing for it — i.e. the thread genuinely isn't live). Preserves whatever content + * had already arrived: if the run had accumulated any parts, they're persisted as an assistant * ChatMessage with `finish: 'stopped'` (unless that message already landed — see finalizeStop), * then distilled into an outcome and the thread is settled to 'done'. + * + * Defense-in-depth: if `runs` reports `threadId` as `'streaming'` but no controller is + * registered for it (a phantom entry — not reachable via any path in this hook today, since the + * unmount-cleanup effect and `finalizeStop` both tear down `controllersRef` and `runs` together, + * but guarded against here so nothing in this file can EVER wedge a thread the UI shows as live + * with no way to reach a terminal state), `stop()` still settles it instead of silently + * no-opping forever. */ readonly stop: (threadId: string) => void /** Abort every in-flight stream across all threads, settling each the same way stop() would. */ @@ -123,9 +144,14 @@ export type UseAgentThreadRunsReturn = { // ── defaults ────────────────────────────────────────────────────────────────── -/** Default toUserParts: wraps raw input in a single text part. */ +/** + * Default toUserParts: wraps raw input in a single text part. This part's id is a display/merge + * identity WITHIN a message's own `parts` array — it is never the value `appendMessage` idempotes + * on (that's the enclosing `ChatMessage.id`, minted separately by the caller) — so `mintThreadId` + * is the right low-collision-cost helper here, not `mintMessageId`. + */ function defaultToUserParts(input: string): AgentPart[] { - return [{ id: crypto.randomUUID(), type: 'text', text: input }] + return [{ id: mintThreadId(), type: 'text', text: input }] } /** @@ -199,6 +225,18 @@ async function consumeAndFinalize(args: { for await (const part of generator) { // Guard: a newer call superseded this stream's controller for this thread, or it // was aborted — stop updating state. + // + // Deliberately NOT a `runs` teardown site (considered and rejected): `controllersRef` + // mismatch here is ambiguous between "this run was aborted and nothing replaced it" and "a + // newer run (a mount-reconcile resume, keyed by the SAME threadId) has already taken over + // and is live in `runs` right now". Only the first case should ever clear `runs[threadId]`, + // and only the abort's OWN originator can tell the two apart without a race — the unmount- + // cleanup effect (see its doc) does that teardown synchronously, in the same tick as the + // abort, before any newer run could exist to be confused with. If this guard also deleted + // `runs[threadId]`, a superseding resume's fresh entry would be clobbered out from under it + // by its OWN predecessor's late-settling loop iteration — reintroducing a wedge instead of + // fixing one. `stop()`'s own abort path has the same property (finalizeStop's teardown runs + // synchronously before `stop()` returns, never racing a subsequent call). if (controllersRef.current.get(threadId) !== controller) return if (controller.signal.aborted) return if (isStartPart(part)) { @@ -221,7 +259,9 @@ async function consumeAndFinalize(args: { } const assistantMessage: ChatMessage = { - id: crypto.randomUUID(), + // appendMessage's idempotency key — mintMessageId, never mintThreadId (see that helper's + // doc): a collided id here would silently drop this message, not merge a duplicate thread. + id: mintMessageId(), role: 'assistant', parts, createdAt: Date.now(), @@ -285,11 +325,37 @@ async function consumeAndFinalize(args: { * invoking this. `alreadyAppended` is precomputed by the caller (stop()) from `appendedRef` — see * that ref's doc for why this is an explicit marker rather than an inferred comparison. * - * Ordering: the append, forcing status to 'done', clearing the resume token, and tearing down the - * run entry ALL happen synchronously (this function's body runs synchronously up to its one - * `await`) — status is never derived from the resolved outcome, so it doesn't need to wait on one, - * matching useAgentStream's stop(), which settles 'done' immediately rather than blocking on async - * work. Only `setOutcome` — which genuinely needs the resolved value — happens after the await. + * Ordering / the append-failure guard: the append is attempted FIRST (so a successful stop still + * reads as 'done' with its partial content intact — this is the common case and the one that + * matters most), but it is wrapped in its own try/catch that CANNOT prevent the terminal + * transition below it. This is deliberate, not incidental: by the time this function runs, `stop()` + * has already deleted `threadId` from `controllersRef` and `appendedRef` (see `stop()`'s own doc), + * so a second `stop()` call is unconditionally a no-op regardless of what happens here — if the + * append (or `mintMessageId`, which now also throws on rung 3 — see `./id.ts`) throws and nothing + * downstream ran, the thread would be wedged at 'streaming' FOREVER with no way for the user to + * clear it. That is exactly the standing invariant this layer forbids (see this hook's module + * doc / the render-path degrade rule), so it must not depend on the append succeeding. The + * alternative ordering — set status/teardown first, append after — was considered and rejected: + * it would mark the thread 'done' before the content the user is about to lose is even attempted, + * which is a strictly worse failure mode for the (overwhelmingly common) success path than this + * try/catch is for the (vanishingly rare) failure path. + * + * On an append failure, status becomes 'error' instead of 'done' — mirroring + * `consumeAndFinalize`'s own catch path (`onFailureStatus`), which is the existing precedent for + * "the persist step failed, tell the user" in this file — and `resolveOutcome` is skipped + * entirely, again matching that catch path: there is no complete/consistent snapshot worth + * distilling into an outcome when the turn's own final message never made it into the store. + * + * `setStatus`/`setResumeToken` are guarded for the SAME reason, and this is not belt-and-braces: + * they are consumer code exactly as `appendMessage` is (a `createThreadsStore` adapter over a + * remote backend, a store that rejects a threadId removed mid-stream), and guarding only the + * append leaves the identical wedge one line further down. The single thing that must ALWAYS run + * is the `setRuns` teardown — that entry is the HOOK'S OWN state, it is what `runs.get(threadId)` + * reports to the UI as "a turn is in flight", and by this point `stop()` has already deleted the + * thread from `controllersRef`, so no later `stop()` can ever reach here again to clean it up. A + * throw above it therefore strands a phantom run for the lifetime of the hook. Each store call + * gets its OWN try so a failing `setStatus` cannot also skip clearing the resume token — a + * surviving token is separately harmful (it is what a later resume replays from). */ async function finalizeStop(args: { threadId: string @@ -301,19 +367,39 @@ async function finalizeStop(args: { }): Promise { const { threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns } = args + let appendFailed = false if (!alreadyAppended && parts.length > 0) { - const stoppedMessage: ChatMessage = { - id: crypto.randomUUID(), - role: 'assistant', - parts, - createdAt: Date.now(), - finish: 'stopped', + try { + const stoppedMessage: ChatMessage = { + // Same idempotency-key reasoning as consumeAndFinalize's assistantMessage — mintMessageId. + // This can throw on rung 3 (no usable crypto — see ./id.ts) just like appendMessage + // (consumer code) can throw for its own reasons; both are caught below so neither can + // wedge the thread — see this function's doc. + id: mintMessageId(), + role: 'assistant', + parts, + createdAt: Date.now(), + finish: 'stopped', + } + storeRef.current.appendMessage(threadId, stoppedMessage) + } catch { + appendFailed = true } - storeRef.current.appendMessage(threadId, stoppedMessage) } - storeRef.current.setStatus(threadId, 'done') - storeRef.current.setResumeToken(threadId, undefined) + let settleFailed = appendFailed + try { + storeRef.current.setStatus(threadId, appendFailed ? 'error' : 'done') + } catch { + settleFailed = true + } + try { + storeRef.current.setResumeToken(threadId, undefined) + } catch { + settleFailed = true + } + + // Unconditional — the one step that cannot be allowed to be skipped. See this function's doc. setRuns((prev) => { if (!prev.has(threadId)) return prev const next = new Map(prev) @@ -321,6 +407,10 @@ async function finalizeStop(args: { return next }) + // No outcome to resolve for a turn whose own terminal message never landed, or whose settle + // never took — see this function's doc on why this mirrors consumeAndFinalize's catch path. + if (settleFailed) return + const snapshot = storeRef.current.threads.find((thread) => thread.id === threadId) if (snapshot === undefined) return const outcome: AgentOutcome = await resolveOutcome(snapshot) @@ -419,11 +509,40 @@ export function useAgentThreadRuns({ // skipping the orphan-resume path and wedging the thread in 'streaming' forever. Reachable // whenever this fiber's effects re-run without the fiber itself unmounting (React 19 StrictMode // double-invoke; `` hide/show) — see this hook's `@example`-adjacent doc / the F3 note. + // + // Also tears down the matching `runs` entries — the F3 fix above stopped this from wedging + // `controllersRef`/the *persisted* thread status, but left `runs` (this hook's own transient + // state) untouched, and on a re-run-without-unmount the component (and its `runs` state) survive + // this cleanup. Without this, the entry the aborted controller was updating stays in `runs` + // reporting `status: 'streaming'` forever: the mount-reconcile effect below DOES correctly settle + // the persisted thread (to a resumed run, or to 'interrupted' if not resumable), but a consumer + // deriving "is this thread live" from `runs.has(id)` — the whole reason `runs` exists — keeps + // seeing a phantom in-flight turn no controller is driving anymore, and `stop()` on it was a + // permanent no-op (nothing left in `controllersRef` to abort) — see `stop()`'s own doc. Scoped to + // exactly the threadIds this pass is aborting (not a blind `runs.clear()`): the mount-reconcile + // effect below runs AFTER this cleanup completes, in the same synchronous commit (both are + // `[]`-dep effects on this same fiber, so StrictMode/`` destroy-then-recreate both + // together, in declaration order) — a resumable thread's reconcile pass registers a FRESH + // controller and a fresh `runs` entry there, and this teardown must never clobber that. It + // can't: by the time this cleanup runs, nothing has created a NEW entry for these threadIds yet + // (that only happens in the reconcile effect, still to come), so scoping to the ids this pass is + // itself aborting is exact, not merely defensive. useEffect( () => () => { + const abortedThreadIds = Array.from(controllersRef.current.keys()) controllersRef.current.forEach((controller) => controller.abort()) controllersRef.current.clear() appendedRef.current.clear() + if (abortedThreadIds.length === 0) return + setRuns((prev) => { + let next: Map> | undefined + for (const threadId of abortedThreadIds) { + if (!prev.has(threadId)) continue + next ??= new Map(prev) + next.delete(threadId) + } + return next ?? prev + }) }, [], ) @@ -500,7 +619,8 @@ export function useAgentThreadRuns({ lastInputRef.current.set(threadId, input) const userMessage: ChatMessage = { - id: crypto.randomUUID(), + // Same idempotency-key reasoning as consumeAndFinalize's assistantMessage — mintMessageId. + id: mintMessageId(), role: 'user', parts: toUserPartsRef.current(input), createdAt: Date.now(), @@ -557,7 +677,23 @@ export function useAgentThreadRuns({ // runsRef still holding the PREVIOUS render's (empty) map. Gating on runsRef here would read // `undefined`, wrongly treat this as a no-op, leave the controller registered and never // abort it — stranding the run in 'streaming' (see the F4 regression test). - if (controller === undefined) return + if (controller === undefined) { + // No controller — but if `runs` still reports this thread as live, that's a phantom entry + // (see this hook's `stop()` JSDoc): nothing is driving it, and with no controller to key + // off of, this is the only remaining path that can ever settle it. Not reachable via any + // path in THIS file today (the unmount-cleanup effect and finalizeStop both tear down + // `controllersRef` and `runs` in the same synchronous step — see their docs), but a true + // no-op here would mean "the UI shows this thread streaming forever, and Stop can never + // clear it" the instant that invariant is ever broken by a future change. Settle it via the + // same finalizeStop() the normal path uses, so there is still exactly one teardown routine + // for "a run is ending" regardless of how it got here. + if (!runsRef.current.has(threadId)) return + const parts = runsRef.current.get(threadId)?.parts ?? [] + const alreadyAppended = appendedRef.current.has(threadId) + appendedRef.current.delete(threadId) + void finalizeStop({ threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns }) + return + } controllersRef.current.delete(threadId) // Read the accumulated parts straight out of the live run entry (runsRef mirrors `runs` as of diff --git a/packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx b/packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx index 5591d2f..cecc9c9 100644 --- a/packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx +++ b/packages/basalt-ui/src/agent/use-agent-thread-runs.wedge.test.tsx @@ -48,6 +48,17 @@ * that the thread SETTLES (leaves `'streaming'`) rather than wedging — the resume() call count is * asserted too, but as an observed constant with the doubling explained, not as evidence of a * single attempt. + * + * (d) is a SEPARATE, later-release defect discovered in the same family: the F3 fix above made the + * PERSISTED thread settle correctly (to a resume, or to 'interrupted'), but left the hook's OWN + * `runs` state untouched by the unmount-cleanup effect — so on a fiber that survives (StrictMode + * double-invoke, `` hide/show), `runs` kept reporting the aborted run as `'streaming'` + * forever, even once the persisted thread had already settled. Unlike (b)/(c) (which use a + * RESUMABLE transport, so a fresh `runs` entry gets created by the surviving resume), (d) uses a + * NON-resumable transport — the case where nothing ever replaces the phantom entry, and `stop()` + * on it was a permanent no-op (see this hook's `stop()` JSDoc). Verified manually: reverting the + * `runs` teardown in the unmount-cleanup effect makes (d) fail — `runs.has(threadId)` stays `true` + * after the thread settles to `'interrupted'` — while (a)/(b)/(c) are unaffected by that revert. */ import { describe, expect, test } from 'bun:test' import type { JSX } from 'react' @@ -304,4 +315,73 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', () }) expect(resumeCalls).toBe(2) }) + + test('(d) BLOCKING regression: a non-resumable run aborted by an hide/show cycle leaves no phantom `runs` entry, and stop() still reaches a terminal state', async () => { + const store = createTestThreadsStore() + + // No `resume` at all — unlike (c), this transport cannot be resumed. Hangs until aborted; + // never resolves on its own (matching the real playground repro this test pins). + const transport: AgentTransport = { + // oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield + async *stream(_input, signal) { + await new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(undefined)) + }) + }, + } + + let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined + let hookResult: ReturnType> | undefined + + function Probe(): null { + hookResult = useAgentThreadRuns({ transport, store, resolveOutcome }) + return null + } + + function Harness(): JSX.Element { + const [mode, setMode] = useState<'visible' | 'hidden'>('visible') + externalSetMode = setMode + return ( + + + + ) + } + + await act(async () => { + render() + }) + + const threadId = store.create() + act(() => { + hookResult?.start(threadId, 'hello') + }) + expect(hookResult?.runs.get(threadId)?.status).toBe('streaming') + + // Hide then show — the cleanup-without-unmount path. The fiber (and its refs/state) survive; + // only its effects are destroyed and recreated, exactly as React 19.2's does in + // PRODUCTION (see the file doc above) or as StrictMode does on every dev mount. + await act(async () => { + externalSetMode?.('hidden') + }) + await act(async () => { + externalSetMode?.('visible') + }) + + await waitFor(() => { + expect(store.threads.find((t) => t.id === threadId)?.status).toBe('interrupted') + }) + + // The BLOCKING defect this test pins: `runs` must not still report this thread as 'streaming' + // once the persisted thread has settled to 'interrupted' — that phantom entry is what made + // stop() a permanent no-op. + expect(hookResult?.runs.has(threadId)).toBe(false) + + // stop() on the now-settled thread reaches (stays at) a terminal state — not a wedge. + act(() => { + hookResult?.stop(threadId) + }) + expect(store.threads.find((t) => t.id === threadId)?.status).toBe('interrupted') + expect(hookResult?.runs.has(threadId)).toBe(false) + }) }) From 20b6dda9856927f75c4859e6891ad32b62ee208f Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 10:20:05 +0200 Subject: [PATCH 04/11] feat: promote the agent guards and raw-scroll-container to error The three agent rules were already error in this repo and only warn in the preset consumers extend; raw-scroll-container was warn in both. All four are now error, one minor after the release that introduced the API they guard, which is the grace period this repo has used before. raw-scroll-container's own documentation said it was warning-level by design. That is no longer true, so the documentation changed with it rather than being left contradicting the code. theme-allow remains the opt-out, and the legitimate scroll owners already carry it. This will fail lint for a consumer on the preset who had warnings. That is the point of the rules: agent-no-raw-usechat and agent-resume-guard exist to stop a consumer hand-rolling what this package now provides, and leaving them at warn while shipping the replacement would waste them. --- .oxlintrc.json | 2 +- packages/basalt-ui/configs/oxlint-plugin.js | 10 +++-- .../basalt-ui/configs/oxlint-plugin.test.ts | 39 ++++++++++++++++--- packages/basalt-ui/configs/oxlint.json | 8 ++-- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index c08fe72..522155d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,7 +24,7 @@ "basalt/raw-size-literal": "error", "basalt/card-inset": "error", "basalt/chart-in-raw-surface": "error", - "basalt/raw-scroll-container": "warn", + "basalt/raw-scroll-container": "error", "basalt/visx-boundary": "error", "basalt/visx-tooltip": "error", "basalt/token-layer-boundary": "error", diff --git a/packages/basalt-ui/configs/oxlint-plugin.js b/packages/basalt-ui/configs/oxlint-plugin.js index 66ae088..ecc695a 100644 --- a/packages/basalt-ui/configs/oxlint-plugin.js +++ b/packages/basalt-ui/configs/oxlint-plugin.js @@ -372,9 +372,13 @@ const OVERFLOW_KEYS = new Set(['overflow', 'overflowY']) const SCROLLING_VALUES = new Set(['auto', 'scroll']) /** - * Reports a `style` object property that turns a node into its own scroll container. Warning-level - * by design: whether a raw scroll box is wrong depends on who owns the scroll node, which no AST - * check can see — so this steers rather than blocks, and `theme-allow` opts out. + * Reports a `style` object property that turns a node into its own scroll container. Whether a raw + * scroll box is wrong depends on who owns the scroll node, which no AST check can see — so the + * `theme-allow` comment is a first-class part of the rule, not an escape valve for exceptional + * cases: a component that legitimately owns its scroll node (`BasaltStickToBottom`, + * `BasaltVirtualList`, `ThreadTranscript`'s virtualized pane) declares that ownership with the + * comment and moves on. Severity went `off` → `warn` (1.12.0) → `error` (1.13.0) once every live + * site in the repo carried that declaration; the opt-out mechanism is identical at either level. */ const rawScrollContainer = { meta: { diff --git a/packages/basalt-ui/configs/oxlint-plugin.test.ts b/packages/basalt-ui/configs/oxlint-plugin.test.ts index 12b3d9b..dc9817c 100644 --- a/packages/basalt-ui/configs/oxlint-plugin.test.ts +++ b/packages/basalt-ui/configs/oxlint-plugin.test.ts @@ -417,21 +417,21 @@ describe('basalt/ai-sdk-major', () => { }) }) -// ── raw-scroll-container — promotion (off → warn shipped) ──────────────────── +// ── raw-scroll-container — promotion (off → warn → error shipped) ─────────── describe('basalt/raw-scroll-container promotion', () => { - it('ships "warn" (not "off") in the consumer preset', () => { + it('ships "error" (not "warn"/"off") in the consumer preset', () => { const shipped = JSON.parse( readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8'), ) as { rules: Record } - expect(shipped.rules['basalt/raw-scroll-container']).toBe('warn') + expect(shipped.rules['basalt/raw-scroll-container']).toBe('error') }) - it('stays "warn" repo-local', () => { + it('stays at the shipped level ("error") repo-local', () => { const repoLocal = JSON.parse( readFileSync(resolve(import.meta.dirname, '..', '..', '..', '.oxlintrc.json'), 'utf8'), ) as { rules: Record } - expect(repoLocal.rules['basalt/raw-scroll-container']).toBe('warn') + expect(repoLocal.rules['basalt/raw-scroll-container']).toBe('error') }) it('still flags a raw overflow:auto style property at the shipped level', () => { @@ -447,3 +447,32 @@ describe('basalt/raw-scroll-container promotion', () => { expect(rules).toContain('raw-scroll-container') }) }) + +// ── agent rules — promotion (warn → error shipped) ────────────────────────── +// The three agent-chat rules were promoted warn → error in the SHIPPED preset alongside +// raw-scroll-container above, but had no equivalent lock — `scripts/gen-oxlint.ts` only regenerates +// the `overrides` array; this top-level `rules` block is hand-maintained and +// `tests/oxlint-preset-sync.test.ts` only checks `overrides` against `projectBanList('shipped')`, so +// a later accidental revert of one of these three back to "warn" would pass the suite silently. + +describe('basalt agent rules promotion', () => { + it.each(['basalt/agent-resume-guard', 'basalt/agent-no-raw-usechat', 'basalt/ai-sdk-major'])( + '%s ships "error" (not "warn"/"off") in the consumer preset', + (rule) => { + const shipped = JSON.parse( + readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8'), + ) as { rules: Record } + expect(shipped.rules[rule]).toBe('error') + }, + ) + + it.each(['basalt/agent-resume-guard', 'basalt/agent-no-raw-usechat', 'basalt/ai-sdk-major'])( + '%s stays at the shipped level ("error") repo-local', + (rule) => { + const repoLocal = JSON.parse( + readFileSync(resolve(import.meta.dirname, '..', '..', '..', '.oxlintrc.json'), 'utf8'), + ) as { rules: Record } + expect(repoLocal.rules[rule]).toBe('error') + }, + ) +}) diff --git a/packages/basalt-ui/configs/oxlint.json b/packages/basalt-ui/configs/oxlint.json index 1a19b43..6bceeab 100644 --- a/packages/basalt-ui/configs/oxlint.json +++ b/packages/basalt-ui/configs/oxlint.json @@ -20,12 +20,12 @@ "basalt/raw-size-literal": "warn", "basalt/card-inset": "error", "basalt/chart-in-raw-surface": "error", - "basalt/raw-scroll-container": "warn", + "basalt/raw-scroll-container": "error", "basalt/visx-boundary": "error", "basalt/visx-tooltip": "error", - "basalt/agent-resume-guard": "warn", - "basalt/agent-no-raw-usechat": "warn", - "basalt/ai-sdk-major": "warn" + "basalt/agent-resume-guard": "error", + "basalt/agent-no-raw-usechat": "error", + "basalt/ai-sdk-major": "error" }, "overrides": [ { From 3502579f21b1eee6a3e78e9e156065b6de9cfa76 Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 10:20:16 +0200 Subject: [PATCH 05/11] test: prove the 1.13.0 surface from outside the package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four demo pages for the release gate: the inline feed against the inbox variant side by side, a 500-message virtualized transcript, a virtualized transcript inside a collapsible row, and a streaming turn into a virtualized transcript. The last one exists because nothing had ever put streaming and virtualization together. anchorTo end, followOnAppend and scrollEndThreshold shipped on a research note, unobservable in happy-dom, which has no layout or scroll engine — a grep of the whole test tree for them returned one hit and it was a comment. Driving these pages measured all three: following holds at zero distance while streaming, moves scrollTop by exactly zero when the reader has scrolled up, and the 64px threshold is sharp at four probe distances. The pages carry their own instrumentation because the invariants are not visible otherwise: per-row mount and stream-start counters make a duplicated replay legible rather than something to take on trust, and overscan, estimateSize and initialScroll are exposed as controls so the options are exercisable from outside the package. Setting estimateSize back to 96 reproduces the pre-1.13.0 scrollbar growth on demand, which is what turns "the symptom is gone" into knowing what caused it. --- .../src/demo/AgentAnchorToEndDemoPage.tsx | 271 +++++++++++++ .../AgentInlineFeedVirtualizedRowDemoPage.tsx | 100 +++++ .../demo/AgentThreadFeedInlineDemoPage.tsx | 357 ++++++++++++++++++ .../AgentTranscriptVirtualizeDemoPage.tsx | 177 +++++++++ apps/playground/src/demo/agent-long-thread.ts | 147 ++++++++ .../agent-transcript-virtualize.type-guard.ts | 68 ++++ apps/playground/src/demo/nav-model.tsx | 28 ++ .../src/routes/agent-anchor-to-end.tsx | 7 + .../routes/agent-inline-feed-virtualized.tsx | 7 + .../src/routes/agent-thread-feed-inline.tsx | 7 + .../routes/agent-transcript-virtualize.tsx | 7 + 11 files changed, 1176 insertions(+) create mode 100644 apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx create mode 100644 apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx create mode 100644 apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx create mode 100644 apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx create mode 100644 apps/playground/src/demo/agent-long-thread.ts create mode 100644 apps/playground/src/demo/agent-transcript-virtualize.type-guard.ts create mode 100644 apps/playground/src/routes/agent-anchor-to-end.tsx create mode 100644 apps/playground/src/routes/agent-inline-feed-virtualized.tsx create mode 100644 apps/playground/src/routes/agent-thread-feed-inline.tsx create mode 100644 apps/playground/src/routes/agent-transcript-virtualize.tsx diff --git a/apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx b/apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx new file mode 100644 index 0000000..a78ddcb --- /dev/null +++ b/apps/playground/src/demo/AgentAnchorToEndDemoPage.tsx @@ -0,0 +1,271 @@ +/** + * AgentAnchorToEndDemoPage — basalt-ui 1.13.0 playground gate demo: the ONE combination no other + * demo in this repo puts together — a virtualized `ThreadTranscript` with a genuinely STREAMING + * turn appending to its tail, long enough that the tail sits well below the fold. + * + * `anchorTo: 'end'` + `followOnAppend` (`thread-message.tsx`'s `VirtualizedRowsInner`) exist + * precisely to keep a virtualized transcript pinned to the bottom while new content streams in, and + * until this page nothing exercised both halves at once: `AgentTranscriptVirtualizeDemoPage` is 500 + * STATIC messages (nothing ever streams into it), `AgentInlineFeedVirtualizedRowDemoPage` is a + * virtualized row that never receives `liveParts`. Neither can show whether the pinned-to-bottom + * behavior actually holds up against a live append. + * + * The scroll threshold that governs this (`scrollEndThreshold`, hardcoded to 64px inside + * `thread-message.tsx` — NOT a public prop; `VirtualizeOptions` exposes `overscan`/`estimateSize`/ + * `initialScroll`, not the threshold itself) is not asserted here, only made LEGIBLE. This page reads + * the real DOM the same way a consumer's own instrumentation would have to — there is no exposed + * ref/prop onto the internal virtualizer's scroll node — via a capture-phase `scroll` listener on a + * wrapper div whose only child is `ThreadTranscript`'s own root (the virtualized branch renders + * exactly one scrollable element as its root, both for the real virtualizer and its `Suspense` + * fallback, so `wrapper.firstElementChild` is unambiguous across that swap). The distance readout + * below the transcript updates live from whichever scroll actually happened, whether the user drove + * it or the virtualizer's own auto-follow did. + * + * 1.13.0: `VirtualizeOptions.initialScroll` now defaults to `'end'` — this page doesn't override it, + * so it now opens ALREADY pinned to the seeded tail (badge reads "pinned", distance ~0px) instead of + * mounting at message #0, 8,913px away. That flips which half of the demo is interesting: scrolling + * DOWN to the tail is no longer the setup step, scrolling UP away from it is. + * + * Drive it: scroll up first (the badge flips to "held", the distance count climbs and stops moving — + * you're now off the tail). Click "Start streaming turn": the stream writes new content below the + * fold and your held position does NOT move, proving `followOnAppend` really did stop re-anchoring + * once you left the threshold, not just once you left the bottom of a static list. Scroll back down + * past ~64px from the bottom and following resumes on its own — badge flips to "pinned", the distance + * count tracks each new chunk back down near zero as it streams in. + * + * Note (expected, not a bug): the distance readout is driven entirely by real `scroll` events — see + * the effect below — so while you're held away from the bottom it FREEZES rather than climbing as + * the stream keeps appending content further below the fold; nothing you can see moved, so nothing + * fires a `scroll` event to react to. It catches up the instant you scroll again. This is the + * tradeoff of reading the real DOM the way a consumer's own instrumentation would have to, and it is + * deliberately NOT "fixed" with a timer or a live resize watch — that would defeat the point of + * observing exactly what a real `scroll` listener sees, no more. + */ +import { Badge, Box, Button, Group, Paper, Stack, Text, Title } from '@mantine/core' +import { EmptyState } from 'basalt-ui' +import { createThreadsStore, heuristicOutcome, useAgentThreadRuns } from 'basalt-ui/agent' +import type { AgentPart, AgentThread, AgentTransport } from 'basalt-ui/agent' +import { ThreadTranscript } from 'basalt-ui/agent-chat' +import { useCallback, useEffect, useRef, useState } from 'react' +import { buildLongThread } from './agent-long-thread' +import { IconReset, IconSparkle } from './icons' + +// ── Scripted, slow, word-by-word stream — long enough to scroll around mid-flight ────────────── +// Same "no signal handling" idiom as AgentStopMidStreamDemoPage's script: this demo never stops the +// turn early, so there is nothing for an abort to interrupt. + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const ANCHOR_STEP_DELAY = 220 + +const ANCHOR_DEMO_ANSWER = + 'Walking through the anchor-to-end mechanics end to end: the virtualizer measures the scroll ' + + 'container on every append, and when the pane was already within scrollEndThreshold of the bottom ' + + 'before that append, it re-anchors to the new bottom on the very same frame. Scroll away from the ' + + 'bottom and that re-anchor stops firing entirely — your position holds even while dozens more rows ' + + 'keep arriving underneath it. Scroll back down past the threshold and following resumes ' + + 'immediately, with no extra click required on your part. This is exactly the rhythm a real chat ' + + 'client needs: stay pinned while someone is reading the latest turn as it writes itself, but never ' + + 'yank the viewport out from under someone who scrolled up to re-read an earlier message while the ' + + 'answer keeps streaming in below.' +const ANCHOR_DEMO_WORDS = ANCHOR_DEMO_ANSWER.split(' ') + +async function* anchorDemoScript(): AsyncGenerator { + const id = crypto.randomUUID() + for (const [index, word] of ANCHOR_DEMO_WORDS.entries()) { + await sleep(ANCHOR_STEP_DELAY) + yield { id, type: 'text', text: index === 0 ? word : ` ${word}` } + } +} + +const anchorDemoTransport: AgentTransport = { + stream: () => anchorDemoScript(), +} + +// ── Store + seed ───────────────────────────────────────────────────────────────── + +const useAnchorThreads = createThreadsStore({ key: 'playground-agent-anchor-to-end', version: 1 }) + +const SEED_MESSAGE_COUNT = 60 +const TRANSCRIPT_HEIGHT = 420 +// Mirrors thread-message.tsx's internal DEFAULT_VIRTUALIZE_SCROLL_END_THRESHOLD — not importable +// (not part of VirtualizeOptions, so not part of the public surface at all); reproduced here purely +// as a legend for this page's own readout, not asserted against anything. +const SCROLL_END_THRESHOLD = 64 + +// ── Page ────────────────────────────────────────────────────────────────────── + +export function AgentAnchorToEndDemoPage() { + const store = useAnchorThreads() + const { runs, start } = useAgentThreadRuns({ + transport: anchorDemoTransport, + store, + resolveOutcome: heuristicOutcome, + }) + + // Seeds ONE already-settled, deliberately long thread straight into the store — same + // no-start()-in-a-mount-effect discipline as AgentThreadFeedInlineDemoPage's fix 2 (see its own + // doc): buildLongThread's messages are synchronous, so nothing here ever registers a controller + // for a StrictMode double-invoke to abort. + const seededRef = useRef(false) + useEffect(() => { + if (seededRef.current || store.threads.length > 0) return + seededRef.current = true + const id = store.create() + const messages = buildLongThread(SEED_MESSAGE_COUNT) + for (const message of messages) store.appendMessage(id, message) + store.setStatus(id, 'done') + const firstMessage = messages[0] + const lastMessage = messages[messages.length - 1] + const snapshot: AgentThread = { + id, + messages, + outcome: null, + status: 'done', + read: true, + createdAt: firstMessage?.createdAt ?? Date.now(), + updatedAt: lastMessage?.createdAt ?? Date.now(), + } + store.setOutcome(id, heuristicOutcome(snapshot)) + }, [store]) + + const thread = store.threads[0] + const run = thread !== undefined ? runs.get(thread.id) : undefined + const streaming = run !== undefined + + const wrapperRef = useRef(null) + const [distanceFromBottom, setDistanceFromBottom] = useState(null) + + // Capture-phase, not bubble: `scroll` events don't bubble in most browsers, but capture always + // sees them regardless of which descendant actually scrolled. Filtering to `wrapper.firstElementChild` + // (re-read fresh on every event, never cached) is what keeps this from ever matching a nested + // scroller instead — a fenced code block in one of the seeded messages sets its own overflow, and + // that element is a grandchild, never the wrapper's direct child. + useEffect(() => { + const wrapper = wrapperRef.current + if (wrapper === null) return + const measure = (target: HTMLElement) => { + const distance = target.scrollHeight - target.scrollTop - target.clientHeight + setDistanceFromBottom(Math.max(0, Math.round(distance))) + } + const handleScroll = (event: Event) => { + const target = event.target + if (!(target instanceof HTMLElement) || target !== wrapper.firstElementChild) return + measure(target) + } + wrapper.addEventListener('scroll', handleScroll, { capture: true, passive: true }) + + // The one-shot rAF this replaced measured too early: `ThreadTranscript`'s virtualized branch + // sits behind a lazy `import('@tanstack/react-virtual')` (thread-message.tsx), so the very first + // commit is the `Suspense` fallback — an EMPTY scroll node whose scrollHeight equals its own + // clientHeight. A single rAF after mount still often landed inside that fallback window, reading + // `scrollHeight - scrollTop - clientHeight` as exactly 0 no matter how far the real seeded + // content sits below the fold — precisely the false "pinned" reading this page exists to avoid + // showing a human. Instead of guessing a frame count, a `MutationObserver` on the wrapper catches + // the EXACT moment the real virtualizer replaces the fallback — its sizer element (the + // `height: getTotalSize()` box) appearing as the scroll node's one child — and takes ONE read + // right then, same "one extra initial read" intent as the rAF it replaces, just correctly timed. + // Deliberately NOT kept alive past that: the readout goes back to being purely `scroll`-event- + // driven afterward (see the module doc's "Note" on this) — a stream that grows the pane's total + // height while the user is scrolled away from the bottom does NOT move this number until the next + // real scroll. That is a real, expected limitation of reading the DOM the way a consumer's own + // instrumentation would have to, not something to paper over with a live ResizeObserver here. + const observer = new MutationObserver(() => { + const scrollNode = wrapper.firstElementChild + const sizer = scrollNode?.firstElementChild + if (!(scrollNode instanceof HTMLElement) || !(sizer instanceof HTMLElement)) return + measure(scrollNode) + observer.disconnect() + }) + observer.observe(wrapper, { childList: true, subtree: true }) + // Covers the case where `@tanstack/react-virtual`'s lazy import already resolved during an + // earlier mount THIS session (`LazyVirtualizedRows` caches its resolution permanently for the + // module's lifetime) — the sizer can already be present synchronously, with no mutation left to + // observe. + const scrollNode = wrapper.firstElementChild + const sizer = scrollNode?.firstElementChild + if (scrollNode instanceof HTMLElement && sizer instanceof HTMLElement) { + measure(scrollNode) + observer.disconnect() + } + + return () => { + wrapper.removeEventListener('scroll', handleScroll, { capture: true }) + observer.disconnect() + } + }, [thread]) + + const handleStart = useCallback(() => { + if (thread === undefined) return + start(thread.id, 'Give me a long, detailed answer so I can scroll around while it streams in.') + }, [thread, start]) + + const handleReset = useCallback(() => { + store.clear() + seededRef.current = false + setDistanceFromBottom(null) + }, [store]) + + const pinned = distanceFromBottom !== null && distanceFromBottom <= SCROLL_END_THRESHOLD + + return ( + +
+ Agent chat — anchor to end while streaming (virtualized) + + {SEED_MESSAGE_COUNT} settled messages, virtualized at {TRANSCRIPT_HEIGHT}px — opens + already pinned to the seeded tail (that's the new default: a virtualized transcript + mounts scrolled to the newest message). Scroll up first to hold your position, then click + "Start streaming turn" and watch the badge stay flipped to held while new content lands + below the fold. Scroll back down past the threshold and following resumes on its own. + +
+ + + + + + {pinned ? 'pinned — following new content' : 'not pinned — position held'} + + + {distanceFromBottom === null ? 'not yet measured' : `${distanceFromBottom}px from bottom`} + + + + + + {thread === undefined ? ( + + } + title="Seeding…" + description="Building the seeded transcript." + variant="section" + /> + + ) : ( + + )} + + +
+ ) +} diff --git a/apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx b/apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx new file mode 100644 index 0000000..cdd7dca --- /dev/null +++ b/apps/playground/src/demo/AgentInlineFeedVirtualizedRowDemoPage.tsx @@ -0,0 +1,100 @@ +/** + * AgentInlineFeedVirtualizedRowDemoPage — the case convergence flagged and no unit test can reach: + * a virtualized `ThreadTranscript` nested inside an inline `ThreadFeedRow`. + * + * `ThreadFeedRow` hides its body with `display: none` while collapsed but keeps it MOUNTED (see its + * module doc — that's the whole point of the lazy-mount-then-kept-mounted invariant). A virtualized + * transcript's scroll element measures 0 height while `display: none`, and whether it recovers on + * re-expand depends on `ResizeObserver` firing on the display toggle — real in a browser, entirely + * absent from happy-dom (the unit harness has no layout engine at all). This page is the only place + * that question can actually be answered: expand the row (first real mount, at full height — + * confirm it scrolls smoothly), collapse it, then re-expand it, and look at whether the transcript + * comes back scrollable and correctly measured, or stuck looking collapsed/zero-height until the + * window resizes. + */ +import { Paper, Stack, Text, Title } from '@mantine/core' +import type { AgentThread } from 'basalt-ui/agent' +import { ThreadFeedRow } from 'basalt-ui/agent-chat' +import type { ComposerSubmit } from 'basalt-ui/agent-chat' +import { useCallback, useMemo, useState } from 'react' +import { buildLongThread } from './agent-long-thread' + +const ROW_MESSAGE_COUNT = 200 +const TRANSCRIPT_HEIGHT = 420 +const SEND_LOG_MAX = 5 + +export function AgentInlineFeedVirtualizedRowDemoPage() { + const seedMessages = useMemo(() => buildLongThread(ROW_MESSAGE_COUNT), []) + const [thread, setThread] = useState(() => ({ + id: 'virtualized-row-demo', + messages: seedMessages, + outcome: { + title: 'Release retro thread', + summary: `${ROW_MESSAGE_COUNT} messages, windowed`, + status: 'done', + }, + status: 'done', + read: true, + createdAt: seedMessages[0]?.createdAt ?? Date.now(), + updatedAt: seedMessages[seedMessages.length - 1]?.createdAt ?? Date.now(), + })) + const [expanded, setExpanded] = useState(false) + const [sendLog, setSendLog] = useState([]) + + const handleSend = useCallback((payload: ComposerSubmit) => { + const message = { + id: crypto.randomUUID(), + role: 'user' as const, + parts: [{ id: crypto.randomUUID(), type: 'text' as const, text: payload.text }], + createdAt: Date.now(), + } + setThread((prev) => ({ ...prev, messages: [...prev.messages, message], updatedAt: Date.now() })) + setSendLog((prev) => [`sent: "${payload.text}"`, ...prev].slice(0, SEND_LOG_MAX)) + }, []) + + return ( + +
+ Virtualized transcript inside a collapsed row + + Expand the row below (first mount — the virtualizer sets up at full height, scrolled to + the newest message by default; confirm it scrolls smoothly through {ROW_MESSAGE_COUNT}{' '} + messages). Collapse it, then re-expand it a few times: does the transcript come back + scrollable and correctly measured at the SAME position you left it, or does it look + stuck/zero-height until you resize the window, or reset back to the newest message? + happy-dom has no layout engine, so this has never been observed anywhere but here. + +
+ + + setExpanded((current) => !current)} + onSend={handleSend} + virtualize + height={TRANSCRIPT_HEIGHT} + /> + + + + + Sent + + {sendLog.length === 0 ? ( + + Nothing sent yet. + + ) : ( + + {sendLog.map((line, index) => ( + + {line} + + ))} + + )} + +
+ ) +} diff --git a/apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx b/apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx new file mode 100644 index 0000000..cff5aa6 --- /dev/null +++ b/apps/playground/src/demo/AgentThreadFeedInlineDemoPage.tsx @@ -0,0 +1,357 @@ +/** + * AgentThreadFeedInlineDemoPage — basalt-ui 1.13.0 playground gate demos 1 + 4: the inline-expanding + * Slack row (`ThreadFeedRow`, driven through `ThreadFeed`'s `renderRow` escape hatch) next to the + * unchanged inbox row (`ThreadOutcomeCard`, `ThreadFeed`'s default `'outcome'` variant) — same + * `AgentThread[]`, rendered two ways side by side, so the two shapes are visibly different + * components, not one component in two skins. + * + * DISCOVERED WHILE BUILDING THIS DEMO: `ThreadFeed`'s built-in `variant="inline"` row does NOT wire + * `liveParts`/`liveStatus`/`onStop` (see `ThreadFeedProps.renderRow`'s own doc — it names this gap + * explicitly). There is no way to watch a thread stream live through the built-in row alone. This + * page therefore drives `ThreadFeedRow` directly via `renderRow`, the documented escape hatch for + * exactly that gap — `variant="inline"` on its own would show only the FINISHED transcript. + * + * The left (inline) panel also makes `ThreadFeedRow`'s LOAD-BEARING mount invariant (see its module + * doc: mount lazily on first expand, then never unmount, hide via `display: none` only) visible + * rather than trusted. Each row's transcript carries a `reasoning`-renderer hijack — real AgentPart + * key, not a foreign one — that mounts a `MountProbe` alongside the row's real "Thinking" UI. A + * SEPARATE counter tracks how many times the per-thread transport's `stream()` was actually invoked. + * Expand a row, note both counts, collapse it, re-expand it several times: neither counter may move + * — a genuinely new turn (sending another message) is the only thing allowed to move either one. + * If a future Mantine bump ever flips `ThreadFeedRow`'s collapse mechanics onto `` (see its + * module doc), this is where that regression shows up first: both counters would climb on every + * re-expand instead of staying flat. + * + * Caveat: in dev (this playground runs ``), the FIRST expand of a row can read "mounted + * 2×" instead of "1×" — React's intentional double-invoke of a fresh effect on first mount, not a + * bug in `ThreadFeedRow`. What matters is that the count stops moving after that first expand. + * + * TWO FIXES FROM THE 1.13.0 GATE, both demo-side only (see each site's own comment): + * + * 1. `onStop` is now wired to the left panel's `ThreadFeedRow`s. It was never forwarded before — + * `ThreadFeedRow` only shows its composer's Stop action when `onStop` is DEFINED (it has no way + * to infer "a run exists" from `liveStatus` alone), so a live row's textarea disabled with no + * visible way to cancel it. + * + * 2. The three seed threads are no longer started via `start()` inside a mount effect. Under + * ``, that effect's cleanup — a SEPARATE effect inside `useAgentThreadRuns` itself, + * which aborts every in-flight controller so a fiber whose effects re-run without unmounting + * doesn't leak stream subscriptions — fires between this page's two mount passes and aborts all + * three freshly-started runs before either the reload-reconciler or the abort path can settle + * `runs` back to empty. `stop()` can't help here either: it is gated on `controllersRef` still + * holding the thread's controller, and that map was already cleared by the same cleanup. The + * result was three threads permanently reading "streaming" with an unremovable phantom entry — + * a REAL framework defect, SINCE FIXED IN 1.13.0, but NOT where this note originally pointed: + * the fix is in `use-agent-thread-runs.ts`'s unmount-cleanup effect, which now tears down the + * `runs` entries for exactly the threadIds it aborts. `consumeAndFinalize`'s + * `if (controller.signal.aborted) return` guard was deliberately left alone (it carries a + * comment saying so): from inside that loop, a `controllersRef` mismatch cannot be told apart + * from "a newer resumed run already owns this key", so a teardown there would clobber a live + * successor. Do not "finish the job" at that guard. + * This page no longer trips it either way: seeding now writes three ALREADY-SETTLED threads into + * the store (`store.appendMessage` + `store.setStatus('done')` + `store.setOutcome`, no + * `start()`, no controller, nothing for that cleanup to abort), so a fresh load lands directly on + * three expandable, non-streaming threads. `start()` is still exercised for real the first time a + * composer sends a message — that call happens from a click, long after both StrictMode mount + * passes have settled, so it never meets the race above. + */ +import { Badge, Box, Button, Group, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core' +import { createThreadsStore, heuristicOutcome, useAgentThreadRuns } from 'basalt-ui/agent' +import type { + AgentPart, + AgentThread, + AgentTransport, + ChatMessage, + ForeignPart, + PartRenderer, + PartRenderers, + ReasoningPart, +} from 'basalt-ui/agent' +import { ThreadFeed, ThreadFeedRow, threadPartRenderers } from 'basalt-ui/agent-chat' +import type { ComposerSubmit } from 'basalt-ui/agent-chat' +import type { JSX } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { AGENT_SCENARIOS, scenarioTransport } from './agent-scenarios' +import { IconReset } from './icons' + +// ── Seed scenarios (skip 'error' — this demo is about mount/stream counting, not failure) ──────── + +const SEED_SCENARIOS = AGENT_SCENARIOS.filter((scenario) => scenario.value !== 'error') + +let scenarioCursor = 0 +function nextSeedScenario() { + const scenario = SEED_SCENARIOS[scenarioCursor % SEED_SCENARIOS.length] + scenarioCursor += 1 + if (scenario === undefined) throw new Error('SEED_SCENARIOS is empty') + return scenario +} + +const SEED_PROMPTS = [ + 'How should I structure the shell/router seam?', + 'Compare the trade-offs of virtualizing this transcript.', + 'What changed in the last release?', +] + +// ── Per-thread counting transport — the "stream started" half of the invariant ─────────────────── + +/** Resolved once per thread id and cached by `useAgentThreadRuns` (see its own doc) — `stream()` + * itself is called once per turn on that thread, which is exactly what `onStreamStart` counts. */ +function makeCountingTransport( + onStreamStart: (threadId: string) => void, +): (threadId: string) => AgentTransport { + return (threadId) => ({ + stream(input, signal) { + onStreamStart(threadId) + return scenarioTransport(nextSeedScenario(), 'normal').stream(input, signal) + }, + }) +} + +// ── Per-thread counters — plain refs + a manual re-render, not React state ──────────────────────── +// (state would need a fresh Map identity per bump; a ref + forced re-render is the cheaper, simpler +// escape hatch for a value this page only ever reads back for display, never diffs against.) + +function usePerThreadCounter(): readonly [Map, (id: string) => void] { + const counts = useRef>(new Map()) + const [, bump] = useReducer((n: number) => n + 1, 0) + const increment = useCallback((id: string) => { + counts.current.set(id, (counts.current.get(id) ?? 0) + 1) + bump() + }, []) + return [counts.current, increment] as const +} + +// ── The mount probe — proves ThreadFeedRow's lazy-mount-then-kept-mounted guarantee from OUTSIDE +// the package, by riding the SAME `renderers` open-registry seam a consumer would use for a real +// foreign part. ───────────────────────────────────────────────────────────────────────────────── + +const ReasoningView = threadPartRenderers.reasoning! + +function MountProbe({ + threadId, + onMount, +}: { + threadId: string + onMount: (id: string) => void +}): null { + // Empty-ish deps (threadId/onMount are both stable for a given row) — fires once per genuine + // mount of THIS component instance, and never again while it stays mounted, regardless of how + // many times the row around it re-renders (streaming deltas, collapse/expand toggles, ...). + useEffect(() => { + onMount(threadId) + }, [threadId, onMount]) + return null +} + +function ProbedReasoning({ + threadId, + onMount, + part, + settled, +}: { + threadId: string + onMount: (id: string) => void + part: ReasoningPart + settled: boolean +}): JSX.Element { + return ( + <> + + + + ) +} + +/** Hijacks the real `reasoning` AgentPart key via `ThreadFeedRow`'s open `renderers` registry — + * chosen over registering a genuinely foreign part type because it needs no `BasaltRegister` + * augmentation and keeps every store/outcome-resolver call in this file on the default `AgentPart` + * generic. Re-uses the exported `threadPartRenderers.reasoning` so the row's real "Thinking" + * disclosure still renders unchanged — this is additive instrumentation, not a replacement UI. */ +function makeReasoningProbe( + threadId: string, + onMount: (id: string) => void, +): PartRenderer { + return (ctx) => ( + + ) +} + +// ── Store ────────────────────────────────────────────────────────────────────────────────────── + +const useThreads = createThreadsStore({ key: 'playground-thread-feed-inline', version: 2 }) + +const PANEL_HEIGHT = 520 + +export function AgentThreadFeedInlineDemoPage() { + const store = useThreads() + const [mountCounts, bumpMount] = usePerThreadCounter() + const [streamCounts, bumpStream] = usePerThreadCounter() + const transport = useMemo(() => makeCountingTransport(bumpStream), [bumpStream]) + const { runs, start, stop } = useAgentThreadRuns({ + transport, + store, + resolveOutcome: heuristicOutcome, + }) + + // `renderRow` bypasses `ThreadFeed`'s own collapsedId tracking entirely (see its doc) — this + // panel owns expand/collapse itself, one row open at a time, mirroring the outcome panel's + // single-selection model. + const [expandedId, setExpandedId] = useState(null) + const [outcomeSelectedId, setOutcomeSelectedId] = useState(null) + + // Seeds three ALREADY-SETTLED threads straight into the store — deliberately NOT via start() + // (see this module's doc, fix 2, for the StrictMode abort race that produced when it was). Each + // scenario's `.parts(prompt)` is a synchronous, fully-formed AgentPart[] (no transport, no + // controller, nothing an unmount-cleanup effect could ever abort), so both StrictMode mount + // passes see the same three finished threads and neither leaves anything registered to tear down. + const seededRef = useRef(false) + useEffect(() => { + if (seededRef.current || store.threads.length > 0) return + seededRef.current = true + for (const prompt of SEED_PROMPTS) { + const scenario = nextSeedScenario() + const id = store.create() + const createdAt = Date.now() + const userMessage: ChatMessage = { + id: crypto.randomUUID(), + role: 'user', + parts: [{ id: crypto.randomUUID(), type: 'text', text: prompt }], + createdAt, + } + const assistantMessage: ChatMessage = { + id: crypto.randomUUID(), + role: 'assistant', + parts: scenario.parts(prompt), + createdAt: createdAt + 1, + finish: 'complete', + } + store.appendMessage(id, userMessage) + store.appendMessage(id, assistantMessage) + store.setStatus(id, 'done') + store.setOutcome( + id, + heuristicOutcome({ + id, + messages: [userMessage, assistantMessage], + outcome: null, + status: 'done', + read: false, + createdAt, + updatedAt: createdAt + 1, + }), + ) + } + }, [store]) + + const handleSend = useCallback( + (thread: AgentThread, payload: ComposerSubmit) => { + start(thread.id, payload.text) + }, + [start], + ) + + const handleReset = useCallback(() => { + store.clear() + seededRef.current = false + }, [store]) + + const inlineRow = useCallback( + (thread: AgentThread) => { + const expanded = thread.id === expandedId + const run = runs.get(thread.id) + return ( + + + + {`mounted ${mountCounts.get(thread.id) ?? 0}×`} + + + {`stream started ${streamCounts.get(thread.id) ?? 0}×`} + + + setExpandedId((current) => (current === id ? null : id))} + {...(run !== undefined + ? { liveParts: run.parts, liveStatus: 'streaming' as const } + : {})} + // `PartRenderers` is augmented PROGRAM-WIDE by ./agent-part-registry.type-guard.ts + // ('data-toolProgress' | 'data-chart') — module augmentation is global for this whole + // tsconfig, so the augmented type no longer accepts an arbitrary extra key by direct + // assignment (excess-property-checked, unlike `definePartRenderers`'s own const-generic + // call site — see that fixture's Fixture 3 for the asymmetry). This demo intentionally + // hijacks the REAL 'reasoning' key, which was never meant to be constrained by that + // augmentation at all, so the cast below is a scoped, deliberate escape — not a case the + // augmented type was ever supposed to validate. + renderers={ + { reasoning: makeReasoningProbe(thread.id, bumpMount) } as unknown as PartRenderers + } + onSend={(payload) => handleSend(thread, payload)} + onStop={() => stop(thread.id)} + /> + + ) + }, + [expandedId, runs, mountCounts, streamCounts, bumpMount, handleSend, stop], + ) + + return ( + +
+ Thread feed — inline row vs outcome row + + The same three threads, rendered two ways: the Slack-shaped inline row on the left (expand + in place, transcript + composer), the unchanged inbox row on the right. Both load already + settled — expand a row on the left, watch its two counters, then collapse and re-expand it + a few times — both must stay flat. Sending a new message (the composer at the bottom of an + expanded row) is the only thing allowed to move either counter, and now shows a Stop + button while it streams. + +
+ + + + + variant="inline" (renderRow → ThreadFeedRow) + + + + + + + + variant="outcome" (ThreadOutcomeCard, unchanged) + + + + + + + + + + +
+ ) +} diff --git a/apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx b/apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx new file mode 100644 index 0000000..5c56868 --- /dev/null +++ b/apps/playground/src/demo/AgentTranscriptVirtualizeDemoPage.tsx @@ -0,0 +1,177 @@ +/** + * AgentTranscriptVirtualizeDemoPage — basalt-ui 1.13.0 playground gate demos 2 + 3: a 500-message + * `ThreadTranscript` with `virtualize` on, plus copy / regenerate / relative timestamps / author + * grouping on the same thread. + * + * The 500 messages (`./agent-long-thread`) are deliberately NOT uniform height: one-line acks, + * bulleted medium replies, and long fenced-code deep-dives sit side by side, and some consecutive + * same-author runs land inside the 5-minute grouping window (chrome collapses) while others land + * just outside it (chrome stays) — a uniform thread would hide both a `measureElement` regression + * and a grouping-boundary regression. + * + * happy-dom (the unit test harness) has no layout engine, so `virtualize`'s actual scroll/measure + * behavior has never been observed anywhere but here — this page is where a human confirms the + * windowed pane scrolls smoothly. The "Virtualize" switch toggles the SAME 500 messages through + * the unwindowed path so the cost of turning it off is felt directly, not just asserted. + * + * The `virtualize: true` + omitted `height` tsc error this gate also asks for is proven as a + * committed fixture instead of re-created live here — see + * `./agent-transcript-virtualize.type-guard.ts` beside this file. + * + * 1.13.0 GATE ADDITION: `overscan`/`estimateSize`/`initialScroll` — the fields of the consumer- + * supplied `VirtualizeOptions` object form (`virtualize: { overscan, estimateSize, initialScroll }` + * instead of the bare `virtualize: true` used everywhere else in this repo) — had never been + * exercised from outside the package. The controls below feed all three straight through: push + * `estimateSize` far from the true row height and scroll to feel the rows visibly jump as they + * measure in, drop `overscan` to 0 and scroll fast to see blank frames at the windowing edge, or + * flip `initialScroll` to see the pane mount at the newest message (`'end'`, the shipped default) + * versus the oldest (`'start'`) on the SAME 500-message thread. + */ +import { + Badge, + Group, + List, + NumberInput, + Paper, + SegmentedControl, + Stack, + Switch, + Text, + Title, +} from '@mantine/core' +import { ThreadTranscript } from 'basalt-ui/agent-chat' +import type { VirtualizeProps } from 'basalt-ui/agent-chat' +import { VX } from 'basalt-ui/tokens' +import { useMemo, useState } from 'react' +import { buildLongThread } from './agent-long-thread' + +const MESSAGE_COUNT = 500 +const TRANSCRIPT_HEIGHT = 560 +const REGENERATE_LOG_MAX = 5 +// Mirrors thread-message.tsx's own internal defaults (DEFAULT_VIRTUALIZE_OVERSCAN / +// DEFAULT_VIRTUALIZE_ESTIMATE_SIZE) purely as this page's starting point — neither is exported, so +// these are just reasonable initial values for the two inputs below, not a reference to the source. +// KEEP THESE IN STEP with those constants: this page ALWAYS passes the object form, so a starting +// value that disagrees with the package default means the shipped default is the one thing this +// page can never show. (1.13.0 raised estimateSize 96 → 160 for exactly the jump-on-first-descent +// reason the copy below describes; 96 was left behind here and is restored to parity.) +const DEFAULT_OVERSCAN = 6 +const DEFAULT_ESTIMATE_SIZE = 160 +// Mirrors the shipped `VirtualizeOptions.initialScroll` default — see the note above, same reasoning. +const DEFAULT_INITIAL_SCROLL = 'end' + +export function AgentTranscriptVirtualizeDemoPage() { + const messages = useMemo(() => buildLongThread(MESSAGE_COUNT), []) + const [virtualizeOn, setVirtualizeOn] = useState(true) + const [overscan, setOverscan] = useState(DEFAULT_OVERSCAN) + const [estimateSize, setEstimateSize] = useState(DEFAULT_ESTIMATE_SIZE) + const [initialScroll, setInitialScroll] = useState<'end' | 'start'>(DEFAULT_INITIAL_SCROLL) + const [regenerateLog, setRegenerateLog] = useState([]) + + const virtualizeProps: VirtualizeProps = virtualizeOn + ? { virtualize: { overscan, estimateSize, initialScroll }, height: TRANSCRIPT_HEIGHT } + : {} + + return ( + +
+ Transcript virtualization — {MESSAGE_COUNT} messages + + Scroll the pane below to confirm it stays smooth with virtualization on. Toggle it off to + feel the cost of rendering all {MESSAGE_COUNT} rows unwindowed on the same thread. Hover + any message for its relative timestamp, a copy action, and — on the last assistant message + only — Regenerate. Scroll through a few turns to see consecutive same-author messages: + some collapse their role label and chrome (grouped, inside the 5-minute window), some + don't (same role, but the gap crossed the window). overscan,{' '} + estimateSize, and initialScroll below are the consumer-supplied{' '} + VirtualizeOptions — push them off their defaults and scroll to feel the + difference. Switching initialScroll remounts the pane below so you can see it + land at the newest message ('end', the shipped default) versus the + oldest ('start'). + +
+ + + setVirtualizeOn(event.currentTarget.checked)} + /> + setOverscan(typeof value === 'number' ? value : DEFAULT_OVERSCAN)} + min={0} + max={50} + disabled={!virtualizeOn} + w={110} + /> + + setEstimateSize(typeof value === 'number' ? value : DEFAULT_ESTIMATE_SIZE) + } + min={16} + max={800} + disabled={!virtualizeOn} + w={130} + /> + setInitialScroll(value === 'start' ? 'start' : 'end')} + disabled={!virtualizeOn} + data={[ + { label: 'initialScroll: end', value: 'end' }, + { label: 'initialScroll: start', value: 'start' }, + ]} + /> + + {`${messages.length} messages`} + + + + + + setRegenerateLog((prev) => + [`Regenerate requested for ${messageId}`, ...prev].slice(0, REGENERATE_LOG_MAX), + ), + }} + {...virtualizeProps} + /> + + + + + Regenerate log + + {regenerateLog.length === 0 ? ( + + Hover the LAST assistant message — already in view when initialScroll is{' '} + 'end', otherwise scroll down — and click Regenerate. + + ) : ( + + {regenerateLog.map((line, index) => ( + + + {line} + + + ))} + + )} + +
+ ) +} diff --git a/apps/playground/src/demo/agent-long-thread.ts b/apps/playground/src/demo/agent-long-thread.ts new file mode 100644 index 0000000..9971f5e --- /dev/null +++ b/apps/playground/src/demo/agent-long-thread.ts @@ -0,0 +1,147 @@ +/** + * agent-long-thread — a deterministic, genuinely variable-height 500-message thread backing the + * virtualization gate demo (`AgentTranscriptVirtualizeDemoPage`). + * + * Three things this generator is deliberately shaped to exercise, all at once: + * - VARIABLE row heights (one-line acks, bulleted medium replies, long fenced-code deep-dives) — + * uniform rows would hide a `measureElement` regression in the virtualizer. + * - Consecutive same-author runs both INSIDE the 5-minute grouping window (role chrome collapses) + * and just OUTSIDE it (role chrome stays), so the Slack-rhythm boundary is actually exercised, + * not just the common alternating-speaker case. + * - Old-to-new timestamps spread across real hours, so `formatRelativeTime` reads naturally + * ("3 hours ago", not a wall of "just now"). + */ +import type { ChatMessage } from 'basalt-ui/agent' + +const SHORT_REPLIES = [ + 'Got it — will do.', + 'That looks right to me.', + 'Confirmed, no issues.', + 'No blockers on my end.', + 'Makes sense, thanks.', +] as const + +function mediumReply(n: number): string { + return ( + `Here's a quick rundown for item #${n}:\n\n` + + `- Checked the current implementation\n` + + `- Confirmed the edge case around empty input\n` + + `- No regressions in the existing test suite\n\n` + + `Let me know if you want a deeper look at any of these.` + ) +} + +function longReply(n: number): string { + return ( + `## Deep dive — item #${n}\n\n` + + `This one took a bit longer to trace through. The root cause sits in how the accumulator ` + + `merges partial updates: when two deltas arrive in the same tick, the second overwrites the ` + + `first instead of appending.\n\n` + + '```ts\n' + + `function merge(parts: Part[], next: Part): Part[] {\n` + + ` const index = parts.findIndex((p) => p.id === next.id)\n` + + ` if (index === -1) return [...parts, next]\n` + + ` const copy = [...parts]\n` + + ` copy[index] = next // should append text, not replace it\n` + + ` return copy\n` + + `}\n` + + '```\n\n' + + `A few follow-ups worth tracking separately:\n\n` + + `1. Add a regression test for the same-tick double-delta case.\n` + + `2. Audit the other three call sites that share this helper.\n` + + `3. Document the invariant so the next person doesn't reintroduce it.\n\n` + + `None of this blocks the current release — flagging it for the next pass.` + ) +} + +const SHORT_PROMPT_TEMPLATES = [ + 'Ship it?', + 'Any concerns?', + 'Status on #%d?', + 'Still good?', +] as const + +function pickPrompt(turn: number): string { + // Non-null: `turn % SHORT_PROMPT_TEMPLATES.length` is always in bounds for this fixed literal array. + const template = SHORT_PROMPT_TEMPLATES[turn % SHORT_PROMPT_TEMPLATES.length]! + return template.includes('%d') ? template.replace('%d', String(turn)) : template +} + +function pickReply(turn: number): string { + const mod = turn % 3 + // Non-null: same in-bounds-by-construction reasoning as pickPrompt above. + if (mod === 0) return SHORT_REPLIES[turn % SHORT_REPLIES.length]! + if (mod === 1) return mediumReply(turn) + return longReply(turn) +} + +function pickFollowUp(turn: number): string { + return SHORT_REPLIES[turn % SHORT_REPLIES.length]! +} + +function textPart(text: string): { id: string; type: 'text'; text: string } { + return { id: crypto.randomUUID(), type: 'text', text } +} + +function makeMessage(role: ChatMessage['role'], createdAt: number, text: string): ChatMessage { + return { + id: crypto.randomUUID(), + role, + parts: [textPart(text)], + createdAt, + ...(role === 'assistant' ? { finish: 'complete' as const } : {}), + } +} + +const GROUPED_GAP_MS = 90_000 // 1.5 min — inside the 5-min grouping window +const UNGROUPED_GAP_MS = 6 * 60_000 // 6 min — just outside it +const REPLY_GAP_MS = 2 * 60_000 +const TURN_GAP_MS = 22 * 60_000 + +/** + * Builds `count` messages (oldest first) — a long-running thread that started roughly + * `count * ~26min` in the past and ends "just now"-ish, cycling through six turn shapes so both + * grouped and ungrouped consecutive-same-author runs occur, alongside short/medium/long replies. + */ +export function buildLongThread(count: number): ChatMessage[] { + const messages: ChatMessage[] = [] + // Worst case is ~1 message per turn (cycle 0, no follow-up) — `count` turns of headroom keeps + // every generated timestamp safely in the past regardless of how the cycle mix actually lands. + let cursor = Date.now() - count * TURN_GAP_MS + let turn = 0 + + while (messages.length < count) { + const cycle = turn % 6 + + messages.push(makeMessage('user', cursor, pickPrompt(turn))) + cursor += GROUPED_GAP_MS + + // Grouped user follow-up: same role, well inside the 5-minute window. + if (cycle === 1 && messages.length < count) { + messages.push(makeMessage('user', cursor, `Actually, one more thing — ${pickPrompt(turn)}`)) + cursor += GROUPED_GAP_MS + } + + cursor += REPLY_GAP_MS + if (messages.length < count) { + messages.push(makeMessage('assistant', cursor, pickReply(turn))) + } + + if (cycle === 3 && messages.length < count) { + // Grouped assistant follow-up: same role, inside the window. + cursor += GROUPED_GAP_MS + messages.push(makeMessage('assistant', cursor, `One more note: ${pickFollowUp(turn)}`)) + } else if (cycle === 4 && messages.length < count) { + // Same role, but OUTSIDE the window — proves the boundary, not just the common case. + cursor += UNGROUPED_GAP_MS + messages.push( + makeMessage('assistant', cursor, `Coming back after a pause — ${pickFollowUp(turn)}`), + ) + } + + cursor += TURN_GAP_MS + turn += 1 + } + + return messages.slice(0, count) +} diff --git a/apps/playground/src/demo/agent-transcript-virtualize.type-guard.ts b/apps/playground/src/demo/agent-transcript-virtualize.type-guard.ts new file mode 100644 index 0000000..4c71c32 --- /dev/null +++ b/apps/playground/src/demo/agent-transcript-virtualize.type-guard.ts @@ -0,0 +1,68 @@ +// PROVES (consumer-facing, not just package-internal): ThreadTranscriptProps and ThreadFeedRowProps +// both inherit VirtualizeProps' virtualize-implies-height guard through the PUBLIC +// `basalt-ui/agent-chat` subpath a real app imports — reproduces the "virtualize: true with no +// height" tsc error the release notes ask for, from the public prop types rather than re-deriving +// the internal `VirtualizeProps` union already proven at its definition by +// packages/basalt-ui/src/agent-chat/virtualize.type-guard.test.ts. +import type { AgentThread } from 'basalt-ui/agent' +import type { ThreadFeedRowProps, ThreadTranscriptProps } from 'basalt-ui/agent-chat' + +function acceptTranscript(props: ThreadTranscriptProps): ThreadTranscriptProps { + return props +} + +function acceptRow(props: ThreadFeedRowProps): ThreadFeedRowProps { + return props +} + +const messages: ThreadTranscriptProps['messages'] = [] + +const thread: AgentThread = { + id: 'fixture-thread', + messages: [], + outcome: null, + status: 'pending', + read: false, + createdAt: 0, + updatedAt: 0, +} + +function noopToggle(_id: string): void { + // fixture-only no-op +} + +function noopSend(): void { + // fixture-only no-op +} + +// ── Valid combinations — must type-check with no error ──────────────────────── + +acceptTranscript({ messages }) +acceptTranscript({ messages, virtualize: true, height: 400 }) +acceptRow({ thread, expanded: false, onToggle: noopToggle, onSend: noopSend }) +acceptRow({ + thread, + expanded: false, + onToggle: noopToggle, + onSend: noopSend, + virtualize: true, + height: 300, +}) + +// ── Invalid combinations — each MUST be a tsc error ─────────────────────────── + +// @ts-expect-error `height` is required when `virtualize: true` (ThreadTranscriptProps) +acceptTranscript({ messages, virtualize: true }) + +// @ts-expect-error `height` is forbidden when `virtualize` is omitted/false (ThreadTranscriptProps) +acceptTranscript({ messages, height: 400 }) + +// @ts-expect-error `height` is required when `virtualize: true` (ThreadFeedRowProps) +acceptRow({ thread, expanded: false, onToggle: noopToggle, onSend: noopSend, virtualize: true }) + +// @ts-expect-error `height` is forbidden when `virtualize` is omitted/false (ThreadFeedRowProps) +acceptRow({ thread, expanded: false, onToggle: noopToggle, onSend: noopSend, height: 400 }) + +// PROVES: the virtualize/height union guard (packages/basalt-ui/src/agent-chat/virtualize.ts) holds +// on the actual public prop types a consumer imports (ThreadTranscriptProps, ThreadFeedRowProps), +// not only on the internal VirtualizeProps type it's built from. diff --git a/apps/playground/src/demo/nav-model.tsx b/apps/playground/src/demo/nav-model.tsx index 602acb1..5281275 100644 --- a/apps/playground/src/demo/nav-model.tsx +++ b/apps/playground/src/demo/nav-model.tsx @@ -249,6 +249,34 @@ export const NAV_MODEL: SidebarSection[] = [ icon: , href: '/threads-adapter', }, + { + key: 'agent-thread-feed-inline', + label: 'Agent thread feed (inline)', + mobile: true, + icon: , + href: '/agent-thread-feed-inline', + }, + { + key: 'agent-transcript-virtualize', + label: 'Agent transcript virtualize', + mobile: true, + icon: , + href: '/agent-transcript-virtualize', + }, + { + key: 'agent-inline-feed-virtualized', + label: 'Agent inline feed (virtualized row)', + mobile: true, + icon: , + href: '/agent-inline-feed-virtualized', + }, + { + key: 'agent-anchor-to-end', + label: 'Agent anchor to end (streaming)', + mobile: true, + icon: , + href: '/agent-anchor-to-end', + }, ], }, { diff --git a/apps/playground/src/routes/agent-anchor-to-end.tsx b/apps/playground/src/routes/agent-anchor-to-end.tsx new file mode 100644 index 0000000..9e37a3c --- /dev/null +++ b/apps/playground/src/routes/agent-anchor-to-end.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AgentAnchorToEndDemoPage } from '../demo/AgentAnchorToEndDemoPage' + +export const Route = createFileRoute('/agent-anchor-to-end')({ + staticData: { title: 'Agent anchor to end' }, + component: AgentAnchorToEndDemoPage, +}) diff --git a/apps/playground/src/routes/agent-inline-feed-virtualized.tsx b/apps/playground/src/routes/agent-inline-feed-virtualized.tsx new file mode 100644 index 0000000..3a7b289 --- /dev/null +++ b/apps/playground/src/routes/agent-inline-feed-virtualized.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AgentInlineFeedVirtualizedRowDemoPage } from '../demo/AgentInlineFeedVirtualizedRowDemoPage' + +export const Route = createFileRoute('/agent-inline-feed-virtualized')({ + staticData: { title: 'Agent inline feed (virtualized row)' }, + component: AgentInlineFeedVirtualizedRowDemoPage, +}) diff --git a/apps/playground/src/routes/agent-thread-feed-inline.tsx b/apps/playground/src/routes/agent-thread-feed-inline.tsx new file mode 100644 index 0000000..6e62b2d --- /dev/null +++ b/apps/playground/src/routes/agent-thread-feed-inline.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AgentThreadFeedInlineDemoPage } from '../demo/AgentThreadFeedInlineDemoPage' + +export const Route = createFileRoute('/agent-thread-feed-inline')({ + staticData: { title: 'Agent thread feed (inline)' }, + component: AgentThreadFeedInlineDemoPage, +}) diff --git a/apps/playground/src/routes/agent-transcript-virtualize.tsx b/apps/playground/src/routes/agent-transcript-virtualize.tsx new file mode 100644 index 0000000..a98dcf7 --- /dev/null +++ b/apps/playground/src/routes/agent-transcript-virtualize.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AgentTranscriptVirtualizeDemoPage } from '../demo/AgentTranscriptVirtualizeDemoPage' + +export const Route = createFileRoute('/agent-transcript-virtualize')({ + staticData: { title: 'Agent transcript virtualize' }, + component: AgentTranscriptVirtualizeDemoPage, +}) From 7a35f4306e7507a641db7bdbd228ed376bc37391 Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 11:25:57 +0200 Subject: [PATCH 06/11] fix: hold the pack-test consumer inside the peer ranges it declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scratch consumer installed @tanstack/react-table@>=8 and react-virtual@>=3, both open-ended, while the package declares >=8 <9 and >=3 <4. react-table 9.0.0 published today, so CI installed a major outside the supported range and export-surface died on an export that major had removed — a red build on every PR, caused by an upstream release rather than by anything in the diff. The ranges now mirror the declared peers, so this harness tests what consumers are actually allowed to resolve. Pinning to exact versions would be the wrong fix: the point of the scratch install is to catch a peer range that has drifted from reality, and an exact pin would hide exactly that. --- packages/basalt-ui/scripts/pack-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/basalt-ui/scripts/pack-test.sh b/packages/basalt-ui/scripts/pack-test.sh index ce3cc70..477d504 100755 --- a/packages/basalt-ui/scripts/pack-test.sh +++ b/packages/basalt-ui/scripts/pack-test.sh @@ -75,7 +75,7 @@ bun add "$ABS_TGZ" \ @mantine/form @mantine/notifications @mantine/spotlight @mantine/modals \ "@tanstack/react-query@^5.101.0" "@tanstack/react-query-devtools@^5.101.0" \ "@tanstack/react-router@^1.170.0" \ - "@tanstack/react-table@>=8" "@tanstack/react-virtual@>=3" \ + "@tanstack/react-table@>=8 <9" "@tanstack/react-virtual@>=3 <4" \ "react-markdown@^10.1.0" "remark-gfm@^4.0.1" \ "use-stick-to-bottom@^1.1.6" \ vite \ From b37723e87eb753e209a027d765d1e2ab99f1defd Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 13:01:25 +0200 Subject: [PATCH 07/11] fix: surface a failed thread load instead of an endless skeleton --- .../src/agent-chat/thread-workspace.test.tsx | 13 ++++++ .../src/agent-chat/thread-workspace.tsx | 46 +++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx b/packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx index 2a4ea91..3d2b34f 100644 --- a/packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-workspace.test.tsx @@ -123,6 +123,19 @@ describe('ThreadWorkspace hydration gating', () => { expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull() }) + test('failed load: renders the error state, never the hydrating hold', () => { + withWideViewport(() => { + render( + renderWorkspace( + makeStore({ hydrated: false, error: new Error('listThreads failed'), threads: [] }), + ), + ) + }) + + expect(screen.getByTestId('thread-workspace-error')).toBeDefined() + expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull() + }) + test('hydrated + empty: renders the empty state (no threads really is the truth)', () => { withWideViewport(() => { render(renderWorkspace(makeStore({ hydrated: true, threads: [] }))) diff --git a/packages/basalt-ui/src/agent-chat/thread-workspace.tsx b/packages/basalt-ui/src/agent-chat/thread-workspace.tsx index cfa56f1..a4fc9ed 100644 --- a/packages/basalt-ui/src/agent-chat/thread-workspace.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-workspace.tsx @@ -37,6 +37,7 @@ import { useMediaQuery } from '@mantine/hooks' import type { JSX, ReactNode } from 'react' import type { AgentPart, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent' import { useAgentThreadRuns } from '../agent' +import { VX } from '../tokens' import { Composer } from './composer' import { ThreadDetailPanel } from './thread-detail-panel' import { ThreadFeed } from './thread-feed' @@ -63,6 +64,13 @@ export type ThreadWorkspaceProps = { * so a server-backed workspace never asserts "no threads yet" before it actually knows that. */ readonly emptyState?: ReactNode + /** + * Rendered in place of the feed when the store's initial load has failed — `!store.hydrated && + * store.error !== undefined` (see `ThreadsStore.hydrated`'s doc for the discriminant). Shown + * instead of `FeedHydratingState`, so a rejected `listThreads` surfaces a legible failure rather + * than an endless skeleton. + */ + readonly errorState?: ReactNode } /** Default hint shown in the feed pane once hydrated with no threads (overridable via `emptyState`). */ @@ -81,10 +89,11 @@ function FeedEmptyState(): JSX.Element { /** * Neutral hold shown in the feed pane while an async store's initial `listThreads` is still in - * flight AND it has no threads to show yet (`!store.hydrated && threads.length === 0`). Never - * shown for the synchronous `createThreadsStore`, whose `hydrated` is always `true` — see - * `ThreadsStore.hydrated`'s doc. A store that already holds cached threads while revalidating - * (`!hydrated` with a non-empty `threads`) skips this entirely and renders the feed normally. + * flight, has no threads to show yet, AND has not failed (`!store.hydrated && threads.length === + * 0 && store.error === undefined`). Never shown for the synchronous `createThreadsStore`, whose + * `hydrated` is always `true` — see `ThreadsStore.hydrated`'s doc. A store that already holds + * cached threads while revalidating (`!hydrated` with a non-empty `threads`) skips this entirely + * and renders the feed normally. A failed load renders `FeedErrorState` instead of this. */ function FeedHydratingState(): JSX.Element { return ( @@ -96,6 +105,32 @@ function FeedHydratingState(): JSX.Element { ) } +/** + * Default failure hold shown in the feed pane when an async store's initial `listThreads` has + * rejected (`!store.hydrated && store.error !== undefined`), overridable via `errorState`. Never + * shown for the synchronous `createThreadsStore`, whose `error` is always `undefined` — see + * `ThreadsStore.error`'s doc. + */ +function FeedErrorState(): JSX.Element { + return ( + + + Couldn't load threads + + + Something went wrong loading your threads. Try refreshing the page. + + + ) +} + /** * A two-pane master-detail thread workspace: a scrollable feed of threads with an anchored * new-thread composer, and a detail panel for the open thread's live transcript. Collapses to a @@ -110,6 +145,7 @@ export function ThreadWorkspace({ resolveOutcome, newThreadPlaceholder, emptyState, + errorState, }: ThreadWorkspaceProps): JSX.Element { const store = useThreads() const runs = useAgentThreadRuns({ transport, store, resolveOutcome }) @@ -155,6 +191,8 @@ export function ThreadWorkspace({ {threads.length === 0 ? ( store.hydrated ? ( (emptyState ?? ) + ) : store.error !== undefined ? ( + (errorState ?? ) ) : ( ) From 4a19d798aa1904baecb06facac21687eabd8833f Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 13:01:28 +0200 Subject: [PATCH 08/11] fix: hold the virtual peer floor at the APIs the transcript calls --- packages/basalt-ui/llms.txt | 6 +++--- packages/basalt-ui/package.json | 2 +- packages/basalt-ui/scripts/pack-test.sh | 2 +- packages/basalt-ui/src/data/index.ts | 6 +++++- packages/basalt-ui/src/data/virtual-list.tsx | 3 ++- packages/basalt-ui/src/data/virtual.ts | 3 ++- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/basalt-ui/llms.txt b/packages/basalt-ui/llms.txt index 4fcebc5..ed62a08 100644 --- a/packages/basalt-ui/llms.txt +++ b/packages/basalt-ui/llms.txt @@ -96,7 +96,7 @@ Import: import { ... } from 'basalt-ui/data' Description: Convenience barrel pulling both TanStack Table + Virtual peer groups: BasaltDataTable, BasaltVirtualList (Mantine-rendered) — prefer ./data/table or ./data/virtual for per-feature opt-in Layer: mantine-coupled Rule: basalt-data -OptionalPeers: @tanstack/react-table@>=8 <9, @tanstack/react-virtual@>=3 <4 +OptionalPeers: @tanstack/react-table@>=8 <9, @tanstack/react-virtual@>=3.13.26 <4 ## basalt-ui/data/table Import: import { ... } from 'basalt-ui/data/table' @@ -110,7 +110,7 @@ Import: import { ... } from 'basalt-ui/data/virtual' Description: BasaltVirtualList: a windowed virtual list over TanStack Virtual, rendered with Mantine (Mantine-rendered) Layer: mantine-coupled Rule: basalt-data -OptionalPeers: @tanstack/react-virtual@>=3 <4 +OptionalPeers: @tanstack/react-virtual@>=3.13.26 <4 ## basalt-ui/agent Import: import { ... } from 'basalt-ui/agent' @@ -124,7 +124,7 @@ Import: import { ... } from 'basalt-ui/agent-chat' Description: Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed (variant/renderRow), ThreadFeedRow (inline-expanding Slack row, lazily mounted + kept mounted), ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props, per-message MessageAffordances, groupConsecutive, and an optional virtualize/height windowing mode whose VirtualizeOptions carry overscan/estimateSize/initialScroll — a virtualized transcript opens scrolled to the newest message unless initialScroll is "start"), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx, and @tanstack/react-virtual the same way through the lazy import() behind ThreadTranscript virtualize (absent peer degrades to an unwindowed, height-bound pane). Layer: mantine-coupled Rule: basalt-agent -OptionalPeers: ai@^7.0.15, motion@12.42.0, remend@1.3.0, use-stick-to-bottom@^1.1.6, react-markdown@^10.1.0, remark-gfm@^4.0.1, shiki@^4.3.0, @shikijs/langs@^4.3.1, @shikijs/themes@^4.3.1, beautiful-mermaid@^1.1.0, @tanstack/react-virtual@>=3 <4 +OptionalPeers: ai@^7.0.15, motion@12.42.0, remend@1.3.0, use-stick-to-bottom@^1.1.6, react-markdown@^10.1.0, remark-gfm@^4.0.1, shiki@^4.3.0, @shikijs/langs@^4.3.1, @shikijs/themes@^4.3.1, beautiful-mermaid@^1.1.0, @tanstack/react-virtual@>=3.13.26 <4 ## basalt-ui/content Import: import { ... } from 'basalt-ui/content' diff --git a/packages/basalt-ui/package.json b/packages/basalt-ui/package.json index ef04b9c..5002c42 100644 --- a/packages/basalt-ui/package.json +++ b/packages/basalt-ui/package.json @@ -190,7 +190,7 @@ "@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-router": "^1.170.0", "@tanstack/react-table": ">=8 <9", - "@tanstack/react-virtual": ">=3 <4", + "@tanstack/react-virtual": ">=3.13.26 <4", "@visx/axis": "4.0.0", "@visx/curve": "4.0.0", "@visx/event": "4.0.0", diff --git a/packages/basalt-ui/scripts/pack-test.sh b/packages/basalt-ui/scripts/pack-test.sh index 477d504..53d0aac 100755 --- a/packages/basalt-ui/scripts/pack-test.sh +++ b/packages/basalt-ui/scripts/pack-test.sh @@ -75,7 +75,7 @@ bun add "$ABS_TGZ" \ @mantine/form @mantine/notifications @mantine/spotlight @mantine/modals \ "@tanstack/react-query@^5.101.0" "@tanstack/react-query-devtools@^5.101.0" \ "@tanstack/react-router@^1.170.0" \ - "@tanstack/react-table@>=8 <9" "@tanstack/react-virtual@>=3 <4" \ + "@tanstack/react-table@>=8 <9" "@tanstack/react-virtual@>=3.13.26 <4" \ "react-markdown@^10.1.0" "remark-gfm@^4.0.1" \ "use-stick-to-bottom@^1.1.6" \ vite \ diff --git a/packages/basalt-ui/src/data/index.ts b/packages/basalt-ui/src/data/index.ts index df77985..1af6f49 100644 --- a/packages/basalt-ui/src/data/index.ts +++ b/packages/basalt-ui/src/data/index.ts @@ -7,7 +7,11 @@ * * Optional peers: * - @tanstack/react-table >=8 <9 (BasaltDataTable) - * - @tanstack/react-virtual >=3 <4 (BasaltVirtualList) + * - @tanstack/react-virtual >=3.13.26 <4 (BasaltVirtualList) + * + * BasaltVirtualList itself works on any 3.x; the declared floor is set by ./agent-chat's + * ThreadTranscript virtualize mode (it calls scrollToEnd/anchorTo, added in virtual-core 3.16.0, + * first pinned by react-virtual 3.13.26). npm has one peer range per package, so do not lower it. * * Install with: * bun add @tanstack/react-table @tanstack/react-virtual diff --git a/packages/basalt-ui/src/data/virtual-list.tsx b/packages/basalt-ui/src/data/virtual-list.tsx index a3de0c9..d12dc2e 100644 --- a/packages/basalt-ui/src/data/virtual-list.tsx +++ b/packages/basalt-ui/src/data/virtual-list.tsx @@ -1,7 +1,8 @@ /** * ./data — BasaltVirtualList: a windowed virtual list over @tanstack/react-virtual, * rendered with a Mantine Box scroll container. - * Optional peer: @tanstack/react-virtual >=3 <4. + * Optional peer: @tanstack/react-virtual >=3.13.26 <4. This list works on any 3.x — the floor is + * set by ./agent-chat's virtualize mode (see ./data/index.ts), so do not lower it. * * @example * import { BasaltVirtualList } from 'basalt-ui/data' diff --git a/packages/basalt-ui/src/data/virtual.ts b/packages/basalt-ui/src/data/virtual.ts index 5b28f36..883213d 100644 --- a/packages/basalt-ui/src/data/virtual.ts +++ b/packages/basalt-ui/src/data/virtual.ts @@ -2,7 +2,8 @@ * ./data/virtual — BasaltVirtualList: a windowed virtual list over @tanstack/react-virtual, * rendered with a Mantine Box scroll container. * - * Optional peer: @tanstack/react-virtual >=3 <4. + * Optional peer: @tanstack/react-virtual >=3.13.26 <4. This list works on any 3.x — the floor is + * set by ./agent-chat's virtualize mode (see ./data/index.ts), so do not lower it. * * Use this fine subpath instead of the `./data` barrel when your app only needs the virtual list * — it does NOT value-import @tanstack/react-table, so the data-table peer is never required. From 67e1172b55bc7e4cd9fef4b4b0d5583570e062e4 Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 13:01:32 +0200 Subject: [PATCH 09/11] docs: name the virtualization peer and drop a reset that cannot fire --- packages/basalt-ui/README.md | 73 ++++++++++--------- .../basalt-ui/src/agent-chat/thread-feed.tsx | 12 +-- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/packages/basalt-ui/README.md b/packages/basalt-ui/README.md index bd480b2..a43b44b 100644 --- a/packages/basalt-ui/README.md +++ b/packages/basalt-ui/README.md @@ -219,31 +219,31 @@ deleted rather than silently surviving into the next real skew. ## Subpath exports -| Subpath | Mantine? | Purpose | -| ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.` | coupled | `BasaltProvider`, `createBasaltTheme` / `baseTheme` / `cssVariablesResolver`, `BasaltShell` + sidebar / mobile-nav / breadcrumbs / page-header, `NavCountBadge`, `SidebarAccount` + the provider-agnostic account contract (`BasaltAccountProps`/`State`/`Actions`), `ThreadWorkspace` + thread-chat components, shell types — the root entry still re-exports the thread-chat components; `./agent-chat` takes the same components standalone, without `BasaltProvider`, the shell, the dashboard composites, or `./connectivity` | -| `./charts` | **free** | visx chart primitives, sparklines, hooks, and token re-exports | -| `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types | -| `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection | -| `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette | -| `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core | -| `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools` | -| `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` | -| `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) | -| `./notifications` | coupled | Mantine notifications: `notify` helpers, typed registry, persisted history, `NotificationBell` | -| `./commands` | coupled | Typed command bus + overlay controller, `toSpotlightActions`, `ShortcutsHelp`, `BasaltOverlays` | -| `./data` | coupled | Convenience barrel pulling both peer groups: `BasaltDataTable`, `BasaltVirtualList` (Mantine-rendered) — prefer `./data/table` / `./data/virtual` for per-feature opt-in | -| `./data/table` | coupled | `BasaltDataTable` — sortable data table over TanStack Table, rendered with Mantine | -| `./data/virtual` | coupled | `BasaltVirtualList` — windowed virtual list over TanStack Virtual, rendered with Mantine | -| `./agent` | **free** | Headless streaming layer (`useAgentStream`, `PartList`) + multi-thread `createThreadsStore` / `useAgentThreadRuns` / outcome seam | -| `./agent-chat` | coupled | Mantine-styled thread-chat components over `./agent`: `ThreadWorkspace`, `ThreadFeed` (`variant`/`renderRow`), `ThreadFeedRow` (inline-expanding Slack row, lazily mounted + kept mounted), `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript` (`groupConsecutive`/`affordances`/`virtualize` — windowed transcripts open at the newest message unless `initialScroll: 'start'`), `threadPartRenderers` — **requires `motion`**, not merely an optional peer; see [Requirements](#requirements) | -| `./state` | **free** | `createPersistedState` (versioned localStorage) + `useOnlineStatus` — Mantine-free state primitives | -| `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` | -| `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` | -| `./styles.css` | — | `@layer basalt` base styles, iOS input safety net, font stack | -| `./tokens.css` | **free** | Prebuilt `--vx-*` stylesheet — the default `buildPaletteCss()` output as a plain file, for a consumer with no bundler, React or Mantine; `basalt-ui tokens:css` re-emits it with a custom scheme selector | -| `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook | -| `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers | +| Subpath | Mantine? | Purpose | +| ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.` | coupled | `BasaltProvider`, `createBasaltTheme` / `baseTheme` / `cssVariablesResolver`, `BasaltShell` + sidebar / mobile-nav / breadcrumbs / page-header, `NavCountBadge`, `SidebarAccount` + the provider-agnostic account contract (`BasaltAccountProps`/`State`/`Actions`), `ThreadWorkspace` + thread-chat components, shell types — the root entry still re-exports the thread-chat components; `./agent-chat` takes the same components standalone, without `BasaltProvider`, the shell, the dashboard composites, or `./connectivity` | +| `./charts` | **free** | visx chart primitives, sparklines, hooks, and token re-exports | +| `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types | +| `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection | +| `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette | +| `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core | +| `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools` | +| `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` | +| `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) | +| `./notifications` | coupled | Mantine notifications: `notify` helpers, typed registry, persisted history, `NotificationBell` | +| `./commands` | coupled | Typed command bus + overlay controller, `toSpotlightActions`, `ShortcutsHelp`, `BasaltOverlays` | +| `./data` | coupled | Convenience barrel pulling both peer groups: `BasaltDataTable`, `BasaltVirtualList` (Mantine-rendered) — prefer `./data/table` / `./data/virtual` for per-feature opt-in | +| `./data/table` | coupled | `BasaltDataTable` — sortable data table over TanStack Table, rendered with Mantine | +| `./data/virtual` | coupled | `BasaltVirtualList` — windowed virtual list over TanStack Virtual, rendered with Mantine | +| `./agent` | **free** | Headless streaming layer (`useAgentStream`, `PartList`) + multi-thread `createThreadsStore` / `useAgentThreadRuns` / outcome seam | +| `./agent-chat` | coupled | Mantine-styled thread-chat components over `./agent`: `ThreadWorkspace`, `ThreadFeed` (`variant`/`renderRow`), `ThreadFeedRow` (inline-expanding Slack row, lazily mounted + kept mounted), `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript` (`groupConsecutive`/`affordances`/`virtualize` — windowed transcripts open at the newest message unless `initialScroll: 'start'`; `virtualize` is enabled by the optional `@tanstack/react-virtual` peer, absent it degrades to an unwindowed, height-bound pane), `threadPartRenderers` — **requires `motion`**, not merely an optional peer; see [Requirements](#requirements) | +| `./state` | **free** | `createPersistedState` (versioned localStorage) + `useOnlineStatus` — Mantine-free state primitives | +| `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` | +| `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` | +| `./styles.css` | — | `@layer basalt` base styles, iOS input safety net, font stack | +| `./tokens.css` | **free** | Prebuilt `--vx-*` stylesheet — the default `buildPaletteCss()` output as a plain file, for a consumer with no bundler, React or Mantine; `basalt-ui tokens:css` re-emits it with a custom scheme selector | +| `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook | +| `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers | Named exports only — no default exports. @@ -609,22 +609,25 @@ Wire the drift gate to catch doctrine falling behind after a basalt-ui upgrade: ## Requirements -| Peer | Version | Notes | -| ----------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `react` / `react-dom` | `^19` | required | -| `@mantine/core` | `^9.3` | required | -| `@mantine/hooks` | `^9.3` | required | -| `@tanstack/react-query` | — | required for the root `.` entry — `BasaltProvider` hard-requires it at build time; NOT required by `./agent-chat`, which doesn't touch it | -| `motion` | `12.42.0` | required for both the root `.` entry and `./agent-chat` — both export `ThreadFeed`/`ThreadDetailPanel` (`agent-chat/thread-feed.tsx` / `thread-detail-panel.tsx`), which import `motion/react` eagerly; the root's `ThemeToggle` also uses it | +| Peer | Version | Notes | +| ------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `react` / `react-dom` | `^19` | required | +| `@mantine/core` | `^9.3` | required | +| `@mantine/hooks` | `^9.3` | required | +| `@tanstack/react-query` | — | required for the root `.` entry — `BasaltProvider` hard-requires it at build time; NOT required by `./agent-chat`, which doesn't touch it | +| `motion` | `12.42.0` | required for both the root `.` entry and `./agent-chat` — both export `ThreadFeed`/`ThreadDetailPanel` (`agent-chat/thread-feed.tsx` / `thread-detail-panel.tsx`), which import `motion/react` eagerly; the root's `ThemeToggle` also uses it | +| `@tanstack/react-virtual` | `>=3.13.26 <4` | optional for `./agent-chat` — enables `ThreadTranscript`'s `virtualize` windowing mode via a lazy `import()`; absent it degrades to an unwindowed, height-bound pane | `remend` was required through 1.10.x — `content/markdown.tsx` imported it at the top of the module, so both the root `.` entry and `./agent-chat`'s `ThreadWorkspace`/`ThreadTranscript` hard-required it transitively. It is now a genuinely optional, lazy peer (dynamic `import()`); see [`./content`](#content--prose--markdown) below. -`./agent-chat`'s `optionalPeers` in `llms.txt`/`surfaces.ts` lists `remend` and `motion` (npm has no -per-subpath optionality in `peerDependenciesMeta`) — only `motion` needs to be treated as required -when installing just this subpath; `remend` is accurately optional there too now. +`./agent-chat`'s `optionalPeers` in `llms.txt`/`surfaces.ts` lists `remend`, `motion`, and +`@tanstack/react-virtual` (npm has no per-subpath optionality in `peerDependenciesMeta`) — only +`motion` needs to be treated as required when installing just this subpath; `remend` and +`@tanstack/react-virtual` are accurately optional there too, both reached only through a lazy +`import()` (the latter behind `ThreadTranscript`'s `virtualize` option). Optional peer batteries and their packages are listed per battery above. diff --git a/packages/basalt-ui/src/agent-chat/thread-feed.tsx b/packages/basalt-ui/src/agent-chat/thread-feed.tsx index ed4375d..518ba6a 100644 --- a/packages/basalt-ui/src/agent-chat/thread-feed.tsx +++ b/packages/basalt-ui/src/agent-chat/thread-feed.tsx @@ -49,8 +49,9 @@ export type ThreadFeedProps = { * collapsing a row that remains the active thread isn't one; wiring `onToggle={onSelect}` * directly (the previous shape) meant a plain `onSelect={setActiveId}` consumer could never * collapse a row, since re-selecting the same id is a no-op state update. Collapsing here is - * local, UI-only state, reset whenever `activeId` moves to point at a different thread (or is - * re-driven to the same one from outside this component). + * local, UI-only state, reset whenever `activeId` changes to point at a DIFFERENT thread. Since + * this reset is a `useEffect` keyed on `activeId`, re-driving it to the SAME id from outside + * this component is a no-op React dep comparison, so it does not clear the override. */ readonly variant?: 'outcome' | 'inline' /** @@ -119,10 +120,11 @@ export function ThreadFeed({ // meaningful for the id it names; a stale value left over for a since-deselected thread is inert // (the per-row `expanded` check below always requires `thread.id === activeId` too). const [collapsedId, setCollapsedId] = useState(null) - // Any externally-driven move of `activeId` — a different thread selected elsewhere, or the SAME - // thread re-selected after being manually collapsed — clears a stale override. A collapse + // Any externally-driven move of `activeId` to a DIFFERENT thread clears a stale override. React + // bails on a same-value dep, so re-driving `activeId` to the id it already holds (e.g. the SAME + // thread re-selected after being manually collapsed) does NOT re-run this effect. A collapse // triggered by this component's own header click does NOT touch `activeId` at all (see - // `handleInlineToggle`), so it does not re-trigger this effect. + // `handleInlineToggle`), so it does not re-trigger this effect either. useEffect(() => { setCollapsedId(null) }, [activeId]) From 53e1dc86e9508dacb0a23e531c6de222b037737c Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 13:01:36 +0200 Subject: [PATCH 10/11] test: stop the relative-time boundary racing the clock --- packages/basalt-ui/src/agent-chat/relative-time.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/basalt-ui/src/agent-chat/relative-time.test.ts b/packages/basalt-ui/src/agent-chat/relative-time.test.ts index c93f9f8..f2d640f 100644 --- a/packages/basalt-ui/src/agent-chat/relative-time.test.ts +++ b/packages/basalt-ui/src/agent-chat/relative-time.test.ts @@ -14,7 +14,7 @@ describe('formatRelativeTime', () => { test('sub-minute past → "just now"', () => { expect(formatRelativeTime(Date.now() - 1)).toBe('just now') expect(formatRelativeTime(Date.now() - 30_000)).toBe('just now') - expect(formatRelativeTime(Date.now() - (MINUTE_MS - 1))).toBe('just now') + expect(formatRelativeTime(Date.now() - (MINUTE_MS - 1_000))).toBe('just now') }) test('sub-minute future → "just now" (the threshold is symmetric on |diff|)', () => { From 79d182edb8e629cb6141531c51cfc830087314bb Mon Sep 17 00:00:00 2001 From: jkrumm Date: Tue, 4 Aug 2026 13:01:39 +0200 Subject: [PATCH 11/11] chore: land the demo thread's newest message near now --- apps/playground/src/demo/agent-long-thread.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/playground/src/demo/agent-long-thread.ts b/apps/playground/src/demo/agent-long-thread.ts index 9971f5e..faaf813 100644 --- a/apps/playground/src/demo/agent-long-thread.ts +++ b/apps/playground/src/demo/agent-long-thread.ts @@ -105,8 +105,10 @@ const TURN_GAP_MS = 22 * 60_000 */ export function buildLongThread(count: number): ChatMessage[] { const messages: ChatMessage[] = [] - // Worst case is ~1 message per turn (cycle 0, no follow-up) — `count` turns of headroom keeps - // every generated timestamp safely in the past regardless of how the cycle mix actually lands. + // This starting point is only a rough headroom guess — it does not need to land the newest + // message near "now" on its own, because every timestamp is shifted by one offset after + // generation (below) so the newest message lands at/just before `Date.now()` regardless of how + // the cycle mix actually plays out. let cursor = Date.now() - count * TURN_GAP_MS let turn = 0 @@ -143,5 +145,15 @@ export function buildLongThread(count: number): ChatMessage[] { turn += 1 } - return messages.slice(0, count) + const sliced = messages.slice(0, count) + + // Shift every timestamp by one offset so the newest message lands at/just before `Date.now()` — + // a post-generation correction rather than a retuned constant, so it stays correct even if the + // cycle mix above changes later (unlike the previous headroom guess, which assumed a worst case + // that didn't hold: cycle 0 alone emits 2 messages per turn, not 1, so the demo's newest message + // was landing days in the past instead of "just now"). + const newest = sliced.at(-1) + if (newest === undefined) return sliced + const shift = Date.now() - newest.createdAt + return sliced.map((message) => ({ ...message, createdAt: message.createdAt + shift })) }