basalt-ui 1.12.0 → 1.14.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 +24 -0
- package/README.md +38 -35
- package/agent/rules/basalt-agent.md +14 -0
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +5 -1
- package/dist/agent/adapter.d.ts.map +1 -1
- package/dist/agent/adapter.js +22 -13
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/id.d.ts +67 -19
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +21 -7
- package/dist/agent/thread.d.ts +4 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- package/dist/agent/use-agent-thread-runs.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent-chat/index.d.ts +4 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +315 -34
- package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +29 -3
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/shell/app-sidebar.d.ts +19 -1
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +30 -26
- package/dist/shell/app-sidebar.module.css +11 -0
- package/dist/shell/app-sidebar.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.test.d.ts +2 -0
- package/dist/shell/app-sidebar.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +10 -1
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +2 -0
- package/dist/shell/index.test.d.ts +2 -0
- package/dist/shell/index.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +3 -2
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/llms.txt +6 -6
- package/package.json +2 -2
- package/src/agent/adapter.test.ts +237 -0
- package/src/agent/adapter.ts +72 -14
- package/src/agent/ai-sdk-transport.test.ts +64 -0
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/id.test.ts +95 -1
- package/src/agent/id.ts +98 -28
- package/src/agent/thread.ts +4 -0
- package/src/agent/use-agent-stream.test.tsx +22 -0
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.test.tsx +326 -0
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
- package/src/agent-chat/index.ts +12 -0
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +937 -3
- package/src/agent-chat/thread-message.tsx +766 -38
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +69 -5
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +5 -0
- package/src/shell/app-sidebar.module.css +11 -0
- package/src/shell/app-sidebar.module.css.d.ts +1 -0
- package/src/shell/app-sidebar.test.tsx +98 -0
- package/src/shell/app-sidebar.tsx +22 -0
- package/src/shell/index.test.tsx +35 -0
- package/src/shell/index.tsx +11 -0
- package/src/surfaces.ts +2 -1
- package/src/theme/shadow-surfaces.test.ts +9 -0
|
@@ -23,6 +23,7 @@ import { useHover } from '@mantine/hooks'
|
|
|
23
23
|
import type { JSX } from 'react'
|
|
24
24
|
import type { AgentThread, ThreadStatus } from '../agent'
|
|
25
25
|
import { alpha, VX } from '../tokens'
|
|
26
|
+
import { formatRelativeTime } from './relative-time'
|
|
26
27
|
|
|
27
28
|
// ── Status badge — shown ONLY for states that need a glance (attention/error). ────
|
|
28
29
|
// 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<
|
|
|
35
36
|
error: { label: 'Failed', statusToken: VX.status.bad },
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
// ── Dependency-free relative-time helper (no date-fns) ────────────────────────
|
|
39
|
-
|
|
40
|
-
const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
|
41
|
-
|
|
42
|
-
const RELATIVE_TIME_UNITS: readonly {
|
|
43
|
-
readonly unit: Intl.RelativeTimeFormatUnit
|
|
44
|
-
readonly ms: number
|
|
45
|
-
}[] = [
|
|
46
|
-
{ unit: 'year', ms: 31_536_000_000 },
|
|
47
|
-
{ unit: 'month', ms: 2_628_000_000 },
|
|
48
|
-
{ unit: 'week', ms: 604_800_000 },
|
|
49
|
-
{ unit: 'day', ms: 86_400_000 },
|
|
50
|
-
{ unit: 'hour', ms: 3_600_000 },
|
|
51
|
-
{ unit: 'minute', ms: 60_000 },
|
|
52
|
-
]
|
|
53
|
-
|
|
54
|
-
/** Formats an epoch-ms timestamp as a short relative string ("3 hours ago", "just now"). */
|
|
55
|
-
function formatRelativeTime(timestamp: number): string {
|
|
56
|
-
const diffMs = timestamp - Date.now()
|
|
57
|
-
const absMs = Math.abs(diffMs)
|
|
58
|
-
if (absMs < 60_000) return 'just now'
|
|
59
|
-
const unit = RELATIVE_TIME_UNITS.find(({ ms }) => absMs >= ms) ?? RELATIVE_TIME_UNITS.at(-1)!
|
|
60
|
-
return RELATIVE_TIME_FORMAT.format(Math.round(diffMs / unit.ms), unit.unit)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
39
|
// ── Row bodies ─────────────────────────────────────────────────────────────────
|
|
64
40
|
|
|
65
41
|
/** Preview skeleton shown while a thread has no resolved outcome yet (never raw prompt/live text). */
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadWorkspace — hydration gating (D1).
|
|
3
|
+
*
|
|
4
|
+
* A store built with `createAdapterThreadsStore` starts `!hydrated` until its first `listThreads`
|
|
5
|
+
* succeeds. Before this fix, `ThreadWorkspace` read only `threads.length` to decide between the
|
|
6
|
+
* feed and the empty state, so a server-backed store with real threads (a database, a real API)
|
|
7
|
+
* would render "no threads yet" for the whole initial round trip, then swap to the populated feed
|
|
8
|
+
* once it landed — a defect invisible in the playground's demo only because that demo's in-memory
|
|
9
|
+
* adapter seeds an empty Map, so there is nothing to flash to.
|
|
10
|
+
*
|
|
11
|
+
* These tests drive `ThreadWorkspace` directly against a hand-built `ThreadsStore` (not a real
|
|
12
|
+
* `createAdapterThreadsStore` instance) so each case can assert one exact `{ hydrated, threads }`
|
|
13
|
+
* combination without racing a real async adapter.
|
|
14
|
+
*/
|
|
15
|
+
import { MantineProvider } from '@mantine/core'
|
|
16
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
17
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
18
|
+
import type { ReactElement } from 'react'
|
|
19
|
+
import type { AgentThread, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent'
|
|
20
|
+
import { createThreadsStore } from '../agent'
|
|
21
|
+
import { ThreadWorkspace } from './thread-workspace'
|
|
22
|
+
|
|
23
|
+
afterEach(cleanup)
|
|
24
|
+
|
|
25
|
+
// `useMediaQuery` reads `window.matchMedia` inside a mount effect RTL flushes synchronously —
|
|
26
|
+
// force the wide-viewport branch so every case renders the feed pane (same idiom as
|
|
27
|
+
// thread-feed.test.tsx's `withReducedMotion`).
|
|
28
|
+
function withWideViewport<T>(fn: () => T): T {
|
|
29
|
+
const original = window.matchMedia
|
|
30
|
+
window.matchMedia = (query: string): MediaQueryList =>
|
|
31
|
+
({
|
|
32
|
+
matches: false,
|
|
33
|
+
media: query,
|
|
34
|
+
onchange: null,
|
|
35
|
+
addListener: () => {},
|
|
36
|
+
removeListener: () => {},
|
|
37
|
+
addEventListener: () => {},
|
|
38
|
+
removeEventListener: () => {},
|
|
39
|
+
dispatchEvent: () => false,
|
|
40
|
+
}) as MediaQueryList
|
|
41
|
+
try {
|
|
42
|
+
return fn()
|
|
43
|
+
} finally {
|
|
44
|
+
window.matchMedia = original
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function noop(): void {}
|
|
49
|
+
|
|
50
|
+
/** A transport that never resolves — no case here calls `start()`, so `stream` is never invoked. */
|
|
51
|
+
const stubTransport: AgentTransport<never, string> = {
|
|
52
|
+
stream: (): AsyncGenerator<never> => {
|
|
53
|
+
throw new Error('stubTransport.stream must not be called by these tests')
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const stubResolveOutcome: OutcomeResolver = async (thread) => ({
|
|
58
|
+
title: thread.id,
|
|
59
|
+
summary: '',
|
|
60
|
+
status: 'done',
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
function buildThread(id: string): AgentThread {
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
messages: [],
|
|
67
|
+
outcome: { title: id, summary: '', status: 'done' },
|
|
68
|
+
status: 'done',
|
|
69
|
+
read: true,
|
|
70
|
+
createdAt: 0,
|
|
71
|
+
updatedAt: 0,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A minimal fixed `ThreadsStore` — every mutator is a no-op since no case here calls one. */
|
|
76
|
+
function makeStore(overrides: Partial<ThreadsStore>): ThreadsStore {
|
|
77
|
+
return {
|
|
78
|
+
threads: [],
|
|
79
|
+
activeId: null,
|
|
80
|
+
hydrated: true,
|
|
81
|
+
error: undefined,
|
|
82
|
+
select: noop,
|
|
83
|
+
create: () => 'unused',
|
|
84
|
+
appendMessage: noop,
|
|
85
|
+
setOutcome: noop,
|
|
86
|
+
setStatus: noop,
|
|
87
|
+
setResumeToken: noop,
|
|
88
|
+
markRead: noop,
|
|
89
|
+
remove: noop,
|
|
90
|
+
clear: noop,
|
|
91
|
+
...overrides,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderWorkspace(store: ThreadsStore): ReactElement {
|
|
96
|
+
return (
|
|
97
|
+
<MantineProvider>
|
|
98
|
+
<ThreadWorkspace
|
|
99
|
+
useThreads={() => store}
|
|
100
|
+
transport={stubTransport}
|
|
101
|
+
resolveOutcome={stubResolveOutcome}
|
|
102
|
+
/>
|
|
103
|
+
</MantineProvider>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('ThreadWorkspace hydration gating', () => {
|
|
108
|
+
test('hydrating + empty: renders the hydrating hold, never the empty-state copy', () => {
|
|
109
|
+
withWideViewport(() => {
|
|
110
|
+
render(renderWorkspace(makeStore({ hydrated: false, threads: [] })))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(screen.getByTestId('thread-workspace-hydrating')).toBeDefined()
|
|
114
|
+
expect(screen.queryByText('No threads yet')).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('hydrating + already-populated: renders the feed content, not the hydrating hold', () => {
|
|
118
|
+
withWideViewport(() => {
|
|
119
|
+
render(renderWorkspace(makeStore({ hydrated: false, threads: [buildThread('cached-1')] })))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(screen.getByText('cached-1')).toBeDefined()
|
|
123
|
+
expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('failed load: renders the error state, never the hydrating hold', () => {
|
|
127
|
+
withWideViewport(() => {
|
|
128
|
+
render(
|
|
129
|
+
renderWorkspace(
|
|
130
|
+
makeStore({ hydrated: false, error: new Error('listThreads failed'), threads: [] }),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(screen.getByTestId('thread-workspace-error')).toBeDefined()
|
|
136
|
+
expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('hydrated + empty: renders the empty state (no threads really is the truth)', () => {
|
|
140
|
+
withWideViewport(() => {
|
|
141
|
+
render(renderWorkspace(makeStore({ hydrated: true, threads: [] })))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(screen.getByText('No threads yet')).toBeDefined()
|
|
145
|
+
expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('real synchronous createThreadsStore: never shows the hydrating hold, even on first render', () => {
|
|
149
|
+
// The real localStorage-backed store (hydrated pinned `true` — see thread.test.ts), not the
|
|
150
|
+
// hand-built stub above, so this proves the gate reads a genuine ThreadsStore correctly and
|
|
151
|
+
// does not regress the synchronous path with a permanent skeleton.
|
|
152
|
+
const useThreads = createThreadsStore({ key: 'thread-workspace-sync-empty', version: 1 })
|
|
153
|
+
|
|
154
|
+
withWideViewport(() => {
|
|
155
|
+
render(
|
|
156
|
+
<MantineProvider>
|
|
157
|
+
<ThreadWorkspace
|
|
158
|
+
useThreads={useThreads}
|
|
159
|
+
transport={stubTransport}
|
|
160
|
+
resolveOutcome={stubResolveOutcome}
|
|
161
|
+
/>
|
|
162
|
+
</MantineProvider>,
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
expect(screen.getByText('No threads yet')).toBeDefined()
|
|
167
|
+
expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -32,11 +32,12 @@
|
|
|
32
32
|
* )
|
|
33
33
|
* }
|
|
34
34
|
*/
|
|
35
|
-
import { Box, Divider, Flex, Stack, Text } from '@mantine/core'
|
|
35
|
+
import { Box, Divider, Flex, Skeleton, Stack, Text } from '@mantine/core'
|
|
36
36
|
import { useMediaQuery } from '@mantine/hooks'
|
|
37
37
|
import type { JSX, ReactNode } from 'react'
|
|
38
38
|
import type { AgentPart, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent'
|
|
39
39
|
import { useAgentThreadRuns } from '../agent'
|
|
40
|
+
import { VX } from '../tokens'
|
|
40
41
|
import { Composer } from './composer'
|
|
41
42
|
import { ThreadDetailPanel } from './thread-detail-panel'
|
|
42
43
|
import { ThreadFeed } from './thread-feed'
|
|
@@ -56,11 +57,23 @@ export type ThreadWorkspaceProps = {
|
|
|
56
57
|
readonly resolveOutcome: OutcomeResolver<AgentPart>
|
|
57
58
|
/** Placeholder for the new-thread composer pinned under the feed. */
|
|
58
59
|
readonly newThreadPlaceholder?: string
|
|
59
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Rendered in place of the feed once the store is hydrated and there really are no threads.
|
|
62
|
+
* Shown ONLY when `store.hydrated` is true — while an async store's initial load is still in
|
|
63
|
+
* flight, `ThreadWorkspace` holds a neutral loading state instead (see `FeedHydratingState`),
|
|
64
|
+
* so a server-backed workspace never asserts "no threads yet" before it actually knows that.
|
|
65
|
+
*/
|
|
60
66
|
readonly emptyState?: ReactNode
|
|
67
|
+
/**
|
|
68
|
+
* Rendered in place of the feed when the store's initial load has failed — `!store.hydrated &&
|
|
69
|
+
* store.error !== undefined` (see `ThreadsStore.hydrated`'s doc for the discriminant). Shown
|
|
70
|
+
* instead of `FeedHydratingState`, so a rejected `listThreads` surfaces a legible failure rather
|
|
71
|
+
* than an endless skeleton.
|
|
72
|
+
*/
|
|
73
|
+
readonly errorState?: ReactNode
|
|
61
74
|
}
|
|
62
75
|
|
|
63
|
-
/** Default hint shown in the feed pane
|
|
76
|
+
/** Default hint shown in the feed pane once hydrated with no threads (overridable via `emptyState`). */
|
|
64
77
|
function FeedEmptyState(): JSX.Element {
|
|
65
78
|
return (
|
|
66
79
|
<Stack align="center" justify="center" gap={4} h="100%" p="xl">
|
|
@@ -74,6 +87,50 @@ function FeedEmptyState(): JSX.Element {
|
|
|
74
87
|
)
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Neutral hold shown in the feed pane while an async store's initial `listThreads` is still in
|
|
92
|
+
* flight, has no threads to show yet, AND has not failed (`!store.hydrated && threads.length ===
|
|
93
|
+
* 0 && store.error === undefined`). Never shown for the synchronous `createThreadsStore`, whose
|
|
94
|
+
* `hydrated` is always `true` — see `ThreadsStore.hydrated`'s doc. A store that already holds
|
|
95
|
+
* cached threads while revalidating (`!hydrated` with a non-empty `threads`) skips this entirely
|
|
96
|
+
* and renders the feed normally. A failed load renders `FeedErrorState` instead of this.
|
|
97
|
+
*/
|
|
98
|
+
function FeedHydratingState(): JSX.Element {
|
|
99
|
+
return (
|
|
100
|
+
<Stack gap="sm" p="sm" data-testid="thread-workspace-hydrating">
|
|
101
|
+
<Skeleton height={54} radius="sm" />
|
|
102
|
+
<Skeleton height={54} radius="sm" />
|
|
103
|
+
<Skeleton height={54} radius="sm" />
|
|
104
|
+
</Stack>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Default failure hold shown in the feed pane when an async store's initial `listThreads` has
|
|
110
|
+
* rejected (`!store.hydrated && store.error !== undefined`), overridable via `errorState`. Never
|
|
111
|
+
* shown for the synchronous `createThreadsStore`, whose `error` is always `undefined` — see
|
|
112
|
+
* `ThreadsStore.error`'s doc.
|
|
113
|
+
*/
|
|
114
|
+
function FeedErrorState(): JSX.Element {
|
|
115
|
+
return (
|
|
116
|
+
<Stack
|
|
117
|
+
align="center"
|
|
118
|
+
justify="center"
|
|
119
|
+
gap={4}
|
|
120
|
+
h="100%"
|
|
121
|
+
p="xl"
|
|
122
|
+
data-testid="thread-workspace-error"
|
|
123
|
+
>
|
|
124
|
+
<Text fw={600} size="sm" style={{ color: VX.status.bad }}>
|
|
125
|
+
Couldn't load threads
|
|
126
|
+
</Text>
|
|
127
|
+
<Text size="xs" c="dimmed" ta="center">
|
|
128
|
+
Something went wrong loading your threads. Try refreshing the page.
|
|
129
|
+
</Text>
|
|
130
|
+
</Stack>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
77
134
|
/**
|
|
78
135
|
* A two-pane master-detail thread workspace: a scrollable feed of threads with an anchored
|
|
79
136
|
* new-thread composer, and a detail panel for the open thread's live transcript. Collapses to a
|
|
@@ -88,6 +145,7 @@ export function ThreadWorkspace({
|
|
|
88
145
|
resolveOutcome,
|
|
89
146
|
newThreadPlaceholder,
|
|
90
147
|
emptyState,
|
|
148
|
+
errorState,
|
|
91
149
|
}: ThreadWorkspaceProps): JSX.Element {
|
|
92
150
|
const store = useThreads()
|
|
93
151
|
const runs = useAgentThreadRuns({ transport, store, resolveOutcome })
|
|
@@ -130,8 +188,14 @@ export function ThreadWorkspace({
|
|
|
130
188
|
const feed = (
|
|
131
189
|
<Flex direction="column" h="100%" style={{ overflow: 'hidden' }}>
|
|
132
190
|
<Box style={{ flex: 1, minHeight: 0 }}>
|
|
133
|
-
{
|
|
134
|
-
|
|
191
|
+
{threads.length === 0 ? (
|
|
192
|
+
store.hydrated ? (
|
|
193
|
+
(emptyState ?? <FeedEmptyState />)
|
|
194
|
+
) : store.error !== undefined ? (
|
|
195
|
+
(errorState ?? <FeedErrorState />)
|
|
196
|
+
) : (
|
|
197
|
+
<FeedHydratingState />
|
|
198
|
+
)
|
|
135
199
|
) : (
|
|
136
200
|
<ThreadFeed threads={threads} activeId={store.activeId} onSelect={handleSelect} />
|
|
137
201
|
)}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtualization contract shared by the transcript/feed components that can opt into
|
|
3
|
+
* `@tanstack/react-virtual` for long lists.
|
|
4
|
+
*
|
|
5
|
+
* The `VirtualizeProps` union IS the guard: a virtualizer needs a measured scroll container, so
|
|
6
|
+
* turning `virtualize` on REQUIRES a `height` and turning it off (or omitting it) FORBIDS one. See
|
|
7
|
+
* `virtualize.type-guard.test.ts` beside this file for a compile-time proof that the union actually
|
|
8
|
+
* rejects the invalid combinations.
|
|
9
|
+
*
|
|
10
|
+
* The one piece of runtime here is {@link resolveVirtualize} — the single narrowing point every
|
|
11
|
+
* component that accepts `VirtualizeProps` goes through, so no call site has to re-derive (or
|
|
12
|
+
* assert its way past) the union's `virtualize`-implies-`height` link.
|
|
13
|
+
*
|
|
14
|
+
* The other half of the contract, not encoded in the types (it's a runtime/composition rule, not
|
|
15
|
+
* a shape): a virtualized transcript OWNS ITS OWN SCROLL NODE (the element `getScrollElement`
|
|
16
|
+
* measures) and must NOT be nested inside `BasaltStickToBottom` — unlike the non-virtual path,
|
|
17
|
+
* which `ThreadDetailPanel` nests inside `BasaltStickToBottom` today (its transcript body).
|
|
18
|
+
* Stacking a virtualizer's own scroll container inside `BasaltStickToBottom`'s would give the
|
|
19
|
+
* transcript two competing owners of "which element scrolls" and break both the virtualizer's
|
|
20
|
+
* `scrollToEnd` and stick-to-bottom's anchor tracking.
|
|
21
|
+
*
|
|
22
|
+
* A third composition rule, same category (runtime, not a shape): a virtualized transcript
|
|
23
|
+
* tolerates being hidden via `display: none` on an ANCESTOR (e.g. sitting inside a collapsible
|
|
24
|
+
* `ThreadFeedRow`) — collapsing does not corrupt its measurement cache, and re-expanding restores
|
|
25
|
+
* the same scroll position (same top-visible row) it had before the collapse. This is NOT free of
|
|
26
|
+
* virtual-core: hiding an ancestor makes every mounted row's ResizeObserver box report `0` (a
|
|
27
|
+
* `display: none` ancestor un-lays-out its descendants), and virtual-core 3.17.1's default
|
|
28
|
+
* `measureElement`/`resizeItem` write that `0` into `itemSizeCache` with no floor — unguarded, a
|
|
29
|
+
* few collapse/expand cycles permanently shrink `getTotalSize()` and silently scroll the visible
|
|
30
|
+
* window to a DIFFERENT part of the transcript (`scrollTop` stays put; the content under it moves).
|
|
31
|
+
* `thread-message.tsx` closes this with a `measureElement` override (`resolveGuardedMeasurement`)
|
|
32
|
+
* that treats a `0` reading as unreliable and keeps the last-known size instead of committing it —
|
|
33
|
+
* see that function's doc for the full mechanism, including why `useVirtualizer`'s `enabled` option
|
|
34
|
+
* is NOT the fix (it wipes the measurement cache outright, worse than the bug). A consumer combining a
|
|
35
|
+
* virtualized transcript with its own hide-via-CSS container gets this for free; a consumer who
|
|
36
|
+
* unmounts/remounts the transcript instead of hiding it is unaffected either way (a fresh
|
|
37
|
+
* `useVirtualizer` call has no stale cache to poison).
|
|
38
|
+
*
|
|
39
|
+
* A fourth composition rule: a virtualized transcript SCROLLS ITSELF on mount. `initialScroll`
|
|
40
|
+
* defaults to `'end'` — `anchorTo: 'end'` and `followOnAppend` (both wired in `thread-message.tsx`)
|
|
41
|
+
* only keep an ALREADY-at-the-end transcript pinned as content changes; neither performs the initial
|
|
42
|
+
* jump, so left alone a freshly mounted virtualized transcript renders at message #0, not the
|
|
43
|
+
* newest. The fix fires exactly once, on first mount, via `scrollToEnd()` — never again, so a user
|
|
44
|
+
* who has scrolled up is never yanked back down by a later append (that is precisely the failure
|
|
45
|
+
* `followOnAppend` exists to avoid, and re-introducing it here would be worse than the defect this
|
|
46
|
+
* closes). A consumer wiring its OWN scroll-restoration (e.g. `initialOffset` /
|
|
47
|
+
* `initialMeasurementsCache`, restoring a prior session's position) wants `initialScroll: 'start'` —
|
|
48
|
+
* otherwise this default jump fires first and the restoration has to fight it. `initialScroll` is
|
|
49
|
+
* ignored (no scroll, no throw) for an empty transcript, and is deferred rather than treated as done
|
|
50
|
+
* if the transcript's own scroll container measures 0px on mount (e.g. mounted behind a hidden
|
|
51
|
+
* ancestor) — it then lands on the first commit where the container is genuinely measurable, which
|
|
52
|
+
* is what makes "opens at the newest message" hold for a transcript whose lazy virtualizer settled
|
|
53
|
+
* while its `ThreadFeedRow` was collapsed. See `thread-message.tsx`'s `applyInitialScroll` for the
|
|
54
|
+
* guard, and `VirtualizedRowsInner` for why its effect is deliberately dependency-free.
|
|
55
|
+
*
|
|
56
|
+
* Firing `scrollToEnd()` once is not sufficient on its own to land there, because virtual-core
|
|
57
|
+
* 3.17.1's own `_willUpdate` writes to the SAME scroll container on the very next commit it runs
|
|
58
|
+
* on, from state that lags behind the DOM: `_scrollToOffset` only ever records the write's
|
|
59
|
+
* intent (`_intendedScrollOffset`) — `this.scrollOffset` itself updates ASYNCHRONOUSLY, from the
|
|
60
|
+
* native `scroll` event, not from the call that requested it. So a commit that runs `_willUpdate`
|
|
61
|
+
* before this effect had a chance to fire (element-attach) or that re-derives an anchor from
|
|
62
|
+
* `getScrollOffset()` while `this.scrollOffset` is still the pre-jump value (the anchor branch,
|
|
63
|
+
* gated on `pendingScrollAnchor`) writes that stale offset — 0, on first mount — straight back to
|
|
64
|
+
* `scrollTop`, clobbering this effect's jump within roughly one extra commit of it landing.
|
|
65
|
+
* `applyInitialScroll` survives this the same way the hidden-ancestor case above already
|
|
66
|
+
* (accidentally) does: by not treating one successful call as permanent. It fires, then — on a
|
|
67
|
+
* LATER call, i.e. a later commit, exactly the cadence this dependency-free effect already runs on
|
|
68
|
+
* — reads the container's REAL `scrollTop`/`scrollHeight`/`clientHeight` (never virtual-core's own
|
|
69
|
+
* `scrollOffset`, which is exactly the stale value that caused the clobber) to check whether the
|
|
70
|
+
* jump survived; if it didn't, it re-fires, bounded by `MAX_INITIAL_SCROLL_ATTEMPTS` so a DOM that
|
|
71
|
+
* pathologically never reports settled can never be fought forever. `resolveInitialScrollAction`
|
|
72
|
+
* itself is untouched by any of this — it is still the one-shot "should I even attempt this"
|
|
73
|
+
* decision; `applyInitialScroll` layers the across-commits verification on top.
|
|
74
|
+
*
|
|
75
|
+
* `initialOffset` (`number | (() => number)`, `virtual-core`'s own construction-time seed for
|
|
76
|
+
* `scrollOffset`) was investigated as a way to avoid the clobber altogether and rejected: it takes
|
|
77
|
+
* a concrete pixel number, not an `'end'` sentinel, and the only number available at the point
|
|
78
|
+
* it would need to resolve — before any row has ever been measured — is an ESTIMATE
|
|
79
|
+
* (`estimateSize` × count), not the real total. It would relocate the transcript's initial paint
|
|
80
|
+
* from message #0 to an approximately-right position, but the moment real measurements replace
|
|
81
|
+
* those estimates the total size (and therefore the true end offset) changes, which still needs a
|
|
82
|
+
* corrective `scrollToEnd()` once rows have actually measured — the exact write this whole
|
|
83
|
+
* mechanism exists to protect. It also can't reference the `virtualizer` instance it would be
|
|
84
|
+
* computing an offset for without a separate ref indirection, since it is itself one of the options
|
|
85
|
+
* passed to construct that instance. Not a fix for this defect, just a smaller wrong first paint.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/** Tuning knobs passed through to `@tanstack/react-virtual`'s `useVirtualizer`. */
|
|
89
|
+
export type VirtualizeOptions = {
|
|
90
|
+
/** Extra rows rendered beyond the visible viewport, each side. Library default: 1. */
|
|
91
|
+
readonly overscan?: number
|
|
92
|
+
/** Estimated row height in px, used before a row has been measured. */
|
|
93
|
+
readonly estimateSize?: number
|
|
94
|
+
/** Where the transcript sits on first mount. Default 'end' — a chat transcript opens at the
|
|
95
|
+
newest message. 'start' opens at the oldest. See this module's fourth composition rule
|
|
96
|
+
above for the mechanism and why a consumer restoring its own scroll position wants 'start'. */
|
|
97
|
+
readonly initialScroll?: 'end' | 'start'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `virtualize: false` (or omitted) → no `height` prop (content-sized, non-scrolling-owner layout).
|
|
102
|
+
* `virtualize: true | VirtualizeOptions` → `height` is REQUIRED — the virtualizer measures a fixed
|
|
103
|
+
* scroll container, so there is no valid virtualized layout without one.
|
|
104
|
+
*/
|
|
105
|
+
export type VirtualizeProps =
|
|
106
|
+
| { readonly virtualize?: false; readonly height?: never }
|
|
107
|
+
| { readonly virtualize: true | VirtualizeOptions; readonly height: number | string }
|
|
108
|
+
|
|
109
|
+
/** The enabled branch of {@link VirtualizeProps}, with `height` no longer optional. */
|
|
110
|
+
export type ResolvedVirtualize = {
|
|
111
|
+
readonly options: VirtualizeOptions
|
|
112
|
+
readonly height: number | string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Narrows a `VirtualizeProps` pair once, centrally: `null` when virtualization is off, otherwise
|
|
117
|
+
* the options object (normalizing the bare `virtualize: true` shorthand to `{}`) alongside the
|
|
118
|
+
* `height` the union guarantees is present. Every component accepting `VirtualizeProps` resolves
|
|
119
|
+
* through here rather than destructuring the two fields apart — destructuring severs the link TS
|
|
120
|
+
* needs, which is what forces a non-null assertion on `height` at each call site.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveVirtualize(props: VirtualizeProps): ResolvedVirtualize | null {
|
|
123
|
+
const { virtualize } = props
|
|
124
|
+
if (virtualize === undefined || virtualize === false) return null
|
|
125
|
+
// The union already REQUIRES `height` on this branch and rejects it on the other — that guarantee
|
|
126
|
+
// is enforced where it matters, at every call site (see virtualize.type-guard.test.ts). What TS
|
|
127
|
+
// won't do is propagate the narrowing FROM `virtualize` TO `height`: `virtualize`'s enabled branch
|
|
128
|
+
// is `true | VirtualizeOptions`, and the object constituent disqualifies it as a discriminant
|
|
129
|
+
// property. So the pairing is re-stated once, here, instead of a `height!` at each consumer.
|
|
130
|
+
const height = props.height as number | string
|
|
131
|
+
return { options: virtualize === true ? {} : virtualize, height }
|
|
132
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time proof that `VirtualizeProps` actually rejects the invalid `virtualize`/`height`
|
|
3
|
+
* combinations (not just documentation).
|
|
4
|
+
*
|
|
5
|
+
* This mirrors the `apps/playground/src/*.type-guard.ts` convention (one `@ts-expect-error` per bad
|
|
6
|
+
* line, proven by CI's `tsc --noEmit`) but lives HERE, beside the type, rather than in the
|
|
7
|
+
* playground app. `VirtualizeProps` IS public now (re-exported type-only from the agent-chat barrel
|
|
8
|
+
* and the root), so a playground fixture would also work — this stays put because a source-relative
|
|
9
|
+
* import proves the union at its definition, before the build, and needs no rebuild to stay honest.
|
|
10
|
+
* A co-located `.type-guard.ts` file under `src/` would get picked up by tsup's
|
|
11
|
+
* `src/**\/*.{ts,tsx}` build glob and ship an unused fixture in the published package; naming it
|
|
12
|
+
* `*.test.ts` excludes it from that glob (tsup.config.ts excludes `src/**\/*.test.{ts,tsx}`) while
|
|
13
|
+
* this package's own `tsc --noEmit` (and `bun test`, which type-checks test files per the repo
|
|
14
|
+
* convention) still verifies it.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from 'bun:test'
|
|
17
|
+
import type { VirtualizeProps } from './virtualize'
|
|
18
|
+
|
|
19
|
+
function accept(props: VirtualizeProps): VirtualizeProps {
|
|
20
|
+
return props
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Valid combinations — must type-check with no error ────────────────────────
|
|
24
|
+
|
|
25
|
+
accept({})
|
|
26
|
+
accept({ virtualize: false })
|
|
27
|
+
accept({ virtualize: true, height: 400 })
|
|
28
|
+
accept({ virtualize: true, height: '100%' })
|
|
29
|
+
accept({ virtualize: { overscan: 3 }, height: 400 })
|
|
30
|
+
accept({ virtualize: { estimateSize: 72 }, height: '50vh' })
|
|
31
|
+
|
|
32
|
+
// ── Invalid combinations — each MUST be a tsc error, one directive per bad line ─
|
|
33
|
+
|
|
34
|
+
// @ts-expect-error `height` is forbidden when `virtualize` is false
|
|
35
|
+
accept({ virtualize: false, height: 400 })
|
|
36
|
+
// @ts-expect-error `height` is required when `virtualize: true`
|
|
37
|
+
accept({ virtualize: true })
|
|
38
|
+
// @ts-expect-error `height` is required when `virtualize` is a VirtualizeOptions object
|
|
39
|
+
accept({ virtualize: { overscan: 2 } })
|
|
40
|
+
// @ts-expect-error `height` is forbidden when `virtualize` is omitted (defaults to the `false` branch)
|
|
41
|
+
accept({ height: 400 })
|
|
42
|
+
|
|
43
|
+
describe('VirtualizeProps (type-guard)', () => {
|
|
44
|
+
test('is a compile-time-only fixture — see the @ts-expect-error directives above', () => {
|
|
45
|
+
// No runtime behavior to assert: the proof is that this file type-checks at all. A single
|
|
46
|
+
// trivial assertion keeps this a normal bun:test file rather than an empty-suite oddity.
|
|
47
|
+
expect(true).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
})
|
package/src/data/index.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Optional peers:
|
|
9
9
|
* - @tanstack/react-table >=8 <9 (BasaltDataTable)
|
|
10
|
-
* - @tanstack/react-virtual >=3 <4 (BasaltVirtualList)
|
|
10
|
+
* - @tanstack/react-virtual >=3.13.26 <4 (BasaltVirtualList)
|
|
11
|
+
*
|
|
12
|
+
* BasaltVirtualList itself works on any 3.x; the declared floor is set by ./agent-chat's
|
|
13
|
+
* ThreadTranscript virtualize mode (it calls scrollToEnd/anchorTo, added in virtual-core 3.16.0,
|
|
14
|
+
* first pinned by react-virtual 3.13.26). npm has one peer range per package, so do not lower it.
|
|
11
15
|
*
|
|
12
16
|
* Install with:
|
|
13
17
|
* bun add @tanstack/react-table @tanstack/react-virtual
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ./data — BasaltVirtualList: a windowed virtual list over @tanstack/react-virtual,
|
|
3
3
|
* rendered with a Mantine Box scroll container.
|
|
4
|
-
* Optional peer: @tanstack/react-virtual >=3 <4.
|
|
4
|
+
* Optional peer: @tanstack/react-virtual >=3.13.26 <4. This list works on any 3.x — the floor is
|
|
5
|
+
* set by ./agent-chat's virtualize mode (see ./data/index.ts), so do not lower it.
|
|
5
6
|
*
|
|
6
7
|
* @example
|
|
7
8
|
* import { BasaltVirtualList } from 'basalt-ui/data'
|
package/src/data/virtual.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* ./data/virtual — BasaltVirtualList: a windowed virtual list over @tanstack/react-virtual,
|
|
3
3
|
* rendered with a Mantine Box scroll container.
|
|
4
4
|
*
|
|
5
|
-
* Optional peer: @tanstack/react-virtual >=3 <4.
|
|
5
|
+
* Optional peer: @tanstack/react-virtual >=3.13.26 <4. This list works on any 3.x — the floor is
|
|
6
|
+
* set by ./agent-chat's virtualize mode (see ./data/index.ts), so do not lower it.
|
|
6
7
|
*
|
|
7
8
|
* Use this fine subpath instead of the `./data` barrel when your app only needs the virtual list
|
|
8
9
|
* — it does NOT value-import @tanstack/react-table, so the data-table peer is never required.
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,7 @@ export type { GuardKind } from './guard/types'
|
|
|
113
113
|
export {
|
|
114
114
|
ThreadWorkspace,
|
|
115
115
|
ThreadFeed,
|
|
116
|
+
ThreadFeedRow,
|
|
116
117
|
ThreadOutcomeCard,
|
|
117
118
|
ThreadDetailPanel,
|
|
118
119
|
Composer,
|
|
@@ -123,6 +124,7 @@ export {
|
|
|
123
124
|
export type {
|
|
124
125
|
ThreadWorkspaceProps,
|
|
125
126
|
ThreadFeedProps,
|
|
127
|
+
ThreadFeedRowProps,
|
|
126
128
|
ThreadOutcomeCardProps,
|
|
127
129
|
ThreadDetailPanelProps,
|
|
128
130
|
ComposerProps,
|
|
@@ -130,6 +132,9 @@ export type {
|
|
|
130
132
|
ComposerAttachment,
|
|
131
133
|
ComposerHandle,
|
|
132
134
|
ThreadTranscriptProps,
|
|
135
|
+
MessageAffordances,
|
|
136
|
+
VirtualizeOptions,
|
|
137
|
+
VirtualizeProps,
|
|
133
138
|
ToolChipProps,
|
|
134
139
|
} from './agent-chat'
|
|
135
140
|
export {
|
|
@@ -419,4 +419,15 @@
|
|
|
419
419
|
.root[data-collapsed] .childList {
|
|
420
420
|
display: none;
|
|
421
421
|
}
|
|
422
|
+
|
|
423
|
+
/* `navExtra` has no sensible icon-rail representation (arbitrary consumer content, not a nav
|
|
424
|
+
* row) — hide it in the rail, same treatment as `.childList`. It stays mounted (never a JS
|
|
425
|
+
* conditional on `collapsed`): `collapsed` is one value shared by the desktop rail AND the
|
|
426
|
+
* mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` only ever un-collapses
|
|
427
|
+
* the mobile side), and the rail-vs-drawer split lives ENTIRELY in this media query, never in JS —
|
|
428
|
+
* a JS gate on `collapsed` would also drop `navExtra` from the mobile drawer, which opens at full
|
|
429
|
+
* width regardless of the persisted rail state. */
|
|
430
|
+
.root[data-collapsed] .navExtra {
|
|
431
|
+
display: none;
|
|
432
|
+
}
|
|
422
433
|
}
|