basalt-ui 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +32 -0
- package/README.md +63 -51
- package/agent/rules/basalt-agent.md +14 -0
- package/agent/rules/basalt-content.md +16 -3
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +150 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +482 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +78 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +30 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +42 -6
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +18 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +5 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +329 -37
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +30 -4
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +14 -4
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +9 -9
- package/package.json +7 -2
- package/src/agent/adapter.test.ts +1640 -0
- package/src/agent/adapter.ts +945 -0
- package/src/agent/ai-sdk-transport.test.ts +117 -26
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +101 -3
- package/src/agent/id.ts +118 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +25 -1
- package/src/agent/use-agent-stream.test.tsx +31 -5
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +356 -17
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +13 -1
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +1078 -4
- package/src/agent-chat/thread-message.tsx +795 -42
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +70 -6
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +18 -1
- package/src/surfaces.ts +13 -3
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +21 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadFeedRow — the lazy-mount / keep-mounted invariant this component exists to guarantee
|
|
3
|
+
* (AGENT-CHAT-SPEC.md §12): never render the transcript before the first expand, never unmount it
|
|
4
|
+
* after. The `effectFireCount` probe is what makes the "no effect re-fire" half of that provable —
|
|
5
|
+
* a re-mount would bump it, a CSS-only hide/show never does.
|
|
6
|
+
*/
|
|
7
|
+
import { MantineProvider } from '@mantine/core'
|
|
8
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
10
|
+
import { useEffect } from 'react'
|
|
11
|
+
import { definePartRenderers } from '../agent'
|
|
12
|
+
import type {
|
|
13
|
+
AgentThread,
|
|
14
|
+
ChatMessage,
|
|
15
|
+
ForeignPart,
|
|
16
|
+
PartRenderer,
|
|
17
|
+
PartRenderers,
|
|
18
|
+
TranscriptPart,
|
|
19
|
+
} from '../agent'
|
|
20
|
+
import { ThreadFeedRow } from './thread-feed-row'
|
|
21
|
+
|
|
22
|
+
afterEach(cleanup)
|
|
23
|
+
|
|
24
|
+
function buildThread(parts: TranscriptPart[]): AgentThread<TranscriptPart> {
|
|
25
|
+
return {
|
|
26
|
+
id: 'thread-1',
|
|
27
|
+
messages: [{ id: 'm1', role: 'assistant', parts, createdAt: 0 }],
|
|
28
|
+
outcome: { title: 'A resolved thread', summary: 'Summary text', status: 'done' },
|
|
29
|
+
status: 'done',
|
|
30
|
+
read: true,
|
|
31
|
+
createdAt: 0,
|
|
32
|
+
updatedAt: 0,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let effectFireCount = 0
|
|
37
|
+
|
|
38
|
+
/** Mounted once per real mount of the subtree it lives in — proves whether a hide/show cycle
|
|
39
|
+
* re-mounted it (increments again) or merely hid it with CSS (stays put). */
|
|
40
|
+
function EffectProbe(): null {
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
effectFireCount += 1
|
|
43
|
+
}, [])
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const probeRenderers: PartRenderers = definePartRenderers({
|
|
48
|
+
probe: () => <EffectProbe />,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/** Renders whatever `part.type` reached it — used to prove `fallbackRenderer` crossed the row's
|
|
52
|
+
* seam into `ThreadTranscript`, since a dropped prop falls back to the built-in dev-only chip
|
|
53
|
+
* instead (a different testid, not this one). */
|
|
54
|
+
const rowFallbackRenderer: PartRenderer<ForeignPart> = ({ part }) => (
|
|
55
|
+
<span data-testid="row-fallback-part">{part.type}</span>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
function renderRow(expanded: boolean) {
|
|
59
|
+
const thread = buildThread([{ id: 'p1', type: 'probe' }])
|
|
60
|
+
return render(
|
|
61
|
+
<MantineProvider>
|
|
62
|
+
<ThreadFeedRow
|
|
63
|
+
thread={thread}
|
|
64
|
+
expanded={expanded}
|
|
65
|
+
onToggle={() => {}}
|
|
66
|
+
renderers={probeRenderers}
|
|
67
|
+
onSend={() => {}}
|
|
68
|
+
/>
|
|
69
|
+
</MantineProvider>,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('ThreadFeedRow — lazy mount, kept mounted', () => {
|
|
74
|
+
test('collapsed initially: the transcript body is not in the DOM', () => {
|
|
75
|
+
renderRow(false)
|
|
76
|
+
|
|
77
|
+
expect(screen.queryByTestId('thread-feed-row-body')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('expanding mounts the transcript body', () => {
|
|
81
|
+
const { rerender } = render(
|
|
82
|
+
<MantineProvider>
|
|
83
|
+
<ThreadFeedRow
|
|
84
|
+
thread={buildThread([{ id: 'p1', type: 'probe' }])}
|
|
85
|
+
expanded={false}
|
|
86
|
+
onToggle={() => {}}
|
|
87
|
+
renderers={probeRenderers}
|
|
88
|
+
onSend={() => {}}
|
|
89
|
+
/>
|
|
90
|
+
</MantineProvider>,
|
|
91
|
+
)
|
|
92
|
+
expect(screen.queryByTestId('thread-feed-row-body')).toBeNull()
|
|
93
|
+
|
|
94
|
+
rerender(
|
|
95
|
+
<MantineProvider>
|
|
96
|
+
<ThreadFeedRow
|
|
97
|
+
thread={buildThread([{ id: 'p1', type: 'probe' }])}
|
|
98
|
+
expanded
|
|
99
|
+
onToggle={() => {}}
|
|
100
|
+
renderers={probeRenderers}
|
|
101
|
+
onSend={() => {}}
|
|
102
|
+
/>
|
|
103
|
+
</MantineProvider>,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
expect(screen.getByTestId('thread-feed-row-body')).toBeDefined()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('collapsing again keeps the subtree mounted, hidden via CSS only', () => {
|
|
110
|
+
const thread = buildThread([{ id: 'p1', type: 'probe' }])
|
|
111
|
+
const { rerender } = render(
|
|
112
|
+
<MantineProvider>
|
|
113
|
+
<ThreadFeedRow
|
|
114
|
+
thread={thread}
|
|
115
|
+
expanded
|
|
116
|
+
onToggle={() => {}}
|
|
117
|
+
renderers={probeRenderers}
|
|
118
|
+
onSend={() => {}}
|
|
119
|
+
/>
|
|
120
|
+
</MantineProvider>,
|
|
121
|
+
)
|
|
122
|
+
expect(screen.getByTestId('thread-feed-row-body')).toBeDefined()
|
|
123
|
+
|
|
124
|
+
rerender(
|
|
125
|
+
<MantineProvider>
|
|
126
|
+
<ThreadFeedRow
|
|
127
|
+
thread={thread}
|
|
128
|
+
expanded={false}
|
|
129
|
+
onToggle={() => {}}
|
|
130
|
+
renderers={probeRenderers}
|
|
131
|
+
onSend={() => {}}
|
|
132
|
+
/>
|
|
133
|
+
</MantineProvider>,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Still present — a `queryByTestId`-returns-null assertion here would be the "unmounted"
|
|
137
|
+
// behavior this test exists to rule out.
|
|
138
|
+
const body = screen.getByTestId('thread-feed-row-body')
|
|
139
|
+
expect(body).toBeDefined()
|
|
140
|
+
expect(body.style.display).toBe('none')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('re-expanding after a collapse does NOT re-fire the subtree effect', () => {
|
|
144
|
+
effectFireCount = 0
|
|
145
|
+
const thread = buildThread([{ id: 'p1', type: 'probe' }])
|
|
146
|
+
const props = {
|
|
147
|
+
thread,
|
|
148
|
+
onToggle: () => {},
|
|
149
|
+
renderers: probeRenderers,
|
|
150
|
+
onSend: () => {},
|
|
151
|
+
} as const
|
|
152
|
+
|
|
153
|
+
const { rerender } = render(
|
|
154
|
+
<MantineProvider>
|
|
155
|
+
<ThreadFeedRow {...props} expanded />
|
|
156
|
+
</MantineProvider>,
|
|
157
|
+
)
|
|
158
|
+
expect(effectFireCount).toBe(1)
|
|
159
|
+
|
|
160
|
+
rerender(
|
|
161
|
+
<MantineProvider>
|
|
162
|
+
<ThreadFeedRow {...props} expanded={false} />
|
|
163
|
+
</MantineProvider>,
|
|
164
|
+
)
|
|
165
|
+
rerender(
|
|
166
|
+
<MantineProvider>
|
|
167
|
+
<ThreadFeedRow {...props} expanded />
|
|
168
|
+
</MantineProvider>,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
// A re-mount (the defect this invariant guards against) would bump this to 2.
|
|
172
|
+
expect(effectFireCount).toBe(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('clicking the header calls onToggle with the thread id', () => {
|
|
176
|
+
// A plain `let` narrows to `null` at the assertion below (TS can't prove the callback ran) —
|
|
177
|
+
// a mutable holder object sidesteps that, since object property writes aren't narrowed the
|
|
178
|
+
// same way across the closure boundary.
|
|
179
|
+
const toggled: { id: string | null } = { id: null }
|
|
180
|
+
const thread: AgentThread<TranscriptPart> = {
|
|
181
|
+
...buildThread([{ id: 'p1', type: 'text', text: 'hi' }]),
|
|
182
|
+
outcome: null,
|
|
183
|
+
}
|
|
184
|
+
render(
|
|
185
|
+
<MantineProvider>
|
|
186
|
+
<ThreadFeedRow
|
|
187
|
+
thread={thread}
|
|
188
|
+
expanded={false}
|
|
189
|
+
onToggle={(id) => {
|
|
190
|
+
toggled.id = id
|
|
191
|
+
}}
|
|
192
|
+
onSend={() => {}}
|
|
193
|
+
/>
|
|
194
|
+
</MantineProvider>,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
screen.getByText('Untitled thread')
|
|
198
|
+
screen.getByRole('button').click()
|
|
199
|
+
|
|
200
|
+
expect(toggled.id).toBe('thread-1')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ── The composer half of `liveStatus` ────────────────────────────────────────────────────────
|
|
205
|
+
// `onStop` was documented ("shown as the composer's Stop action while liveStatus === 'streaming'")
|
|
206
|
+
// but never forwarded together with the `streaming` flag `Composer` actually gates on
|
|
207
|
+
// (`composer.tsx`'s `showStop`/`inputDisabled`) — so Stop never appeared and a second turn could
|
|
208
|
+
// still be typed into a live thread. These assert the composer itself reflects `liveStatus`.
|
|
209
|
+
|
|
210
|
+
describe('ThreadFeedRow — composer reflects liveStatus', () => {
|
|
211
|
+
test('liveStatus="streaming" with onStop shows Stop instead of Send, and calls onStop', () => {
|
|
212
|
+
let stops = 0
|
|
213
|
+
render(
|
|
214
|
+
<MantineProvider>
|
|
215
|
+
<ThreadFeedRow
|
|
216
|
+
thread={buildThread([])}
|
|
217
|
+
expanded
|
|
218
|
+
onToggle={() => {}}
|
|
219
|
+
liveStatus="streaming"
|
|
220
|
+
onStop={() => {
|
|
221
|
+
stops += 1
|
|
222
|
+
}}
|
|
223
|
+
onSend={() => {}}
|
|
224
|
+
/>
|
|
225
|
+
</MantineProvider>,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
expect(screen.queryByLabelText('Send message')).toBeNull()
|
|
229
|
+
fireEvent.click(screen.getByLabelText('Stop generating'))
|
|
230
|
+
expect(stops).toBe(1)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('liveStatus="streaming" disables the composer textarea', () => {
|
|
234
|
+
render(
|
|
235
|
+
<MantineProvider>
|
|
236
|
+
<ThreadFeedRow
|
|
237
|
+
thread={buildThread([])}
|
|
238
|
+
expanded
|
|
239
|
+
onToggle={() => {}}
|
|
240
|
+
liveStatus="streaming"
|
|
241
|
+
onStop={() => {}}
|
|
242
|
+
onSend={() => {}}
|
|
243
|
+
/>
|
|
244
|
+
</MantineProvider>,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
expect((screen.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('liveStatus="done" (or unset) shows Send, not Stop, and leaves the textarea enabled', () => {
|
|
251
|
+
render(
|
|
252
|
+
<MantineProvider>
|
|
253
|
+
<ThreadFeedRow
|
|
254
|
+
thread={buildThread([])}
|
|
255
|
+
expanded
|
|
256
|
+
onToggle={() => {}}
|
|
257
|
+
liveStatus="done"
|
|
258
|
+
onStop={() => {}}
|
|
259
|
+
onSend={() => {}}
|
|
260
|
+
/>
|
|
261
|
+
</MantineProvider>,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
expect(screen.getByLabelText('Send message')).toBeDefined()
|
|
265
|
+
expect(screen.queryByLabelText('Stop generating')).toBeNull()
|
|
266
|
+
expect((screen.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// ── The row → transcript seam ────────────────────────────────────────────────────────────────
|
|
271
|
+
// `ThreadFeedRow` and `ThreadTranscript` were built in parallel: the row declared the shared
|
|
272
|
+
// contract (`affordances`, and later `groupConsecutive`/`virtualize`) while the transcript grew the
|
|
273
|
+
// props that consume it. Each half was correct alone and the pair still compiled with the row
|
|
274
|
+
// silently DROPPING all three on the floor — a defect no type or single-component test can see.
|
|
275
|
+
// These assert the forwarding itself: every one of them goes red if a prop stops being passed
|
|
276
|
+
// through, which is exactly the failure that shipped.
|
|
277
|
+
|
|
278
|
+
function threadWith(messages: ChatMessage<TranscriptPart>[]): AgentThread<TranscriptPart> {
|
|
279
|
+
return { ...buildThread([]), messages }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const T0 = 1_000_000_000
|
|
283
|
+
|
|
284
|
+
function textMessage(
|
|
285
|
+
id: string,
|
|
286
|
+
role: ChatMessage<TranscriptPart>['role'],
|
|
287
|
+
createdAt: number,
|
|
288
|
+
): ChatMessage<TranscriptPart> {
|
|
289
|
+
return { id, role, parts: [{ id: `${id}-p1`, type: 'text', text: `body ${id}` }], createdAt }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
describe('ThreadFeedRow forwards the transcript contract it declares', () => {
|
|
293
|
+
test('affordances reach the row’s messages — a custom action renders inside the body', () => {
|
|
294
|
+
render(
|
|
295
|
+
<MantineProvider>
|
|
296
|
+
<ThreadFeedRow
|
|
297
|
+
thread={threadWith([textMessage('fm1', 'assistant', T0)])}
|
|
298
|
+
expanded
|
|
299
|
+
onToggle={() => {}}
|
|
300
|
+
affordances={{ actions: () => <span data-testid="row-custom-action">extra</span> }}
|
|
301
|
+
onSend={() => {}}
|
|
302
|
+
/>
|
|
303
|
+
</MantineProvider>,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
// Dropping the prop leaves the DEFAULT affordance row (timestamp + copy) rendering perfectly —
|
|
307
|
+
// which is why the assertion is on the CONSUMER-supplied action, the one thing that cannot
|
|
308
|
+
// appear unless `affordances` actually crossed the seam.
|
|
309
|
+
expect(screen.getByTestId('row-custom-action')).toBeDefined()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('affordances’ onRegenerate reaches the last assistant message', () => {
|
|
313
|
+
const regenerated: { id: string | null } = { id: null }
|
|
314
|
+
render(
|
|
315
|
+
<MantineProvider>
|
|
316
|
+
<ThreadFeedRow
|
|
317
|
+
thread={threadWith([
|
|
318
|
+
textMessage('fm1', 'user', T0),
|
|
319
|
+
textMessage('fm2', 'assistant', T0 + 60_000),
|
|
320
|
+
])}
|
|
321
|
+
expanded
|
|
322
|
+
onToggle={() => {}}
|
|
323
|
+
affordances={{
|
|
324
|
+
onRegenerate: (id) => {
|
|
325
|
+
regenerated.id = id
|
|
326
|
+
},
|
|
327
|
+
}}
|
|
328
|
+
onSend={() => {}}
|
|
329
|
+
/>
|
|
330
|
+
</MantineProvider>,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
screen.getByText('Regenerate').click()
|
|
334
|
+
|
|
335
|
+
expect(regenerated.id).toBe('fm2')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('liveParts + liveStatus reach the transcript as an in-flight live message', () => {
|
|
339
|
+
render(
|
|
340
|
+
<MantineProvider>
|
|
341
|
+
<ThreadFeedRow
|
|
342
|
+
thread={threadWith([textMessage('fm1', 'user', T0)])}
|
|
343
|
+
expanded
|
|
344
|
+
onToggle={() => {}}
|
|
345
|
+
liveParts={[{ id: 'live-p1', type: 'text', text: 'partial reply' }]}
|
|
346
|
+
liveStatus="streaming"
|
|
347
|
+
onSend={() => {}}
|
|
348
|
+
/>
|
|
349
|
+
</MantineProvider>,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
// `ThreadTranscript` synthesizes a `__live__` message from `liveParts` only when it actually
|
|
353
|
+
// receives both props — dropping either leaves this testid absent.
|
|
354
|
+
expect(screen.getByTestId('agent-message-__live__')).toBeDefined()
|
|
355
|
+
expect(screen.getByText('partial reply')).toBeDefined()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('renderers reaches the transcript — a registered part type renders the consumer renderer', () => {
|
|
359
|
+
const rowRenderers: PartRenderers = definePartRenderers({
|
|
360
|
+
'row-custom': () => <span data-testid="row-custom-part">custom</span>,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
render(
|
|
364
|
+
<MantineProvider>
|
|
365
|
+
<ThreadFeedRow
|
|
366
|
+
thread={threadWith([
|
|
367
|
+
{
|
|
368
|
+
id: 'fm1',
|
|
369
|
+
role: 'assistant',
|
|
370
|
+
parts: [{ id: 'p1', type: 'row-custom' }],
|
|
371
|
+
createdAt: T0,
|
|
372
|
+
},
|
|
373
|
+
])}
|
|
374
|
+
expanded
|
|
375
|
+
onToggle={() => {}}
|
|
376
|
+
renderers={rowRenderers}
|
|
377
|
+
onSend={() => {}}
|
|
378
|
+
/>
|
|
379
|
+
</MantineProvider>,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
expect(screen.getByTestId('row-custom-part')).toBeDefined()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('fallbackRenderer reaches the transcript for an unregistered foreign part', () => {
|
|
386
|
+
render(
|
|
387
|
+
<MantineProvider>
|
|
388
|
+
<ThreadFeedRow
|
|
389
|
+
thread={threadWith([
|
|
390
|
+
{
|
|
391
|
+
id: 'fm1',
|
|
392
|
+
role: 'assistant',
|
|
393
|
+
parts: [{ id: 'p1', type: 'unregistered-foreign-type' }],
|
|
394
|
+
createdAt: T0,
|
|
395
|
+
},
|
|
396
|
+
])}
|
|
397
|
+
expanded
|
|
398
|
+
onToggle={() => {}}
|
|
399
|
+
fallbackRenderer={rowFallbackRenderer}
|
|
400
|
+
onSend={() => {}}
|
|
401
|
+
/>
|
|
402
|
+
</MantineProvider>,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
expect(screen.getByTestId('row-fallback-part').textContent).toBe('unregistered-foreign-type')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test('composerProps reaches the row’s Composer', () => {
|
|
409
|
+
render(
|
|
410
|
+
<MantineProvider>
|
|
411
|
+
<ThreadFeedRow
|
|
412
|
+
thread={threadWith([])}
|
|
413
|
+
expanded
|
|
414
|
+
onToggle={() => {}}
|
|
415
|
+
onSend={() => {}}
|
|
416
|
+
composerProps={{ placeholder: 'row-composer-placeholder' }}
|
|
417
|
+
/>
|
|
418
|
+
</MantineProvider>,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
expect(screen.getByPlaceholderText('row-composer-placeholder')).toBeDefined()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('groupConsecutive={false} reaches the transcript', () => {
|
|
425
|
+
const messages = [
|
|
426
|
+
textMessage('fm1', 'assistant', T0),
|
|
427
|
+
textMessage('fm2', 'assistant', T0 + 60_000),
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
render(
|
|
431
|
+
<MantineProvider>
|
|
432
|
+
<ThreadFeedRow
|
|
433
|
+
thread={threadWith(messages)}
|
|
434
|
+
expanded
|
|
435
|
+
onToggle={() => {}}
|
|
436
|
+
onSend={() => {}}
|
|
437
|
+
/>
|
|
438
|
+
</MantineProvider>,
|
|
439
|
+
)
|
|
440
|
+
// Default (`true`, forwarded as `undefined` and defaulted inside the transcript): the second
|
|
441
|
+
// same-role message inside the 5-minute window groups.
|
|
442
|
+
expect(screen.getByTestId('agent-message-fm2').getAttribute('data-grouped')).toBe('true')
|
|
443
|
+
|
|
444
|
+
// A fresh mount, not a `rerender` — the first tree is torn down here, so the second render
|
|
445
|
+
// starts from a clean DOM and `getByTestId` can't match the stale row.
|
|
446
|
+
cleanup()
|
|
447
|
+
render(
|
|
448
|
+
<MantineProvider>
|
|
449
|
+
<ThreadFeedRow
|
|
450
|
+
thread={threadWith(messages)}
|
|
451
|
+
expanded
|
|
452
|
+
onToggle={() => {}}
|
|
453
|
+
groupConsecutive={false}
|
|
454
|
+
onSend={() => {}}
|
|
455
|
+
/>
|
|
456
|
+
</MantineProvider>,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
expect(screen.getByTestId('agent-message-fm2').getAttribute('data-grouped')).toBe('false')
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
describe('ThreadFeedRow forwards virtualize/height to the transcript', () => {
|
|
464
|
+
let originalOffsetHeight: PropertyDescriptor | undefined
|
|
465
|
+
|
|
466
|
+
beforeEach(() => {
|
|
467
|
+
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')
|
|
468
|
+
// Same reason as thread-message.test.tsx's virtualization block: happy-dom has no layout
|
|
469
|
+
// engine, so an unpatched offsetHeight of 0 makes TanStack Virtual compute an empty viewport.
|
|
470
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 300 })
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
afterEach(() => {
|
|
474
|
+
if (originalOffsetHeight !== undefined) {
|
|
475
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight)
|
|
476
|
+
} else {
|
|
477
|
+
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('an expanded row with virtualize windows its thread instead of rendering every message', async () => {
|
|
482
|
+
const messages = Array.from({ length: 120 }, (_, i) =>
|
|
483
|
+
// 10 minutes apart — never groups, keeping this orthogonal to the grouping seam above.
|
|
484
|
+
textMessage(`vfm${i}`, i % 2 === 0 ? 'user' : 'assistant', T0 + i * 10 * 60_000),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
const { container } = render(
|
|
488
|
+
<MantineProvider>
|
|
489
|
+
<ThreadFeedRow
|
|
490
|
+
thread={threadWith(messages)}
|
|
491
|
+
expanded
|
|
492
|
+
onToggle={() => {}}
|
|
493
|
+
virtualize
|
|
494
|
+
height={300}
|
|
495
|
+
onSend={() => {}}
|
|
496
|
+
/>
|
|
497
|
+
</MantineProvider>,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
// Deliberately asserts WINDOWING, not which end is anchored. `waitFor` also rides out the
|
|
501
|
+
// lazy `@tanstack/react-virtual` import: until it resolves, the Suspense fallback renders all
|
|
502
|
+
// 120 rows unwindowed — the same count a row that DROPPED `virtualize` would render forever,
|
|
503
|
+
// which is what makes the timeout a real failure rather than a slow pass.
|
|
504
|
+
await waitFor(() => {
|
|
505
|
+
const rendered = container.querySelectorAll('[data-testid^="agent-message-"]').length
|
|
506
|
+
expect(rendered).toBeGreaterThan(0)
|
|
507
|
+
expect(rendered).toBeLessThan(120)
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
})
|