snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
2
packages/helpers/.gitignore
vendored
Normal file
2
packages/helpers/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
.turbo
|
||||
15
packages/helpers/CHANGELOG.md
Normal file
15
packages/helpers/CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @shadcn/helpers
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Add human-in-the-loop mocking for the AI SDK.
|
||||
|
||||
- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Make `createChat` generic over the UI message type, like `useChat`.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#11168](https://github.com/shadcn-ui/ui/pull/11168) [`0ec4a7ffbf21af08ed31b721696614533aa591c7`](https://github.com/shadcn-ui/ui/commit/0ec4a7ffbf21af08ed31b721696614533aa591c7) Thanks [@shadcn](https://github.com/shadcn)! - initial release of @shadcn/helpers
|
||||
94
packages/helpers/README.md
Normal file
94
packages/helpers/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# @shadcn/helpers
|
||||
|
||||
A collection of helpers for building AI applications.
|
||||
|
||||
## createChat
|
||||
|
||||
```ts
|
||||
import { createChat } from "@shadcn/helpers/ai-sdk"
|
||||
|
||||
const chat = createChat()
|
||||
.user("What's the weather in San Francisco?")
|
||||
.sleep(800)
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning("I should call the weather tool first.")
|
||||
writer
|
||||
.tool("getWeather", { input: { city: "San Francisco" } })
|
||||
.sleep(1200)
|
||||
.output({ temperature: 72, condition: "sunny" })
|
||||
writer.text("It's sunny and 72 degrees in San Francisco.")
|
||||
})
|
||||
```
|
||||
|
||||
## Human in the loop
|
||||
|
||||
A tool call left unresolved pauses the turn: the input streams, the turn
|
||||
finishes, and the client decides what happens next. A callback turn scripted
|
||||
after a paused turn becomes a continuation. It materializes when the follow-up
|
||||
request arrives and receives the resolved `toolCall` in its context.
|
||||
|
||||
For client-executed tools, the user supplies the output through
|
||||
`addToolOutput` and the continuation reads it:
|
||||
|
||||
```ts
|
||||
const chat = createChat()
|
||||
.user("Help me plan the release.")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("askQuestions", { dynamic: true, input: { questions } })
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(`Got it. Starting with ${toolCall?.output?.answers.direction}.`)
|
||||
})
|
||||
```
|
||||
|
||||
For approval-gated tools, `needsApproval` pauses behind the user's decision.
|
||||
`output` (or `errorText`) then means "stream this if approved"; denial streams
|
||||
`tool-output-denied` automatically:
|
||||
|
||||
```ts
|
||||
const chat = createChat()
|
||||
.user("Clean up old deployments.")
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("This will delete 3 deployments. Approve?")
|
||||
writer.tool("deleteDeployments", {
|
||||
input: { count: 3 },
|
||||
needsApproval: true,
|
||||
output: { deleted: 3 },
|
||||
})
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(
|
||||
toolCall?.approved
|
||||
? "Deleted 3 deployments."
|
||||
: "Okay, leaving them in place."
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Wire the client with the AI SDK's own triggers:
|
||||
`sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` for
|
||||
client-executed tools, or `lastAssistantMessageIsCompleteWithApprovalResponses`
|
||||
plus `addToolApprovalResponse({ id: part.approval.id, approved })` for
|
||||
approvals.
|
||||
|
||||
Continuation callbacks must be pure; regenerating re-resolves them against the
|
||||
current transcript. `get()` stops before the first continuation turn, since a
|
||||
continuation has no message without a live transcript.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @shadcn/helpers
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit https://ui.shadcn.com/docs to view the documentation.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read the [contributing guide](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
|
||||
81
packages/helpers/package.json
Normal file
81
packages/helpers/package.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "@shadcn/helpers",
|
||||
"version": "0.2.0",
|
||||
"description": "Small helpers for developing apps.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "shadcn",
|
||||
"url": "https://twitter.com/shadcn"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shadcn-ui/ui.git",
|
||||
"directory": "packages/helpers"
|
||||
},
|
||||
"homepage": "https://ui.shadcn.com",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shadcn-ui/ui/issues"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"ai",
|
||||
"chat",
|
||||
"helpers",
|
||||
"development",
|
||||
"ai-sdk",
|
||||
"tanstack",
|
||||
"shadcn"
|
||||
],
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
"./ai-sdk": {
|
||||
"types": "./dist/ai-sdk/index.d.ts",
|
||||
"default": "./dist/ai-sdk/index.js"
|
||||
},
|
||||
"./tanstack-ai": {
|
||||
"types": "./dist/tanstack-ai/index.d.ts",
|
||||
"default": "./dist/tanstack-ai/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsup --watch",
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"clean": "rimraf dist",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/ai": ">=0.40.0 <0.41.0",
|
||||
"@tanstack/ai-client": ">=0.20.0 <0.21.0",
|
||||
"ai": ">=7.0.0 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tanstack/ai": {
|
||||
"optional": true
|
||||
},
|
||||
"@tanstack/ai-client": {
|
||||
"optional": true
|
||||
},
|
||||
"ai": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/ai": "0.40.0",
|
||||
"@tanstack/ai-client": "0.20.0",
|
||||
"ai": "7.0.0-canary.159",
|
||||
"zod": "3.25.76",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
390
packages/helpers/src/ai-sdk/chat.test.ts
Normal file
390
packages/helpers/src/ai-sdk/chat.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import type { UIMessage } from "ai"
|
||||
import { describe, expect, expectTypeOf, it } from "vitest"
|
||||
|
||||
import { testChatContract } from "../test/chat-contract"
|
||||
import { createChat } from "./index"
|
||||
import { getMessageText } from "./parts"
|
||||
import {
|
||||
artifact,
|
||||
weatherLoading,
|
||||
weatherOutput,
|
||||
weatherSuccess,
|
||||
} from "./test-utils"
|
||||
import type { DataParts, TestMessage, Tools } from "./test-utils"
|
||||
|
||||
testChatContract("AI SDK", () =>
|
||||
createChat().user("One").assistant("A").user("Two").assistant("B")
|
||||
)
|
||||
|
||||
describe("AI SDK chat", () => {
|
||||
it("narrows the continuation toolCall by name", () => {
|
||||
createChat<TestMessage>()
|
||||
.user("Weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("getWeather", { input: { city: "San Francisco" } })
|
||||
})
|
||||
.assistant(({ toolCall }) => {
|
||||
if (toolCall?.name === "getWeather") {
|
||||
expectTypeOf(toolCall.input).toEqualTypeOf<
|
||||
Tools["getWeather"]["input"]
|
||||
>()
|
||||
expectTypeOf(toolCall.output).toEqualTypeOf<
|
||||
Tools["getWeather"]["output"] | undefined
|
||||
>()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves the native message type through get and next", () => {
|
||||
type Metadata = { model: string }
|
||||
|
||||
const chat = createChat<UIMessage<Metadata, DataParts, Tools>>()
|
||||
.user("Weather?", { metadata: { model: "test" } })
|
||||
.assistant("Sunny.", { metadata: { model: "test" } })
|
||||
const messages = chat.get()
|
||||
const next = chat.next([])
|
||||
|
||||
expectTypeOf(messages).toEqualTypeOf<
|
||||
Array<UIMessage<Metadata, DataParts, Tools>>
|
||||
>()
|
||||
expectTypeOf(next).toEqualTypeOf<UIMessage<
|
||||
Metadata,
|
||||
DataParts,
|
||||
Tools
|
||||
> | null>()
|
||||
})
|
||||
|
||||
it("creates chat fixture user messages with attachments", () => {
|
||||
const chat = createChat().user("Analyze this PDF and summarize it.", {
|
||||
files: [
|
||||
{
|
||||
filename: "report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://example.com/report.pdf",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const [message] = chat.get()
|
||||
|
||||
expect(message).toMatchObject({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Analyze this PDF and summarize it.",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
filename: "report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://example.com/report.pdf",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("creates a default chat fixture with createChat", () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
|
||||
expect(chat.get().map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
})
|
||||
|
||||
it("gets text from chat fixture messages", () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning("Reasoning should not be included.")
|
||||
writer.text("Hey, ")
|
||||
writer.text("how's it going?")
|
||||
})
|
||||
.user("What's next?")
|
||||
|
||||
const messages = chat.get(2)
|
||||
const [, assistantMessage] = messages
|
||||
const nextMessage = chat.next(messages)
|
||||
|
||||
expect(messages.map(getMessageText).join("\n")).toBe(
|
||||
"Hello\nHey, how's it going?"
|
||||
)
|
||||
expect(getMessageText(assistantMessage)).toBe("Hey, how's it going?")
|
||||
expect(nextMessage ? getMessageText(nextMessage) : "").toBe("What's next?")
|
||||
})
|
||||
|
||||
it("supports assistant ids and metadata in chat fixtures", () => {
|
||||
const chat = createChat<UIMessage<{ model: string }>>()
|
||||
.user("Hello")
|
||||
.assistant("Hey.", {
|
||||
id: "assistant-1",
|
||||
metadata: {
|
||||
model: "gpt-5",
|
||||
},
|
||||
})
|
||||
|
||||
const [, assistantMessage] = chat.get()
|
||||
|
||||
expect(assistantMessage).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
metadata: {
|
||||
model: "gpt-5",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("creates arbitrary data and tool parts with createChat", () => {
|
||||
const chat = createChat().assistant(({ writer }) => {
|
||||
writer.data({
|
||||
type: "data-invoice",
|
||||
id: "invoice-1",
|
||||
data: {
|
||||
id: "inv-1",
|
||||
status: "paid",
|
||||
},
|
||||
})
|
||||
|
||||
writer
|
||||
.tool("lookupInvoice", {
|
||||
input: {
|
||||
id: "inv-1",
|
||||
},
|
||||
})
|
||||
.output({
|
||||
id: "inv-1",
|
||||
total: 128,
|
||||
})
|
||||
})
|
||||
|
||||
const [message] = chat.get()
|
||||
|
||||
expect(message.parts).toMatchObject([
|
||||
{
|
||||
type: "data-invoice",
|
||||
id: "invoice-1",
|
||||
data: {
|
||||
status: "paid",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool-lookupInvoice",
|
||||
state: "output-available",
|
||||
input: {
|
||||
id: "inv-1",
|
||||
},
|
||||
output: {
|
||||
total: 128,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("returns top messages and then the next scripted message", () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant("Hey, how's it going?")
|
||||
.user("What's the weather?")
|
||||
.assistant("It's sunny.")
|
||||
|
||||
const initialMessages = chat.get(2)
|
||||
const nextMessage = chat.next(initialMessages)
|
||||
|
||||
expect(initialMessages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(nextMessage).toMatchObject({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What's the weather?",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("does not mutate playback when next is called again", () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant("Hey.")
|
||||
.user("What's the weather?")
|
||||
.assistant("It's sunny.")
|
||||
.user("What time is it?")
|
||||
.assistant("It is noon.")
|
||||
|
||||
const initialMessages = chat.get(2)
|
||||
const first = chat.next(initialMessages)
|
||||
const second = chat.next(initialMessages)
|
||||
|
||||
expect(first).toMatchObject({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What's the weather?",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(second).toMatchObject({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What's the weather?",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("finds the next user message from a transcript", () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant("Hey.")
|
||||
.user("What's the weather?")
|
||||
.assistant("It's sunny.")
|
||||
|
||||
const [firstMessage] = chat.get(1)
|
||||
const nextMessage = chat.next([firstMessage])
|
||||
|
||||
expect(nextMessage).toMatchObject({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What's the weather?",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("materializes assistant writer parts into the final chat transcript", () => {
|
||||
const chat = createChat<TestMessage>().assistant(({ writer }) => {
|
||||
writer.reasoning("I need to call the weather tool.")
|
||||
writer.data({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: weatherLoading,
|
||||
})
|
||||
|
||||
const weather = writer.tool("getWeather", {
|
||||
title: "Checking weather",
|
||||
input: {
|
||||
city: "San Francisco",
|
||||
},
|
||||
})
|
||||
|
||||
writer.sleep(100)
|
||||
|
||||
weather.output(weatherOutput)
|
||||
|
||||
writer.data({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: weatherSuccess,
|
||||
})
|
||||
writer.text("It's sunny and 72 degrees in San Francisco.")
|
||||
})
|
||||
|
||||
const [message] = chat.get()
|
||||
|
||||
expect(message.parts.map((part) => part.type)).toEqual([
|
||||
"reasoning",
|
||||
"data-weather",
|
||||
"tool-getWeather",
|
||||
"text",
|
||||
])
|
||||
expect(message.parts[1]).toMatchObject({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: {
|
||||
status: "success",
|
||||
},
|
||||
})
|
||||
expect(message.parts[2]).toMatchObject({
|
||||
type: "tool-getWeather",
|
||||
state: "output-available",
|
||||
output: {
|
||||
condition: "sunny",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("produces identical transcripts for identical scripts", () => {
|
||||
function script() {
|
||||
return createChat<TestMessage>()
|
||||
.user("Hello")
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning("Checking the forecast.")
|
||||
writer
|
||||
.tool("getWeather", {
|
||||
input: {
|
||||
city: "San Francisco",
|
||||
},
|
||||
})
|
||||
.output(weatherOutput)
|
||||
writer.text("It's sunny.")
|
||||
})
|
||||
}
|
||||
|
||||
const first = script()
|
||||
const second = script()
|
||||
|
||||
expect(first.get()).toEqual(second.get())
|
||||
expect(first.get().map((message) => message.id)).toEqual(["msg-1", "msg-2"])
|
||||
})
|
||||
|
||||
it("materializes user text parts without a streaming state", () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
|
||||
const [userMessage, assistantMessage] = chat.get()
|
||||
|
||||
expect(userMessage.parts[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
})
|
||||
expect(assistantMessage.parts[0]).toMatchObject({
|
||||
type: "text",
|
||||
state: "done",
|
||||
})
|
||||
})
|
||||
|
||||
it("hydrates a chat from an existing transcript", () => {
|
||||
const source = createChat<TestMessage>()
|
||||
.user("What's the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer
|
||||
.tool("getWeather", {
|
||||
input: { city: "San Francisco" },
|
||||
})
|
||||
.output(weatherOutput)
|
||||
writer.text("It's sunny.", { mode: "instant" })
|
||||
})
|
||||
.user("Thanks!")
|
||||
.assistant("Anytime.")
|
||||
const recorded = source.get()
|
||||
|
||||
const replay = createChat({ messages: recorded })
|
||||
|
||||
expect(replay.get(2)).toEqual(recorded.slice(0, 2))
|
||||
expect(replay.next(recorded.slice(0, 2))).toMatchObject({
|
||||
id: recorded[2].id,
|
||||
role: "user",
|
||||
})
|
||||
expect(replay.get()).toEqual(recorded)
|
||||
})
|
||||
it("continues generated ids past the seeded range", () => {
|
||||
const source = createChat().user("Hello").assistant("Hey.")
|
||||
const replay = createChat({ messages: source.get() }).user("What's next?")
|
||||
|
||||
const messages = replay.get()
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
"msg-1",
|
||||
"msg-2",
|
||||
"msg-3",
|
||||
])
|
||||
})
|
||||
})
|
||||
143
packages/helpers/src/ai-sdk/chat.ts
Normal file
143
packages/helpers/src/ai-sdk/chat.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
ChatTransport,
|
||||
UIDataTypes,
|
||||
UIMessage,
|
||||
UIMessagePart,
|
||||
UITools,
|
||||
} from "ai"
|
||||
|
||||
import { createChatRuntime, wait } from "../core"
|
||||
import type { Chat, ChatOptions } from "../core"
|
||||
import { createAiSdkFormat } from "./format"
|
||||
|
||||
// Local equivalents of the AI SDK's InferUIMessage* utilities; not all of
|
||||
// them are exported from the pinned ai version.
|
||||
type MessageMetadata<UI_MESSAGE extends UIMessage> =
|
||||
UI_MESSAGE extends UIMessage<infer METADATA> ? METADATA : unknown
|
||||
|
||||
type MessageData<UI_MESSAGE extends UIMessage> =
|
||||
UI_MESSAGE extends UIMessage<unknown, infer DATA_PARTS>
|
||||
? DATA_PARTS
|
||||
: UIDataTypes
|
||||
|
||||
type MessageTools<UI_MESSAGE extends UIMessage> =
|
||||
UI_MESSAGE extends UIMessage<unknown, UIDataTypes, infer TOOLS>
|
||||
? TOOLS
|
||||
: UITools
|
||||
|
||||
/** Options for creating an AI SDK chat, optionally hydrated from existing messages. */
|
||||
export type CreateChatOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
||||
ChatOptions & {
|
||||
messages?: Array<
|
||||
UIMessage<
|
||||
MessageMetadata<UI_MESSAGE>,
|
||||
MessageData<UI_MESSAGE>,
|
||||
MessageTools<UI_MESSAGE>
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
export type YieldMessagePartsOptions = {
|
||||
/** Also yield an initial snapshot with zero parts. */
|
||||
includeEmpty?: boolean
|
||||
}
|
||||
|
||||
export type StreamMessagePartsOptions = YieldMessagePartsOptions & {
|
||||
/** Delay before each snapshot. */
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
/** The chat type returned by the AI SDK adapter's `createChat`. */
|
||||
export type AiSdkChat<UI_MESSAGE extends UIMessage = UIMessage> = Chat<
|
||||
UIMessage<
|
||||
MessageMetadata<UI_MESSAGE>,
|
||||
MessageData<UI_MESSAGE>,
|
||||
MessageTools<UI_MESSAGE>
|
||||
>,
|
||||
UIMessagePart<MessageData<UI_MESSAGE>, MessageTools<UI_MESSAGE>>,
|
||||
ChatTransport<
|
||||
UIMessage<
|
||||
MessageMetadata<UI_MESSAGE>,
|
||||
MessageData<UI_MESSAGE>,
|
||||
MessageTools<UI_MESSAGE>
|
||||
>
|
||||
>,
|
||||
MessageMetadata<UI_MESSAGE>,
|
||||
MessageData<UI_MESSAGE>,
|
||||
MessageTools<UI_MESSAGE>
|
||||
>
|
||||
|
||||
/**
|
||||
* Yields progressive copies of a message — one more part per snapshot — for
|
||||
* rendering "message assembling itself" states without a transport.
|
||||
*/
|
||||
export function* yieldMessageParts<
|
||||
METADATA = unknown,
|
||||
DATA_PARTS extends UIDataTypes = UIDataTypes,
|
||||
TOOLS extends UITools = UITools,
|
||||
>(
|
||||
message: UIMessage<METADATA, DATA_PARTS, TOOLS>,
|
||||
options: YieldMessagePartsOptions = {}
|
||||
) {
|
||||
if (options.includeEmpty) {
|
||||
yield {
|
||||
...message,
|
||||
parts: [],
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < message.parts.length; index++) {
|
||||
yield {
|
||||
...message,
|
||||
parts: message.parts.slice(0, index + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Async variant of {@link yieldMessageParts} that waits `delayMs` before each snapshot. */
|
||||
export async function* streamMessageParts<
|
||||
METADATA = unknown,
|
||||
DATA_PARTS extends UIDataTypes = UIDataTypes,
|
||||
TOOLS extends UITools = UITools,
|
||||
>(
|
||||
message: UIMessage<METADATA, DATA_PARTS, TOOLS>,
|
||||
options: StreamMessagePartsOptions = {}
|
||||
) {
|
||||
const snapshots = yieldMessageParts(message, options)
|
||||
|
||||
while (true) {
|
||||
const snapshot = snapshots.next()
|
||||
|
||||
if (snapshot.done) {
|
||||
break
|
||||
}
|
||||
|
||||
if (options.delayMs) {
|
||||
await wait(options.delayMs)
|
||||
}
|
||||
|
||||
yield snapshot.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AI SDK chat, optionally hydrated from existing messages. Generic
|
||||
* over the UI message type, mirroring `useChat`.
|
||||
*/
|
||||
export function createChat<UI_MESSAGE extends UIMessage = UIMessage>(
|
||||
options: CreateChatOptions<UI_MESSAGE> = {}
|
||||
) {
|
||||
const { messages, ...chatOptions } = options
|
||||
|
||||
return createChatRuntime(
|
||||
createAiSdkFormat<
|
||||
MessageMetadata<UI_MESSAGE>,
|
||||
MessageData<UI_MESSAGE>,
|
||||
MessageTools<UI_MESSAGE>
|
||||
>(),
|
||||
{
|
||||
...chatOptions,
|
||||
messages,
|
||||
}
|
||||
)
|
||||
}
|
||||
931
packages/helpers/src/ai-sdk/format.test.ts
Normal file
931
packages/helpers/src/ai-sdk/format.test.ts
Normal file
@@ -0,0 +1,931 @@
|
||||
import {
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses,
|
||||
lastAssistantMessageIsCompleteWithToolCalls,
|
||||
readUIMessageStream,
|
||||
} from "ai"
|
||||
import type { ChatTransport, UIMessage } from "ai"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createChat } from "./index"
|
||||
import { readStream, weatherLoading, weatherOutput } from "./test-utils"
|
||||
import type { DataParts, TestMessage, Tools } from "./test-utils"
|
||||
|
||||
describe("AI SDK transport", () => {
|
||||
it("chains tool output after an in-stream sleep", async () => {
|
||||
const chat = createChat<TestMessage>()
|
||||
.user("Weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer
|
||||
.tool("getWeather", {
|
||||
input: {
|
||||
city: "San Francisco",
|
||||
},
|
||||
})
|
||||
.sleep(100)
|
||||
.output(weatherOutput)
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const stream = await chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
|
||||
expect((await reader.read()).value?.type).toBe("start")
|
||||
expect((await reader.read()).value?.type).toBe("tool-input-available")
|
||||
|
||||
const startedAt = Date.now()
|
||||
const outputChunk = await reader.read()
|
||||
|
||||
expect(outputChunk.value?.type).toBe("tool-output-available")
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(75)
|
||||
})
|
||||
|
||||
it("streams denied static tool parts", async () => {
|
||||
const chat = createChat<TestMessage>()
|
||||
.user("Use the available tools.")
|
||||
.assistant([
|
||||
{
|
||||
type: "tool-getWeather",
|
||||
toolCallId: "call-weather",
|
||||
state: "output-denied",
|
||||
input: { city: "San Francisco" },
|
||||
approval: {
|
||||
id: "approval-weather",
|
||||
approved: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "dynamic-tool",
|
||||
toolName: "searchDocs",
|
||||
toolCallId: "call-docs",
|
||||
state: "output-denied",
|
||||
input: { query: "streaming" },
|
||||
approval: {
|
||||
id: "approval-docs",
|
||||
approved: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
const [userMessage] = chat.get(1)
|
||||
const stream = await chat.transport({ delayMs: 0 }).sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"tool-input-available",
|
||||
"tool-output-denied",
|
||||
"tool-input-available",
|
||||
"tool-output-denied",
|
||||
"finish",
|
||||
])
|
||||
})
|
||||
|
||||
it("streams the next scripted assistant response through the transport", async () => {
|
||||
const chat = createChat<TestMessage>()
|
||||
.user("Hello")
|
||||
.assistant("Hey, how's it going?")
|
||||
.user("What's the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning("I need to call the weather tool.", {
|
||||
mode: "instant",
|
||||
})
|
||||
writer.data({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: weatherLoading,
|
||||
})
|
||||
|
||||
const weather = writer.tool("getWeather", {
|
||||
input: {
|
||||
city: "San Francisco",
|
||||
},
|
||||
})
|
||||
|
||||
weather.output(weatherOutput)
|
||||
writer.text("It's sunny.", {
|
||||
mode: "instant",
|
||||
})
|
||||
})
|
||||
|
||||
const initialMessages = chat.get(2)
|
||||
const nextMessage = chat.next(initialMessages)
|
||||
|
||||
if (!nextMessage) {
|
||||
throw new Error("Expected next message.")
|
||||
}
|
||||
|
||||
const stream = await chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [
|
||||
...initialMessages,
|
||||
{
|
||||
id: nextMessage.id ?? "next-message",
|
||||
role: nextMessage.role ?? "user",
|
||||
metadata: nextMessage.metadata,
|
||||
parts: nextMessage.parts,
|
||||
},
|
||||
],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"reasoning-start",
|
||||
"reasoning-delta",
|
||||
"reasoning-end",
|
||||
"data-weather",
|
||||
"tool-input-available",
|
||||
"tool-output-available",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "tool-output-available",
|
||||
output: {
|
||||
city: "San Francisco",
|
||||
temperature: 72,
|
||||
condition: "sunny",
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves streamed file filenames", async () => {
|
||||
const chat = createChat()
|
||||
.user("Send the report.")
|
||||
.assistant(({ writer }) => {
|
||||
writer.file({
|
||||
filename: "report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://example.com/report.pdf",
|
||||
})
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const stream = await chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "file",
|
||||
filename: "report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://example.com/report.pdf",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("sleeps before the next scripted turn", async () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.sleep(100)
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("Hey.", {
|
||||
mode: "instant",
|
||||
})
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const startedAt = Date.now()
|
||||
const stream = await chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
const firstChunk = await reader.read()
|
||||
|
||||
if (firstChunk.done) {
|
||||
throw new Error("Expected first chunk.")
|
||||
}
|
||||
|
||||
const chunks = [firstChunk.value]
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
expect(firstChunk.value?.type).toBe("start")
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(75)
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
})
|
||||
|
||||
it("emits a start chunk carrying the assistant message id", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
|
||||
const [userMessage, assistantMessage] = chat.get()
|
||||
const stream = await chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
const firstChunk = await reader.read()
|
||||
|
||||
if (firstChunk.done) {
|
||||
throw new Error("Expected first chunk.")
|
||||
}
|
||||
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "start",
|
||||
messageId: assistantMessage.id,
|
||||
})
|
||||
|
||||
const secondChunk = await reader.read()
|
||||
|
||||
if (secondChunk.done) {
|
||||
throw new Error("Expected second chunk.")
|
||||
}
|
||||
|
||||
expect(secondChunk.value?.type).toBe("text-start")
|
||||
})
|
||||
|
||||
it("streams an app-level error before assistant content", async () => {
|
||||
const chat = createChat().user("Hello").error("Model unavailable.")
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const stream = await chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{
|
||||
type: "start",
|
||||
messageId: "msg-2",
|
||||
},
|
||||
{
|
||||
type: "error",
|
||||
errorText: "Model unavailable.",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("streams an app-level error after partial assistant content", async () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("Partial answer.", {
|
||||
mode: "instant",
|
||||
})
|
||||
writer.error("Connection lost.")
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const stream = await chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"error",
|
||||
])
|
||||
expect(chunks.at(-1)).toEqual({
|
||||
type: "error",
|
||||
errorText: "Connection lost.",
|
||||
})
|
||||
})
|
||||
|
||||
it("emits an abort chunk when the request is aborted mid-stream", async () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("First.", {
|
||||
mode: "instant",
|
||||
})
|
||||
writer.sleep(150)
|
||||
writer.text("Second.", {
|
||||
mode: "instant",
|
||||
})
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const controller = new AbortController()
|
||||
const stream = await chat.transport({ delayMs: 0 }).sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: controller.signal,
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
const received: string[] = []
|
||||
|
||||
while (received.at(-1) !== "text-end") {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
throw new Error("Expected more chunks before the abort.")
|
||||
}
|
||||
|
||||
received.push(value.type)
|
||||
}
|
||||
|
||||
controller.abort()
|
||||
|
||||
const chunks = []
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
expect(received).toEqual(["start", "text-start", "text-delta", "text-end"])
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual(["abort"])
|
||||
})
|
||||
|
||||
it("throws when no scripted assistant response remains", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
const messages = chat.get()
|
||||
|
||||
await expect(
|
||||
chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages,
|
||||
abortSignal: undefined,
|
||||
})
|
||||
).rejects.toThrow("No assistant response found for this transcript.")
|
||||
})
|
||||
|
||||
it("disables delta delays when delayMs is explicitly undefined", async () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant("This reply has several streamed words.")
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const startedAt = Date.now()
|
||||
const stream = await chat.transport({ delayMs: undefined }).sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(100)
|
||||
expect(
|
||||
chunks.filter((chunk) => chunk.type === "text-delta").length
|
||||
).toBeGreaterThan(1)
|
||||
})
|
||||
it("streams hydrated assistant turns through the transport", async () => {
|
||||
const source = createChat<TestMessage>()
|
||||
.user("What's the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer
|
||||
.tool("getWeather", {
|
||||
input: { city: "San Francisco" },
|
||||
})
|
||||
.output(weatherOutput)
|
||||
writer.text("It's sunny.", { mode: "instant" })
|
||||
})
|
||||
|
||||
const replay = createChat<TestMessage>({
|
||||
messages: source.get(),
|
||||
})
|
||||
const [userMessage] = replay.get(1)
|
||||
const stream = await replay.transport({ delayMs: 0 }).sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"tool-input-available",
|
||||
"tool-output-available",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
})
|
||||
it("streams a string fallback when the script is exhausted", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
const [unknownMessage] = createChat()
|
||||
.user("Something unscripted", { id: "unknown-1" })
|
||||
.get()
|
||||
const stream = await chat
|
||||
.transport({ delayMs: 0, fallback: "This demo is scripted." })
|
||||
.sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [...chat.get(), unknownMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-delta",
|
||||
"text-delta",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "text-delta",
|
||||
delta: "scripted.",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("passes the transcript to a fallback writer callback", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
const [unknownMessage] = createChat()
|
||||
.user("Something unscripted", { id: "unknown-1" })
|
||||
.get()
|
||||
const stream = await chat
|
||||
.transport({
|
||||
delayMs: 0,
|
||||
fallback: ({ writer, messages }) => {
|
||||
writer.text(`You sent ${messages.length} messages.`, {
|
||||
mode: "instant",
|
||||
})
|
||||
},
|
||||
})
|
||||
.sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [...chat.get(), unknownMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "text-delta",
|
||||
delta: "You sent 3 messages.",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("prefers scripted turns over the fallback", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.", {
|
||||
id: "scripted-reply",
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const stream = await chat
|
||||
.transport({ delayMs: 0, fallback: "Should not stream." })
|
||||
.sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: [userMessage],
|
||||
abortSignal: undefined,
|
||||
})
|
||||
const chunks = await readStream(stream)
|
||||
|
||||
expect(chunks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "text-delta",
|
||||
delta: "Hey.",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("still throws without a fallback", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
|
||||
await expect(
|
||||
chat.transport().sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: chat.get(),
|
||||
abortSignal: undefined,
|
||||
})
|
||||
).rejects.toThrow("No assistant response found for this transcript.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AI SDK live client integration", () => {
|
||||
// Sends the transcript the way the client does — `messageId` only on
|
||||
// regeneration — then reduces the response through the real AI SDK
|
||||
// message state machine, so streamed messages carry the ids a live
|
||||
// `useChat` transcript would.
|
||||
async function runLiveTurn(
|
||||
transport: ChatTransport<UIMessage>,
|
||||
messages: UIMessage[],
|
||||
options: {
|
||||
trigger?: "submit-message" | "regenerate-message"
|
||||
messageId?: string
|
||||
} = {}
|
||||
) {
|
||||
const stream = await transport.sendMessages({
|
||||
trigger: options.trigger ?? "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: options.messageId,
|
||||
messages,
|
||||
abortSignal: undefined,
|
||||
})
|
||||
|
||||
let assistantMessage: UIMessage | undefined
|
||||
|
||||
for await (const message of readUIMessageStream({ stream })) {
|
||||
assistantMessage = message
|
||||
}
|
||||
|
||||
if (!assistantMessage) {
|
||||
throw new Error("The stream produced no assistant message.")
|
||||
}
|
||||
|
||||
return assistantMessage
|
||||
}
|
||||
|
||||
function getText(message: UIMessage) {
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
}
|
||||
|
||||
it("streams assistant messages under their configured ids", async () => {
|
||||
const chat = createChat()
|
||||
.user("What changed?")
|
||||
.assistant("Keyboard shortcuts and faster search.")
|
||||
const [userMessage, assistantMessage] = chat.get()
|
||||
const transport = chat.transport({ delayMs: 0 })
|
||||
|
||||
const streamed = await runLiveTurn(transport, [userMessage])
|
||||
|
||||
expect(streamed.id).toBe(assistantMessage.id)
|
||||
expect(getText(streamed)).toBe("Keyboard shortcuts and faster search.")
|
||||
})
|
||||
|
||||
it("falls back once a live transcript exhausts the conversation", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hi.")
|
||||
const transport = chat.transport({
|
||||
delayMs: 0,
|
||||
fallback: "No more scripted replies.",
|
||||
})
|
||||
const [userMessage] = chat.get(1)
|
||||
|
||||
const transcript: UIMessage[] = [userMessage]
|
||||
transcript.push(await runLiveTurn(transport, transcript))
|
||||
transcript.push({
|
||||
id: "client-user-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Anything else?" }],
|
||||
})
|
||||
|
||||
const fallbackMessage = await runLiveTurn(transport, transcript)
|
||||
|
||||
expect(getText(fallbackMessage)).toBe("No more scripted replies.")
|
||||
})
|
||||
|
||||
it("regenerates the same turn from its adopted id", async () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.assistant("Hi.")
|
||||
.user("More?")
|
||||
.assistant("Sure.")
|
||||
const transport = chat.transport({ delayMs: 0 })
|
||||
const [userMessage] = chat.get(1)
|
||||
|
||||
const streamed = await runLiveTurn(transport, [userMessage])
|
||||
const regenerated = await runLiveTurn(transport, [userMessage], {
|
||||
trigger: "regenerate-message",
|
||||
messageId: streamed.id,
|
||||
})
|
||||
|
||||
expect(regenerated.id).toBe(streamed.id)
|
||||
expect(getText(regenerated)).toBe("Hi.")
|
||||
})
|
||||
|
||||
it("regenerates a fallback response as another fallback", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hi.")
|
||||
const transport = chat.transport({
|
||||
delayMs: 0,
|
||||
fallback: "Still nothing scripted.",
|
||||
})
|
||||
const [userMessage] = chat.get(1)
|
||||
|
||||
const transcript: UIMessage[] = [userMessage]
|
||||
transcript.push(await runLiveTurn(transport, transcript))
|
||||
transcript.push({
|
||||
id: "client-user-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "More?" }],
|
||||
})
|
||||
|
||||
const fallbackMessage = await runLiveTurn(transport, transcript)
|
||||
|
||||
const regenerated = await runLiveTurn(transport, transcript, {
|
||||
trigger: "regenerate-message",
|
||||
messageId: fallbackMessage.id,
|
||||
})
|
||||
|
||||
expect(getText(regenerated)).toBe("Still nothing scripted.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AI SDK human-in-the-loop", () => {
|
||||
function createApprovalChat() {
|
||||
return createChat<TestMessage>()
|
||||
.user("Fetch the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("I need your approval first.", { mode: "instant" })
|
||||
writer.tool("getWeather", {
|
||||
input: { city: "San Francisco" },
|
||||
needsApproval: true,
|
||||
output: weatherOutput,
|
||||
})
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.", {
|
||||
mode: "instant",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function readTurnChunks(
|
||||
transport: ChatTransport<UIMessage<unknown, DataParts, Tools>>,
|
||||
messages: Array<UIMessage<unknown, DataParts, Tools>>
|
||||
) {
|
||||
// Mirrors useChat's automatic sends, which pass the last message's id
|
||||
// with the submit-message trigger. The transport must not treat that as
|
||||
// a regeneration of the identified turn.
|
||||
const stream = await transport.sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: messages[messages.length - 1]?.id,
|
||||
messages,
|
||||
abortSignal: undefined,
|
||||
})
|
||||
|
||||
return readStream(stream)
|
||||
}
|
||||
|
||||
function respondToApproval(
|
||||
message: UIMessage<unknown, DataParts, Tools>,
|
||||
approved: boolean
|
||||
) {
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part) =>
|
||||
part.type === "tool-getWeather"
|
||||
? ({
|
||||
...part,
|
||||
state: "approval-responded",
|
||||
approval: { id: "approval-1", approved },
|
||||
} as (typeof message.parts)[number])
|
||||
: part
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
it("streams the approval request and the client parks the call", async () => {
|
||||
const chat = createApprovalChat()
|
||||
const chunks = await readTurnChunks(
|
||||
chat.transport({ delayMs: undefined }),
|
||||
chat.get(1)
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"tool-input-available",
|
||||
"tool-approval-request",
|
||||
"finish",
|
||||
])
|
||||
expect(chunks[5]).toMatchObject({
|
||||
approvalId: "approval-1",
|
||||
toolCallId: "call-1",
|
||||
})
|
||||
|
||||
const stream = await chat.transport({ delayMs: undefined }).sendMessages({
|
||||
trigger: "submit-message",
|
||||
chatId: "chat-1",
|
||||
messageId: undefined,
|
||||
messages: chat.get(1),
|
||||
abortSignal: undefined,
|
||||
})
|
||||
let clientMessage: UIMessage<unknown, DataParts, Tools> | undefined
|
||||
|
||||
for await (const message of readUIMessageStream({ stream })) {
|
||||
clientMessage = message as UIMessage<unknown, DataParts, Tools>
|
||||
}
|
||||
|
||||
expect(clientMessage?.parts).toMatchObject([
|
||||
{ type: "text", text: "I need your approval first." },
|
||||
{
|
||||
type: "tool-getWeather",
|
||||
state: "approval-requested",
|
||||
approval: { id: "approval-1" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("streams the gated output and approved wording after approval", async () => {
|
||||
const chat = createApprovalChat()
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const chunks = await readTurnChunks(
|
||||
chat.transport({ delayMs: undefined }),
|
||||
[userMessage, respondToApproval(assistantMessage, true)]
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"start-step",
|
||||
"tool-output-available",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
// Continuations omit the message id so the client updates the paused
|
||||
// assistant message in place instead of forking a duplicate.
|
||||
expect(chunks[0]).toMatchObject({ messageId: undefined })
|
||||
expect(chunks[2]).toMatchObject({
|
||||
toolCallId: "call-1",
|
||||
output: weatherOutput,
|
||||
})
|
||||
expect(chunks[4]).toMatchObject({ delta: "Fetched it." })
|
||||
})
|
||||
|
||||
it("streams the denial and denied wording after denial", async () => {
|
||||
const chat = createApprovalChat()
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const chunks = await readTurnChunks(
|
||||
chat.transport({ delayMs: undefined }),
|
||||
[userMessage, respondToApproval(assistantMessage, false)]
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"start-step",
|
||||
"tool-output-denied",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
expect(chunks[2]).toMatchObject({ toolCallId: "call-1" })
|
||||
expect(chunks[4]).toMatchObject({ delta: "Okay, skipping." })
|
||||
})
|
||||
|
||||
it("does not retrigger automatic sending after a continuation merges", async () => {
|
||||
const chat = createApprovalChat()
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const respondedMessage = respondToApproval(assistantMessage, true)
|
||||
const chunks = await readTurnChunks(
|
||||
chat.transport({ delayMs: undefined }),
|
||||
[userMessage, respondedMessage]
|
||||
)
|
||||
|
||||
// The client merges these chunks into the prior assistant message as a
|
||||
// new step. Rebuild that merged shape and assert the triggers ignore the
|
||||
// already-resolved tool call behind the step boundary.
|
||||
const mergedMessage = {
|
||||
...respondedMessage,
|
||||
parts: [
|
||||
...respondedMessage.parts.map((part) =>
|
||||
part.type === "tool-getWeather"
|
||||
? ({
|
||||
...part,
|
||||
state: "output-available",
|
||||
output: weatherOutput,
|
||||
approval: { id: "approval-1", approved: true },
|
||||
} as (typeof respondedMessage.parts)[number])
|
||||
: part
|
||||
),
|
||||
{ type: "step-start" } as (typeof respondedMessage.parts)[number],
|
||||
{
|
||||
type: "text",
|
||||
text: "Fetched it.",
|
||||
} as (typeof respondedMessage.parts)[number],
|
||||
],
|
||||
}
|
||||
|
||||
expect(chunks.some((chunk) => chunk.type === "start-step")).toBe(true)
|
||||
expect(
|
||||
lastAssistantMessageIsCompleteWithToolCalls({
|
||||
messages: [userMessage, mergedMessage],
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses({
|
||||
messages: [userMessage, mergedMessage],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("continues an elicitation with the user-submitted output", async () => {
|
||||
const chat = createChat<TestMessage>()
|
||||
.user("Help me plan the release.")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("createFile", {
|
||||
dynamic: true,
|
||||
input: { filename: "plan.md", content: "" },
|
||||
})
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(
|
||||
toolCall?.name === "createFile" && toolCall.output
|
||||
? `Saved ${toolCall.output.filename}.`
|
||||
: "No file yet.",
|
||||
{ mode: "instant" }
|
||||
)
|
||||
})
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const answeredMessage = {
|
||||
...assistantMessage,
|
||||
parts: assistantMessage.parts.map((part) =>
|
||||
part.type === "dynamic-tool"
|
||||
? ({
|
||||
...part,
|
||||
state: "output-available",
|
||||
output: {
|
||||
filename: "plan.md",
|
||||
url: "https://example.com/plan.md",
|
||||
},
|
||||
} as (typeof assistantMessage.parts)[number])
|
||||
: part
|
||||
),
|
||||
}
|
||||
const chunks = await readTurnChunks(
|
||||
chat.transport({ delayMs: undefined }),
|
||||
[userMessage, answeredMessage]
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"start-step",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
expect(chunks[3]).toMatchObject({ delta: "Saved plan.md." })
|
||||
})
|
||||
})
|
||||
276
packages/helpers/src/ai-sdk/format.ts
Normal file
276
packages/helpers/src/ai-sdk/format.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type {
|
||||
ChatTransport,
|
||||
InferUIMessageChunk,
|
||||
TextUIPart,
|
||||
UIDataTypes,
|
||||
UIMessage,
|
||||
UIMessagePart,
|
||||
UITools,
|
||||
} from "ai"
|
||||
|
||||
import { assertNever } from "../core"
|
||||
import type { ChatFormat, ChunkEncoder } from "../core"
|
||||
import {
|
||||
eventsFromParts,
|
||||
getMessageText,
|
||||
getToolCalls,
|
||||
materializeParts,
|
||||
} from "./parts"
|
||||
|
||||
/**
|
||||
* The AI SDK `ChatFormat` plugin. Pass to `createChatRuntime` for advanced
|
||||
* composition; `createChat` wires it up for you. Its transport `satisfies
|
||||
* ChatTransport` and throws `"No assistant response found for this transcript."`
|
||||
* when no assistant response remains.
|
||||
*/
|
||||
export function createAiSdkFormat<
|
||||
METADATA = unknown,
|
||||
DATA_PARTS extends UIDataTypes = UIDataTypes,
|
||||
TOOLS extends UITools = UITools,
|
||||
>(): ChatFormat<
|
||||
UIMessage<METADATA, DATA_PARTS, TOOLS>,
|
||||
UIMessagePart<DATA_PARTS, TOOLS>,
|
||||
InferUIMessageChunk<UIMessage<METADATA, DATA_PARTS, TOOLS>>,
|
||||
ChatTransport<UIMessage<METADATA, DATA_PARTS, TOOLS>>,
|
||||
METADATA,
|
||||
DATA_PARTS,
|
||||
TOOLS
|
||||
> {
|
||||
type Message = UIMessage<METADATA, DATA_PARTS, TOOLS>
|
||||
type Chunk = InferUIMessageChunk<Message>
|
||||
|
||||
const format: ChatFormat<
|
||||
Message,
|
||||
UIMessagePart<DATA_PARTS, TOOLS>,
|
||||
Chunk,
|
||||
ChatTransport<Message>,
|
||||
METADATA,
|
||||
DATA_PARTS,
|
||||
TOOLS
|
||||
> & {
|
||||
encodeChunk: ChunkEncoder<Chunk, METADATA, DATA_PARTS, TOOLS>
|
||||
} = {
|
||||
materializeParts,
|
||||
eventsFromParts,
|
||||
|
||||
createMessage(input) {
|
||||
// User turns never stream, so their text parts carry no `state` —
|
||||
// matching messages hand-built from `parts.text()`.
|
||||
const parts =
|
||||
input.role === "user"
|
||||
? input.parts.map((part) => {
|
||||
if (part.type !== "text") {
|
||||
return part
|
||||
}
|
||||
|
||||
const { state: _state, ...textPart } = part as TextUIPart
|
||||
|
||||
return textPart as UIMessagePart<DATA_PARTS, TOOLS>
|
||||
})
|
||||
: input.parts
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
role: input.role,
|
||||
metadata: input.metadata,
|
||||
parts,
|
||||
} as Message
|
||||
},
|
||||
|
||||
getMessageId(message) {
|
||||
return message.id
|
||||
},
|
||||
|
||||
getMessageRole(message) {
|
||||
return message.role
|
||||
},
|
||||
|
||||
getMessageText(message) {
|
||||
return getMessageText(message)
|
||||
},
|
||||
|
||||
getMessageParts(message) {
|
||||
return message.parts
|
||||
},
|
||||
|
||||
getMessageMetadata(message) {
|
||||
return message.metadata
|
||||
},
|
||||
|
||||
getToolCalls(message) {
|
||||
return getToolCalls(message)
|
||||
},
|
||||
|
||||
encodeChunk(chunk) {
|
||||
switch (chunk.type) {
|
||||
case "start":
|
||||
// The client adopts this id for the streamed assistant message, so
|
||||
// live transcripts keep the configured ids that turn matching,
|
||||
// fallback, and regeneration rely on.
|
||||
return {
|
||||
type: "start",
|
||||
messageId: chunk.messageId,
|
||||
} as Chunk
|
||||
case "start-step":
|
||||
return {
|
||||
type: "start-step",
|
||||
} as Chunk
|
||||
case "finish":
|
||||
return {
|
||||
type: "finish",
|
||||
finishReason: chunk.finishReason,
|
||||
messageMetadata: chunk.messageMetadata,
|
||||
} as Chunk
|
||||
case "abort":
|
||||
return {
|
||||
type: "abort",
|
||||
reason: chunk.reason,
|
||||
} as Chunk
|
||||
case "error":
|
||||
return {
|
||||
type: "error",
|
||||
errorText: chunk.errorText,
|
||||
} as Chunk
|
||||
case "text-start":
|
||||
return {
|
||||
type: "text-start",
|
||||
id: chunk.id,
|
||||
} as Chunk
|
||||
case "text-delta":
|
||||
return {
|
||||
type: "text-delta",
|
||||
id: chunk.id,
|
||||
delta: chunk.delta,
|
||||
} as Chunk
|
||||
case "text-end":
|
||||
return {
|
||||
type: "text-end",
|
||||
id: chunk.id,
|
||||
} as Chunk
|
||||
case "reasoning-start":
|
||||
return {
|
||||
type: "reasoning-start",
|
||||
id: chunk.id,
|
||||
} as Chunk
|
||||
case "reasoning-delta":
|
||||
return {
|
||||
type: "reasoning-delta",
|
||||
id: chunk.id,
|
||||
delta: chunk.delta,
|
||||
} as Chunk
|
||||
case "reasoning-end":
|
||||
return {
|
||||
type: "reasoning-end",
|
||||
id: chunk.id,
|
||||
} as Chunk
|
||||
case "data":
|
||||
return {
|
||||
type: `data-${chunk.name}`,
|
||||
id: chunk.id,
|
||||
data: chunk.data,
|
||||
transient: chunk.transient,
|
||||
} as Chunk
|
||||
case "tool-input-available":
|
||||
return {
|
||||
type: "tool-input-available",
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
input: chunk.input,
|
||||
providerExecuted: chunk.providerExecuted,
|
||||
toolMetadata: chunk.toolMetadata,
|
||||
dynamic: chunk.dynamic,
|
||||
title: chunk.title,
|
||||
} as Chunk
|
||||
case "tool-approval-request":
|
||||
return {
|
||||
type: "tool-approval-request",
|
||||
approvalId: chunk.approvalId,
|
||||
toolCallId: chunk.toolCallId,
|
||||
} as Chunk
|
||||
case "tool-output-available":
|
||||
return {
|
||||
type: "tool-output-available",
|
||||
toolCallId: chunk.toolCallId,
|
||||
output: chunk.output,
|
||||
providerExecuted: chunk.providerExecuted,
|
||||
toolMetadata: chunk.toolMetadata,
|
||||
dynamic: chunk.dynamic,
|
||||
} as Chunk
|
||||
case "tool-output-error":
|
||||
return {
|
||||
type: "tool-output-error",
|
||||
toolCallId: chunk.toolCallId,
|
||||
errorText: chunk.errorText,
|
||||
providerExecuted: chunk.providerExecuted,
|
||||
toolMetadata: chunk.toolMetadata,
|
||||
dynamic: chunk.dynamic,
|
||||
} as Chunk
|
||||
case "tool-output-denied":
|
||||
return {
|
||||
type: "tool-output-denied",
|
||||
toolCallId: chunk.toolCallId,
|
||||
} as Chunk
|
||||
case "file":
|
||||
return {
|
||||
type: "file",
|
||||
filename: chunk.part.filename,
|
||||
mediaType: chunk.part.mediaType,
|
||||
url: chunk.part.url,
|
||||
providerMetadata: chunk.part.providerMetadata,
|
||||
} as Chunk
|
||||
case "reasoning-file":
|
||||
return {
|
||||
...chunk.part,
|
||||
type: "reasoning-file",
|
||||
} as Chunk
|
||||
case "source-url":
|
||||
return {
|
||||
...chunk.part,
|
||||
type: "source-url",
|
||||
} as Chunk
|
||||
case "source-document":
|
||||
return {
|
||||
...chunk.part,
|
||||
type: "source-document",
|
||||
} as Chunk
|
||||
case "custom":
|
||||
return {
|
||||
...chunk.part,
|
||||
type: "custom",
|
||||
} as Chunk
|
||||
default:
|
||||
return assertNever(chunk)
|
||||
}
|
||||
},
|
||||
|
||||
createTransport(transportContext, options = {}) {
|
||||
return {
|
||||
async sendMessages({ messages, messageId, abortSignal, trigger }) {
|
||||
// Automatic sends after tool results and approvals pass the last
|
||||
// assistant message's id; only regeneration may replay a turn by id.
|
||||
const turn = transportContext.resolveTurn(
|
||||
messages,
|
||||
trigger === "regenerate-message" ? messageId : undefined
|
||||
)
|
||||
|
||||
if (!turn) {
|
||||
throw new Error("No assistant response found for this transcript.")
|
||||
}
|
||||
|
||||
return transportContext.streamTurn(
|
||||
turn,
|
||||
format.encodeChunk,
|
||||
options,
|
||||
abortSignal
|
||||
)
|
||||
},
|
||||
|
||||
async reconnectToStream() {
|
||||
return null
|
||||
},
|
||||
} satisfies ChatTransport<Message>
|
||||
},
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
2
packages/helpers/src/ai-sdk/index.ts
Normal file
2
packages/helpers/src/ai-sdk/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createChat } from "./chat"
|
||||
export type { AiSdkChat, CreateChatOptions } from "./chat"
|
||||
270
packages/helpers/src/ai-sdk/parts.test.ts
Normal file
270
packages/helpers/src/ai-sdk/parts.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { streamMessageParts, yieldMessageParts } from "./chat"
|
||||
import { createChat } from "./index"
|
||||
import { getToolCalls } from "./parts"
|
||||
import {
|
||||
artifact,
|
||||
weatherLoading,
|
||||
weatherOutput,
|
||||
weatherSuccess,
|
||||
} from "./test-utils"
|
||||
import type { DataParts, TestMessage, Tools } from "./test-utils"
|
||||
|
||||
describe("AI SDK parts", () => {
|
||||
it("creates typed messages with explicit parts", () => {
|
||||
const [message] = createChat<TestMessage>()
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("Here is the forecast.")
|
||||
writer.data({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: weatherSuccess,
|
||||
})
|
||||
writer.tool("getWeather", {
|
||||
toolCallId: "call-weather",
|
||||
input: {
|
||||
city: "San Francisco",
|
||||
},
|
||||
output: weatherOutput,
|
||||
})
|
||||
})
|
||||
.get()
|
||||
|
||||
expect(message).toMatchObject({
|
||||
id: "msg-1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Here is the forecast.",
|
||||
},
|
||||
{
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: {
|
||||
city: "San Francisco",
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool-getWeather",
|
||||
toolCallId: "call-weather",
|
||||
state: "output-available",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("creates rich messages with the fluent writer", () => {
|
||||
const [message] = createChat<TestMessage>()
|
||||
.assistant(
|
||||
({ writer }) => {
|
||||
writer.stepStart()
|
||||
writer.reasoning("I should call a tool first.")
|
||||
writer.tool("createFile", {
|
||||
input: {
|
||||
filename: "answer.md",
|
||||
content: "Done.",
|
||||
},
|
||||
output: {
|
||||
filename: "answer.md",
|
||||
url: "https://example.com/answer.md",
|
||||
},
|
||||
})
|
||||
writer.data({
|
||||
type: "data-artifact",
|
||||
data: artifact,
|
||||
})
|
||||
writer.text("The file is ready.")
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
}
|
||||
)
|
||||
.get()
|
||||
|
||||
expect(message.id).toBe("assistant-1")
|
||||
expect(message.parts.map((part) => part.type)).toEqual([
|
||||
"step-start",
|
||||
"reasoning",
|
||||
"tool-createFile",
|
||||
"data-artifact",
|
||||
"text",
|
||||
])
|
||||
})
|
||||
|
||||
it("yields message snapshots as parts become available", () => {
|
||||
const [message] = createChat()
|
||||
.assistant(
|
||||
({ writer }) => {
|
||||
writer.reasoning("Checking context.")
|
||||
writer.text("Done.")
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
}
|
||||
)
|
||||
.get()
|
||||
|
||||
const snapshots = Array.from(
|
||||
yieldMessageParts(message, {
|
||||
includeEmpty: true,
|
||||
})
|
||||
)
|
||||
|
||||
expect(snapshots.map((snapshot) => snapshot.parts.length)).toEqual([
|
||||
0, 1, 2,
|
||||
])
|
||||
expect(message.parts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("streams message snapshots with the async generator", async () => {
|
||||
const [message] = createChat()
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("First.")
|
||||
writer.text("Second.")
|
||||
})
|
||||
.get()
|
||||
const snapshots = []
|
||||
|
||||
for await (const snapshot of streamMessageParts(message)) {
|
||||
snapshots.push(snapshot)
|
||||
}
|
||||
|
||||
expect(snapshots.map((snapshot) => snapshot.parts.length)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it("materializes all writer part variants", () => {
|
||||
const [message] = createChat<TestMessage>()
|
||||
.assistant(
|
||||
({ writer }) => {
|
||||
writer.stepStart()
|
||||
writer.reasoning("I will gather context before answering.")
|
||||
writer.sourceUrl({
|
||||
sourceId: "source-ai-sdk",
|
||||
title: "AI SDK",
|
||||
url: "https://ai-sdk.dev",
|
||||
})
|
||||
writer.file({
|
||||
filename: "receipt.png",
|
||||
mediaType: "image/png",
|
||||
url: "https://example.com/receipt.png",
|
||||
})
|
||||
writer.data({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: weatherLoading,
|
||||
})
|
||||
writer.tool("getWeather", {
|
||||
title: "Get weather",
|
||||
input: { city: "San Francisco" },
|
||||
output: weatherOutput,
|
||||
})
|
||||
writer.data({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: weatherSuccess,
|
||||
})
|
||||
writer.data({
|
||||
type: "data-artifact",
|
||||
data: artifact,
|
||||
})
|
||||
writer.text("The weather is sunny and the artifact is ready.")
|
||||
},
|
||||
{
|
||||
id: "sample-all-parts",
|
||||
}
|
||||
)
|
||||
.get()
|
||||
|
||||
expect(message.id).toBe("sample-all-parts")
|
||||
expect(message.parts.map((part) => part.type)).toEqual([
|
||||
"step-start",
|
||||
"reasoning",
|
||||
"source-url",
|
||||
"file",
|
||||
"data-weather",
|
||||
"tool-getWeather",
|
||||
"data-artifact",
|
||||
"text",
|
||||
])
|
||||
expect(message.parts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: weatherSuccess,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AI SDK approval parts", () => {
|
||||
function createApprovalMessage() {
|
||||
const [, assistantMessage] = createChat<TestMessage>()
|
||||
.user("Fetch the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("getWeather", {
|
||||
input: { city: "San Francisco" },
|
||||
needsApproval: true,
|
||||
output: weatherOutput,
|
||||
})
|
||||
})
|
||||
.get(2)
|
||||
|
||||
return assistantMessage
|
||||
}
|
||||
|
||||
it("materializes a needsApproval call as an approval-requested part", () => {
|
||||
expect(createApprovalMessage().parts).toMatchObject([
|
||||
{
|
||||
type: "tool-getWeather",
|
||||
toolCallId: "call-1",
|
||||
state: "approval-requested",
|
||||
input: { city: "San Francisco" },
|
||||
approval: { id: "approval-1" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("summarizes tool calls with their approval state", () => {
|
||||
const message = createApprovalMessage()
|
||||
const respondedMessage = {
|
||||
...message,
|
||||
parts: message.parts.map((part) =>
|
||||
part.type === "tool-getWeather"
|
||||
? ({
|
||||
...part,
|
||||
state: "approval-responded",
|
||||
approval: { id: "approval-1", approved: true },
|
||||
} as (typeof message.parts)[number])
|
||||
: part
|
||||
),
|
||||
}
|
||||
|
||||
expect(getToolCalls(respondedMessage)).toMatchObject([
|
||||
{
|
||||
toolCallId: "call-1",
|
||||
name: "getWeather",
|
||||
state: "approval-responded",
|
||||
input: { city: "San Francisco" },
|
||||
approval: { id: "approval-1", approved: true },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("hydrates approval-requested parts back into approval events", () => {
|
||||
const chat = createChat<TestMessage>({
|
||||
messages: [createApprovalMessage()],
|
||||
})
|
||||
const [message] = chat.get(1)
|
||||
|
||||
expect(message.parts).toMatchObject([
|
||||
{
|
||||
type: "tool-getWeather",
|
||||
state: "approval-requested",
|
||||
approval: { id: "approval-1" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
478
packages/helpers/src/ai-sdk/parts.ts
Normal file
478
packages/helpers/src/ai-sdk/parts.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import type {
|
||||
CustomContentUIPart,
|
||||
DynamicToolUIPart,
|
||||
FileUIPart,
|
||||
ReasoningFileUIPart,
|
||||
ReasoningUIPart,
|
||||
SourceDocumentUIPart,
|
||||
SourceUrlUIPart,
|
||||
StepStartUIPart,
|
||||
TextUIPart,
|
||||
ToolUIPart,
|
||||
UIDataTypes,
|
||||
UIMessage,
|
||||
UIMessagePart,
|
||||
UITools,
|
||||
} from "ai"
|
||||
|
||||
import { assertNever } from "../core"
|
||||
import type { ChatEvent, ToolCallSummary } from "../core"
|
||||
|
||||
/** Concatenates the text of every `text` part in a message. */
|
||||
export function getMessageText(message: Pick<UIMessage, "parts">) {
|
||||
return message.parts
|
||||
.filter((part): part is TextUIPart => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
}
|
||||
|
||||
function toToolCallSummary(
|
||||
name: string,
|
||||
part: ToolUIPart | DynamicToolUIPart,
|
||||
dynamic?: boolean
|
||||
): ToolCallSummary {
|
||||
return {
|
||||
toolCallId: part.toolCallId,
|
||||
name,
|
||||
dynamic,
|
||||
input: "input" in part ? part.input : undefined,
|
||||
output: part.state === "output-available" ? part.output : undefined,
|
||||
errorText: part.state === "output-error" ? part.errorText : undefined,
|
||||
state: part.state,
|
||||
approval:
|
||||
"approval" in part && part.approval
|
||||
? {
|
||||
id: part.approval.id,
|
||||
approved: part.approval.approved,
|
||||
reason: part.approval.reason,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a message's typed and dynamic tool calls into framework-neutral summaries. */
|
||||
export function getToolCalls(message: Pick<UIMessage, "parts">) {
|
||||
const summaries: ToolCallSummary[] = []
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "dynamic-tool") {
|
||||
const dynamicToolPart = part as DynamicToolUIPart
|
||||
|
||||
summaries.push(
|
||||
toToolCallSummary(dynamicToolPart.toolName, dynamicToolPart, true)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type.startsWith("tool-") && "toolCallId" in part) {
|
||||
const toolPart = part as ToolUIPart
|
||||
|
||||
summaries.push(
|
||||
toToolCallSummary(part.type.replace("tool-", ""), toolPart)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
/** Replaces the part with the same `type` + `id` in place, or appends. */
|
||||
export function replaceOrPushPart<
|
||||
DATA_PARTS extends UIDataTypes,
|
||||
TOOLS extends UITools,
|
||||
>(
|
||||
parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>,
|
||||
part: UIMessagePart<DATA_PARTS, TOOLS>
|
||||
) {
|
||||
if ("id" in part && part.id) {
|
||||
const index = parts.findIndex(
|
||||
(currentPart) =>
|
||||
currentPart.type === part.type &&
|
||||
"id" in currentPart &&
|
||||
currentPart.id === part.id
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
parts[index] = part
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(part)
|
||||
}
|
||||
|
||||
function findToolPartIndex<
|
||||
DATA_PARTS extends UIDataTypes,
|
||||
TOOLS extends UITools,
|
||||
>(parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>, toolCallId: string) {
|
||||
return parts.findIndex(
|
||||
(part) =>
|
||||
(part.type === "dynamic-tool" || part.type.startsWith("tool-")) &&
|
||||
"toolCallId" in part &&
|
||||
part.toolCallId === toolCallId
|
||||
)
|
||||
}
|
||||
|
||||
/** Materializes an event log into final AI SDK message parts (transient data excluded). */
|
||||
export function materializeParts<
|
||||
DATA_PARTS extends UIDataTypes,
|
||||
TOOLS extends UITools,
|
||||
>(
|
||||
events: ChatEvent<DATA_PARTS, TOOLS>[]
|
||||
): Array<UIMessagePart<DATA_PARTS, TOOLS>> {
|
||||
const parts: Array<UIMessagePart<DATA_PARTS, TOOLS>> = []
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.kind) {
|
||||
case "text": {
|
||||
parts.push({
|
||||
type: "text",
|
||||
text: event.text,
|
||||
state: "done",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "reasoning": {
|
||||
parts.push({
|
||||
type: "reasoning",
|
||||
text: event.text,
|
||||
state: "done",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "data": {
|
||||
if (event.transient) {
|
||||
continue
|
||||
}
|
||||
|
||||
replaceOrPushPart(parts, {
|
||||
type: `data-${event.name}`,
|
||||
id: event.id,
|
||||
data: event.data,
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "tool-input": {
|
||||
replaceOrPushPart(parts, {
|
||||
type: event.dynamic ? "dynamic-tool" : `tool-${event.name}`,
|
||||
...(event.dynamic ? { toolName: event.name } : {}),
|
||||
toolCallId: event.toolCallId,
|
||||
title: event.title,
|
||||
toolMetadata: event.toolMetadata,
|
||||
providerExecuted: event.providerExecuted,
|
||||
state: "input-available",
|
||||
input: event.input,
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "tool-approval-request": {
|
||||
const index = findToolPartIndex(parts, event.toolCallId)
|
||||
|
||||
if (index !== -1) {
|
||||
parts[index] = {
|
||||
...parts[index],
|
||||
state: "approval-requested",
|
||||
approval: { id: event.approvalId },
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool-output": {
|
||||
const index = findToolPartIndex(parts, event.toolCallId)
|
||||
|
||||
if (index !== -1) {
|
||||
parts[index] = {
|
||||
...parts[index],
|
||||
state: "output-available",
|
||||
output: event.output,
|
||||
providerExecuted: event.providerExecuted,
|
||||
toolMetadata: event.toolMetadata,
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool-error": {
|
||||
const index = findToolPartIndex(parts, event.toolCallId)
|
||||
|
||||
if (index !== -1) {
|
||||
parts[index] = {
|
||||
...parts[index],
|
||||
state: "output-error",
|
||||
errorText: event.errorText,
|
||||
providerExecuted: event.providerExecuted,
|
||||
toolMetadata: event.toolMetadata,
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool-denied": {
|
||||
const index = findToolPartIndex(parts, event.toolCallId)
|
||||
|
||||
if (index !== -1) {
|
||||
parts[index] = {
|
||||
...parts[index],
|
||||
state: "output-denied",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>
|
||||
}
|
||||
break
|
||||
}
|
||||
case "source-url": {
|
||||
parts.push({
|
||||
...event.part,
|
||||
type: "source-url",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "source-document": {
|
||||
parts.push({
|
||||
...event.part,
|
||||
type: "source-document",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "file": {
|
||||
parts.push({
|
||||
...event.part,
|
||||
type: "file",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "reasoning-file": {
|
||||
parts.push({
|
||||
...event.part,
|
||||
type: "reasoning-file",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "custom": {
|
||||
parts.push({
|
||||
...event.part,
|
||||
type: "custom",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "step-start": {
|
||||
parts.push({
|
||||
type: "step-start",
|
||||
} as UIMessagePart<DATA_PARTS, TOOLS>)
|
||||
break
|
||||
}
|
||||
case "sleep":
|
||||
case "error":
|
||||
break
|
||||
default:
|
||||
assertNever(event)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Parses AI SDK message parts back into an event log so static parts can stream. */
|
||||
export function eventsFromParts<
|
||||
DATA_PARTS extends UIDataTypes,
|
||||
TOOLS extends UITools,
|
||||
>(
|
||||
parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>
|
||||
): ChatEvent<DATA_PARTS, TOOLS>[] {
|
||||
const events: ChatEvent<DATA_PARTS, TOOLS>[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
// Framework-owned part unions are open, so parsing is non-exhaustive.
|
||||
if (part.type === "text") {
|
||||
const textPart = part as TextUIPart
|
||||
|
||||
events.push({
|
||||
kind: "text",
|
||||
id: `text-${events.length + 1}`,
|
||||
text: textPart.text,
|
||||
options: {
|
||||
mode: "instant",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
const reasoningPart = part as ReasoningUIPart
|
||||
|
||||
events.push({
|
||||
kind: "reasoning",
|
||||
id: `reasoning-${events.length + 1}`,
|
||||
text: reasoningPart.text,
|
||||
options: {
|
||||
mode: "instant",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type.startsWith("data-")) {
|
||||
events.push({
|
||||
kind: "data",
|
||||
name: part.type.replace("data-", "") as keyof DATA_PARTS & string,
|
||||
id: "id" in part ? part.id : undefined,
|
||||
data: "data" in part ? part.data : undefined,
|
||||
} as ChatEvent<DATA_PARTS, TOOLS>)
|
||||
}
|
||||
|
||||
if (part.type.startsWith("tool-") && "toolCallId" in part) {
|
||||
const toolPart = part as ToolUIPart<TOOLS>
|
||||
const name = part.type.replace("tool-", "") as keyof TOOLS & string
|
||||
|
||||
events.push({
|
||||
kind: "tool-input",
|
||||
name,
|
||||
toolCallId: toolPart.toolCallId,
|
||||
title: toolPart.title,
|
||||
toolMetadata: toolPart.toolMetadata,
|
||||
providerExecuted: toolPart.providerExecuted,
|
||||
input: "input" in toolPart ? toolPart.input : {},
|
||||
})
|
||||
|
||||
if (
|
||||
toolPart.state === "approval-requested" ||
|
||||
toolPart.state === "approval-responded"
|
||||
) {
|
||||
events.push({
|
||||
kind: "tool-approval-request",
|
||||
toolCallId: toolPart.toolCallId,
|
||||
approvalId: toolPart.approval.id,
|
||||
})
|
||||
}
|
||||
|
||||
if (toolPart.state === "output-available") {
|
||||
events.push({
|
||||
kind: "tool-output",
|
||||
toolCallId: toolPart.toolCallId,
|
||||
output: toolPart.output,
|
||||
providerExecuted: toolPart.providerExecuted,
|
||||
toolMetadata: toolPart.toolMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
if (toolPart.state === "output-error") {
|
||||
events.push({
|
||||
kind: "tool-error",
|
||||
toolCallId: toolPart.toolCallId,
|
||||
errorText: toolPart.errorText,
|
||||
providerExecuted: toolPart.providerExecuted,
|
||||
toolMetadata: toolPart.toolMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
if (toolPart.state === "output-denied") {
|
||||
events.push({
|
||||
kind: "tool-denied",
|
||||
toolCallId: toolPart.toolCallId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "dynamic-tool") {
|
||||
const dynamicToolPart = part as DynamicToolUIPart
|
||||
|
||||
events.push({
|
||||
kind: "tool-input",
|
||||
name: dynamicToolPart.toolName as keyof TOOLS & string,
|
||||
toolCallId: dynamicToolPart.toolCallId,
|
||||
title: dynamicToolPart.title,
|
||||
toolMetadata: dynamicToolPart.toolMetadata,
|
||||
providerExecuted: dynamicToolPart.providerExecuted,
|
||||
dynamic: true,
|
||||
input: "input" in dynamicToolPart ? dynamicToolPart.input : {},
|
||||
})
|
||||
|
||||
if (
|
||||
dynamicToolPart.state === "approval-requested" ||
|
||||
dynamicToolPart.state === "approval-responded"
|
||||
) {
|
||||
events.push({
|
||||
kind: "tool-approval-request",
|
||||
toolCallId: dynamicToolPart.toolCallId,
|
||||
approvalId: dynamicToolPart.approval.id,
|
||||
})
|
||||
}
|
||||
|
||||
if (dynamicToolPart.state === "output-available") {
|
||||
events.push({
|
||||
kind: "tool-output",
|
||||
toolCallId: dynamicToolPart.toolCallId,
|
||||
output: dynamicToolPart.output,
|
||||
providerExecuted: dynamicToolPart.providerExecuted,
|
||||
toolMetadata: dynamicToolPart.toolMetadata,
|
||||
dynamic: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (dynamicToolPart.state === "output-error") {
|
||||
events.push({
|
||||
kind: "tool-error",
|
||||
toolCallId: dynamicToolPart.toolCallId,
|
||||
errorText: dynamicToolPart.errorText,
|
||||
providerExecuted: dynamicToolPart.providerExecuted,
|
||||
toolMetadata: dynamicToolPart.toolMetadata,
|
||||
dynamic: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (dynamicToolPart.state === "output-denied") {
|
||||
events.push({
|
||||
kind: "tool-denied",
|
||||
toolCallId: dynamicToolPart.toolCallId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "source-url") {
|
||||
const sourceUrlPart = part as SourceUrlUIPart
|
||||
|
||||
events.push({
|
||||
kind: "source-url",
|
||||
part: sourceUrlPart,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "source-document") {
|
||||
const sourceDocumentPart = part as SourceDocumentUIPart
|
||||
|
||||
events.push({
|
||||
kind: "source-document",
|
||||
part: sourceDocumentPart,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
const filePart = part as FileUIPart
|
||||
|
||||
events.push({
|
||||
kind: "file",
|
||||
part: filePart,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "reasoning-file") {
|
||||
const reasoningFilePart = part as ReasoningFileUIPart
|
||||
|
||||
events.push({
|
||||
kind: "reasoning-file",
|
||||
part: reasoningFilePart,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "custom") {
|
||||
const customPart = part as CustomContentUIPart
|
||||
|
||||
events.push({
|
||||
kind: "custom",
|
||||
part: customPart,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
events.push({
|
||||
kind: "step-start",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
83
packages/helpers/src/ai-sdk/test-utils.ts
Normal file
83
packages/helpers/src/ai-sdk/test-utils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
export type TestMessage = UIMessage<unknown, DataParts, Tools>
|
||||
|
||||
export type DataParts = {
|
||||
weather: {
|
||||
city: string
|
||||
status: "loading" | "success" | "error"
|
||||
temperature?: number
|
||||
condition?: string
|
||||
}
|
||||
artifact: {
|
||||
id: string
|
||||
title: string
|
||||
kind: "code" | "document" | "image"
|
||||
status: "draft" | "streaming" | "complete"
|
||||
}
|
||||
}
|
||||
|
||||
export type Tools = {
|
||||
getWeather: {
|
||||
input: {
|
||||
city: string
|
||||
}
|
||||
output: {
|
||||
city: string
|
||||
temperature: number
|
||||
condition: string
|
||||
}
|
||||
}
|
||||
createFile: {
|
||||
input: {
|
||||
filename: string
|
||||
content: string
|
||||
}
|
||||
output: {
|
||||
filename: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const weatherLoading: DataParts["weather"] = {
|
||||
city: "San Francisco",
|
||||
status: "loading",
|
||||
}
|
||||
|
||||
export const weatherSuccess: DataParts["weather"] = {
|
||||
city: "San Francisco",
|
||||
status: "success",
|
||||
temperature: 72,
|
||||
condition: "sunny",
|
||||
}
|
||||
|
||||
export const weatherOutput: Tools["getWeather"]["output"] = {
|
||||
city: "San Francisco",
|
||||
temperature: 72,
|
||||
condition: "sunny",
|
||||
}
|
||||
|
||||
export const artifact: DataParts["artifact"] = {
|
||||
id: "artifact-1",
|
||||
title: "CitedAnswer.jsx",
|
||||
kind: "code",
|
||||
status: "complete",
|
||||
}
|
||||
|
||||
export async function readStream<T>(stream: ReadableStream<T>) {
|
||||
const chunks = []
|
||||
const reader = stream.getReader()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
621
packages/helpers/src/core/chat.test.ts
Normal file
621
packages/helpers/src/core/chat.test.ts
Normal file
@@ -0,0 +1,621 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { createChatRuntime } from "./chat"
|
||||
import { createTestChat, createTestFormat, readStream } from "./test-utils"
|
||||
import type {
|
||||
TestData,
|
||||
TestMessage,
|
||||
TestPart,
|
||||
TestTools,
|
||||
TestTransport,
|
||||
} from "./test-utils"
|
||||
import type { NeutralChunk } from "./types"
|
||||
|
||||
describe("createChatRuntime", () => {
|
||||
it("creates user and assistant turns with sequential ids and metadata", () => {
|
||||
const chat = createTestChat().user("Hello").assistant("Hey.")
|
||||
const messages = chat.get()
|
||||
|
||||
expect(messages).toMatchObject([
|
||||
{
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
metadata: { createdAt: "2026-01-01T00:00:00.000Z" },
|
||||
parts: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "Hey." }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("attaches user files as parts", () => {
|
||||
const chat = createTestChat().user("See attached.", {
|
||||
files: [
|
||||
{
|
||||
filename: "report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://example.com/report.pdf",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(chat.get()[0].parts).toMatchObject([
|
||||
{ type: "text", text: "See attached." },
|
||||
{ type: "file", filename: "report.pdf" },
|
||||
])
|
||||
})
|
||||
|
||||
it("stores explicit assistant parts as-is", () => {
|
||||
const parts = [{ type: "text", text: "Prebuilt." }]
|
||||
const chat = createTestChat().assistant(parts)
|
||||
|
||||
expect(chat.get()[0].parts).toMatchObject(parts)
|
||||
})
|
||||
|
||||
it("returns cloned messages from get", () => {
|
||||
const chat = createTestChat().user("Hello")
|
||||
const [first] = chat.get()
|
||||
|
||||
first.parts[0].text = "Mutated"
|
||||
|
||||
expect(chat.get()[0].parts[0].text).toBe("Hello")
|
||||
})
|
||||
|
||||
it("owns explicit parts and deeply detached snapshots", () => {
|
||||
const parts = [
|
||||
{
|
||||
type: "tool-getWeather",
|
||||
input: { location: { city: "SF" } },
|
||||
},
|
||||
]
|
||||
const chat = createTestChat().assistant(parts)
|
||||
|
||||
;(parts[0].input.location as { city: string }).city = "mutated input"
|
||||
|
||||
const [snapshot] = chat.get()
|
||||
const snapshotPart = snapshot.parts[0] as unknown as {
|
||||
input: { location: { city: string } }
|
||||
}
|
||||
|
||||
snapshotPart.input.location.city = "mutated snapshot"
|
||||
|
||||
expect(chat.get()[0].parts[0]).toMatchObject({
|
||||
input: { location: { city: "SF" } },
|
||||
})
|
||||
})
|
||||
|
||||
it("returns a transcript prefix without mutating playback", () => {
|
||||
const chat = createTestChat()
|
||||
.user("One")
|
||||
.assistant("A")
|
||||
.user("Two")
|
||||
.assistant("B")
|
||||
.user("Three")
|
||||
|
||||
const initialMessages = chat.get(2)
|
||||
const first = chat.next(initialMessages)
|
||||
const second = chat.next(initialMessages)
|
||||
|
||||
expect(first?.parts[0].text).toBe("Two")
|
||||
expect(second?.parts[0].text).toBe("Two")
|
||||
})
|
||||
|
||||
it("finds the next user turn from a transcript by id", () => {
|
||||
const chat = createTestChat().user("One").assistant("A").user("Two")
|
||||
|
||||
const [firstMessage] = chat.get(1)
|
||||
const nextMessage = chat.next([firstMessage])
|
||||
|
||||
expect(nextMessage?.parts[0].text).toBe("Two")
|
||||
})
|
||||
|
||||
it("falls back to role and text matching for unscripted transcripts", () => {
|
||||
const chat = createTestChat().user("One").assistant("A").user("Two")
|
||||
|
||||
const next = chat.next([
|
||||
{
|
||||
id: "external-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "One" }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(next?.parts[0].text).toBe("Two")
|
||||
})
|
||||
|
||||
it("returns the first user message for an empty transcript and null when exhausted", () => {
|
||||
const chat = createTestChat().user("One").assistant("A")
|
||||
|
||||
expect(chat.next([])?.parts[0].text).toBe("One")
|
||||
expect(chat.next(chat.get())).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects invalid get counts", () => {
|
||||
const chat = createTestChat().user("One")
|
||||
|
||||
expect(() => chat.get(-1)).toThrowError(
|
||||
"count must be a non-negative integer."
|
||||
)
|
||||
expect(() => chat.get(1.5)).toThrowError(
|
||||
"count must be a non-negative integer."
|
||||
)
|
||||
})
|
||||
|
||||
it("requires counts for get and message transcripts for next", () => {
|
||||
const chat = createTestChat()
|
||||
|
||||
if (false) {
|
||||
// @ts-expect-error get accepts a count, not an options object.
|
||||
chat.get({ count: 1 })
|
||||
// @ts-expect-error next requires the current message transcript.
|
||||
chat.next()
|
||||
// @ts-expect-error next accepts messages, not transcript indexes.
|
||||
chat.next([1])
|
||||
}
|
||||
|
||||
expect(chat.get()).toEqual([])
|
||||
})
|
||||
|
||||
it("streams the next scripted assistant turn through the transport", async () => {
|
||||
const chat = createTestChat()
|
||||
.user("Weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning("Checking.", { mode: "instant" })
|
||||
writer
|
||||
.tool("getWeather", { input: { city: "SF" } })
|
||||
.output({ temperature: 72 })
|
||||
writer.text("Sunny.", { mode: "instant" })
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const { context, options } = chat.transport({ delayMs: 0 })
|
||||
const turn = context.resolveTurn([userMessage])
|
||||
|
||||
expect(options).toEqual({ delayMs: 0 })
|
||||
expect(turn?.role).toBe("assistant")
|
||||
|
||||
const chunks = await readStream(
|
||||
context.streamTurn(turn!, (chunk) => chunk, options)
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"start",
|
||||
"reasoning-start",
|
||||
"reasoning-delta",
|
||||
"reasoning-end",
|
||||
"tool-input-available",
|
||||
"tool-output-available",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
])
|
||||
})
|
||||
|
||||
it("streams scripted errors and stops", async () => {
|
||||
const chat = createTestChat().user("Hello").error("Model unavailable.")
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const { context } = chat.transport()
|
||||
const turn = context.resolveTurn([userMessage])
|
||||
const chunks = await readStream(
|
||||
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
|
||||
)
|
||||
|
||||
expect(chunks).toMatchObject([
|
||||
{ type: "start" },
|
||||
{ type: "error", errorText: "Model unavailable." },
|
||||
])
|
||||
})
|
||||
|
||||
it("resolves turns by message id", () => {
|
||||
const chat = createTestChat()
|
||||
.user("One")
|
||||
.assistant("A", { id: "assistant-a" })
|
||||
.user("Two")
|
||||
.assistant("B", { id: "assistant-b" })
|
||||
|
||||
const { context } = chat.transport()
|
||||
|
||||
expect(context.resolveTurn([], "assistant-b")?.message.id).toBe(
|
||||
"assistant-b"
|
||||
)
|
||||
|
||||
const turnAfterOne = context.resolveTurn(chat.get(1))
|
||||
|
||||
expect(turnAfterOne?.message.id).toBe("assistant-a")
|
||||
})
|
||||
|
||||
it("carries assistant metadata on the finish chunk", async () => {
|
||||
const chat = createTestChat()
|
||||
.user("Hello")
|
||||
.assistant("Hey.", { metadata: { model: "gpt-5" } })
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const { context } = chat.transport()
|
||||
const turn = context.resolveTurn([userMessage])
|
||||
const chunks = await readStream(
|
||||
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
|
||||
)
|
||||
const finish = chunks.find((chunk) => chunk.type === "finish")
|
||||
|
||||
expect(finish).toMatchObject({
|
||||
messageMetadata: { model: "gpt-5" },
|
||||
})
|
||||
})
|
||||
|
||||
it("sleeps before the next scripted turn", async () => {
|
||||
const chat = createTestChat()
|
||||
.user("Hello")
|
||||
.sleep(60)
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("Hey.", { mode: "instant" })
|
||||
})
|
||||
|
||||
const [userMessage] = chat.get(1)
|
||||
const { context } = chat.transport()
|
||||
const turn = context.resolveTurn([userMessage])
|
||||
const startedAt = Date.now()
|
||||
const chunks = await readStream(
|
||||
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
|
||||
)
|
||||
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(40)
|
||||
expect(chunks[0]).toMatchObject({ type: "start" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("createChatRuntime hydration and fallback", () => {
|
||||
it("seeds turns from existing messages and reserves message ids", () => {
|
||||
const format = createTestFormat<TestData, TestTools>()
|
||||
const source = createTestChat().user("Hello").assistant("Hey.")
|
||||
const recorded = source.get()
|
||||
|
||||
const replay = createChatRuntime<
|
||||
TestMessage,
|
||||
TestPart,
|
||||
NeutralChunk<unknown, TestData, TestTools>,
|
||||
TestTransport<TestData, TestTools>,
|
||||
unknown,
|
||||
TestData,
|
||||
TestTools
|
||||
>(format, { messages: recorded })
|
||||
|
||||
expect(replay.get()).toEqual(recorded)
|
||||
|
||||
replay.user("What's next?")
|
||||
|
||||
const messages = replay.get()
|
||||
|
||||
expect(messages[2].id).toBe("msg-3")
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
])
|
||||
})
|
||||
|
||||
it("continues past the highest seeded id without relying on transcript length", () => {
|
||||
const format = createTestFormat<TestData, TestTools>()
|
||||
const replay = createChatRuntime<
|
||||
TestMessage,
|
||||
TestPart,
|
||||
NeutralChunk<unknown, TestData, TestTools>,
|
||||
TestTransport<TestData, TestTools>,
|
||||
unknown,
|
||||
TestData,
|
||||
TestTools
|
||||
>(format, {
|
||||
messages: [
|
||||
{
|
||||
id: "msg-2",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "Seeded" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
replay.user("Next")
|
||||
|
||||
expect(replay.get().map((message) => message.id)).toEqual([
|
||||
"msg-2",
|
||||
"msg-3",
|
||||
])
|
||||
})
|
||||
|
||||
it("resolves a fallback turn only when configured", () => {
|
||||
const chat = createTestChat().user("Hello").assistant("Hey.")
|
||||
// A transcript containing the full script is exhausted — no scripted
|
||||
// assistant turn remains, which is when the fallback kicks in.
|
||||
const exhausted: TestMessage[] = [
|
||||
...chat.get(),
|
||||
{
|
||||
id: "unknown-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Unscripted" }],
|
||||
},
|
||||
]
|
||||
|
||||
const strict = chat.transport()
|
||||
|
||||
expect(strict.context.resolveTurn(exhausted)).toBeUndefined()
|
||||
|
||||
const lenient = chat.transport({ fallback: "Scripted demo." })
|
||||
const turn = lenient.context.resolveTurn(exhausted)
|
||||
|
||||
expect(turn).toMatchObject({
|
||||
role: "assistant",
|
||||
})
|
||||
expect(turn?.events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "text",
|
||||
text: "Scripted demo.",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("does not add fallback turns to the script", () => {
|
||||
const chat = createTestChat().user("Hello").assistant("Hey.")
|
||||
const transport = chat.transport({ fallback: "Scripted demo." })
|
||||
|
||||
transport.context.resolveTurn([
|
||||
...chat.get(),
|
||||
{
|
||||
id: "unknown-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Unscripted" }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(chat.get()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("human-in-the-loop turns", () => {
|
||||
function createApprovalChat() {
|
||||
return createTestChat()
|
||||
.user("Fetch the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("getWeather", {
|
||||
input: { city: "Paris" },
|
||||
needsApproval: true,
|
||||
output: { temperature: 21 },
|
||||
})
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.")
|
||||
})
|
||||
}
|
||||
|
||||
function withApprovalResponse(message: TestMessage, approved: boolean) {
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part) =>
|
||||
part.type === "tool-getWeather"
|
||||
? {
|
||||
...part,
|
||||
state: "approval-responded",
|
||||
approval: { id: "approval-1", approved },
|
||||
}
|
||||
: part
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
it("scripts the approval request and pauses the turn", () => {
|
||||
const chat = createApprovalChat()
|
||||
const turn = chat.transport().context.resolveTurn(chat.get(1))
|
||||
|
||||
expect(turn?.events).toMatchObject([
|
||||
{ kind: "tool-input", toolCallId: "call-1" },
|
||||
{
|
||||
kind: "tool-approval-request",
|
||||
toolCallId: "call-1",
|
||||
approvalId: "approval-1",
|
||||
output: { temperature: 21 },
|
||||
},
|
||||
])
|
||||
expect(turn?.message.parts).toMatchObject([
|
||||
{
|
||||
type: "tool-getWeather",
|
||||
state: "approval-requested",
|
||||
approval: { id: "approval-1" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("bounds get() at the first continuation turn", () => {
|
||||
const chat = createApprovalChat()
|
||||
|
||||
expect(chat.get()).toHaveLength(2)
|
||||
expect(() => chat.get(3)).toThrow(/continuation/)
|
||||
})
|
||||
|
||||
it("streams the gated output and approved context after approval", () => {
|
||||
const chat = createApprovalChat()
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const turn = chat
|
||||
.transport()
|
||||
.context.resolveTurn([
|
||||
userMessage,
|
||||
withApprovalResponse(assistantMessage, true),
|
||||
])
|
||||
|
||||
expect(turn?.message.id).toBe("msg-3")
|
||||
expect(turn?.events).toMatchObject([
|
||||
{ kind: "step-start" },
|
||||
{
|
||||
kind: "tool-output",
|
||||
toolCallId: "call-1",
|
||||
output: { temperature: 21 },
|
||||
},
|
||||
{ kind: "text", text: "Fetched it." },
|
||||
])
|
||||
})
|
||||
|
||||
it("streams the denial and denied context after denial", () => {
|
||||
const chat = createApprovalChat()
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const turn = chat
|
||||
.transport()
|
||||
.context.resolveTurn([
|
||||
userMessage,
|
||||
withApprovalResponse(assistantMessage, false),
|
||||
])
|
||||
|
||||
expect(turn?.events).toMatchObject([
|
||||
{ kind: "step-start" },
|
||||
{ kind: "tool-denied", toolCallId: "call-1" },
|
||||
{ kind: "text", text: "Okay, skipping." },
|
||||
])
|
||||
})
|
||||
|
||||
it("re-resolves a continuation to the same message", () => {
|
||||
const chat = createApprovalChat()
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const transcript = [
|
||||
userMessage,
|
||||
withApprovalResponse(assistantMessage, true),
|
||||
]
|
||||
const { context } = chat.transport()
|
||||
const first = context.resolveTurn(transcript)
|
||||
const second = context.resolveTurn(transcript)
|
||||
|
||||
expect(second?.message).toEqual(first?.message)
|
||||
})
|
||||
|
||||
it("reads a user-submitted elicitation output into the continuation", () => {
|
||||
const chat = createTestChat()
|
||||
.user("Plan the release?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("getWeather", { input: { city: "Paris" } })
|
||||
})
|
||||
.assistant(({ writer, toolCall }) => {
|
||||
writer.text(
|
||||
toolCall?.name === "getWeather" && toolCall.output
|
||||
? `Temperature ${toolCall.output.temperature}.`
|
||||
: "No answer."
|
||||
)
|
||||
})
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const answeredMessage = {
|
||||
...assistantMessage,
|
||||
parts: assistantMessage.parts.map((part) =>
|
||||
part.type === "tool-getWeather"
|
||||
? { ...part, state: "output-available", output: { temperature: 72 } }
|
||||
: part
|
||||
),
|
||||
}
|
||||
const turn = chat
|
||||
.transport()
|
||||
.context.resolveTurn([userMessage, answeredMessage])
|
||||
|
||||
expect(turn?.events).toMatchObject([
|
||||
{ kind: "step-start" },
|
||||
{ kind: "text", text: "Temperature 72." },
|
||||
])
|
||||
})
|
||||
|
||||
it("chains continuations and warns when nothing is pending", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const chat = createTestChat()
|
||||
.user("Plan the release?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("getWeather", { input: { city: "Paris" } })
|
||||
})
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("Thanks.")
|
||||
})
|
||||
.assistant(({ writer, toolCalls }) => {
|
||||
writer.text(`Pending ${toolCalls?.length ?? 0}.`)
|
||||
})
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const answeredMessage = {
|
||||
...assistantMessage,
|
||||
parts: assistantMessage.parts.map((part) =>
|
||||
part.type === "tool-getWeather"
|
||||
? { ...part, state: "output-available", output: { temperature: 72 } }
|
||||
: part
|
||||
),
|
||||
}
|
||||
const { context } = chat.transport()
|
||||
const firstContinuation = context.resolveTurn([
|
||||
userMessage,
|
||||
answeredMessage,
|
||||
])
|
||||
const secondContinuation = context.resolveTurn([
|
||||
userMessage,
|
||||
answeredMessage,
|
||||
firstContinuation!.message,
|
||||
])
|
||||
|
||||
expect(firstContinuation?.events).toMatchObject([
|
||||
{ kind: "step-start" },
|
||||
{ kind: "text", text: "Thanks." },
|
||||
])
|
||||
expect(secondContinuation?.events).toMatchObject([
|
||||
{ kind: "step-start" },
|
||||
{ kind: "text", text: "Pending 0." },
|
||||
])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("without a pending tool call")
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it("starts eager continuation turns with a step boundary", () => {
|
||||
const chat = createTestChat()
|
||||
.user("Plan the release?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("getWeather", { input: { city: "Paris" } })
|
||||
})
|
||||
.assistant("Thanks.")
|
||||
const [userMessage, assistantMessage] = chat.get(2)
|
||||
const turn = chat
|
||||
.transport()
|
||||
.context.resolveTurn([userMessage, assistantMessage])
|
||||
|
||||
expect(turn?.events).toMatchObject([
|
||||
{ kind: "step-start" },
|
||||
{ kind: "text", text: "Thanks." },
|
||||
])
|
||||
})
|
||||
|
||||
it("warns when an approval request has no continuation turn", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
|
||||
createTestChat()
|
||||
.user("Fetch the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.tool("getWeather", {
|
||||
input: { city: "Paris" },
|
||||
needsApproval: true,
|
||||
output: { temperature: 21 },
|
||||
})
|
||||
})
|
||||
.transport()
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("no continuation turn follows")
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it("throws when a needsApproval handle is resolved by the script", () => {
|
||||
createTestChat()
|
||||
.user("Fetch the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
const handle = writer.tool("getWeather", {
|
||||
input: { city: "Paris" },
|
||||
needsApproval: true,
|
||||
})
|
||||
|
||||
expect(() => handle.output({ temperature: 21 })).toThrow(
|
||||
/needsApproval/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
814
packages/helpers/src/core/chat.ts
Normal file
814
packages/helpers/src/core/chat.ts
Normal file
@@ -0,0 +1,814 @@
|
||||
import { createChatIds } from "./ids"
|
||||
import { lowerEvents } from "./lower"
|
||||
import { createChatPayloads, createMetadataFactory } from "./payloads"
|
||||
import { createTurnStream } from "./stream"
|
||||
import type {
|
||||
ChatAssistantOptions,
|
||||
ChatEvent,
|
||||
ChatFormat,
|
||||
ChatOptions,
|
||||
ChatTurn,
|
||||
ChatUserOptions,
|
||||
DataTypes,
|
||||
MessageRole,
|
||||
PendingToolCall,
|
||||
ToolSet,
|
||||
TurnStreamOptions,
|
||||
} from "./types"
|
||||
import {
|
||||
cloneValue,
|
||||
DEFAULT_STREAM_DELAY_MS,
|
||||
DEFAULT_TEXT,
|
||||
devWarn,
|
||||
} from "./utils"
|
||||
import { createEventWriter } from "./writer"
|
||||
import type { EventWriter } from "./writer"
|
||||
|
||||
/**
|
||||
* A transport response for when no configured assistant response remains —
|
||||
* most commonly an exhausted chat (the user keeps sending past the end).
|
||||
* A string (one streamed text part), a parts array, or a writer callback
|
||||
* that also receives the incoming transcript. Note: fully unrecognized
|
||||
* transcripts replay the chat from the top rather than falling back.
|
||||
*/
|
||||
export type ChatFallback<
|
||||
MESSAGE,
|
||||
PART,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
WRITER = EventWriter<DATA, TOOLS>,
|
||||
> =
|
||||
| string
|
||||
| PART[]
|
||||
| ((context: { writer: WRITER; messages: MESSAGE[] }) => void)
|
||||
|
||||
/**
|
||||
* Transport options: `delayMs` between text and reasoning deltas (default 50,
|
||||
* explicit `undefined` disables delays) and an optional `fallback` used when
|
||||
* no configured assistant response remains instead of throwing.
|
||||
*/
|
||||
export type ChatTransportOptions<
|
||||
MESSAGE,
|
||||
PART,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
WRITER = EventWriter<DATA, TOOLS>,
|
||||
> = TurnStreamOptions & {
|
||||
fallback?: ChatFallback<MESSAGE, PART, DATA, TOOLS, WRITER>
|
||||
}
|
||||
|
||||
/**
|
||||
* The context handed to assistant writer callbacks. A continuation turn — a
|
||||
* callback turn scripted after a turn that pauses for user input — also
|
||||
* receives the live transcript and the resolved human-in-the-loop tool
|
||||
* calls; eager turns receive only the writer.
|
||||
*/
|
||||
export type AssistantTurnContext<
|
||||
WRITER,
|
||||
MESSAGE,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> = {
|
||||
writer: WRITER
|
||||
messages?: MESSAGE[]
|
||||
toolCall?: PendingToolCall<TOOLS>
|
||||
toolCalls?: Array<PendingToolCall<TOOLS>>
|
||||
}
|
||||
|
||||
/** A deterministic conversation: chain turns, then read them back or stream them through a transport. */
|
||||
export type Chat<
|
||||
MESSAGE,
|
||||
PART,
|
||||
TRANSPORT,
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
WRITER = EventWriter<DATA, TOOLS>,
|
||||
> = {
|
||||
/** Scripts a user turn: one text part plus optional file attachments. */
|
||||
user(
|
||||
text?: string,
|
||||
options?: ChatUserOptions<METADATA>
|
||||
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
|
||||
/**
|
||||
* Scripts an assistant turn from a string (one streamed text part), a
|
||||
* parts array (static parts, streamed instantly), or a writer callback
|
||||
* (full control over parts and timing). A callback turn scripted after a
|
||||
* paused turn — an unresolved tool call or `needsApproval` — becomes a
|
||||
* continuation: it materializes when the follow-up request arrives, with
|
||||
* the live transcript and resolved tool calls in its context.
|
||||
*/
|
||||
assistant(
|
||||
input?:
|
||||
| string
|
||||
| PART[]
|
||||
| ((context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void),
|
||||
options?: ChatAssistantOptions<METADATA>
|
||||
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
|
||||
/** Scripts an assistant turn that streams a bare error chunk and ends. */
|
||||
error(
|
||||
errorText?: string
|
||||
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
|
||||
/** Delays the next turn's stream. */
|
||||
sleep(
|
||||
delayMs: number
|
||||
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
|
||||
/**
|
||||
* Returns clones of the first `count` configured messages, or all
|
||||
* materializable messages when omitted. Continuation turns have no message
|
||||
* without a live transcript, so `get` stops before the first one and
|
||||
* throws when `count` reaches past it.
|
||||
*/
|
||||
get(count?: number): MESSAGE[]
|
||||
/**
|
||||
* Returns the next configured user message after the given transcript, or
|
||||
* `null` when none remain. Matches by message id, falling back to role and
|
||||
* text, without mutating the chat.
|
||||
*/
|
||||
next(messages: readonly MESSAGE[]): MESSAGE | null
|
||||
/** Creates the framework transport that streams configured assistant responses. */
|
||||
transport(
|
||||
options?: ChatTransportOptions<MESSAGE, PART, DATA, TOOLS, WRITER>
|
||||
): TRANSPORT
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Chat} bound to a {@link ChatFormat}. This is the
|
||||
* factory every adapter's `createChat` wraps — pass your own format to
|
||||
* target a framework this package doesn't ship an adapter for.
|
||||
*/
|
||||
export function createChatRuntime<
|
||||
MESSAGE,
|
||||
PART,
|
||||
CHUNK,
|
||||
TRANSPORT,
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
WRITER = EventWriter<DATA, TOOLS>,
|
||||
>(
|
||||
format: ChatFormat<MESSAGE, PART, CHUNK, TRANSPORT, METADATA, DATA, TOOLS>,
|
||||
options: ChatOptions & {
|
||||
/** Hydrate the chat from existing messages, e.g. a recorded transcript. */
|
||||
messages?: MESSAGE[]
|
||||
} = {}
|
||||
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER> {
|
||||
type InternalTurn = {
|
||||
role: MessageRole
|
||||
events: ChatEvent<DATA, TOOLS>[]
|
||||
messageId: string
|
||||
metadata?: METADATA
|
||||
// Continuation turns carry `resolve` instead of a materialized message.
|
||||
message?: MESSAGE
|
||||
resolve?: (context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void
|
||||
// Continuations stream without a message id so the client keeps merging
|
||||
// into the assistant message it is already continuing.
|
||||
continuation?: boolean
|
||||
// The events last streamed for a continuation turn. Later continuations
|
||||
// read pending calls from here so re-runs cannot drift generated ids
|
||||
// away from the transcript.
|
||||
lastEvents?: ChatEvent<DATA, TOOLS>[]
|
||||
}
|
||||
|
||||
type ResolvedTurn = ChatTurn<MESSAGE, DATA, TOOLS> & {
|
||||
metadata?: METADATA
|
||||
continuation?: boolean
|
||||
}
|
||||
|
||||
const ids = createChatIds(options)
|
||||
const payloads = createChatPayloads(ids)
|
||||
const metadata = createMetadataFactory<METADATA>(options.now)
|
||||
|
||||
const turns: InternalTurn[] = []
|
||||
const pendingEvents: ChatEvent<DATA, TOOLS>[] = []
|
||||
|
||||
function reserveEventIds(events: ChatEvent<DATA, TOOLS>[]) {
|
||||
for (const event of events) {
|
||||
if (
|
||||
event.kind === "tool-input" ||
|
||||
event.kind === "tool-output" ||
|
||||
event.kind === "tool-error" ||
|
||||
event.kind === "tool-denied" ||
|
||||
event.kind === "tool-approval-request"
|
||||
) {
|
||||
ids.reserveToolCallId(event.toolCallId)
|
||||
}
|
||||
|
||||
if (event.kind === "tool-approval-request") {
|
||||
ids.reserveApprovalId(event.approvalId)
|
||||
}
|
||||
|
||||
if (event.kind === "source-url" || event.kind === "source-document") {
|
||||
ids.reserveSourceId(event.part.sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPendingToolInputs(events: ChatEvent<DATA, TOOLS>[]) {
|
||||
const resolvedToolCallIds = new Set<string>()
|
||||
|
||||
for (const event of events) {
|
||||
if (
|
||||
event.kind === "tool-output" ||
|
||||
event.kind === "tool-error" ||
|
||||
event.kind === "tool-denied"
|
||||
) {
|
||||
resolvedToolCallIds.add(event.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
return events.flatMap((event) =>
|
||||
event.kind === "tool-input" && !resolvedToolCallIds.has(event.toolCallId)
|
||||
? [
|
||||
{
|
||||
input: event,
|
||||
approval: events.find(
|
||||
(
|
||||
candidate
|
||||
): candidate is Extract<
|
||||
ChatEvent<DATA, TOOLS>,
|
||||
{ kind: "tool-approval-request" }
|
||||
> =>
|
||||
candidate.kind === "tool-approval-request" &&
|
||||
candidate.toolCallId === event.toolCallId
|
||||
),
|
||||
},
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMessageId(id?: string) {
|
||||
if (id !== undefined) {
|
||||
ids.reserveMessageId(id)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
return ids.nextMessageId()
|
||||
}
|
||||
|
||||
for (const seededMessage of options.messages ?? []) {
|
||||
const message = cloneValue(seededMessage)
|
||||
const events = format.eventsFromParts(format.getMessageParts(message))
|
||||
|
||||
ids.reserveMessageId(format.getMessageId(message))
|
||||
reserveEventIds(events)
|
||||
|
||||
turns.push({
|
||||
role: format.getMessageRole(message),
|
||||
message,
|
||||
messageId: format.getMessageId(message),
|
||||
events,
|
||||
metadata: format.getMessageMetadata?.(message),
|
||||
})
|
||||
}
|
||||
|
||||
function takePendingEvents() {
|
||||
return pendingEvents.splice(0, pendingEvents.length)
|
||||
}
|
||||
|
||||
function pushTurn(turn: InternalTurn) {
|
||||
turns.push(turn)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
function findLatestChatIndex(messages: readonly MESSAGE[]) {
|
||||
const messageIds = new Set(
|
||||
messages.map((message) => format.getMessageId(message))
|
||||
)
|
||||
let latestChatIndex = -1
|
||||
|
||||
for (let index = 0; index < turns.length; index++) {
|
||||
if (messageIds.has(turns[index].messageId)) {
|
||||
latestChatIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
if (latestChatIndex === -1) {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
latestChatIndex = turns.findIndex(
|
||||
(turn) =>
|
||||
lastMessage !== undefined &&
|
||||
turn.message !== undefined &&
|
||||
turn.role === format.getMessageRole(lastMessage) &&
|
||||
format.getMessageText(turn.message) ===
|
||||
format.getMessageText(lastMessage)
|
||||
)
|
||||
}
|
||||
|
||||
return latestChatIndex
|
||||
}
|
||||
|
||||
function findAssistantTurnIndexAfter(startIndex: number) {
|
||||
for (let index = startIndex; index < turns.length; index++) {
|
||||
if (turns[index].role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function findNextAssistantTurnIndex(messages: MESSAGE[], messageId?: string) {
|
||||
if (messageId) {
|
||||
const index = turns.findIndex((turn) => turn.messageId === messageId)
|
||||
|
||||
if (turns[index]?.role === "assistant") {
|
||||
return index
|
||||
}
|
||||
|
||||
// Unknown ids fall through to transcript matching so regenerating a
|
||||
// fallback-produced message falls back again instead of replaying the
|
||||
// first configured response.
|
||||
if (index !== -1) {
|
||||
return findAssistantTurnIndexAfter(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return findAssistantTurnIndexAfter(findLatestChatIndex(messages) + 1)
|
||||
}
|
||||
|
||||
function findNextUserTurn(messages: readonly MESSAGE[]) {
|
||||
const latestChatIndex = findLatestChatIndex(messages)
|
||||
|
||||
return turns.slice(latestChatIndex + 1).find((turn) => turn.role === "user")
|
||||
}
|
||||
|
||||
function materializeAssistantInput(
|
||||
input:
|
||||
| string
|
||||
| PART[]
|
||||
| ((context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void),
|
||||
events: ChatEvent<DATA, TOOLS>[]
|
||||
): PART[] {
|
||||
if (typeof input === "string") {
|
||||
const writer = createEventWriter(events, { ids, payloads })
|
||||
|
||||
writer.text(input)
|
||||
|
||||
return format.materializeParts(events)
|
||||
}
|
||||
|
||||
if (typeof input === "function") {
|
||||
// `WRITER` is an adapter-narrowed subset of the full writer created
|
||||
// here. This is the single cast point that passes the full writer
|
||||
// through the adapter's narrower callback type.
|
||||
const writer = createEventWriter(events, {
|
||||
ids,
|
||||
payloads,
|
||||
}) as unknown as WRITER
|
||||
|
||||
input({ writer })
|
||||
reserveEventIds(events)
|
||||
|
||||
return format.materializeParts(events)
|
||||
}
|
||||
|
||||
const parts = cloneValue(input)
|
||||
const partEvents = format.eventsFromParts(parts)
|
||||
|
||||
reserveEventIds(partEvents)
|
||||
events.push(...partEvents)
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function createFallbackTurn(
|
||||
fallback: ChatFallback<MESSAGE, PART, DATA, TOOLS, WRITER>,
|
||||
messages: MESSAGE[]
|
||||
): ResolvedTurn {
|
||||
const events: ChatEvent<DATA, TOOLS>[] = []
|
||||
const input =
|
||||
typeof fallback === "function"
|
||||
? (context: { writer: WRITER }) => fallback({ ...context, messages })
|
||||
: fallback
|
||||
const parts = materializeAssistantInput(input, events)
|
||||
const turnMetadata = metadata()
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
message: format.createMessage({
|
||||
id: resolveMessageId(),
|
||||
role: "assistant",
|
||||
metadata: turnMetadata,
|
||||
parts,
|
||||
}),
|
||||
events,
|
||||
metadata: turnMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
function findPreviousAssistantIndex(turnIndex: number) {
|
||||
for (let index = turnIndex - 1; index >= 0; index--) {
|
||||
if (turns[index].role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function isPreviousTurnPausedAssistant() {
|
||||
const previousTurn = turns[turns.length - 1]
|
||||
|
||||
if (previousTurn?.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
// A continuation may itself pause, so turns after one stay continuations.
|
||||
return (
|
||||
previousTurn.resolve !== undefined ||
|
||||
getPendingToolInputs(previousTurn.events).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePendingToolCalls(turnIndex: number, messages: MESSAGE[]) {
|
||||
const previousIndex = findPreviousAssistantIndex(turnIndex)
|
||||
const previousTurn = turns[previousIndex]
|
||||
|
||||
if (!previousTurn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const previousEvents = previousTurn.resolve
|
||||
? (previousTurn.lastEvents ??
|
||||
materializeDeferredTurn(previousIndex, messages).events)
|
||||
: previousTurn.events
|
||||
const pendingInputs = getPendingToolInputs(previousEvents)
|
||||
|
||||
if (!pendingInputs.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const previousMessage = messages.find(
|
||||
(message) => format.getMessageId(message) === previousTurn.messageId
|
||||
)
|
||||
const summaries =
|
||||
previousMessage && format.getToolCalls
|
||||
? format.getToolCalls(previousMessage)
|
||||
: []
|
||||
|
||||
return pendingInputs.map((pendingInput) => ({
|
||||
...pendingInput,
|
||||
summary: summaries.find(
|
||||
(summary) => summary.toolCallId === pendingInput.input.toolCallId
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function toPendingToolCall(
|
||||
call: ReturnType<typeof resolvePendingToolCalls>[number]
|
||||
) {
|
||||
const approved = call.approval
|
||||
? call.summary?.approval?.approved
|
||||
: undefined
|
||||
|
||||
return {
|
||||
name: call.input.name,
|
||||
toolCallId: call.input.toolCallId,
|
||||
input: call.summary?.input ?? call.input.input,
|
||||
output: call.approval
|
||||
? approved === true
|
||||
? call.approval.output
|
||||
: undefined
|
||||
: call.summary?.state === "output-available"
|
||||
? call.summary.output
|
||||
: undefined,
|
||||
...(call.approval
|
||||
? {
|
||||
approved: approved === true,
|
||||
denied: approved === false,
|
||||
}
|
||||
: {}),
|
||||
} as PendingToolCall<TOOLS>
|
||||
}
|
||||
|
||||
function materializeDeferredTurn(
|
||||
turnIndex: number,
|
||||
messages: MESSAGE[]
|
||||
): ResolvedTurn {
|
||||
const turn = turns[turnIndex]
|
||||
const resolve = turn.resolve
|
||||
|
||||
if (!resolve) {
|
||||
throw new Error("Only continuation turns materialize from a transcript.")
|
||||
}
|
||||
|
||||
const events = [...turn.events]
|
||||
|
||||
// The client merges a continuation into the prior assistant message as a
|
||||
// new step. The step boundary keeps the resolved tool call out of the
|
||||
// latest step so automatic sending does not retrigger on it.
|
||||
events.push({ kind: "step-start" })
|
||||
|
||||
const pending = resolvePendingToolCalls(turnIndex, messages)
|
||||
|
||||
if (!pending.length) {
|
||||
devWarn(
|
||||
"A continuation turn resolved without a pending tool call. Its toolCall context is empty."
|
||||
)
|
||||
}
|
||||
|
||||
for (const call of pending) {
|
||||
if (!call.approval || call.summary?.state !== "approval-responded") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (call.summary.approval?.approved) {
|
||||
if (call.approval.errorText !== undefined) {
|
||||
events.push({
|
||||
kind: "tool-error",
|
||||
toolCallId: call.input.toolCallId,
|
||||
errorText: call.approval.errorText,
|
||||
providerExecuted: call.input.providerExecuted,
|
||||
toolMetadata: call.input.toolMetadata,
|
||||
dynamic: call.input.dynamic,
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
kind: "tool-output",
|
||||
toolCallId: call.input.toolCallId,
|
||||
output: call.approval.output,
|
||||
providerExecuted: call.input.providerExecuted,
|
||||
toolMetadata: call.input.toolMetadata,
|
||||
dynamic: call.input.dynamic,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
events.push({
|
||||
kind: "tool-denied",
|
||||
toolCallId: call.input.toolCallId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toolCalls = pending.map(toPendingToolCall)
|
||||
// Same cast point as materializeAssistantInput: the full writer passes
|
||||
// through the adapter's narrower callback type.
|
||||
const writer = createEventWriter(events, {
|
||||
ids,
|
||||
payloads,
|
||||
}) as unknown as WRITER
|
||||
|
||||
resolve({
|
||||
writer,
|
||||
messages,
|
||||
toolCall: toolCalls[toolCalls.length - 1],
|
||||
toolCalls,
|
||||
})
|
||||
reserveEventIds(events)
|
||||
turn.lastEvents = events
|
||||
|
||||
const parts = format.materializeParts(events)
|
||||
const turnMetadata =
|
||||
turn.metadata === undefined ? metadata() : cloneValue(turn.metadata)
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
message: format.createMessage({
|
||||
id: turn.messageId,
|
||||
role: "assistant",
|
||||
metadata: turnMetadata,
|
||||
parts,
|
||||
}),
|
||||
events,
|
||||
metadata: turnMetadata,
|
||||
continuation: true,
|
||||
}
|
||||
}
|
||||
|
||||
const api: Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER> = {
|
||||
user(text = DEFAULT_TEXT, userOptions: ChatUserOptions<METADATA> = {}) {
|
||||
const events = takePendingEvents()
|
||||
const turnEvents: ChatEvent<DATA, TOOLS>[] = []
|
||||
|
||||
turnEvents.push({
|
||||
kind: "text",
|
||||
id: `text-${turnEvents.length + 1}`,
|
||||
text,
|
||||
options: {
|
||||
mode: "instant",
|
||||
},
|
||||
})
|
||||
|
||||
for (const file of userOptions.files ?? []) {
|
||||
const { type: _type, ...part } = file
|
||||
|
||||
turnEvents.push({
|
||||
kind: "file",
|
||||
part: cloneValue(part),
|
||||
})
|
||||
}
|
||||
|
||||
const parts = format.materializeParts(turnEvents)
|
||||
|
||||
events.push(...turnEvents)
|
||||
|
||||
const turnMetadata =
|
||||
userOptions.metadata === undefined
|
||||
? metadata()
|
||||
: cloneValue(userOptions.metadata)
|
||||
const messageId = resolveMessageId(userOptions.id)
|
||||
const userMessage = format.createMessage({
|
||||
id: messageId,
|
||||
role: "user",
|
||||
metadata: turnMetadata,
|
||||
parts,
|
||||
})
|
||||
|
||||
return pushTurn({
|
||||
role: "user",
|
||||
message: userMessage,
|
||||
messageId,
|
||||
events,
|
||||
metadata: turnMetadata,
|
||||
})
|
||||
},
|
||||
|
||||
assistant(
|
||||
input:
|
||||
| string
|
||||
| PART[]
|
||||
| ((
|
||||
context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>
|
||||
) => void) = DEFAULT_TEXT,
|
||||
assistantOptions: ChatAssistantOptions<METADATA> = {}
|
||||
) {
|
||||
const events = takePendingEvents()
|
||||
const continuation = isPreviousTurnPausedAssistant()
|
||||
|
||||
if (typeof input === "function" && continuation) {
|
||||
return pushTurn({
|
||||
role: "assistant",
|
||||
messageId: resolveMessageId(assistantOptions.id),
|
||||
events,
|
||||
metadata:
|
||||
assistantOptions.metadata === undefined
|
||||
? undefined
|
||||
: cloneValue(assistantOptions.metadata),
|
||||
resolve: input,
|
||||
continuation,
|
||||
})
|
||||
}
|
||||
|
||||
if (continuation) {
|
||||
events.push({ kind: "step-start" })
|
||||
}
|
||||
|
||||
const messageParts = materializeAssistantInput(input, events)
|
||||
const turnMetadata =
|
||||
assistantOptions.metadata === undefined
|
||||
? metadata()
|
||||
: cloneValue(assistantOptions.metadata)
|
||||
const messageId = resolveMessageId(assistantOptions.id)
|
||||
const assistantMessage = format.createMessage({
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
metadata: turnMetadata,
|
||||
parts: messageParts,
|
||||
})
|
||||
|
||||
return pushTurn({
|
||||
role: "assistant",
|
||||
message: assistantMessage,
|
||||
messageId,
|
||||
events,
|
||||
metadata: turnMetadata,
|
||||
continuation: continuation || undefined,
|
||||
})
|
||||
},
|
||||
|
||||
error(errorText = "An error occurred.") {
|
||||
const events = takePendingEvents()
|
||||
|
||||
events.push({
|
||||
kind: "error",
|
||||
errorText,
|
||||
})
|
||||
|
||||
const turnMetadata = metadata()
|
||||
const messageId = resolveMessageId()
|
||||
|
||||
return pushTurn({
|
||||
role: "assistant",
|
||||
message: format.createMessage({
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
metadata: turnMetadata,
|
||||
parts: [],
|
||||
}),
|
||||
messageId,
|
||||
events,
|
||||
metadata: turnMetadata,
|
||||
})
|
||||
},
|
||||
|
||||
sleep(delayMs: number) {
|
||||
pendingEvents.push({
|
||||
kind: "sleep",
|
||||
delayMs,
|
||||
phase: "before-start",
|
||||
})
|
||||
|
||||
return api
|
||||
},
|
||||
|
||||
get(count) {
|
||||
const deferredIndex = turns.findIndex(
|
||||
(turn) => turn.resolve !== undefined
|
||||
)
|
||||
const limit = deferredIndex === -1 ? turns.length : deferredIndex
|
||||
|
||||
if (count === undefined) {
|
||||
count = limit
|
||||
}
|
||||
|
||||
if (!Number.isInteger(count) || count < 0) {
|
||||
throw new RangeError("count must be a non-negative integer.")
|
||||
}
|
||||
|
||||
if (deferredIndex !== -1 && count > deferredIndex) {
|
||||
throw new Error(
|
||||
"get() cannot materialize a continuation turn without a live transcript."
|
||||
)
|
||||
}
|
||||
|
||||
return turns
|
||||
.slice(0, count)
|
||||
.map((turn) => cloneValue(turn.message as MESSAGE))
|
||||
},
|
||||
|
||||
next(messages) {
|
||||
const turn = findNextUserTurn(messages)
|
||||
|
||||
return turn?.message ? cloneValue(turn.message) : null
|
||||
},
|
||||
|
||||
transport(
|
||||
transportOptions: ChatTransportOptions<
|
||||
MESSAGE,
|
||||
PART,
|
||||
DATA,
|
||||
TOOLS,
|
||||
WRITER
|
||||
> = {}
|
||||
) {
|
||||
const lastAssistantIndex = findPreviousAssistantIndex(turns.length)
|
||||
const lastAssistant = turns[lastAssistantIndex]
|
||||
|
||||
if (
|
||||
lastAssistant &&
|
||||
!lastAssistant.resolve &&
|
||||
getPendingToolInputs(lastAssistant.events).some(
|
||||
(pendingInput) => pendingInput.approval
|
||||
)
|
||||
) {
|
||||
devWarn(
|
||||
"The last scripted assistant turn requests approval but no continuation turn follows. The decision will have no response."
|
||||
)
|
||||
}
|
||||
|
||||
return format.createTransport(
|
||||
{
|
||||
resolveTurn(messages, messageId) {
|
||||
const turnIndex = findNextAssistantTurnIndex(messages, messageId)
|
||||
const turn = turnIndex === -1 ? undefined : turns[turnIndex]
|
||||
|
||||
if (turn) {
|
||||
return turn.resolve
|
||||
? materializeDeferredTurn(turnIndex, messages)
|
||||
: (turn as ChatTurn<MESSAGE, DATA, TOOLS>)
|
||||
}
|
||||
|
||||
if (transportOptions.fallback === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return createFallbackTurn(transportOptions.fallback, messages)
|
||||
},
|
||||
|
||||
streamTurn(turn, encodeChunk, streamOptions = {}, abortSignal) {
|
||||
const steps = lowerEvents<METADATA, DATA, TOOLS>(turn.events, {
|
||||
delayMs: DEFAULT_STREAM_DELAY_MS,
|
||||
...streamOptions,
|
||||
// A continuation stream carries no message id: the client is
|
||||
// already continuing the paused assistant message, and a new
|
||||
// id would fork it into a duplicate instead of updating it.
|
||||
messageId: (turn as ResolvedTurn).continuation
|
||||
? undefined
|
||||
: format.getMessageId(turn.message),
|
||||
messageMetadata: (turn as ResolvedTurn).metadata,
|
||||
})
|
||||
|
||||
return createTurnStream(steps, encodeChunk, abortSignal)
|
||||
},
|
||||
},
|
||||
transportOptions
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
41
packages/helpers/src/core/ids.test.ts
Normal file
41
packages/helpers/src/core/ids.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createChatIds } from "./ids"
|
||||
|
||||
describe("createChatIds", () => {
|
||||
it("generates sequential ids with default prefixes", () => {
|
||||
const ids = createChatIds()
|
||||
|
||||
expect(ids.nextMessageId()).toBe("msg-1")
|
||||
expect(ids.nextMessageId()).toBe("msg-2")
|
||||
expect(ids.nextToolCallId()).toBe("call-1")
|
||||
expect(ids.nextSourceId()).toBe("source-1")
|
||||
expect(ids.nextApprovalId()).toBe("approval-1")
|
||||
})
|
||||
|
||||
it("supports custom prefixes", () => {
|
||||
const ids = createChatIds({
|
||||
messageIdPrefix: "m",
|
||||
toolCallIdPrefix: "t",
|
||||
sourceIdPrefix: "s",
|
||||
})
|
||||
|
||||
expect(ids.nextMessageId()).toBe("m-1")
|
||||
expect(ids.nextToolCallId()).toBe("t-1")
|
||||
expect(ids.nextSourceId()).toBe("s-1")
|
||||
})
|
||||
|
||||
it("continues after explicitly reserved ids", () => {
|
||||
const ids = createChatIds()
|
||||
|
||||
ids.reserveMessageId("msg-2")
|
||||
ids.reserveToolCallId("call-4")
|
||||
ids.reserveSourceId("source-3")
|
||||
ids.reserveApprovalId("approval-2")
|
||||
|
||||
expect(ids.nextMessageId()).toBe("msg-3")
|
||||
expect(ids.nextToolCallId()).toBe("call-5")
|
||||
expect(ids.nextSourceId()).toBe("source-4")
|
||||
expect(ids.nextApprovalId()).toBe("approval-3")
|
||||
})
|
||||
})
|
||||
94
packages/helpers/src/core/ids.ts
Normal file
94
packages/helpers/src/core/ids.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ChatIds, ChatIdsOptions } from "./types"
|
||||
|
||||
/**
|
||||
* Creates deterministic sequential id generators (`msg-1`, `call-1`,
|
||||
* `source-1`, …). A chat and its transport share one instance so ids never
|
||||
* collide across scripted turns.
|
||||
*/
|
||||
export function createChatIds(options: ChatIdsOptions = {}): ChatIds {
|
||||
const messageIdPrefix = options.messageIdPrefix ?? "msg"
|
||||
const toolCallIdPrefix = options.toolCallIdPrefix ?? "call"
|
||||
const sourceIdPrefix = options.sourceIdPrefix ?? "source"
|
||||
const approvalIdPrefix = options.approvalIdPrefix ?? "approval"
|
||||
|
||||
function createSequence(prefix: string) {
|
||||
const reserved = new Set<string>()
|
||||
let index = 0
|
||||
|
||||
function reserve(id: string) {
|
||||
reserved.add(id)
|
||||
|
||||
const prefixWithSeparator = `${prefix}-`
|
||||
|
||||
if (!id.startsWith(prefixWithSeparator)) {
|
||||
return
|
||||
}
|
||||
|
||||
const suffix = id.slice(prefixWithSeparator.length)
|
||||
|
||||
if (!/^\d+$/.test(suffix)) {
|
||||
return
|
||||
}
|
||||
|
||||
const observedIndex = Number(suffix)
|
||||
|
||||
if (Number.isSafeInteger(observedIndex)) {
|
||||
index = Math.max(index, observedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
let id: string
|
||||
|
||||
do {
|
||||
index += 1
|
||||
id = `${prefix}-${index}`
|
||||
} while (reserved.has(id))
|
||||
|
||||
reserve(id)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
return { next, reserve }
|
||||
}
|
||||
|
||||
const messageIds = createSequence(messageIdPrefix)
|
||||
const toolCallIds = createSequence(toolCallIdPrefix)
|
||||
const sourceIds = createSequence(sourceIdPrefix)
|
||||
const approvalIds = createSequence(approvalIdPrefix)
|
||||
|
||||
return {
|
||||
nextMessageId() {
|
||||
return messageIds.next()
|
||||
},
|
||||
|
||||
nextToolCallId() {
|
||||
return toolCallIds.next()
|
||||
},
|
||||
|
||||
nextSourceId() {
|
||||
return sourceIds.next()
|
||||
},
|
||||
|
||||
nextApprovalId() {
|
||||
return approvalIds.next()
|
||||
},
|
||||
|
||||
reserveMessageId(id) {
|
||||
messageIds.reserve(id)
|
||||
},
|
||||
|
||||
reserveToolCallId(id) {
|
||||
toolCallIds.reserve(id)
|
||||
},
|
||||
|
||||
reserveSourceId(id) {
|
||||
sourceIds.reserve(id)
|
||||
},
|
||||
|
||||
reserveApprovalId(id) {
|
||||
approvalIds.reserve(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
8
packages/helpers/src/core/index.ts
Normal file
8
packages/helpers/src/core/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./types"
|
||||
export * from "./ids"
|
||||
export * from "./utils"
|
||||
export * from "./payloads"
|
||||
export * from "./writer"
|
||||
export * from "./lower"
|
||||
export * from "./stream"
|
||||
export * from "./chat"
|
||||
218
packages/helpers/src/core/lower.test.ts
Normal file
218
packages/helpers/src/core/lower.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { lowerEvents } from "./lower"
|
||||
import { chunkSteps } from "./test-utils"
|
||||
import type { TestData, TestTools } from "./test-utils"
|
||||
import { splitTextDeltas } from "./utils"
|
||||
|
||||
describe("lowerEvents", () => {
|
||||
it("places before-start sleeps ahead of the start chunk", () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{ kind: "sleep", delayMs: 100, phase: "before-start" },
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"sleep",
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
"close",
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps later sleeps after the start chunk", () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{ kind: "step-start" },
|
||||
{ kind: "sleep", delayMs: 100, phase: "after-start" },
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"start",
|
||||
"start-step",
|
||||
"sleep",
|
||||
"finish",
|
||||
"close",
|
||||
])
|
||||
})
|
||||
|
||||
it("omits delta sleeps when delayMs is undefined", () => {
|
||||
const steps = lowerEvents(
|
||||
[{ kind: "text", id: "text-1", text: "Hello there world" }],
|
||||
{ delayMs: undefined }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-delta",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
"close",
|
||||
])
|
||||
})
|
||||
|
||||
it("splits streamed text into word deltas with sleeps", () => {
|
||||
const steps = lowerEvents(
|
||||
[{ kind: "text", id: "text-1", text: "Hello there world" }],
|
||||
{ delayMs: 25 }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"sleep",
|
||||
"text-delta",
|
||||
"sleep",
|
||||
"text-delta",
|
||||
"sleep",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
"close",
|
||||
])
|
||||
expect(
|
||||
steps.flatMap((step) =>
|
||||
step.kind === "chunk" && step.chunk.type === "text-delta"
|
||||
? [step.chunk.delta]
|
||||
: []
|
||||
)
|
||||
).toEqual(splitTextDeltas("Hello there world"))
|
||||
})
|
||||
|
||||
it("emits a single delta in instant mode and none of the word sleeps when delay is zero", () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{
|
||||
kind: "reasoning",
|
||||
id: "reasoning-1",
|
||||
text: "Thinking about it",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"start",
|
||||
"reasoning-start",
|
||||
"reasoning-delta",
|
||||
"reasoning-end",
|
||||
"finish",
|
||||
"close",
|
||||
])
|
||||
})
|
||||
|
||||
it("honors per-event delay overrides", () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant", delayMs: 10 },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"sleep",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"finish",
|
||||
"close",
|
||||
])
|
||||
})
|
||||
|
||||
it("closes after an error chunk without a finish", () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
{ kind: "error", errorText: "Boom." },
|
||||
{ kind: "step-start" },
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"error",
|
||||
"close",
|
||||
])
|
||||
})
|
||||
|
||||
it("carries message metadata on the finish chunk", () => {
|
||||
const steps = lowerEvents([], {
|
||||
delayMs: 0,
|
||||
messageMetadata: { model: "gpt-5" },
|
||||
})
|
||||
const finish = steps.find(
|
||||
(step) => step.kind === "chunk" && step.chunk.type === "finish"
|
||||
)
|
||||
|
||||
expect(finish).toMatchObject({
|
||||
chunk: {
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
messageMetadata: { model: "gpt-5" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("maps tool events to tool chunks", () => {
|
||||
const steps = lowerEvents<unknown, TestData, TestTools>(
|
||||
[
|
||||
{
|
||||
kind: "tool-input",
|
||||
name: "getWeather",
|
||||
toolCallId: "call-1",
|
||||
input: { city: "SF" },
|
||||
},
|
||||
{
|
||||
kind: "tool-output",
|
||||
toolCallId: "call-1",
|
||||
output: { temperature: 72 },
|
||||
},
|
||||
{ kind: "tool-error", toolCallId: "call-1", errorText: "Down." },
|
||||
{ kind: "tool-denied", toolCallId: "call-1" },
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
|
||||
expect(chunkSteps(steps)).toEqual([
|
||||
"start",
|
||||
"tool-input-available",
|
||||
"tool-output-available",
|
||||
"tool-output-error",
|
||||
"tool-output-denied",
|
||||
"finish",
|
||||
"close",
|
||||
])
|
||||
})
|
||||
})
|
||||
298
packages/helpers/src/core/lower.ts
Normal file
298
packages/helpers/src/core/lower.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type {
|
||||
ChatEvent,
|
||||
DataTypes,
|
||||
NeutralChunk,
|
||||
StreamStep,
|
||||
ToolSet,
|
||||
} from "./types"
|
||||
import { assertNever, splitTextDeltas } from "./utils"
|
||||
|
||||
/**
|
||||
* Options for {@link lowerEvents}. An `undefined` `delayMs` disables
|
||||
* delta delays entirely; `messageId` rides on the `start` chunk and
|
||||
* `messageMetadata` on the `finish` chunk.
|
||||
*/
|
||||
export type LowerEventsOptions<METADATA> = {
|
||||
delayMs?: number
|
||||
messageId?: string
|
||||
messageMetadata?: METADATA
|
||||
}
|
||||
|
||||
function lowerTextEvent<
|
||||
METADATA,
|
||||
DATA extends DataTypes,
|
||||
TOOLS extends ToolSet,
|
||||
>(
|
||||
steps: StreamStep<METADATA, DATA, TOOLS>[],
|
||||
event: Extract<ChatEvent<DATA, TOOLS>, { kind: "text" | "reasoning" }>,
|
||||
options: LowerEventsOptions<METADATA>
|
||||
) {
|
||||
const startType = event.kind === "text" ? "text-start" : "reasoning-start"
|
||||
const deltaType = event.kind === "text" ? "text-delta" : "reasoning-delta"
|
||||
const endType = event.kind === "text" ? "text-end" : "reasoning-end"
|
||||
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: startType,
|
||||
id: event.id,
|
||||
} as NeutralChunk<METADATA, DATA, TOOLS>,
|
||||
})
|
||||
|
||||
const deltas =
|
||||
event.options?.mode === "instant"
|
||||
? [event.text]
|
||||
: splitTextDeltas(event.text)
|
||||
const delayMs = event.options?.delayMs ?? options.delayMs
|
||||
|
||||
for (const delta of deltas) {
|
||||
if (delayMs) {
|
||||
steps.push({
|
||||
kind: "sleep",
|
||||
delayMs,
|
||||
})
|
||||
}
|
||||
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: deltaType,
|
||||
id: event.id,
|
||||
delta,
|
||||
} as NeutralChunk<METADATA, DATA, TOOLS>,
|
||||
})
|
||||
}
|
||||
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: endType,
|
||||
id: event.id,
|
||||
} as NeutralChunk<METADATA, DATA, TOOLS>,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowers a turn's event log into playback steps: leading `before-start`
|
||||
* sleeps precede the `start` chunk, text/reasoning split into
|
||||
* start/delta/end with per-delta sleeps, an `error` event ends the stream
|
||||
* without a `finish` chunk, and everything else maps to one chunk.
|
||||
*/
|
||||
export function lowerEvents<
|
||||
METADATA,
|
||||
DATA extends DataTypes,
|
||||
TOOLS extends ToolSet,
|
||||
>(
|
||||
events: ChatEvent<DATA, TOOLS>[],
|
||||
options: LowerEventsOptions<METADATA>
|
||||
): StreamStep<METADATA, DATA, TOOLS>[] {
|
||||
const steps: StreamStep<METADATA, DATA, TOOLS>[] = []
|
||||
let eventIndex = 0
|
||||
|
||||
while (true) {
|
||||
const event = events[eventIndex]
|
||||
|
||||
if (event?.kind !== "sleep" || event.phase !== "before-start") {
|
||||
break
|
||||
}
|
||||
|
||||
steps.push({
|
||||
kind: "sleep",
|
||||
delayMs: event.delayMs,
|
||||
})
|
||||
eventIndex += 1
|
||||
}
|
||||
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "start",
|
||||
messageId: options.messageId,
|
||||
},
|
||||
})
|
||||
|
||||
for (const event of events.slice(eventIndex)) {
|
||||
switch (event.kind) {
|
||||
case "sleep": {
|
||||
steps.push({
|
||||
kind: "sleep",
|
||||
delayMs: event.delayMs,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "text":
|
||||
case "reasoning": {
|
||||
lowerTextEvent(steps, event, options)
|
||||
break
|
||||
}
|
||||
case "data": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "data",
|
||||
name: event.name,
|
||||
id: event.id,
|
||||
data: event.data,
|
||||
transient: event.transient,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool-input": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "tool-input-available",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.name,
|
||||
input: event.input,
|
||||
providerExecuted: event.providerExecuted,
|
||||
toolMetadata: event.toolMetadata,
|
||||
dynamic: event.dynamic,
|
||||
title: event.title,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool-approval-request": {
|
||||
// The gated output stays script-side; only the request goes on the wire.
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "tool-approval-request",
|
||||
approvalId: event.approvalId,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool-output": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "tool-output-available",
|
||||
toolCallId: event.toolCallId,
|
||||
output: event.output,
|
||||
providerExecuted: event.providerExecuted,
|
||||
toolMetadata: event.toolMetadata,
|
||||
dynamic: event.dynamic,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool-error": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "tool-output-error",
|
||||
toolCallId: event.toolCallId,
|
||||
errorText: event.errorText,
|
||||
providerExecuted: event.providerExecuted,
|
||||
toolMetadata: event.toolMetadata,
|
||||
dynamic: event.dynamic,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool-denied": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "tool-output-denied",
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "error": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "error",
|
||||
errorText: event.errorText,
|
||||
},
|
||||
})
|
||||
steps.push({
|
||||
kind: "close",
|
||||
})
|
||||
|
||||
return steps
|
||||
}
|
||||
case "source-url": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "source-url",
|
||||
part: event.part,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "source-document": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "source-document",
|
||||
part: event.part,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "file": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "file",
|
||||
part: event.part,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "reasoning-file": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "reasoning-file",
|
||||
part: event.part,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "custom": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "custom",
|
||||
part: event.part,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "step-start": {
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "start-step",
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
return assertNever(event)
|
||||
}
|
||||
}
|
||||
|
||||
steps.push({
|
||||
kind: "chunk",
|
||||
chunk: {
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
messageMetadata: options.messageMetadata,
|
||||
},
|
||||
})
|
||||
steps.push({
|
||||
kind: "close",
|
||||
})
|
||||
|
||||
return steps
|
||||
}
|
||||
73
packages/helpers/src/core/payloads.test.ts
Normal file
73
packages/helpers/src/core/payloads.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createChatIds } from "./ids"
|
||||
import { createChatPayloads, createMetadataFactory } from "./payloads"
|
||||
|
||||
describe("createChatPayloads", () => {
|
||||
it("creates default file payloads with overrides", () => {
|
||||
const payloads = createChatPayloads(createChatIds())
|
||||
|
||||
expect(payloads.file()).toMatchObject({
|
||||
mediaType: "image/png",
|
||||
filename: "receipt.png",
|
||||
url: "https://example.com/receipt.png",
|
||||
})
|
||||
expect(payloads.file({ filename: "report.pdf" })).toMatchObject({
|
||||
filename: "report.pdf",
|
||||
mediaType: "image/png",
|
||||
})
|
||||
})
|
||||
|
||||
it("assigns sequential source ids", () => {
|
||||
const payloads = createChatPayloads(createChatIds())
|
||||
|
||||
expect(payloads.sourceUrl().sourceId).toBe("source-1")
|
||||
expect(payloads.sourceDocument().sourceId).toBe("source-2")
|
||||
expect(payloads.sourceUrl({ sourceId: "fixed" }).sourceId).toBe("fixed")
|
||||
})
|
||||
|
||||
it("reserves explicit source ids and detaches nested metadata", () => {
|
||||
const payloads = createChatPayloads(createChatIds())
|
||||
const providerMetadata = { nested: { source: "original" } }
|
||||
const source = payloads.sourceUrl({
|
||||
sourceId: "source-2",
|
||||
providerMetadata,
|
||||
})
|
||||
|
||||
providerMetadata.nested.source = "mutated"
|
||||
|
||||
expect(payloads.sourceUrl().sourceId).toBe("source-3")
|
||||
expect(source.providerMetadata).toEqual({
|
||||
nested: { source: "original" },
|
||||
})
|
||||
})
|
||||
|
||||
it("creates custom payloads with a default kind", () => {
|
||||
const payloads = createChatPayloads(createChatIds())
|
||||
|
||||
expect(payloads.custom()).toEqual({ kind: "test.output" })
|
||||
expect(payloads.custom("app.widget")).toEqual({ kind: "app.widget" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMetadataFactory", () => {
|
||||
it("stamps a deterministic createdAt", () => {
|
||||
const metadata = createMetadataFactory()
|
||||
|
||||
expect(metadata()).toEqual({
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
})
|
||||
|
||||
it("accepts a custom now and overrides", () => {
|
||||
const metadata = createMetadataFactory<{
|
||||
createdAt: string
|
||||
model: string
|
||||
}>("2025-06-15T12:00:00.000Z")
|
||||
|
||||
expect(metadata({ model: "gpt-5" })).toEqual({
|
||||
createdAt: "2025-06-15T12:00:00.000Z",
|
||||
model: "gpt-5",
|
||||
})
|
||||
})
|
||||
})
|
||||
106
packages/helpers/src/core/payloads.ts
Normal file
106
packages/helpers/src/core/payloads.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ChatIds,
|
||||
CustomPayload,
|
||||
FilePayload,
|
||||
JsonRecord,
|
||||
ReasoningFilePayload,
|
||||
SourceDocumentPayload,
|
||||
SourceUrlPayload,
|
||||
} from "./types"
|
||||
import { cloneValue } from "./utils"
|
||||
|
||||
/** Framework-neutral payload factories with sensible defaults for anything omitted. */
|
||||
export type ChatPayloads = {
|
||||
file(overrides?: Partial<FilePayload>): FilePayload
|
||||
reasoningFile(overrides?: Partial<ReasoningFilePayload>): ReasoningFilePayload
|
||||
sourceUrl(overrides?: Partial<SourceUrlPayload>): SourceUrlPayload
|
||||
sourceDocument(
|
||||
overrides?: Partial<SourceDocumentPayload>
|
||||
): SourceDocumentPayload
|
||||
custom(
|
||||
kind?: string,
|
||||
options?: { providerMetadata?: JsonRecord }
|
||||
): CustomPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the default payload factories. Source payloads draw their
|
||||
* `sourceId` from the shared {@link ChatIds} sequence unless overridden.
|
||||
*/
|
||||
export function createChatPayloads(ids: ChatIds): ChatPayloads {
|
||||
function resolveSourceId(sourceId?: string) {
|
||||
if (sourceId !== undefined) {
|
||||
ids.reserveSourceId(sourceId)
|
||||
|
||||
return sourceId
|
||||
}
|
||||
|
||||
return ids.nextSourceId()
|
||||
}
|
||||
|
||||
return {
|
||||
file(overrides = {}) {
|
||||
return {
|
||||
mediaType: "image/png",
|
||||
filename: "receipt.png",
|
||||
url: "https://example.com/receipt.png",
|
||||
...cloneValue(overrides),
|
||||
}
|
||||
},
|
||||
|
||||
reasoningFile(overrides = {}) {
|
||||
return {
|
||||
mediaType: "text/plain",
|
||||
url: "data:text/plain;base64,cmVhc29uaW5n",
|
||||
...cloneValue(overrides),
|
||||
}
|
||||
},
|
||||
|
||||
sourceUrl(overrides = {}) {
|
||||
const clonedOverrides = cloneValue(overrides)
|
||||
|
||||
return {
|
||||
sourceId: resolveSourceId(clonedOverrides.sourceId),
|
||||
url: clonedOverrides.url ?? "https://ai-sdk.dev/docs",
|
||||
title: clonedOverrides.title ?? "AI SDK Docs",
|
||||
providerMetadata: clonedOverrides.providerMetadata,
|
||||
}
|
||||
},
|
||||
|
||||
sourceDocument(overrides = {}) {
|
||||
const clonedOverrides = cloneValue(overrides)
|
||||
|
||||
return {
|
||||
sourceId: resolveSourceId(clonedOverrides.sourceId),
|
||||
mediaType: clonedOverrides.mediaType ?? "text/markdown",
|
||||
title: clonedOverrides.title ?? "Retrieved document",
|
||||
filename: clonedOverrides.filename,
|
||||
providerMetadata: clonedOverrides.providerMetadata,
|
||||
}
|
||||
},
|
||||
|
||||
custom(kind = "test.output", options = {}) {
|
||||
return {
|
||||
kind,
|
||||
...cloneValue(options),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the default message-metadata factory: a fixed-clock
|
||||
* `{ createdAt }` stamp (default `2026-01-01T00:00:00.000Z`) merged with any
|
||||
* overrides, so snapshots stay stable across runs.
|
||||
*/
|
||||
export function createMetadataFactory<METADATA>(now?: Date | string) {
|
||||
const resolvedNow =
|
||||
now instanceof Date ? now : new Date(now ?? "2026-01-01T00:00:00.000Z")
|
||||
|
||||
return function metadata(overrides: Partial<METADATA> = {}) {
|
||||
return {
|
||||
createdAt: resolvedNow.toISOString(),
|
||||
...cloneValue(overrides),
|
||||
} as METADATA
|
||||
}
|
||||
}
|
||||
182
packages/helpers/src/core/stream.test.ts
Normal file
182
packages/helpers/src/core/stream.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { lowerEvents } from "./lower"
|
||||
import { createTurnStream } from "./stream"
|
||||
import { readStream } from "./test-utils"
|
||||
|
||||
describe("createTurnStream", () => {
|
||||
it("plays chunks through the encoder in order", async () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
const chunks = await readStream(
|
||||
createTurnStream(steps, (chunk) => ({ encoded: chunk.type }))
|
||||
)
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ encoded: "start" },
|
||||
{ encoded: "text-start" },
|
||||
{ encoded: "text-delta" },
|
||||
{ encoded: "text-end" },
|
||||
{ encoded: "finish" },
|
||||
])
|
||||
})
|
||||
|
||||
it("skips chunks when the encoder returns null and expands arrays", async () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
const chunks = await readStream(
|
||||
createTurnStream(steps, (chunk) => {
|
||||
if (chunk.type === "finish") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (chunk.type === "text-delta") {
|
||||
return [chunk.type, chunk.type]
|
||||
}
|
||||
|
||||
return chunk.type
|
||||
})
|
||||
)
|
||||
|
||||
expect(chunks).toEqual([
|
||||
"start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
])
|
||||
})
|
||||
|
||||
it("waits for sleep steps", async () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{ kind: "sleep", delayMs: 60, phase: "before-start" },
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
const startedAt = Date.now()
|
||||
const chunks = await readStream(
|
||||
createTurnStream(steps, (chunk) => chunk.type)
|
||||
)
|
||||
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(40)
|
||||
expect(chunks[0]).toBe("start")
|
||||
})
|
||||
|
||||
it("closes silently when aborted during before-start sleeps", async () => {
|
||||
const steps = lowerEvents(
|
||||
[{ kind: "sleep", delayMs: 500, phase: "before-start" }],
|
||||
{
|
||||
delayMs: 0,
|
||||
}
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const startedAt = Date.now()
|
||||
const streamPromise = readStream(
|
||||
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
|
||||
)
|
||||
|
||||
setTimeout(() => controller.abort(), 10)
|
||||
|
||||
expect(await streamPromise).toEqual([])
|
||||
expect(Date.now() - startedAt).toBeLessThan(250)
|
||||
})
|
||||
|
||||
it("closes without chunks when the signal is already aborted", async () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
const controller = new AbortController()
|
||||
|
||||
controller.abort()
|
||||
|
||||
const chunks = await readStream(
|
||||
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
|
||||
)
|
||||
|
||||
expect(chunks).toEqual([])
|
||||
})
|
||||
|
||||
it("stops encoding after the consumer cancels the stream", async () => {
|
||||
const encoded: string[] = []
|
||||
const stream = createTurnStream(
|
||||
[
|
||||
{ kind: "chunk", chunk: { type: "start" } },
|
||||
{ kind: "sleep", delayMs: 30 },
|
||||
{ kind: "chunk", chunk: { type: "text-start", id: "text-1" } },
|
||||
{ kind: "chunk", chunk: { type: "text-end", id: "text-1" } },
|
||||
{ kind: "close" },
|
||||
],
|
||||
(chunk) => {
|
||||
encoded.push(chunk.type)
|
||||
|
||||
return chunk.type
|
||||
}
|
||||
)
|
||||
const reader = stream.getReader()
|
||||
|
||||
expect((await reader.read()).value).toBe("start")
|
||||
|
||||
await reader.cancel()
|
||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||
|
||||
expect(encoded).toEqual(["start"])
|
||||
})
|
||||
|
||||
it("emits an abort chunk when aborted mid-stream", async () => {
|
||||
const steps = lowerEvents(
|
||||
[
|
||||
{ kind: "sleep", delayMs: 500, phase: "after-start" },
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: "Hi",
|
||||
options: { mode: "instant" },
|
||||
},
|
||||
],
|
||||
{ delayMs: 0 }
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const startedAt = Date.now()
|
||||
const streamPromise = readStream(
|
||||
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
|
||||
)
|
||||
|
||||
setTimeout(() => controller.abort(), 10)
|
||||
|
||||
expect(await streamPromise).toEqual(["start", "abort"])
|
||||
expect(Date.now() - startedAt).toBeLessThan(250)
|
||||
})
|
||||
})
|
||||
116
packages/helpers/src/core/stream.ts
Normal file
116
packages/helpers/src/core/stream.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
ChunkEncoder,
|
||||
DataTypes,
|
||||
NeutralChunk,
|
||||
StreamStep,
|
||||
ToolSet,
|
||||
} from "./types"
|
||||
import { wait } from "./utils"
|
||||
|
||||
/**
|
||||
* Plays lowered steps through an encoder into a `ReadableStream`. Honors
|
||||
* sleep steps, skips chunks the encoder maps to `null`, fans out arrays, and
|
||||
* handles aborts: before the `start` chunk the stream closes silently, after
|
||||
* it an `abort` chunk is emitted first.
|
||||
*/
|
||||
export function createTurnStream<
|
||||
CHUNK,
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
>(
|
||||
steps: StreamStep<METADATA, DATA, TOOLS>[],
|
||||
encode: ChunkEncoder<CHUNK, METADATA, DATA, TOOLS>,
|
||||
abortSignal?: AbortSignal
|
||||
): ReadableStream<CHUNK> {
|
||||
let cancelled = false
|
||||
|
||||
return new ReadableStream<CHUNK>({
|
||||
async start(controller) {
|
||||
let aborted = abortSignal?.aborted ?? false
|
||||
const abort = () => {
|
||||
aborted = true
|
||||
}
|
||||
|
||||
abortSignal?.addEventListener("abort", abort)
|
||||
|
||||
function enqueue(chunk: NeutralChunk<METADATA, DATA, TOOLS>) {
|
||||
const encoded = encode(chunk)
|
||||
|
||||
if (encoded === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(encoded)) {
|
||||
for (const item of encoded) {
|
||||
controller.enqueue(item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
controller.enqueue(encoded)
|
||||
}
|
||||
|
||||
try {
|
||||
let started = false
|
||||
|
||||
function closeAfterAbort() {
|
||||
if (started) {
|
||||
enqueue({
|
||||
type: "abort",
|
||||
reason: "aborted",
|
||||
})
|
||||
}
|
||||
|
||||
controller.close()
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
// The consumer already tore the stream down — enqueueing or
|
||||
// closing a cancelled stream would throw.
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
closeAfterAbort()
|
||||
return
|
||||
}
|
||||
|
||||
if (step.kind === "sleep") {
|
||||
await wait(step.delayMs, abortSignal)
|
||||
|
||||
if (aborted) {
|
||||
closeAfterAbort()
|
||||
return
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (step.kind === "close") {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
enqueue(step.chunk)
|
||||
|
||||
if (step.chunk.type === "start") {
|
||||
started = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
controller.close()
|
||||
}
|
||||
} finally {
|
||||
abortSignal?.removeEventListener("abort", abort)
|
||||
}
|
||||
},
|
||||
|
||||
cancel() {
|
||||
cancelled = true
|
||||
},
|
||||
})
|
||||
}
|
||||
231
packages/helpers/src/core/test-utils.ts
Normal file
231
packages/helpers/src/core/test-utils.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { createChatRuntime } from "./chat"
|
||||
import type {
|
||||
ChatEvent,
|
||||
ChatFormat,
|
||||
DataTypes,
|
||||
MessageRole,
|
||||
NeutralChunk,
|
||||
StreamStep,
|
||||
ToolCallSummary,
|
||||
ToolSet,
|
||||
TransportContext,
|
||||
TurnStreamOptions,
|
||||
} from "./types"
|
||||
|
||||
export type TestData = {
|
||||
weather: {
|
||||
city: string
|
||||
status: "loading" | "success"
|
||||
}
|
||||
}
|
||||
|
||||
export type TestTools = {
|
||||
getWeather: {
|
||||
input: {
|
||||
city: string
|
||||
}
|
||||
output: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type TestPart = {
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type TestMessage = {
|
||||
id: string
|
||||
role: MessageRole
|
||||
metadata?: unknown
|
||||
parts: TestPart[]
|
||||
}
|
||||
|
||||
export type TestTransport<
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> = {
|
||||
context: TransportContext<
|
||||
TestMessage,
|
||||
NeutralChunk<unknown, DATA, TOOLS>,
|
||||
unknown,
|
||||
DATA,
|
||||
TOOLS
|
||||
>
|
||||
options?: TurnStreamOptions
|
||||
}
|
||||
|
||||
export function createTestFormat<
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
>(): ChatFormat<
|
||||
TestMessage,
|
||||
TestPart,
|
||||
NeutralChunk<unknown, DATA, TOOLS>,
|
||||
TestTransport<DATA, TOOLS>,
|
||||
unknown,
|
||||
DATA,
|
||||
TOOLS
|
||||
> {
|
||||
return {
|
||||
materializeParts(events) {
|
||||
const parts: TestPart[] = []
|
||||
|
||||
for (const event of events) {
|
||||
if (event.kind === "text") {
|
||||
parts.push({ type: "text", text: event.text })
|
||||
}
|
||||
|
||||
if (event.kind === "reasoning") {
|
||||
parts.push({ type: "reasoning", text: event.text })
|
||||
}
|
||||
|
||||
if (event.kind === "data" && !event.transient) {
|
||||
parts.push({
|
||||
type: `data-${event.name}`,
|
||||
id: event.id,
|
||||
data: event.data,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.kind === "tool-input") {
|
||||
parts.push({
|
||||
type: `tool-${event.name}`,
|
||||
toolCallId: event.toolCallId,
|
||||
state: "input-available",
|
||||
input: event.input,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.kind === "tool-approval-request") {
|
||||
const index = parts.findIndex(
|
||||
(part) => part.toolCallId === event.toolCallId
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
parts[index] = {
|
||||
...parts[index],
|
||||
state: "approval-requested",
|
||||
approval: { id: event.approvalId },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === "tool-output") {
|
||||
const index = parts.findIndex(
|
||||
(part) => part.toolCallId === event.toolCallId
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
parts[index] = {
|
||||
...parts[index],
|
||||
state: "output-available",
|
||||
output: event.output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === "file") {
|
||||
parts.push({ type: "file", ...event.part })
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
},
|
||||
|
||||
eventsFromParts(parts) {
|
||||
const events: ChatEvent<DATA, TOOLS>[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
events.push({
|
||||
kind: "text",
|
||||
id: `text-${events.length + 1}`,
|
||||
text: part.text as string,
|
||||
options: {
|
||||
mode: "instant",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
},
|
||||
|
||||
createMessage(input) {
|
||||
return { ...input }
|
||||
},
|
||||
|
||||
getMessageId(message) {
|
||||
return message.id
|
||||
},
|
||||
|
||||
getMessageRole(message) {
|
||||
return message.role
|
||||
},
|
||||
|
||||
getMessageText(message) {
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
},
|
||||
|
||||
getMessageParts(message) {
|
||||
return message.parts
|
||||
},
|
||||
|
||||
getToolCalls(message) {
|
||||
return message.parts
|
||||
.filter((part) => part.type.startsWith("tool-"))
|
||||
.map((part) => ({
|
||||
toolCallId: part.toolCallId as string,
|
||||
name: part.type.replace("tool-", ""),
|
||||
input: part.input,
|
||||
output: part.state === "output-available" ? part.output : undefined,
|
||||
state: part.state as ToolCallSummary["state"],
|
||||
approval: part.approval as ToolCallSummary["approval"],
|
||||
}))
|
||||
},
|
||||
|
||||
createTransport(context, options) {
|
||||
return { context, options }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createTestChat() {
|
||||
return createChatRuntime<
|
||||
TestMessage,
|
||||
TestPart,
|
||||
NeutralChunk<unknown, TestData, TestTools>,
|
||||
TestTransport<TestData, TestTools>,
|
||||
unknown,
|
||||
TestData,
|
||||
TestTools
|
||||
>(createTestFormat<TestData, TestTools>())
|
||||
}
|
||||
|
||||
export async function readStream<T>(stream: ReadableStream<T>) {
|
||||
const chunks = []
|
||||
const reader = stream.getReader()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
export function chunkSteps(steps: StreamStep[]) {
|
||||
return steps.map((step) =>
|
||||
step.kind === "chunk" ? step.chunk.type : step.kind
|
||||
)
|
||||
}
|
||||
404
packages/helpers/src/core/types.ts
Normal file
404
packages/helpers/src/core/types.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/** A JSON-like object used for framework provider metadata. */
|
||||
export type JsonRecord = Record<string, unknown>
|
||||
|
||||
/** Message roles supported by the framework-neutral chat model. */
|
||||
export type MessageRole = "system" | "user" | "assistant"
|
||||
|
||||
/** Map of data part names to their payload shapes, e.g. `{ weather: { temp: number } }`. */
|
||||
export type DataTypes = Record<string, unknown>
|
||||
|
||||
/** The input and output shapes associated with one scripted tool. */
|
||||
export type ToolDefinition = {
|
||||
input: unknown
|
||||
output: unknown
|
||||
}
|
||||
|
||||
/** Map of tool names to their `input`/`output` shapes. Structurally compatible with the AI SDK's `UITools`. */
|
||||
export type ToolSet = Record<string, ToolDefinition>
|
||||
|
||||
/** Options for scripting a typed or dynamic tool call. */
|
||||
export type ToolWriterOptions<
|
||||
TOOLS extends ToolSet,
|
||||
NAME extends keyof TOOLS,
|
||||
> = {
|
||||
toolCallId?: string
|
||||
title?: string
|
||||
toolMetadata?: JsonRecord
|
||||
providerExecuted?: boolean
|
||||
input?: TOOLS[NAME]["input"]
|
||||
output?: TOOLS[NAME]["output"]
|
||||
errorText?: string
|
||||
/** Script the call as a `dynamic-tool` part instead of a typed `tool-<name>` part. */
|
||||
dynamic?: boolean
|
||||
/**
|
||||
* Pause the turn behind a user approval. `output` (or `errorText`) then
|
||||
* means "stream this after approval" instead of "resolve immediately";
|
||||
* denial streams `tool-output-denied` automatically.
|
||||
*/
|
||||
needsApproval?: boolean
|
||||
approvalId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A human-in-the-loop tool call as a continuation turn sees it: the scripted
|
||||
* input, the user-submitted output (elicitation) or gated output (approval),
|
||||
* and the approval decision when one was requested.
|
||||
*/
|
||||
export type PendingToolCall<TOOLS extends ToolSet> = {
|
||||
[NAME in keyof TOOLS & string]: {
|
||||
name: NAME
|
||||
toolCallId: string
|
||||
input: TOOLS[NAME]["input"]
|
||||
output: TOOLS[NAME]["output"] | undefined
|
||||
approved?: boolean
|
||||
denied?: boolean
|
||||
}
|
||||
}[keyof TOOLS & string]
|
||||
|
||||
/**
|
||||
* A framework-neutral summary of one tool call read back from a transcript
|
||||
* message. Adapters produce these through `ChatFormat.getToolCalls`.
|
||||
*/
|
||||
export type ToolCallSummary = {
|
||||
toolCallId: string
|
||||
name: string
|
||||
dynamic?: boolean
|
||||
input: unknown
|
||||
output?: unknown
|
||||
errorText?: string
|
||||
state:
|
||||
| "input-streaming"
|
||||
| "input-available"
|
||||
| "approval-requested"
|
||||
| "approval-responded"
|
||||
| "output-available"
|
||||
| "output-error"
|
||||
| "output-denied"
|
||||
| "undefined-input"
|
||||
approval?: { id: string; approved?: boolean; reason?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-part streaming controls. `delayMs` overrides the transport delay for
|
||||
* this part; `mode: "instant"` emits the whole text as a single delta instead
|
||||
* of word-by-word.
|
||||
*/
|
||||
export type StreamTextOptions = {
|
||||
id?: string
|
||||
delayMs?: number
|
||||
mode?: "stream" | "instant"
|
||||
}
|
||||
|
||||
/** Framework-neutral file attachment payload. */
|
||||
export type FilePayload = {
|
||||
mediaType: string
|
||||
filename?: string
|
||||
url: string
|
||||
providerMetadata?: JsonRecord
|
||||
}
|
||||
|
||||
/** Framework-neutral file attached to a reasoning part. */
|
||||
export type ReasoningFilePayload = {
|
||||
mediaType: string
|
||||
filename?: string
|
||||
url: string
|
||||
providerMetadata?: JsonRecord
|
||||
}
|
||||
|
||||
/** Framework-neutral URL source citation. */
|
||||
export type SourceUrlPayload = {
|
||||
sourceId: string
|
||||
url: string
|
||||
title?: string
|
||||
providerMetadata?: JsonRecord
|
||||
}
|
||||
|
||||
/** Framework-neutral document source citation. */
|
||||
export type SourceDocumentPayload = {
|
||||
sourceId: string
|
||||
mediaType: string
|
||||
title: string
|
||||
filename?: string
|
||||
providerMetadata?: JsonRecord
|
||||
}
|
||||
|
||||
/** Framework-neutral custom part payload. */
|
||||
export type CustomPayload = {
|
||||
kind: string
|
||||
providerMetadata?: JsonRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed `data-<name>` part input. Repeating an `id` updates the previous
|
||||
* part in place (reconciliation); `transient` parts stream but never
|
||||
* materialize into message parts.
|
||||
*/
|
||||
export type DataEventInput<DATA extends DataTypes> = {
|
||||
[NAME in keyof DATA & string]: {
|
||||
type: `data-${NAME}`
|
||||
id?: string
|
||||
data: DATA[NAME]
|
||||
transient?: boolean
|
||||
}
|
||||
}[keyof DATA & string]
|
||||
|
||||
/**
|
||||
* One entry in a turn's event log — the framework-neutral source of truth a
|
||||
* turn is scripted as. Adapters materialize events into message parts and
|
||||
* the stream driver lowers them into chunks.
|
||||
*/
|
||||
export type ChatEvent<
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> =
|
||||
| { kind: "sleep"; delayMs: number; phase: "before-start" | "after-start" }
|
||||
| { kind: "text"; id: string; text: string; options?: StreamTextOptions }
|
||||
| { kind: "reasoning"; id: string; text: string; options?: StreamTextOptions }
|
||||
| {
|
||||
kind: "data"
|
||||
name: keyof DATA & string
|
||||
id?: string
|
||||
data: DATA[keyof DATA & string]
|
||||
transient?: boolean
|
||||
}
|
||||
| {
|
||||
kind: "tool-input"
|
||||
name: keyof TOOLS & string
|
||||
toolCallId: string
|
||||
title?: string
|
||||
toolMetadata?: JsonRecord
|
||||
providerExecuted?: boolean
|
||||
dynamic?: boolean
|
||||
input: unknown
|
||||
}
|
||||
| {
|
||||
kind: "tool-approval-request"
|
||||
toolCallId: string
|
||||
approvalId: string
|
||||
output?: unknown
|
||||
errorText?: string
|
||||
}
|
||||
| {
|
||||
kind: "tool-output"
|
||||
toolCallId: string
|
||||
output: unknown
|
||||
providerExecuted?: boolean
|
||||
toolMetadata?: JsonRecord
|
||||
dynamic?: boolean
|
||||
}
|
||||
| {
|
||||
kind: "tool-error"
|
||||
toolCallId: string
|
||||
errorText: string
|
||||
providerExecuted?: boolean
|
||||
toolMetadata?: JsonRecord
|
||||
dynamic?: boolean
|
||||
}
|
||||
| { kind: "tool-denied"; toolCallId: string }
|
||||
| { kind: "error"; errorText: string }
|
||||
| { kind: "source-url"; part: SourceUrlPayload }
|
||||
| { kind: "source-document"; part: SourceDocumentPayload }
|
||||
| { kind: "file"; part: FilePayload }
|
||||
| { kind: "reasoning-file"; part: ReasoningFilePayload }
|
||||
| { kind: "custom"; part: CustomPayload }
|
||||
| { kind: "step-start" }
|
||||
|
||||
/**
|
||||
* The framework-neutral stream chunk vocabulary. An adapter's
|
||||
* {@link ChunkEncoder} maps each chunk to its framework's wire format —
|
||||
* return `null` to skip a chunk the framework has no equivalent for.
|
||||
*/
|
||||
export type NeutralChunk<
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> =
|
||||
| { type: "start"; messageId?: string }
|
||||
| { type: "start-step" }
|
||||
| { type: "finish"; finishReason: "stop"; messageMetadata?: METADATA }
|
||||
| { type: "abort"; reason: string }
|
||||
| { type: "error"; errorText: string }
|
||||
| { type: "text-start"; id: string }
|
||||
| { type: "text-delta"; id: string; delta: string }
|
||||
| { type: "text-end"; id: string }
|
||||
| { type: "reasoning-start"; id: string }
|
||||
| { type: "reasoning-delta"; id: string; delta: string }
|
||||
| { type: "reasoning-end"; id: string }
|
||||
| {
|
||||
type: "data"
|
||||
name: keyof DATA & string
|
||||
id?: string
|
||||
data: DATA[keyof DATA & string]
|
||||
transient?: boolean
|
||||
}
|
||||
| {
|
||||
type: "tool-input-available"
|
||||
toolCallId: string
|
||||
toolName: keyof TOOLS & string
|
||||
input: unknown
|
||||
providerExecuted?: boolean
|
||||
toolMetadata?: JsonRecord
|
||||
dynamic?: boolean
|
||||
title?: string
|
||||
}
|
||||
| {
|
||||
type: "tool-approval-request"
|
||||
approvalId: string
|
||||
toolCallId: string
|
||||
}
|
||||
| {
|
||||
type: "tool-output-available"
|
||||
toolCallId: string
|
||||
output: unknown
|
||||
providerExecuted?: boolean
|
||||
toolMetadata?: JsonRecord
|
||||
dynamic?: boolean
|
||||
}
|
||||
| {
|
||||
type: "tool-output-error"
|
||||
toolCallId: string
|
||||
errorText: string
|
||||
providerExecuted?: boolean
|
||||
toolMetadata?: JsonRecord
|
||||
dynamic?: boolean
|
||||
}
|
||||
| { type: "tool-output-denied"; toolCallId: string }
|
||||
| { type: "file"; part: FilePayload }
|
||||
| { type: "reasoning-file"; part: ReasoningFilePayload }
|
||||
| { type: "source-url"; part: SourceUrlPayload }
|
||||
| { type: "source-document"; part: SourceDocumentPayload }
|
||||
| { type: "custom"; part: CustomPayload }
|
||||
|
||||
/** One playback instruction produced by `lowerEvents`: emit a chunk, sleep, or close the stream. */
|
||||
export type StreamStep<
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> =
|
||||
| { kind: "sleep"; delayMs: number }
|
||||
| { kind: "chunk"; chunk: NeutralChunk<METADATA, DATA, TOOLS> }
|
||||
| { kind: "close" }
|
||||
|
||||
/** A scripted turn: the materialized message plus the event log it streams from. */
|
||||
export type ChatTurn<
|
||||
MESSAGE,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> = {
|
||||
role: MessageRole
|
||||
message: MESSAGE
|
||||
events: ChatEvent<DATA, TOOLS>[]
|
||||
}
|
||||
|
||||
/** Deterministic sequential id generators (`msg-1`, `call-1`, `source-1`, …). */
|
||||
export type ChatIds = {
|
||||
nextMessageId(): string
|
||||
nextToolCallId(): string
|
||||
nextSourceId(): string
|
||||
nextApprovalId(): string
|
||||
reserveMessageId(id: string): void
|
||||
reserveToolCallId(id: string): void
|
||||
reserveSourceId(id: string): void
|
||||
reserveApprovalId(id: string): void
|
||||
}
|
||||
|
||||
/** Prefixes used by the deterministic chat id generators. */
|
||||
export type ChatIdsOptions = {
|
||||
messageIdPrefix?: string
|
||||
toolCallIdPrefix?: string
|
||||
sourceIdPrefix?: string
|
||||
approvalIdPrefix?: string
|
||||
}
|
||||
|
||||
/** Chat-wide options: id prefixes plus the fixed clock used for default metadata. */
|
||||
export type ChatOptions = ChatIdsOptions & {
|
||||
now?: Date | string
|
||||
}
|
||||
|
||||
/** Transport options. `delayMs` is the delay between text/reasoning deltas; defaults to 50ms. */
|
||||
export type TurnStreamOptions = {
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a {@link NeutralChunk} to a framework chunk. Return `null` to skip
|
||||
* the chunk or an array to fan one neutral chunk out into several.
|
||||
*/
|
||||
export type ChunkEncoder<
|
||||
CHUNK,
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> = (chunk: NeutralChunk<METADATA, DATA, TOOLS>) => CHUNK | CHUNK[] | null
|
||||
|
||||
/**
|
||||
* What the core hands an adapter's `createTransport`: `resolveTurn` matches a
|
||||
* transcript to the next scripted assistant turn, `streamTurn` plays that
|
||||
* turn as a chunk stream.
|
||||
*/
|
||||
export type TransportContext<
|
||||
MESSAGE,
|
||||
CHUNK,
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> = {
|
||||
resolveTurn(
|
||||
messages: MESSAGE[],
|
||||
messageId?: string
|
||||
): ChatTurn<MESSAGE, DATA, TOOLS> | undefined
|
||||
streamTurn(
|
||||
turn: ChatTurn<MESSAGE, DATA, TOOLS>,
|
||||
encodeChunk: ChunkEncoder<CHUNK, METADATA, DATA, TOOLS>,
|
||||
options?: TurnStreamOptions,
|
||||
abortSignal?: AbortSignal
|
||||
): ReadableStream<CHUNK>
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter plugin interface. Implement one of these per framework and pass
|
||||
* it to `createChatRuntime` — the core never imports a framework itself.
|
||||
*/
|
||||
export type ChatFormat<
|
||||
MESSAGE,
|
||||
PART,
|
||||
CHUNK,
|
||||
TRANSPORT,
|
||||
METADATA = unknown,
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
> = {
|
||||
materializeParts(events: ChatEvent<DATA, TOOLS>[]): PART[]
|
||||
eventsFromParts(parts: PART[]): ChatEvent<DATA, TOOLS>[]
|
||||
createMessage(input: {
|
||||
id: string
|
||||
role: MessageRole
|
||||
metadata: METADATA
|
||||
parts: PART[]
|
||||
}): MESSAGE
|
||||
getMessageId(message: MESSAGE): string
|
||||
getMessageRole(message: MESSAGE): MessageRole
|
||||
getMessageText(message: MESSAGE): string
|
||||
getMessageParts(message: MESSAGE): PART[]
|
||||
/** Optional: extract a message's metadata when hydrating chats from existing messages. */
|
||||
getMessageMetadata?(message: MESSAGE): METADATA | undefined
|
||||
/** Optional: read a message's tool calls so continuation turns can resolve human-in-the-loop state. */
|
||||
getToolCalls?(message: MESSAGE): ToolCallSummary[]
|
||||
createTransport(
|
||||
context: TransportContext<MESSAGE, CHUNK, METADATA, DATA, TOOLS>,
|
||||
options?: TurnStreamOptions
|
||||
): TRANSPORT
|
||||
}
|
||||
|
||||
/** Options for one user turn. */
|
||||
export type ChatUserOptions<METADATA> = {
|
||||
id?: string
|
||||
metadata?: METADATA
|
||||
files?: Array<FilePayload & { type?: "file" }>
|
||||
}
|
||||
|
||||
/** Options for one assistant turn. */
|
||||
export type ChatAssistantOptions<METADATA> = {
|
||||
id?: string
|
||||
metadata?: METADATA
|
||||
}
|
||||
55
packages/helpers/src/core/utils.ts
Normal file
55
packages/helpers/src/core/utils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Sample text used when `text()`/`user()` is called without content. */
|
||||
export const DEFAULT_TEXT = "Summarize the uploaded receipt."
|
||||
|
||||
/** Sample reasoning used when `reasoning()` is called without content. */
|
||||
export const DEFAULT_REASONING =
|
||||
"I need to inspect the available context before answering."
|
||||
|
||||
/** Default delay between streamed text/reasoning deltas. */
|
||||
export const DEFAULT_STREAM_DELAY_MS = 50
|
||||
|
||||
/** Compile-time exhaustiveness guard for discriminated unions we own. */
|
||||
export function assertNever(value: never): never {
|
||||
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
/** Resolves after `delayMs`, or immediately when `abortSignal` aborts. */
|
||||
export function wait(delayMs: number, abortSignal?: AbortSignal) {
|
||||
if (abortSignal?.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeoutId = setTimeout(finish, delayMs)
|
||||
|
||||
function finish() {
|
||||
clearTimeout(timeoutId)
|
||||
abortSignal?.removeEventListener("abort", finish)
|
||||
resolve()
|
||||
}
|
||||
|
||||
abortSignal?.addEventListener("abort", finish, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns a detached copy of a scripted value. */
|
||||
export function cloneValue<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
/** Splits text into word-plus-whitespace deltas for streaming. */
|
||||
export function splitTextDeltas(text: string) {
|
||||
return text.match(/\S+\s*/g) ?? [text]
|
||||
}
|
||||
|
||||
/** Extracts the data part name from a `data-<name>` type string. */
|
||||
export function getDataPartName(type: `data-${string}`) {
|
||||
return type.slice("data-".length)
|
||||
}
|
||||
|
||||
/** Logs a development-only warning. */
|
||||
export function devWarn(message: string) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.warn(`[helpers] ${message}`)
|
||||
}
|
||||
}
|
||||
214
packages/helpers/src/core/writer.test.ts
Normal file
214
packages/helpers/src/core/writer.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createChatIds } from "./ids"
|
||||
import { createChatPayloads } from "./payloads"
|
||||
import type { TestData, TestTools } from "./test-utils"
|
||||
import type { ChatEvent } from "./types"
|
||||
import { DEFAULT_REASONING, DEFAULT_TEXT } from "./utils"
|
||||
import { createEventWriter } from "./writer"
|
||||
|
||||
describe("createEventWriter", () => {
|
||||
function createWriterContext() {
|
||||
const ids = createChatIds()
|
||||
|
||||
return { ids, payloads: createChatPayloads(ids) }
|
||||
}
|
||||
|
||||
it("records text and reasoning events with generated part ids", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const writer = createEventWriter(events, createWriterContext())
|
||||
|
||||
writer.text().reasoning()
|
||||
|
||||
expect(events).toMatchObject([
|
||||
{
|
||||
kind: "text",
|
||||
id: "text-1",
|
||||
text: DEFAULT_TEXT,
|
||||
},
|
||||
{
|
||||
kind: "reasoning",
|
||||
id: "reasoning-2",
|
||||
text: DEFAULT_REASONING,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("respects explicit stream text ids", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const writer = createEventWriter(events, createWriterContext())
|
||||
|
||||
writer.text("Hello", { id: "greeting" })
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
kind: "text",
|
||||
id: "greeting",
|
||||
})
|
||||
})
|
||||
|
||||
it("records sleeps as after-start events", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
|
||||
createEventWriter(events, createWriterContext()).sleep(100)
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
kind: "sleep",
|
||||
delayMs: 100,
|
||||
phase: "after-start",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("extracts data part names", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
|
||||
createEventWriter(events, createWriterContext()).data({
|
||||
type: "data-weather",
|
||||
id: "weather-1",
|
||||
data: { city: "SF", status: "loading" },
|
||||
transient: true,
|
||||
})
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
kind: "data",
|
||||
name: "weather",
|
||||
id: "weather-1",
|
||||
transient: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("chains tool events through the handle", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const writer = createEventWriter(events, createWriterContext())
|
||||
|
||||
writer
|
||||
.tool("getWeather", { input: { city: "SF" } })
|
||||
.sleep(50)
|
||||
.output({ temperature: 72 })
|
||||
|
||||
expect(events.map((event) => event.kind)).toEqual([
|
||||
"tool-input",
|
||||
"sleep",
|
||||
"tool-output",
|
||||
])
|
||||
expect(events[0]).toMatchObject({
|
||||
kind: "tool-input",
|
||||
name: "getWeather",
|
||||
toolCallId: "call-1",
|
||||
input: { city: "SF" },
|
||||
})
|
||||
expect(events[2]).toMatchObject({
|
||||
kind: "tool-output",
|
||||
toolCallId: "call-1",
|
||||
output: { temperature: 72 },
|
||||
})
|
||||
})
|
||||
|
||||
it("reserves explicit tool ids and detaches tool values", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const context = createWriterContext()
|
||||
const input = { city: "SF" }
|
||||
const output = { temperature: 72 }
|
||||
const writer = createEventWriter(events, context)
|
||||
|
||||
writer.tool("getWeather", { toolCallId: "call-2", input }).output(output)
|
||||
writer.tool("getWeather", { input: { city: "NYC" } })
|
||||
|
||||
input.city = "mutated"
|
||||
output.temperature = 0
|
||||
|
||||
expect(events).toMatchObject([
|
||||
{ toolCallId: "call-2", input: { city: "SF" } },
|
||||
{ toolCallId: "call-2", output: { temperature: 72 } },
|
||||
{ toolCallId: "call-3" },
|
||||
])
|
||||
})
|
||||
|
||||
it("types tool inputs and outputs", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const writer = createEventWriter(events, createWriterContext())
|
||||
|
||||
if (false) {
|
||||
writer.tool("getWeather", {
|
||||
// @ts-expect-error getWeather input requires a string city.
|
||||
input: { city: 72 },
|
||||
})
|
||||
writer.tool("getWeather", {
|
||||
// @ts-expect-error getWeather output requires a numeric temperature.
|
||||
output: { temperature: "warm" },
|
||||
})
|
||||
}
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it("emits tool output and error from writer options", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const writer = createEventWriter(events, createWriterContext())
|
||||
|
||||
writer.tool("getWeather", {
|
||||
input: { city: "SF" },
|
||||
output: { temperature: 72 },
|
||||
})
|
||||
writer.tool("getWeather", {
|
||||
input: { city: "SF" },
|
||||
errorText: "Service down.",
|
||||
})
|
||||
|
||||
expect(events.map((event) => event.kind)).toEqual([
|
||||
"tool-input",
|
||||
"tool-output",
|
||||
"tool-input",
|
||||
"tool-error",
|
||||
])
|
||||
})
|
||||
|
||||
it("records denied tool calls and defaults tool input to an empty object", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const writer = createEventWriter(events, createWriterContext())
|
||||
|
||||
writer.tool("getWeather").denied()
|
||||
|
||||
expect(events).toMatchObject([
|
||||
{
|
||||
kind: "tool-input",
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
kind: "tool-denied",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("records payload-backed events", () => {
|
||||
const events: ChatEvent<TestData, TestTools>[] = []
|
||||
const writer = createEventWriter(events, createWriterContext())
|
||||
|
||||
writer
|
||||
.sourceUrl()
|
||||
.sourceDocument()
|
||||
.file({ filename: "notes.txt" })
|
||||
.reasoningFile()
|
||||
.custom("app.widget")
|
||||
.stepStart()
|
||||
.error()
|
||||
|
||||
expect(events.map((event) => event.kind)).toEqual([
|
||||
"source-url",
|
||||
"source-document",
|
||||
"file",
|
||||
"reasoning-file",
|
||||
"custom",
|
||||
"step-start",
|
||||
"error",
|
||||
])
|
||||
expect(events[0]).toMatchObject({
|
||||
part: { sourceId: "source-1" },
|
||||
})
|
||||
expect(events[2]).toMatchObject({
|
||||
part: { filename: "notes.txt" },
|
||||
})
|
||||
})
|
||||
})
|
||||
303
packages/helpers/src/core/writer.ts
Normal file
303
packages/helpers/src/core/writer.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import type { ChatPayloads } from "./payloads"
|
||||
import type {
|
||||
ChatEvent,
|
||||
ChatIds,
|
||||
DataEventInput,
|
||||
DataTypes,
|
||||
FilePayload,
|
||||
ReasoningFilePayload,
|
||||
SourceDocumentPayload,
|
||||
SourceUrlPayload,
|
||||
StreamTextOptions,
|
||||
ToolSet,
|
||||
ToolWriterOptions,
|
||||
} from "./types"
|
||||
import {
|
||||
cloneValue,
|
||||
DEFAULT_REASONING,
|
||||
DEFAULT_TEXT,
|
||||
getDataPartName,
|
||||
} from "./utils"
|
||||
|
||||
/**
|
||||
* Lifecycle handle returned by `writer.tool()`. Chain `sleep()` between the
|
||||
* input and its `output()`/`error()`/`denied()` resolution to script tool
|
||||
* latency.
|
||||
*/
|
||||
export type ToolHandle<OUTPUT> = {
|
||||
sleep(delayMs: number): ToolHandle<OUTPUT>
|
||||
output(output: OUTPUT): ToolHandle<OUTPUT>
|
||||
error(errorText?: string): ToolHandle<OUTPUT>
|
||||
denied(): ToolHandle<OUTPUT>
|
||||
}
|
||||
|
||||
/**
|
||||
* The fluent writer DSL handed to `assistant(({ writer }) => …)`. Each call
|
||||
* appends events to the turn's log in order; every method chains except
|
||||
* `tool()`, which returns a {@link ToolHandle}.
|
||||
*/
|
||||
export type EventWriter<DATA extends DataTypes, TOOLS extends ToolSet> = {
|
||||
text(text?: string, options?: StreamTextOptions): EventWriter<DATA, TOOLS>
|
||||
reasoning(
|
||||
text?: string,
|
||||
options?: StreamTextOptions
|
||||
): EventWriter<DATA, TOOLS>
|
||||
sleep(delayMs: number): EventWriter<DATA, TOOLS>
|
||||
data(part: DataEventInput<DATA>): EventWriter<DATA, TOOLS>
|
||||
error(errorText?: string): EventWriter<DATA, TOOLS>
|
||||
tool<NAME extends keyof TOOLS & string>(
|
||||
name: NAME,
|
||||
options?: ToolWriterOptions<TOOLS, NAME>
|
||||
): ToolHandle<TOOLS[NAME]["output"]>
|
||||
sourceUrl(overrides?: Partial<SourceUrlPayload>): EventWriter<DATA, TOOLS>
|
||||
sourceDocument(
|
||||
overrides?: Partial<SourceDocumentPayload>
|
||||
): EventWriter<DATA, TOOLS>
|
||||
file(overrides?: Partial<FilePayload>): EventWriter<DATA, TOOLS>
|
||||
reasoningFile(
|
||||
overrides?: Partial<ReasoningFilePayload>
|
||||
): EventWriter<DATA, TOOLS>
|
||||
custom(kind?: string): EventWriter<DATA, TOOLS>
|
||||
stepStart(): EventWriter<DATA, TOOLS>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an {@link EventWriter} that appends to `events`. Generated part ids
|
||||
* are `<kind>-<events.length + 1>` at push time, matching the reference
|
||||
* implementation this package was extracted from.
|
||||
*/
|
||||
export function createEventWriter<
|
||||
DATA extends DataTypes,
|
||||
TOOLS extends ToolSet,
|
||||
>(
|
||||
events: ChatEvent<DATA, TOOLS>[],
|
||||
context: { ids: ChatIds; payloads: ChatPayloads }
|
||||
): EventWriter<DATA, TOOLS> {
|
||||
function nextPartId(kind: string) {
|
||||
return `${kind}-${events.length + 1}`
|
||||
}
|
||||
|
||||
const writer: EventWriter<DATA, TOOLS> = {
|
||||
text(text = DEFAULT_TEXT, options: StreamTextOptions = {}) {
|
||||
events.push({
|
||||
kind: "text",
|
||||
id: options.id ?? nextPartId("text"),
|
||||
text,
|
||||
options,
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
reasoning(text = DEFAULT_REASONING, options: StreamTextOptions = {}) {
|
||||
events.push({
|
||||
kind: "reasoning",
|
||||
id: options.id ?? nextPartId("reasoning"),
|
||||
text,
|
||||
options,
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
sleep(delayMs: number) {
|
||||
events.push({
|
||||
kind: "sleep",
|
||||
delayMs,
|
||||
phase: "after-start",
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
data(part) {
|
||||
const name = getDataPartName(part.type) as keyof DATA & string
|
||||
const clonedPart = cloneValue(part)
|
||||
|
||||
events.push({
|
||||
kind: "data",
|
||||
name,
|
||||
id: clonedPart.id,
|
||||
data: clonedPart.data,
|
||||
transient: clonedPart.transient,
|
||||
} as ChatEvent<DATA, TOOLS>)
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
error(errorText = "An error occurred.") {
|
||||
events.push({
|
||||
kind: "error",
|
||||
errorText,
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
tool<NAME extends keyof TOOLS & string>(
|
||||
name: NAME,
|
||||
options: ToolWriterOptions<TOOLS, NAME> = {}
|
||||
) {
|
||||
const clonedOptions = cloneValue(options)
|
||||
const toolCallId =
|
||||
clonedOptions.toolCallId ?? context.ids.nextToolCallId()
|
||||
|
||||
if (clonedOptions.toolCallId !== undefined) {
|
||||
context.ids.reserveToolCallId(clonedOptions.toolCallId)
|
||||
}
|
||||
|
||||
events.push({
|
||||
kind: "tool-input",
|
||||
name,
|
||||
toolCallId,
|
||||
title: clonedOptions.title,
|
||||
toolMetadata: clonedOptions.toolMetadata,
|
||||
providerExecuted: clonedOptions.providerExecuted,
|
||||
dynamic: clonedOptions.dynamic,
|
||||
input: clonedOptions.input ?? {},
|
||||
})
|
||||
|
||||
function assertResolvable(method: string) {
|
||||
if (clonedOptions.needsApproval) {
|
||||
throw new Error(
|
||||
`A needsApproval tool call resolves from the user's decision; remove the ${method}() call.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handle: ToolHandle<TOOLS[NAME]["output"]> = {
|
||||
sleep(delayMs: number) {
|
||||
events.push({
|
||||
kind: "sleep",
|
||||
delayMs,
|
||||
phase: "after-start",
|
||||
})
|
||||
|
||||
return handle
|
||||
},
|
||||
|
||||
output(output: TOOLS[NAME]["output"]) {
|
||||
assertResolvable("output")
|
||||
events.push({
|
||||
kind: "tool-output",
|
||||
toolCallId,
|
||||
output: cloneValue(output),
|
||||
providerExecuted: clonedOptions.providerExecuted,
|
||||
toolMetadata: clonedOptions.toolMetadata,
|
||||
dynamic: clonedOptions.dynamic,
|
||||
})
|
||||
|
||||
return handle
|
||||
},
|
||||
|
||||
error(errorText = "Tool call failed.") {
|
||||
assertResolvable("error")
|
||||
events.push({
|
||||
kind: "tool-error",
|
||||
toolCallId,
|
||||
errorText,
|
||||
providerExecuted: clonedOptions.providerExecuted,
|
||||
toolMetadata: clonedOptions.toolMetadata,
|
||||
dynamic: clonedOptions.dynamic,
|
||||
})
|
||||
|
||||
return handle
|
||||
},
|
||||
|
||||
denied() {
|
||||
assertResolvable("denied")
|
||||
events.push({
|
||||
kind: "tool-denied",
|
||||
toolCallId,
|
||||
})
|
||||
|
||||
return handle
|
||||
},
|
||||
}
|
||||
|
||||
if (clonedOptions.needsApproval) {
|
||||
const approvalId =
|
||||
clonedOptions.approvalId ?? context.ids.nextApprovalId()
|
||||
|
||||
if (clonedOptions.approvalId !== undefined) {
|
||||
context.ids.reserveApprovalId(clonedOptions.approvalId)
|
||||
}
|
||||
|
||||
events.push({
|
||||
kind: "tool-approval-request",
|
||||
toolCallId,
|
||||
approvalId,
|
||||
output: clonedOptions.output,
|
||||
errorText: clonedOptions.errorText,
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
if (clonedOptions.output !== undefined) {
|
||||
handle.output(clonedOptions.output)
|
||||
}
|
||||
|
||||
if (clonedOptions.errorText !== undefined) {
|
||||
handle.error(clonedOptions.errorText)
|
||||
}
|
||||
|
||||
return handle
|
||||
},
|
||||
|
||||
sourceUrl(overrides: Partial<SourceUrlPayload> = {}) {
|
||||
events.push({
|
||||
kind: "source-url",
|
||||
part: context.payloads.sourceUrl(overrides),
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
sourceDocument(overrides: Partial<SourceDocumentPayload> = {}) {
|
||||
events.push({
|
||||
kind: "source-document",
|
||||
part: context.payloads.sourceDocument(overrides),
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
file(overrides: Partial<FilePayload> = {}) {
|
||||
events.push({
|
||||
kind: "file",
|
||||
part: context.payloads.file(overrides),
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
reasoningFile(overrides: Partial<ReasoningFilePayload> = {}) {
|
||||
events.push({
|
||||
kind: "reasoning-file",
|
||||
part: context.payloads.reasoningFile(overrides),
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
custom(kind?: string) {
|
||||
events.push({
|
||||
kind: "custom",
|
||||
part: context.payloads.custom(kind),
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
|
||||
stepStart() {
|
||||
events.push({
|
||||
kind: "step-start",
|
||||
})
|
||||
|
||||
return writer
|
||||
},
|
||||
}
|
||||
|
||||
return writer
|
||||
}
|
||||
103
packages/helpers/src/tanstack-ai/chat.test.ts
Normal file
103
packages/helpers/src/tanstack-ai/chat.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { UIMessage } from "@tanstack/ai-client"
|
||||
import type { ClientTool } from "@tanstack/ai/client"
|
||||
import { describe, expect, expectTypeOf, it } from "vitest"
|
||||
|
||||
import { testChatContract } from "../test/chat-contract"
|
||||
import { createChat } from "./index"
|
||||
import { weatherOutput } from "./test-utils"
|
||||
|
||||
type TestSchema<T> = {
|
||||
readonly "~standard": {
|
||||
readonly version: 1
|
||||
readonly vendor: "test"
|
||||
readonly types: {
|
||||
readonly input: T
|
||||
readonly output: T
|
||||
}
|
||||
readonly jsonSchema: {
|
||||
readonly input: () => Record<string, unknown>
|
||||
readonly output: () => Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WeatherTools = readonly [
|
||||
ClientTool<
|
||||
TestSchema<{ city: string }>,
|
||||
TestSchema<typeof weatherOutput>,
|
||||
"getWeather"
|
||||
>,
|
||||
]
|
||||
|
||||
testChatContract("TanStack AI", () =>
|
||||
createChat().user("One").assistant("A").user("Two").assistant("B")
|
||||
)
|
||||
|
||||
describe("TanStack chat", () => {
|
||||
it("returns native messages and exposes only supported writer operations", () => {
|
||||
const chat = createChat<WeatherTools, { summary: string }>().assistant(
|
||||
({ writer }) => {
|
||||
writer
|
||||
.tool("getWeather", { input: { city: "San Francisco" } })
|
||||
.output(weatherOutput)
|
||||
|
||||
if (false) {
|
||||
// @ts-expect-error TanStack's AG-UI transport has no data-part equivalent.
|
||||
writer.data({ type: "data-weather", data: {} })
|
||||
// @ts-expect-error TanStack's AG-UI transport has no source-part equivalent.
|
||||
writer.sourceUrl()
|
||||
// @ts-expect-error Only tools declared in the client tool tuple are accepted.
|
||||
writer.tool("missing")
|
||||
writer.tool("getWeather", {
|
||||
// @ts-expect-error The declared input requires a string city.
|
||||
input: { city: 72 },
|
||||
})
|
||||
// @ts-expect-error TanStack messages do not preserve text part IDs.
|
||||
writer.text("Hello", { id: "text-1" })
|
||||
writer.tool("getWeather", {
|
||||
// @ts-expect-error TanStack messages do not preserve neutral tool titles.
|
||||
title: "Weather",
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
const messages = chat.get()
|
||||
|
||||
expectTypeOf(messages).toEqualTypeOf<
|
||||
Array<UIMessage<WeatherTools, { summary: string }>>
|
||||
>()
|
||||
expect(messages[0].parts[0]).toMatchObject({
|
||||
type: "tool-call",
|
||||
name: "getWeather",
|
||||
})
|
||||
|
||||
if (false) {
|
||||
// @ts-expect-error TanStack AI does not expose source parts.
|
||||
createChat({ sourceIdPrefix: "source" })
|
||||
}
|
||||
})
|
||||
|
||||
it("hydrates a chat from an existing transcript", () => {
|
||||
const source = createChat()
|
||||
.user("What's the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer
|
||||
.tool("getWeather", {
|
||||
input: { city: "San Francisco" },
|
||||
})
|
||||
.output(weatherOutput)
|
||||
writer.text("It's sunny.", { mode: "instant" })
|
||||
})
|
||||
.user("Thanks!")
|
||||
.assistant("Anytime.")
|
||||
const recorded = source.get()
|
||||
|
||||
const replay = createChat({ messages: recorded })
|
||||
|
||||
expect(replay.get()).toEqual(recorded)
|
||||
expect(replay.next(recorded.slice(0, 2))).toMatchObject({
|
||||
id: recorded[2].id,
|
||||
role: "user",
|
||||
})
|
||||
})
|
||||
})
|
||||
61
packages/helpers/src/tanstack-ai/chat.ts
Normal file
61
packages/helpers/src/tanstack-ai/chat.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
AnyClientTool,
|
||||
UIMessage as ClientUIMessage,
|
||||
ConnectConnectionAdapter,
|
||||
MessagePart,
|
||||
} from "@tanstack/ai-client"
|
||||
import type { StreamChunk } from "@tanstack/ai/client"
|
||||
|
||||
import { createChatRuntime } from "../core"
|
||||
import type { Chat, ChatOptions } from "../core"
|
||||
import { createTanStackFormat } from "./format"
|
||||
import type {
|
||||
TanStackEventData,
|
||||
TanStackMessageMetadata,
|
||||
TanStackToolSet,
|
||||
TanStackWriter,
|
||||
} from "./types"
|
||||
|
||||
/** Options for creating a TanStack AI chat, optionally hydrated from existing messages. */
|
||||
export type CreateChatOptions<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
|
||||
DATA = unknown,
|
||||
> = Omit<ChatOptions, "sourceIdPrefix"> & {
|
||||
messages?: Array<ClientUIMessage<TOOLS, DATA>>
|
||||
}
|
||||
|
||||
/** The chat type returned by the TanStack AI adapter's `createChat`. */
|
||||
export type TanStackChat<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
|
||||
DATA = unknown,
|
||||
> = Chat<
|
||||
ClientUIMessage<TOOLS, DATA>,
|
||||
MessagePart<TOOLS, DATA>,
|
||||
ConnectConnectionAdapter,
|
||||
TanStackMessageMetadata,
|
||||
TanStackEventData,
|
||||
TanStackToolSet<TOOLS>,
|
||||
TanStackWriter<TOOLS>
|
||||
>
|
||||
|
||||
/** Creates a TanStack AI chat, optionally hydrated from existing messages. */
|
||||
export function createChat<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
|
||||
DATA = unknown,
|
||||
>(options: CreateChatOptions<TOOLS, DATA> = {}) {
|
||||
const { messages, ...chatOptions } = options
|
||||
|
||||
return createChatRuntime<
|
||||
ClientUIMessage<TOOLS, DATA>,
|
||||
MessagePart<TOOLS, DATA>,
|
||||
StreamChunk,
|
||||
ConnectConnectionAdapter,
|
||||
TanStackMessageMetadata,
|
||||
TanStackEventData,
|
||||
TanStackToolSet<TOOLS>,
|
||||
TanStackWriter<TOOLS>
|
||||
>(createTanStackFormat<TOOLS, DATA>(), {
|
||||
...chatOptions,
|
||||
messages,
|
||||
})
|
||||
}
|
||||
230
packages/helpers/src/tanstack-ai/format.test.ts
Normal file
230
packages/helpers/src/tanstack-ai/format.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { StreamProcessor } from "@tanstack/ai/client"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createChat } from "./index"
|
||||
import { getMessageText } from "./parts"
|
||||
import { collectChunks, createWeatherChat, weatherOutput } from "./test-utils"
|
||||
|
||||
describe("TanStack transport", () => {
|
||||
it("streams the golden AG-UI event sequence with runContext ids", async () => {
|
||||
const chat = createWeatherChat()
|
||||
const initialMessages = chat.get(1)
|
||||
const transport = chat.transport({ delayMs: 0 })
|
||||
const chunks = await collectChunks(
|
||||
transport.connect(initialMessages, undefined, undefined, {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"RUN_STARTED",
|
||||
"REASONING_MESSAGE_START",
|
||||
"REASONING_MESSAGE_CONTENT",
|
||||
"REASONING_MESSAGE_END",
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"TOOL_CALL_RESULT",
|
||||
"TEXT_MESSAGE_START",
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"TEXT_MESSAGE_END",
|
||||
"RUN_FINISHED",
|
||||
])
|
||||
expect(chunks[0]).toMatchObject({
|
||||
type: "RUN_STARTED",
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
expect(chunks[4]).toMatchObject({
|
||||
type: "TOOL_CALL_START",
|
||||
toolCallId: "call-1",
|
||||
toolCallName: "getWeather",
|
||||
parentMessageId: "msg-2",
|
||||
})
|
||||
expect(chunks[7]).toMatchObject({
|
||||
type: "TOOL_CALL_RESULT",
|
||||
toolCallId: "call-1",
|
||||
content: JSON.stringify(weatherOutput),
|
||||
})
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: "RUN_FINISHED",
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
finishReason: "stop",
|
||||
})
|
||||
})
|
||||
|
||||
it("uses chat ids when runContext is omitted", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hi")
|
||||
const initialMessages = chat.get(1)
|
||||
const transport = chat.transport({ delayMs: 0 })
|
||||
const chunks = await collectChunks(transport.connect(initialMessages))
|
||||
|
||||
expect(chunks[0]).toMatchObject({
|
||||
type: "RUN_STARTED",
|
||||
threadId: "thread-chat",
|
||||
runId: "run-chat",
|
||||
})
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: "RUN_FINISHED",
|
||||
threadId: "thread-chat",
|
||||
runId: "run-chat",
|
||||
})
|
||||
})
|
||||
|
||||
it("round-trips through the real TanStack StreamProcessor", async () => {
|
||||
const chat = createWeatherChat()
|
||||
const initialMessages = chat.get(1)
|
||||
const transport = chat.transport({ delayMs: 0 })
|
||||
const processor = new StreamProcessor()
|
||||
|
||||
processor.setMessages(initialMessages)
|
||||
processor.prepareAssistantMessage()
|
||||
|
||||
await processor.process(
|
||||
transport.connect(initialMessages, undefined, undefined, {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
)
|
||||
|
||||
const messages = processor.getMessages()
|
||||
const assistantMessage = messages[messages.length - 1]
|
||||
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2)
|
||||
expect(assistantMessage.role).toBe("assistant")
|
||||
expect(getMessageText(assistantMessage)).toBe("It's sunny.")
|
||||
|
||||
const toolCallPart = assistantMessage.parts.find(
|
||||
(part) => part.type === "tool-call"
|
||||
)
|
||||
|
||||
if (!toolCallPart || toolCallPart.type !== "tool-call") {
|
||||
throw new Error("Expected a tool-call part.")
|
||||
}
|
||||
|
||||
// The installed StreamProcessor (0.40.0) keeps the accumulated
|
||||
// `arguments` JSON string on the final part; it does not persist a
|
||||
// parsed `input` field.
|
||||
expect(toolCallPart).toMatchObject({
|
||||
name: "getWeather",
|
||||
state: "complete",
|
||||
arguments: JSON.stringify({ city: "San Francisco" }),
|
||||
output: weatherOutput,
|
||||
})
|
||||
})
|
||||
|
||||
it("streams an app-level error as RUN_ERROR", async () => {
|
||||
const chat = createChat().user("Hello").error("Model unavailable.")
|
||||
const initialMessages = chat.get(1)
|
||||
const transport = chat.transport({ delayMs: 0 })
|
||||
const chunks = await collectChunks(
|
||||
transport.connect(initialMessages, undefined, undefined, {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toEqual([
|
||||
"RUN_STARTED",
|
||||
"RUN_ERROR",
|
||||
])
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: "RUN_ERROR",
|
||||
message: "Model unavailable.",
|
||||
})
|
||||
})
|
||||
|
||||
it("throws when no scripted assistant turn matches", () => {
|
||||
const chat = createChat().user("Hello")
|
||||
const transport = chat.transport()
|
||||
|
||||
expect(() =>
|
||||
transport.connect(chat.get(), undefined, undefined, {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
).toThrowError("No assistant response found for this transcript.")
|
||||
})
|
||||
|
||||
it("honors scripted before-start sleeps", async () => {
|
||||
const chat = createChat()
|
||||
.user("Hello")
|
||||
.sleep(50)
|
||||
.assistant(({ writer }) => {
|
||||
writer.text("Hey.", {
|
||||
mode: "instant",
|
||||
})
|
||||
})
|
||||
|
||||
const initialMessages = chat.get(1)
|
||||
const transport = chat.transport({ delayMs: 0 })
|
||||
const startedAt = Date.now()
|
||||
const iterator = transport
|
||||
.connect(initialMessages, undefined, undefined, {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(35)
|
||||
expect(first.value).toMatchObject({
|
||||
type: "RUN_STARTED",
|
||||
})
|
||||
|
||||
while (!(await iterator.next()).done) {
|
||||
// Drain the remaining chunks.
|
||||
}
|
||||
})
|
||||
it("streams hydrated assistant turns through connect", async () => {
|
||||
const source = createChat().user("Hello").assistant("Hey there.")
|
||||
const replay = createChat({ messages: source.get() })
|
||||
const initialMessages = replay.get(1)
|
||||
|
||||
const chunks = await collectChunks(
|
||||
replay
|
||||
.transport({ delayMs: 0 })
|
||||
.connect(initialMessages, undefined, undefined, {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
)
|
||||
|
||||
expect(chunks[0]).toMatchObject({
|
||||
type: "RUN_STARTED",
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: "RUN_FINISHED",
|
||||
})
|
||||
})
|
||||
it("streams a fallback response when the script is exhausted", async () => {
|
||||
const chat = createChat().user("Hello").assistant("Hey.")
|
||||
const [unknownMessage] = createChat()
|
||||
.user("Something unscripted", { id: "unknown-1" })
|
||||
.get()
|
||||
|
||||
const chunks = await collectChunks(
|
||||
chat
|
||||
.transport({ delayMs: 0, fallback: "This demo is scripted." })
|
||||
.connect([...chat.get(), unknownMessage], undefined, undefined, {
|
||||
threadId: "thread-1",
|
||||
runId: "run-1",
|
||||
})
|
||||
)
|
||||
|
||||
expect(chunks.map((chunk) => chunk.type)).toContain("TEXT_MESSAGE_CONTENT")
|
||||
expect(
|
||||
chunks
|
||||
.filter((chunk) => chunk.type === "TEXT_MESSAGE_CONTENT")
|
||||
.map((chunk) => ("delta" in chunk ? chunk.delta : ""))
|
||||
.join("")
|
||||
).toBe("This demo is scripted.")
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: "RUN_FINISHED",
|
||||
})
|
||||
})
|
||||
})
|
||||
325
packages/helpers/src/tanstack-ai/format.ts
Normal file
325
packages/helpers/src/tanstack-ai/format.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import type {
|
||||
AnyClientTool,
|
||||
UIMessage as ClientUIMessage,
|
||||
ConnectConnectionAdapter,
|
||||
MessagePart,
|
||||
RunAgentInputContext,
|
||||
} from "@tanstack/ai-client"
|
||||
import type { ModelMessage, StreamChunk, UIMessage } from "@tanstack/ai/client"
|
||||
|
||||
import { assertNever } from "../core"
|
||||
import type {
|
||||
ChatFormat,
|
||||
ChunkEncoder,
|
||||
DataTypes,
|
||||
JsonRecord,
|
||||
MessageRole,
|
||||
ToolSet,
|
||||
TransportContext,
|
||||
TurnStreamOptions,
|
||||
} from "../core"
|
||||
import { eventsFromParts, getMessageText, materializeParts } from "./parts"
|
||||
import type {
|
||||
TanStackEventData,
|
||||
TanStackMessageMetadata,
|
||||
TanStackToolSet,
|
||||
} from "./types"
|
||||
|
||||
/** Ids stamped onto every AG-UI event of one run. Taken from `runContext` when the client connects. */
|
||||
export type TanStackRunIds = {
|
||||
threadId: string
|
||||
runId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
// AG-UI event discriminants are typed as @ag-ui/core enum members
|
||||
// (nominal), so plain string literals are not assignable even though the
|
||||
// runtime values are identical. This is the single cast point.
|
||||
function asStreamChunk(chunk: JsonRecord): StreamChunk {
|
||||
return chunk as unknown as StreamChunk
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `NeutralChunk` → AG-UI event encoder scoped to one run. Chunks
|
||||
* with no AG-UI equivalent (`abort`, files, sources, `start-step`,
|
||||
* `tool-output-denied`) encode to `null`; data and custom parts become
|
||||
* `CUSTOM` events.
|
||||
*/
|
||||
export function createRunEncoder<
|
||||
DATA extends DataTypes = DataTypes,
|
||||
TOOLS extends ToolSet = ToolSet,
|
||||
>(
|
||||
runIds: TanStackRunIds
|
||||
): ChunkEncoder<StreamChunk, TanStackMessageMetadata, DATA, TOOLS> {
|
||||
const { threadId, runId, messageId } = runIds
|
||||
|
||||
return (chunk) => {
|
||||
switch (chunk.type) {
|
||||
case "start":
|
||||
return asStreamChunk({
|
||||
type: "RUN_STARTED",
|
||||
threadId,
|
||||
runId,
|
||||
})
|
||||
case "finish":
|
||||
return asStreamChunk({
|
||||
type: "RUN_FINISHED",
|
||||
threadId,
|
||||
runId,
|
||||
finishReason: "stop",
|
||||
})
|
||||
case "error":
|
||||
return asStreamChunk({
|
||||
type: "RUN_ERROR",
|
||||
message: chunk.errorText,
|
||||
})
|
||||
case "text-start":
|
||||
return asStreamChunk({
|
||||
type: "TEXT_MESSAGE_START",
|
||||
messageId,
|
||||
role: "assistant",
|
||||
})
|
||||
case "text-delta":
|
||||
return asStreamChunk({
|
||||
type: "TEXT_MESSAGE_CONTENT",
|
||||
messageId,
|
||||
delta: chunk.delta,
|
||||
})
|
||||
case "text-end":
|
||||
return asStreamChunk({
|
||||
type: "TEXT_MESSAGE_END",
|
||||
messageId,
|
||||
})
|
||||
case "reasoning-start":
|
||||
return asStreamChunk({
|
||||
type: "REASONING_MESSAGE_START",
|
||||
messageId,
|
||||
role: "reasoning",
|
||||
})
|
||||
case "reasoning-delta":
|
||||
return asStreamChunk({
|
||||
type: "REASONING_MESSAGE_CONTENT",
|
||||
messageId,
|
||||
delta: chunk.delta,
|
||||
})
|
||||
case "reasoning-end":
|
||||
return asStreamChunk({
|
||||
type: "REASONING_MESSAGE_END",
|
||||
messageId,
|
||||
})
|
||||
case "tool-input-available":
|
||||
return [
|
||||
asStreamChunk({
|
||||
type: "TOOL_CALL_START",
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolCallName: chunk.toolName,
|
||||
toolName: chunk.toolName,
|
||||
parentMessageId: messageId,
|
||||
}),
|
||||
asStreamChunk({
|
||||
type: "TOOL_CALL_ARGS",
|
||||
toolCallId: chunk.toolCallId,
|
||||
delta: JSON.stringify(chunk.input ?? {}),
|
||||
}),
|
||||
asStreamChunk({
|
||||
type: "TOOL_CALL_END",
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolCallName: chunk.toolName,
|
||||
input: chunk.input,
|
||||
}),
|
||||
]
|
||||
case "tool-output-available":
|
||||
return asStreamChunk({
|
||||
type: "TOOL_CALL_RESULT",
|
||||
messageId: `${chunk.toolCallId}-result`,
|
||||
toolCallId: chunk.toolCallId,
|
||||
content: JSON.stringify(chunk.output ?? {}),
|
||||
role: "tool",
|
||||
})
|
||||
case "tool-output-error":
|
||||
return asStreamChunk({
|
||||
type: "TOOL_CALL_RESULT",
|
||||
messageId: `${chunk.toolCallId}-result`,
|
||||
toolCallId: chunk.toolCallId,
|
||||
content: chunk.errorText,
|
||||
role: "tool",
|
||||
state: "output-error",
|
||||
})
|
||||
case "data":
|
||||
return asStreamChunk({
|
||||
type: "CUSTOM",
|
||||
name: `data-${chunk.name}`,
|
||||
value: {
|
||||
id: chunk.id,
|
||||
data: chunk.data,
|
||||
transient: chunk.transient,
|
||||
},
|
||||
})
|
||||
case "custom":
|
||||
return asStreamChunk({
|
||||
type: "CUSTOM",
|
||||
name: chunk.part.kind,
|
||||
value: chunk.part,
|
||||
})
|
||||
case "abort":
|
||||
case "tool-approval-request":
|
||||
case "tool-output-denied":
|
||||
case "start-step":
|
||||
case "file":
|
||||
case "reasoning-file":
|
||||
case "source-url":
|
||||
case "source-document":
|
||||
// These chunks have no AG-UI streaming equivalent.
|
||||
return null
|
||||
default:
|
||||
return assertNever(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a `ReadableStream` as an `AsyncIterable` via `getReader()` (Safari
|
||||
* lacks native stream iteration). Returned synchronously, as TanStack's
|
||||
* connection contract requires; early `for await` exits cancel the reader.
|
||||
*/
|
||||
export function streamToAsyncIterable<T>(
|
||||
stream: ReadableStream<T>
|
||||
): AsyncIterable<T> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
const reader = stream.getReader()
|
||||
|
||||
return {
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
return { done: true, value: undefined }
|
||||
}
|
||||
|
||||
return { done: false, value }
|
||||
},
|
||||
|
||||
async return(): Promise<IteratorResult<T>> {
|
||||
await reader.cancel()
|
||||
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The TanStack AI `ChatFormat` plugin. Its transport is a
|
||||
* `ConnectConnectionAdapter` for `useChat({ connection })` and throws
|
||||
* `"No assistant response found for this transcript."` when no assistant
|
||||
* response remains.
|
||||
*/
|
||||
export function createTanStackFormat<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
|
||||
DATA = unknown,
|
||||
>(): ChatFormat<
|
||||
ClientUIMessage<TOOLS, DATA>,
|
||||
MessagePart<TOOLS, DATA>,
|
||||
StreamChunk,
|
||||
ConnectConnectionAdapter,
|
||||
TanStackMessageMetadata,
|
||||
TanStackEventData,
|
||||
TanStackToolSet<TOOLS>
|
||||
> {
|
||||
return {
|
||||
materializeParts(events) {
|
||||
return materializeParts<TOOLS, DATA>(events)
|
||||
},
|
||||
|
||||
eventsFromParts(parts) {
|
||||
return eventsFromParts<TOOLS, DATA>(parts)
|
||||
},
|
||||
|
||||
createMessage(input) {
|
||||
const message: ClientUIMessage<TOOLS, DATA> = {
|
||||
id: input.id,
|
||||
role: input.role,
|
||||
parts: input.parts,
|
||||
}
|
||||
const createdAt = input.metadata.createdAt
|
||||
|
||||
if (typeof createdAt === "string" || createdAt instanceof Date) {
|
||||
message.createdAt = new Date(createdAt)
|
||||
}
|
||||
|
||||
return message
|
||||
},
|
||||
|
||||
getMessageId(message) {
|
||||
return message.id
|
||||
},
|
||||
|
||||
getMessageRole(message) {
|
||||
return message.role as MessageRole
|
||||
},
|
||||
|
||||
getMessageText(message) {
|
||||
return getMessageText(message)
|
||||
},
|
||||
|
||||
getMessageParts(message) {
|
||||
return message.parts
|
||||
},
|
||||
|
||||
getMessageMetadata(message) {
|
||||
return {
|
||||
createdAt: message.createdAt,
|
||||
}
|
||||
},
|
||||
|
||||
createTransport(context, options = {}) {
|
||||
return createConnectionAdapter<TOOLS, DATA>(context, options)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createConnectionAdapter<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool>,
|
||||
DATA,
|
||||
>(
|
||||
context: TransportContext<
|
||||
ClientUIMessage<TOOLS, DATA>,
|
||||
StreamChunk,
|
||||
TanStackMessageMetadata,
|
||||
TanStackEventData,
|
||||
TanStackToolSet<TOOLS>
|
||||
>,
|
||||
options: TurnStreamOptions
|
||||
): ConnectConnectionAdapter {
|
||||
return {
|
||||
connect(
|
||||
messages: UIMessage[] | ModelMessage[],
|
||||
_data?: Record<string, unknown>,
|
||||
abortSignal?: AbortSignal,
|
||||
runContext?: RunAgentInputContext
|
||||
) {
|
||||
// The chat client hands custom adapters the full UIMessage history.
|
||||
const turn = context.resolveTurn(
|
||||
messages as ClientUIMessage<TOOLS, DATA>[]
|
||||
)
|
||||
|
||||
if (!turn) {
|
||||
throw new Error("No assistant response found for this transcript.")
|
||||
}
|
||||
|
||||
const encode = createRunEncoder<
|
||||
TanStackEventData,
|
||||
TanStackToolSet<TOOLS>
|
||||
>({
|
||||
threadId: runContext?.threadId ?? "thread-chat",
|
||||
runId: runContext?.runId ?? "run-chat",
|
||||
messageId: turn.message.id,
|
||||
})
|
||||
return streamToAsyncIterable(
|
||||
context.streamTurn(turn, encode, options, abortSignal)
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
7
packages/helpers/src/tanstack-ai/index.ts
Normal file
7
packages/helpers/src/tanstack-ai/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createChat } from "./chat"
|
||||
export type { CreateChatOptions, TanStackChat } from "./chat"
|
||||
export type {
|
||||
TanStackMessageMetadata,
|
||||
TanStackToolHandle,
|
||||
TanStackWriter,
|
||||
} from "./types"
|
||||
42
packages/helpers/src/tanstack-ai/parts.test.ts
Normal file
42
packages/helpers/src/tanstack-ai/parts.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createWeatherChat, weatherOutput } from "./test-utils"
|
||||
|
||||
describe("TanStack parts", () => {
|
||||
it("materializes writer events into TanStack message parts", () => {
|
||||
const chat = createWeatherChat()
|
||||
const [userMessage, assistantMessage] = chat.get()
|
||||
|
||||
expect(userMessage).toMatchObject({
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
content: "What's the weather?",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(assistantMessage.parts.map((part) => part.type)).toEqual([
|
||||
"thinking",
|
||||
"tool-call",
|
||||
"tool-result",
|
||||
"text",
|
||||
])
|
||||
expect(assistantMessage.parts[1]).toMatchObject({
|
||||
type: "tool-call",
|
||||
id: "call-1",
|
||||
name: "getWeather",
|
||||
state: "complete",
|
||||
input: {
|
||||
city: "San Francisco",
|
||||
},
|
||||
output: weatherOutput,
|
||||
})
|
||||
expect(assistantMessage.parts[2]).toMatchObject({
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
state: "complete",
|
||||
})
|
||||
})
|
||||
})
|
||||
347
packages/helpers/src/tanstack-ai/parts.ts
Normal file
347
packages/helpers/src/tanstack-ai/parts.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import type { AnyClientTool, MessagePart, UIMessage } from "@tanstack/ai-client"
|
||||
|
||||
import { assertNever } from "../core"
|
||||
import type { ChatEvent, FilePayload } from "../core"
|
||||
import type { TanStackEventData, TanStackToolSet } from "./types"
|
||||
|
||||
type TextPart = Extract<MessagePart, { type: "text" }>
|
||||
type ToolCallPart<TOOLS extends ReadonlyArray<AnyClientTool>> = Extract<
|
||||
MessagePart<TOOLS>,
|
||||
{ type: "tool-call" }
|
||||
>
|
||||
type ToolResultPart = Extract<MessagePart, { type: "tool-result" }>
|
||||
|
||||
/** Options for a static TanStack `tool-result` part. */
|
||||
export type ToolResultPartOptions = {
|
||||
output?: unknown
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Concatenates the `content` of every `text` part in a message. */
|
||||
export function getMessageText<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool>,
|
||||
DATA,
|
||||
>(message: Pick<UIMessage<TOOLS, DATA>, "parts">) {
|
||||
return message.parts
|
||||
.filter((part): part is TextPart => part.type === "text")
|
||||
.map((part) => part.content)
|
||||
.join("")
|
||||
}
|
||||
|
||||
/** Converts a neutral file payload into a TanStack media part (`image`/`audio`/`video`/`document` by media type). */
|
||||
export function mediaPartFromFile(file: FilePayload): MessagePart {
|
||||
const source = {
|
||||
type: "url" as const,
|
||||
value: file.url,
|
||||
mimeType: file.mediaType,
|
||||
}
|
||||
|
||||
if (file.mediaType.startsWith("image/")) {
|
||||
return {
|
||||
type: "image",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
if (file.mediaType.startsWith("audio/")) {
|
||||
return {
|
||||
type: "audio",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
if (file.mediaType.startsWith("video/")) {
|
||||
return {
|
||||
type: "video",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "document",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
function fileFromMediaPart(part: MessagePart): FilePayload | null {
|
||||
if (
|
||||
part.type !== "image" &&
|
||||
part.type !== "audio" &&
|
||||
part.type !== "video" &&
|
||||
part.type !== "document"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (part.source.type !== "url") {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallbackMediaType =
|
||||
part.type === "image"
|
||||
? "image/png"
|
||||
: part.type === "audio"
|
||||
? "audio/mpeg"
|
||||
: part.type === "video"
|
||||
? "video/mp4"
|
||||
: "application/octet-stream"
|
||||
|
||||
return {
|
||||
mediaType: part.source.mimeType ?? fallbackMediaType,
|
||||
url: part.source.value,
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolResultPart(
|
||||
toolCallId: string,
|
||||
options: ToolResultPartOptions = {}
|
||||
): ToolResultPart {
|
||||
if (options.errorText !== undefined) {
|
||||
return {
|
||||
type: "tool-result",
|
||||
toolCallId,
|
||||
content: options.errorText,
|
||||
state: "error",
|
||||
error: options.errorText,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool-result",
|
||||
toolCallId,
|
||||
content: JSON.stringify(options.output ?? {}),
|
||||
state: "complete",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes an event log into final TanStack message parts, mirroring the
|
||||
* end state the real StreamProcessor produces (tool outputs update the
|
||||
* `tool-call` part and add a sibling `tool-result`). Data, source, custom,
|
||||
* and step events have no TanStack part and are skipped.
|
||||
*/
|
||||
export function materializeParts<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool>,
|
||||
DATA,
|
||||
>(
|
||||
events: ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[]
|
||||
): Array<MessagePart<TOOLS, DATA>> {
|
||||
const parts: Array<MessagePart<TOOLS, DATA>> = []
|
||||
|
||||
function findToolCallPart(toolCallId: string) {
|
||||
const index = parts.findIndex(
|
||||
(part) => part.type === "tool-call" && part.id === toolCallId
|
||||
)
|
||||
|
||||
return index === -1 ? null : (parts[index] as ToolCallPart<TOOLS>)
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.kind) {
|
||||
case "text": {
|
||||
parts.push({
|
||||
type: "text",
|
||||
content: event.text,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "reasoning": {
|
||||
parts.push({
|
||||
type: "thinking",
|
||||
content: event.text,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool-input": {
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
id: event.toolCallId,
|
||||
name: event.name,
|
||||
arguments: JSON.stringify(event.input ?? {}),
|
||||
input: event.input ?? {},
|
||||
state: "input-complete",
|
||||
} as MessagePart<TOOLS, DATA>)
|
||||
break
|
||||
}
|
||||
case "tool-output": {
|
||||
const toolCallPart = findToolCallPart(event.toolCallId)
|
||||
|
||||
if (toolCallPart) {
|
||||
toolCallPart.state = "complete"
|
||||
toolCallPart.output = event.output
|
||||
}
|
||||
|
||||
parts.push(
|
||||
createToolResultPart(event.toolCallId, {
|
||||
output: event.output,
|
||||
}) as MessagePart<TOOLS, DATA>
|
||||
)
|
||||
break
|
||||
}
|
||||
case "tool-error": {
|
||||
const toolCallPart = findToolCallPart(event.toolCallId)
|
||||
|
||||
if (toolCallPart) {
|
||||
toolCallPart.state = "error"
|
||||
}
|
||||
|
||||
parts.push(
|
||||
createToolResultPart(event.toolCallId, {
|
||||
errorText: event.errorText,
|
||||
}) as MessagePart<TOOLS, DATA>
|
||||
)
|
||||
break
|
||||
}
|
||||
case "tool-denied": {
|
||||
const toolCallPart = findToolCallPart(event.toolCallId)
|
||||
|
||||
if (toolCallPart) {
|
||||
toolCallPart.approval = {
|
||||
id: `${event.toolCallId}-approval`,
|
||||
needsApproval: true,
|
||||
approved: false,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "file": {
|
||||
parts.push(mediaPartFromFile(event.part) as MessagePart<TOOLS, DATA>)
|
||||
break
|
||||
}
|
||||
case "sleep":
|
||||
case "data":
|
||||
case "error":
|
||||
case "source-url":
|
||||
case "source-document":
|
||||
case "reasoning-file":
|
||||
case "custom":
|
||||
case "step-start":
|
||||
case "tool-approval-request":
|
||||
// These events have no TanStack message part.
|
||||
break
|
||||
default:
|
||||
assertNever(event)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Parses TanStack message parts back into an event log so static parts can stream. */
|
||||
export function eventsFromParts<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool>,
|
||||
DATA,
|
||||
>(
|
||||
parts: Array<MessagePart<TOOLS, DATA>>
|
||||
): ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[] {
|
||||
const events: ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[] = []
|
||||
const resolvedToolCallIds = new Set<string>()
|
||||
|
||||
for (const part of parts) {
|
||||
// Framework-owned part unions are open, so parsing is non-exhaustive.
|
||||
if (part.type === "text") {
|
||||
events.push({
|
||||
kind: "text",
|
||||
id: `text-${events.length + 1}`,
|
||||
text: part.content,
|
||||
options: {
|
||||
mode: "instant",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "thinking") {
|
||||
events.push({
|
||||
kind: "reasoning",
|
||||
id: `reasoning-${events.length + 1}`,
|
||||
text: part.content,
|
||||
options: {
|
||||
mode: "instant",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (part.type === "tool-call") {
|
||||
const input =
|
||||
part.input !== undefined ? part.input : parseJson(part.arguments)
|
||||
|
||||
events.push({
|
||||
kind: "tool-input",
|
||||
name: part.name as keyof TanStackToolSet<TOOLS> & string,
|
||||
toolCallId: part.id,
|
||||
input,
|
||||
})
|
||||
|
||||
if (part.state === "error") {
|
||||
resolvedToolCallIds.add(part.id)
|
||||
events.push({
|
||||
kind: "tool-error",
|
||||
toolCallId: part.id,
|
||||
errorText: "Tool call failed.",
|
||||
})
|
||||
} else if (part.state === "complete" || part.output !== undefined) {
|
||||
resolvedToolCallIds.add(part.id)
|
||||
events.push({
|
||||
kind: "tool-output",
|
||||
toolCallId: part.id,
|
||||
output: part.output,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "tool-result") {
|
||||
if (resolvedToolCallIds.has(part.toolCallId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
resolvedToolCallIds.add(part.toolCallId)
|
||||
|
||||
if (part.state === "error") {
|
||||
events.push({
|
||||
kind: "tool-error",
|
||||
toolCallId: part.toolCallId,
|
||||
errorText:
|
||||
part.error ??
|
||||
(typeof part.content === "string"
|
||||
? part.content
|
||||
: "Tool call failed."),
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
kind: "tool-output",
|
||||
toolCallId: part.toolCallId,
|
||||
output:
|
||||
typeof part.content === "string"
|
||||
? parseJson(part.content)
|
||||
: part.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "image" ||
|
||||
part.type === "audio" ||
|
||||
part.type === "video" ||
|
||||
part.type === "document"
|
||||
) {
|
||||
const file = fileFromMediaPart(part)
|
||||
|
||||
if (file) {
|
||||
events.push({
|
||||
kind: "file",
|
||||
part: file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
46
packages/helpers/src/tanstack-ai/test-utils.ts
Normal file
46
packages/helpers/src/tanstack-ai/test-utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { StreamChunk } from "@tanstack/ai/client"
|
||||
|
||||
import { createChat } from "./index"
|
||||
|
||||
export type WeatherOutput = {
|
||||
city: string
|
||||
temperature: number
|
||||
condition: string
|
||||
}
|
||||
|
||||
export const weatherOutput: WeatherOutput = {
|
||||
city: "San Francisco",
|
||||
temperature: 72,
|
||||
condition: "sunny",
|
||||
}
|
||||
|
||||
export async function collectChunks(iterable: AsyncIterable<StreamChunk>) {
|
||||
const chunks: StreamChunk[] = []
|
||||
|
||||
for await (const chunk of iterable) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
export function createWeatherChat() {
|
||||
return createChat()
|
||||
.user("What's the weather?")
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning("I need to call the weather tool.", {
|
||||
mode: "instant",
|
||||
})
|
||||
|
||||
const weather = writer.tool("getWeather", {
|
||||
input: {
|
||||
city: "San Francisco",
|
||||
},
|
||||
})
|
||||
|
||||
weather.output(weatherOutput)
|
||||
writer.text("It's sunny.", {
|
||||
mode: "instant",
|
||||
})
|
||||
})
|
||||
}
|
||||
54
packages/helpers/src/tanstack-ai/types.ts
Normal file
54
packages/helpers/src/tanstack-ai/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
AnyClientTool,
|
||||
ExtractToolInput,
|
||||
ExtractToolOutput,
|
||||
} from "@tanstack/ai-client"
|
||||
|
||||
import type { StreamTextOptions, ToolWriterOptions } from "../core"
|
||||
|
||||
type TanStackStreamTextOptions = Omit<StreamTextOptions, "id">
|
||||
|
||||
type TanStackToolWriterOptions<
|
||||
TOOLS extends ReadonlyArray<AnyClientTool>,
|
||||
NAME extends keyof TanStackToolSet<TOOLS>,
|
||||
> = Pick<
|
||||
ToolWriterOptions<TanStackToolSet<TOOLS>, NAME>,
|
||||
"errorText" | "input" | "output" | "toolCallId"
|
||||
>
|
||||
|
||||
export type TanStackMessageMetadata = {
|
||||
createdAt?: Date | string
|
||||
}
|
||||
|
||||
export type TanStackEventData = Record<never, never>
|
||||
|
||||
export type TanStackToolSet<TOOLS extends ReadonlyArray<AnyClientTool>> = {
|
||||
[NAME in TOOLS[number]["name"]]: {
|
||||
input: ExtractToolInput<TOOLS, NAME>
|
||||
output: ExtractToolOutput<TOOLS, NAME>
|
||||
}
|
||||
}
|
||||
|
||||
export type TanStackToolHandle<OUTPUT> = {
|
||||
sleep(delayMs: number): TanStackToolHandle<OUTPUT>
|
||||
output(output: OUTPUT): TanStackToolHandle<OUTPUT>
|
||||
error(errorText?: string): TanStackToolHandle<OUTPUT>
|
||||
}
|
||||
|
||||
/** Writer operations that TanStack AI can represent end-to-end over AG-UI. */
|
||||
export type TanStackWriter<TOOLS extends ReadonlyArray<AnyClientTool>> = {
|
||||
text(
|
||||
text?: string,
|
||||
options?: TanStackStreamTextOptions
|
||||
): TanStackWriter<TOOLS>
|
||||
reasoning(
|
||||
text?: string,
|
||||
options?: TanStackStreamTextOptions
|
||||
): TanStackWriter<TOOLS>
|
||||
sleep(delayMs: number): TanStackWriter<TOOLS>
|
||||
error(errorText?: string): TanStackWriter<TOOLS>
|
||||
tool<NAME extends keyof TanStackToolSet<TOOLS> & string>(
|
||||
name: NAME,
|
||||
options?: TanStackToolWriterOptions<TOOLS, NAME>
|
||||
): TanStackToolHandle<TanStackToolSet<TOOLS>[NAME]["output"]>
|
||||
}
|
||||
76
packages/helpers/src/test/chat-contract.ts
Normal file
76
packages/helpers/src/test/chat-contract.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
type ContractMessage = {
|
||||
id: string
|
||||
role: string
|
||||
parts: unknown[]
|
||||
}
|
||||
|
||||
type ContractChat<MESSAGE extends ContractMessage> = {
|
||||
get(count?: number): MESSAGE[]
|
||||
next(messages: readonly MESSAGE[]): MESSAGE | null
|
||||
}
|
||||
|
||||
function mutateNestedValue(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(mutateNestedValue)
|
||||
}
|
||||
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (typeof child === "string") {
|
||||
const object = value as Record<string, unknown>
|
||||
|
||||
object[key] = "mutated"
|
||||
return true
|
||||
}
|
||||
|
||||
if (mutateNestedValue(child)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function testChatContract<MESSAGE extends ContractMessage>(
|
||||
adapter: string,
|
||||
createFixture: () => ContractChat<MESSAGE>
|
||||
) {
|
||||
describe(`${adapter} chat contract`, () => {
|
||||
it("reads the full transcript or a prefix without advancing", () => {
|
||||
const chat = createFixture()
|
||||
const messages = chat.get()
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(chat.get(2)).toEqual(messages.slice(0, 2))
|
||||
expect(chat.get()).toEqual(messages)
|
||||
})
|
||||
|
||||
it("finds the next user message from the supplied transcript", () => {
|
||||
const chat = createFixture()
|
||||
const messages = chat.get()
|
||||
|
||||
expect(chat.next(messages.slice(0, 2))?.id).toBe(messages[2].id)
|
||||
expect(chat.next(messages.slice(0, 2))?.id).toBe(messages[2].id)
|
||||
expect(chat.next(messages)).toBeNull()
|
||||
})
|
||||
|
||||
it("returns detached transcript snapshots", () => {
|
||||
const chat = createFixture()
|
||||
const expected = chat.get()
|
||||
const snapshot = chat.get()
|
||||
|
||||
expect(mutateNestedValue(snapshot[0].parts)).toBe(true)
|
||||
expect(chat.get()).toEqual(expected)
|
||||
})
|
||||
})
|
||||
}
|
||||
13
packages/helpers/tsconfig.json
Normal file
13
packages/helpers/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": false,
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
16
packages/helpers/tsup.config.ts
Normal file
16
packages/helpers/tsup.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig((options) => ({
|
||||
clean: !options.watch,
|
||||
dts: true,
|
||||
entry: {
|
||||
"ai-sdk/index": "src/ai-sdk/index.ts",
|
||||
"tanstack-ai/index": "src/tanstack-ai/index.ts",
|
||||
},
|
||||
format: ["esm"],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
target: "esnext",
|
||||
outDir: "dist",
|
||||
treeshake: true,
|
||||
}))
|
||||
8
packages/helpers/vitest.config.ts
Normal file
8
packages/helpers/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
testTimeout: 8000,
|
||||
},
|
||||
})
|
||||
2
packages/react/.gitignore
vendored
Normal file
2
packages/react/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
.turbo
|
||||
29
packages/react/CHANGELOG.md
Normal file
29
packages/react/CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# @shadcn/react
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11720](https://github.com/shadcn-ui/ui/pull/11720) [`503a3a57aec9a3817e37f90aa0817b1fabd284d0`](https://github.com/shadcn-ui/ui/commit/503a3a57aec9a3817e37f90aa0817b1fabd284d0) Thanks [@shadcn](https://github.com/shadcn)! - Set `data-pending-scroll` on the MessageScroller root and viewport until `defaultScrollPosition` (`"end"` or `"last-anchor"`) is applied, so the viewport can be hidden and a server-rendered transcript does not flash the top of the thread on reload.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#11414](https://github.com/shadcn-ui/ui/pull/11414) [`3e54530e31020e2277df03490a30a08e3bc1792b`](https://github.com/shadcn-ui/ui/commit/3e54530e31020e2277df03490a30a08e3bc1792b) Thanks [@shadcn](https://github.com/shadcn)! - Add the Questionnaire primitive for multi-step questions, freeform answers, validation, and keyboard navigation.
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#11106](https://github.com/shadcn-ui/ui/pull/11106) [`f6a26478547c7057aeda82516666dc3473dc39bd`](https://github.com/shadcn-ui/ui/commit/f6a26478547c7057aeda82516666dc3473dc39bd) Thanks [@shadcn](https://github.com/shadcn)! - Fix MessageScroller autoScroll around anchored turns: the anchor hold now hands off to follow-output once the streamed reply fills the viewport, content growth outrunning the frame-coalesced resize handler no longer releases follow-output, and the scroll button no longer flickers while follow-output closes the gap a streamed chunk just opened.
|
||||
|
||||
- [#11100](https://github.com/shadcn-ui/ui/pull/11100) [`dd6ce77cf1606eee2839368a97cc35417f763232`](https://github.com/shadcn-ui/ui/commit/dd6ce77cf1606eee2839368a97cc35417f763232) Thanks [@shadcn](https://github.com/shadcn)! - Fix MessageScroller abandoning a newly appended scrollAnchor placement when autoScroll is enabled, so the anchored turn holds at the reading line while the reply streams.
|
||||
|
||||
- [#11085](https://github.com/shadcn-ui/ui/pull/11085) [`628e427231ca4bb7f586a69f99fe7809749a53e2`](https://github.com/shadcn-ui/ui/commit/628e427231ca4bb7f586a69f99fe7809749a53e2) Thanks [@devnomic](https://github.com/devnomic)! - Fix MessageScroller firing "ResizeObserver loop completed with undelivered notifications" during streamed content growth by coalescing ResizeObserver callbacks into requestAnimationFrame.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#11022](https://github.com/shadcn-ui/ui/pull/11022) [`18fcf0f766857a7249cc0daac3c1609610edd158`](https://github.com/shadcn-ui/ui/commit/18fcf0f766857a7249cc0daac3c1609610edd158) Thanks [@shadcn](https://github.com/shadcn)! - initial release of @shadcn/react
|
||||
21
packages/react/README.md
Normal file
21
packages/react/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @shadcn/react
|
||||
|
||||
Unstyled components for React.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @shadcn/react
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit https://ui.shadcn.com/docs to view the documentation.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read the [contributing guide](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
|
||||
79
packages/react/package.json
Normal file
79
packages/react/package.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "@shadcn/react",
|
||||
"version": "0.3.1",
|
||||
"description": "Unstyled components for React.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "shadcn",
|
||||
"url": "https://twitter.com/shadcn"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shadcn-ui/ui.git",
|
||||
"directory": "packages/react"
|
||||
},
|
||||
"homepage": "https://ui.shadcn.com",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shadcn-ui/ui/issues"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"react",
|
||||
"headless",
|
||||
"unstyled",
|
||||
"primitives",
|
||||
"accessible",
|
||||
"ui",
|
||||
"shadcn"
|
||||
],
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
"./message-scroller": {
|
||||
"types": "./dist/message-scroller/index.d.ts",
|
||||
"default": "./dist/message-scroller/index.js"
|
||||
},
|
||||
"./questionnaire": {
|
||||
"types": "./dist/questionnaire/index.d.ts",
|
||||
"default": "./dist/questionnaire/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsup --watch",
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf dist",
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run -c vitest.browser.config.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=19",
|
||||
"react": ">=19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@vitest/browser": "^3.2.6",
|
||||
"jsdom": "^28.1.0",
|
||||
"playwright": "^1.61.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
176
packages/react/src/message-scroller/PERFORMANCE.md
Normal file
176
packages/react/src/message-scroller/PERFORMANCE.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# MessageScroller — performance
|
||||
|
||||
MessageScroller is **not virtualized** by default. It renders every message as a real DOM
|
||||
node and tracks scroll position with geometry reads on the scroll, resize, and
|
||||
content-change paths. The question this doc answers — with a runnable benchmark,
|
||||
not assertions of faith — is whether that design holds up for long, markdown-rich
|
||||
transcripts.
|
||||
|
||||
**Verdict: yes, with wide headroom.** A scroll commit on 1000 messages blocks the
|
||||
main thread for under 1ms; the same on 600 heavy-markdown items (64k DOM nodes)
|
||||
is well under 1ms with zero dropped frames; and while a heavy reply streams in,
|
||||
the scroller's own per-token work is ~1–2ms — a fraction of a frame on top of the
|
||||
markdown render. The limiting factor at extreme scale is the un-virtualized row
|
||||
count itself, not the scroller's work.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
# from packages/react
|
||||
pnpm test:browser src/message-scroller/message-scroller.perf.browser.test.tsx
|
||||
```
|
||||
|
||||
It runs in real Chromium via Playwright (`vitest.browser.config.ts`) and prints a
|
||||
numbers table per layer. **It must run in a real browser** — the entire cost here
|
||||
is forced layout, and jsdom stubs `getBoundingClientRect` to a free property
|
||||
read, so a jsdom "benchmark" would measure nothing real. The file matches the
|
||||
`*.browser.test.tsx` glob, so it also runs in CI (`browser-tests.yml`) and
|
||||
doubles as a regression guard.
|
||||
|
||||
**The suite runs outside React's act environment.** Both browser test files set
|
||||
`IS_REACT_ACT_ENVIRONMENT = false`. This component is driven by the event loop —
|
||||
`requestAnimationFrame`, `ResizeObserver`, `IntersectionObserver` — which `act()`
|
||||
cannot wrap, so the tests flush the renders they time with `flushSync` and settle
|
||||
async scroll work on real frames. Do not reintroduce `act()` here: it re-adds the
|
||||
spurious "update not wrapped in act(...)" warnings from the rAF-driven visibility
|
||||
subscriber without changing what is measured.
|
||||
|
||||
## Why this is the right thing to measure
|
||||
|
||||
The scroller's hot-path cost is not the `getBoundingClientRect` _call_ — it is the
|
||||
**forced synchronous reflow** that call triggers when layout is dirty. The
|
||||
benchmark is built around two facts about when layout is and isn't dirty:
|
||||
|
||||
- **Scrolling does not dirty layout.** Changing `scrollTop` is a scroll-offset
|
||||
change, not a layout invalidation, so during steady scroll `getBoundingClientRect`
|
||||
reads already-computed boxes. Cost tracks the _number of items iterated_ (one
|
||||
rect read per top-level row in `getContentBottom`), and is nearly blind to how
|
||||
deep each row's subtree is. This is why heavy markdown barely changes the scroll
|
||||
numbers.
|
||||
- **Growth dirties layout.** When a streaming reply gets taller, the next geometry
|
||||
read pays for a real reflow. That is the only path where DOM weight can bite, so
|
||||
it gets its own layer and an A/B isolation against a plain scroll div.
|
||||
|
||||
## The three layers
|
||||
|
||||
### Layer 1 — algorithmic scaling (isolated)
|
||||
|
||||
Calls `getMessageScrollerScrollable` (which drives the O(n) `getContentBottom` scan) in
|
||||
a tight loop against a real laid-out DOM, toggling `scrollTop` each iteration to
|
||||
force the reflow that real scrolling forces. Answers: how does one scroll commit
|
||||
scale with row count?
|
||||
|
||||
### Layer 2 — real scroll under load (the integration proof)
|
||||
|
||||
Mounts a large transcript with scroll-state and visibility both subscribed (a real
|
||||
app), drives a top-to-bottom scroll, and times the **synchronous scroll handler**
|
||||
(`handleScroll → syncAfterScroll → commitScrollState → getContentBottom` runs
|
||||
inside `dispatchEvent`, so timing the dispatch is timing the exact main-thread
|
||||
block a user scroll causes). Counts long tasks. Run both with trivial rows and
|
||||
with heavy markdown rows.
|
||||
|
||||
### Layer 3 — streaming (the real-chat worst case)
|
||||
|
||||
Streams an assistant reply token-by-token at the bottom of a long transcript of
|
||||
**memoized** heavy turns (so only the growing reply re-renders, like a real app).
|
||||
Runs the identical growth inside MessageScroller (auto-follow) and inside a plain
|
||||
scroll `<div>` (manual follow); the **delta** isolates the scroller's per-token
|
||||
cost from the markdown's own render cost.
|
||||
|
||||
## Indicative numbers
|
||||
|
||||
> Measured on Apple Silicon, headless Chromium (Playwright), 2026-06. These are
|
||||
> **indicative, not guarantees** — absolute timings drift with hardware and CI.
|
||||
> The durable contract is the budget assertions below; the test is the source of
|
||||
> truth. Re-run to get numbers for your machine.
|
||||
|
||||
**Layer 1 — per-commit cost vs. row count (trivial rows):**
|
||||
|
||||
| messages | median | p95 | max |
|
||||
| -------- | ------ | ----- | ----- |
|
||||
| 100 | 0.1ms | 0.1ms | 0.7ms |
|
||||
| 500 | 0.3ms | 0.3ms | 1.2ms |
|
||||
| 1000 | 0.5ms | 0.6ms | 1.9ms |
|
||||
| 2000 | 1.1ms | 1.2ms | 4.6ms |
|
||||
|
||||
Linear in row count with a tiny constant. Extrapolated, a single commit alone
|
||||
would not reach one 16ms frame until ~30,000 rendered rows.
|
||||
|
||||
**Layer 1 — per-commit cost vs. DOM weight (heavy markdown):**
|
||||
|
||||
| turns | items | DOM nodes | median | p95 |
|
||||
| ----- | ----- | --------- | ------ | ----- |
|
||||
| 50 | 100 | 10,509 | 0.1ms | 0.2ms |
|
||||
| 150 | 300 | 31,996 | 0.2ms | 0.3ms |
|
||||
| 300 | 600 | 64,135 | 0.4ms | 0.5ms |
|
||||
|
||||
64k DOM nodes barely moves the cost — it tracks item count, not subtree depth,
|
||||
because scrolling doesn't dirty layout (see above).
|
||||
|
||||
**Layer 2 — real top-to-bottom scroll:**
|
||||
|
||||
| scenario | handler median | handler p95 | handler max | long tasks |
|
||||
| --------------------------- | -------------- | ----------- | ----------- | ---------- |
|
||||
| 1000 trivial messages | 0.9ms | 1.2ms | 2.1ms | 0 |
|
||||
| 300 heavy turns (64k nodes) | 0.6ms | 0.8ms | 0.9ms | 0 |
|
||||
|
||||
**Layer 3 — streaming a heavy reply (per-token commit, `flushSync`-timed):**
|
||||
|
||||
| tree | median | p95 | max | long tasks |
|
||||
| ---------------------- | ---------- | ----- | ----- | ---------- |
|
||||
| plain `<div>` baseline | 1.2ms | 2.2ms | 2.3ms | 0 |
|
||||
| MessageScroller | 3.2ms | 5.7ms | 8.3ms | 0 |
|
||||
| **scroller overhead** | **~1–2ms** | | | |
|
||||
|
||||
Each token re-renders the growing reply; `flushSync` times that commit plus the
|
||||
scroller's `handleContentChange` layout effect (re-scan items, follow-bottom).
|
||||
The overhead over a plain `<div>` is the scroller's synchronous content-change
|
||||
work — a consistent **~1–2ms per token**, a fraction of a frame, with zero long
|
||||
tasks. (An earlier `act()`-wrapped version of this layer timed the whole async
|
||||
flush and spuriously reported ~0/negative overhead; the `flushSync` timing is the
|
||||
honest one.) The per-token total is still dominated by the markdown render, which
|
||||
is the message component's cost, not the scroller's.
|
||||
|
||||
## Preventing regressions
|
||||
|
||||
The suite runs in CI on every PR that touches `packages/react/**`
|
||||
(`.github/workflows/browser-tests.yml`). It guards against regressions two
|
||||
ways, in order of strength:
|
||||
|
||||
### 1. Deterministic guard (the real one)
|
||||
|
||||
`getBoundingClientRect` is spied during a single scroll commit and the call count
|
||||
is asserted to be **O(items), not O(items²)** — currently ~1.0 reads per item
|
||||
(2030 reads for 2000 items). This count is **machine-independent and has zero
|
||||
variance**, so it can be a tight bound: the classic perf regression (a nested rect
|
||||
loop, an accidental per-item `getComputedStyle`) makes the per-item ratio scale
|
||||
with item count and blows past the bound deterministically, where a timing test
|
||||
would just shrug. This is the assertion to trust.
|
||||
|
||||
### 2. Timing budgets (coarse backstop)
|
||||
|
||||
Wall-clock assertions catch a catastrophic slowdown, but they are
|
||||
hardware-dependent, so they carry generous slack and only flag multi-× blowups:
|
||||
|
||||
- Layer 1: per-commit median at the largest row count `< 16ms` (one frame).
|
||||
- Layer 2: scroll-handler median `< 16ms`, p95 `< 32ms`.
|
||||
- Layer 3: scroller per-token median `< 16ms`, and overhead over baseline `< 16ms`.
|
||||
|
||||
Do not tighten the timing budgets toward the indicative numbers — CI runner noise
|
||||
will make them flake. If you want an earlier warning on gradual creep, prefer
|
||||
adding more invariants to the deterministic guard (more counts, scaling-ratio
|
||||
checks between two row counts measured in the same run) over shrinking the ms
|
||||
thresholds.
|
||||
|
||||
## Boundaries and non-goals
|
||||
|
||||
- **No virtualization.** Cost is O(rendered rows). Realistic transcripts (hundreds
|
||||
to low thousands of turns) have enormous headroom; tens of thousands of _live_
|
||||
rows would eventually make `getContentBottom`'s all-items scan matter. The fix is
|
||||
already scoped if ever needed: the last non-spacer child carries the max bottom,
|
||||
so the scan can collapse to O(1).
|
||||
- **Markdown render cost is out of scope.** Layer 3 shows per-token cost is the
|
||||
content renderer's, not the scroller's. Mitigations (memoizing history, batching
|
||||
tokens, virtualizing code blocks) belong to the message components.
|
||||
- **Timings are machine-relative.** Don't copy these numbers into user-facing docs
|
||||
as promises; cite the methodology and re-run.
|
||||
73
packages/react/src/message-scroller/README.md
Normal file
73
packages/react/src/message-scroller/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# MessageScroller
|
||||
|
||||
Headless scroll container for chat transcripts. Owns scroll position, anchoring,
|
||||
auto-follow, and visibility tracking so message components can stay presentational.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {
|
||||
MessageScroller,
|
||||
useMessageScroller,
|
||||
useMessageScrollerVisibility,
|
||||
} from "@shadcn/react/message-scroller"
|
||||
|
||||
;<MessageScroller.Provider autoScroll>
|
||||
<MessageScroller.Root>
|
||||
<MessageScroller.Viewport>
|
||||
<MessageScroller.Content>
|
||||
<MessageScroller.Item messageId="m1" scrollAnchor>
|
||||
…
|
||||
</MessageScroller.Item>
|
||||
</MessageScroller.Content>
|
||||
</MessageScroller.Viewport>
|
||||
<MessageScroller.Button />
|
||||
</MessageScroller.Root>
|
||||
</MessageScroller.Provider>
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
All from `@shadcn/react/message-scroller`.
|
||||
|
||||
### Parts
|
||||
|
||||
| Part | Role | Notable props |
|
||||
| -------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `MessageScroller.Provider` | Headless root; owns scroll state, anchoring, auto-follow, and visibility | `autoScroll`, `defaultScrollPosition`, `scrollPreviousItemPeek`, `scrollMargin`, `scrollEdgeThreshold` |
|
||||
| `MessageScroller.Root` | Styled frame; lays out the viewport, content, and controls in the provider | `data-pending-scroll` until the opening position applies |
|
||||
| `MessageScroller.Viewport` | Scrollable frame | `preserveScrollOnPrepend`; `data-pending-scroll` until the opening position applies |
|
||||
| `MessageScroller.Content` | Message list; defaults `role="log"` + `aria-relevant="additions"` | — |
|
||||
| `MessageScroller.Item` | One message wrapper | `messageId`, `scrollAnchor` |
|
||||
| `MessageScroller.Button` | Scroll-to-end/start affordance; auto-hides when caught up | `direction` |
|
||||
|
||||
### Hooks (flat siblings)
|
||||
|
||||
| Hook | Returns |
|
||||
| -------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `useMessageScroller()` | `{ scrollToMessage, scrollToStart, scrollToEnd }` |
|
||||
| `useMessageScrollerScrollable()` | `MessageScrollerScrollable` — `{ start, end }`, the edges the viewport can scroll toward |
|
||||
| `useMessageScrollerVisibility()` | `MessageScrollerVisibilityState` — `currentAnchorId`, `visibleMessageIds` |
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Meaning |
|
||||
| -------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `MessageScrollerScrollable` | result of `useMessageScrollerScrollable()` |
|
||||
| `MessageScrollerVisibilityState` | result of `useMessageScrollerVisibility()` |
|
||||
| `MessageScrollerScrollOptions` | options for the scroll commands (`align`, `behavior`, `scrollMargin`) |
|
||||
| `MessageScrollerScrollAlign` | `"start" \| "center" \| "end" \| "nearest"` |
|
||||
| `MessageScrollerDefaultScrollPosition` | `"start" \| "end" \| "last-anchor"` |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Environment | Covers |
|
||||
| ---------------------------------------- | ----------- | -------------------------------------------------------------------------- |
|
||||
| `geometry.test.ts` | jsdom | Geometry math with stubbed rects. |
|
||||
| `message-scroller.browser.test.tsx` | chromium | Behavior jsdom can't model (native scroll anchoring, prepend, visibility). |
|
||||
| `message-scroller.perf.browser.test.tsx` | chromium | Performance benchmark + regression guard. |
|
||||
|
||||
```bash
|
||||
pnpm test # unit (jsdom)
|
||||
pnpm test:browser # behavior + performance (chromium)
|
||||
```
|
||||
446
packages/react/src/message-scroller/components.tsx
Normal file
446
packages/react/src/message-scroller/components.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { composeRefs, mergeProps, useRender } from "../use-render"
|
||||
import { USER_SCROLL_KEYS } from "./types"
|
||||
import type {
|
||||
MessageScrollerButtonProps,
|
||||
MessageScrollerContentProps,
|
||||
MessageScrollerContextValue,
|
||||
MessageScrollerItemProps,
|
||||
MessageScrollerProps,
|
||||
MessageScrollerProviderProps,
|
||||
MessageScrollerRegisterMessage,
|
||||
MessageScrollerViewportProps,
|
||||
} from "./types"
|
||||
import { useMessageScrollerController } from "./use-message-scroller-controller"
|
||||
import { useLatest } from "./utils"
|
||||
|
||||
const MessageScrollerContext =
|
||||
React.createContext<MessageScrollerContextValue | null>(null)
|
||||
const MessageScrollerItemContext =
|
||||
React.createContext<MessageScrollerRegisterMessage | null>(null)
|
||||
|
||||
function useMessageScrollerContext() {
|
||||
const context = React.useContext(MessageScrollerContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useMessageScroller must be used within a MessageScroller.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function useMessageScrollerItemContext() {
|
||||
const context = React.useContext(MessageScrollerItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageScrollerItem must be used within a MessageScroller."
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function useMessageScroller() {
|
||||
const { scrollToEnd, scrollToMessage, scrollToStart } =
|
||||
useMessageScrollerContext()
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
scrollToEnd,
|
||||
scrollToMessage,
|
||||
scrollToStart,
|
||||
}),
|
||||
[scrollToEnd, scrollToMessage, scrollToStart]
|
||||
)
|
||||
}
|
||||
|
||||
function useMessageScrollerScrollable() {
|
||||
const { stateStore } = useMessageScrollerContext()
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
stateStore.subscribe,
|
||||
stateStore.getSnapshot,
|
||||
stateStore.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
function useMessageScrollerVisibility() {
|
||||
const { observeVisibility, unobserveVisibility, visibilityStore } =
|
||||
useMessageScrollerContext()
|
||||
const subscribe = React.useCallback(
|
||||
(listener: () => void) =>
|
||||
visibilityStore.subscribe(
|
||||
listener,
|
||||
observeVisibility,
|
||||
unobserveVisibility
|
||||
),
|
||||
[observeVisibility, unobserveVisibility, visibilityStore]
|
||||
)
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
visibilityStore.getSnapshot,
|
||||
visibilityStore.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
function MessageScrollerProvider({
|
||||
autoScroll = false,
|
||||
children,
|
||||
defaultScrollPosition = "end",
|
||||
scrollEdgeThreshold,
|
||||
scrollPreviousItemPeek,
|
||||
scrollMargin,
|
||||
}: MessageScrollerProviderProps) {
|
||||
const { context, registerMessage } = useMessageScrollerController({
|
||||
autoScroll,
|
||||
defaultScrollPosition,
|
||||
scrollEdgeThreshold,
|
||||
scrollPreviousItemPeek,
|
||||
scrollMargin,
|
||||
})
|
||||
|
||||
return (
|
||||
<MessageScrollerContext.Provider value={context}>
|
||||
<MessageScrollerItemContext.Provider value={registerMessage}>
|
||||
{children}
|
||||
</MessageScrollerItemContext.Provider>
|
||||
</MessageScrollerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function usePendingDefaultScroll() {
|
||||
const { pendingDefaultScrollStore } = useMessageScrollerContext()
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
pendingDefaultScrollStore.subscribe,
|
||||
pendingDefaultScrollStore.getSnapshot,
|
||||
pendingDefaultScrollStore.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
function MessageScroller({ children, ...props }: MessageScrollerProps) {
|
||||
const { setRootElement } = useMessageScrollerContext()
|
||||
const pendingDefaultScroll = usePendingDefaultScroll()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRootElement}
|
||||
{...props}
|
||||
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageScrollerViewport({
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
onKeyDown,
|
||||
onScroll,
|
||||
onTouchMove,
|
||||
onWheel,
|
||||
preserveScrollOnPrepend = true,
|
||||
ref,
|
||||
role,
|
||||
tabIndex,
|
||||
...props
|
||||
}: MessageScrollerViewportProps) {
|
||||
const {
|
||||
handleResize,
|
||||
preserveScrollOnPrependRef,
|
||||
setViewportElement,
|
||||
syncAfterScroll,
|
||||
userScrollIntent,
|
||||
viewportRef,
|
||||
} = useMessageScrollerContext()
|
||||
const pendingDefaultScroll = usePendingDefaultScroll()
|
||||
|
||||
preserveScrollOnPrependRef.current = preserveScrollOnPrepend
|
||||
|
||||
const setViewportRef = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
setViewportElement(element)
|
||||
composeRefs(ref)?.(element)
|
||||
},
|
||||
[ref, setViewportElement]
|
||||
)
|
||||
|
||||
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
|
||||
syncAfterScroll()
|
||||
onScroll?.(event)
|
||||
}
|
||||
|
||||
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
|
||||
userScrollIntent()
|
||||
onWheel?.(event)
|
||||
}
|
||||
|
||||
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
|
||||
userScrollIntent()
|
||||
onTouchMove?.(event)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (USER_SCROLL_KEYS.has(event.key)) {
|
||||
userScrollIntent()
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport || typeof ResizeObserver === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
// Coalesce into rAF: handleResize mutates the spacer inside the observed
|
||||
// content, and resizing an observed element during delivery fires
|
||||
// "ResizeObserver loop completed with undelivered notifications".
|
||||
let frame = 0
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(handleResize)
|
||||
})
|
||||
|
||||
observer.observe(viewport)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [handleResize, viewportRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setViewportRef}
|
||||
role={role ?? "region"}
|
||||
aria-label={ariaLabel ?? "Messages"}
|
||||
tabIndex={tabIndex ?? 0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleScroll}
|
||||
onTouchMove={handleTouchMove}
|
||||
onWheel={handleWheel}
|
||||
{...props}
|
||||
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageScrollerContent({
|
||||
"aria-relevant": ariaRelevant,
|
||||
children,
|
||||
ref,
|
||||
role,
|
||||
spacerClassName,
|
||||
...props
|
||||
}: MessageScrollerContentProps) {
|
||||
const {
|
||||
handleContentChange,
|
||||
handleResize,
|
||||
setContentElement,
|
||||
setSpacerElement,
|
||||
} = useMessageScrollerContext()
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const setContentRef = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
contentRef.current = element
|
||||
setContentElement(element)
|
||||
composeRefs(ref)?.(element)
|
||||
},
|
||||
[ref, setContentElement]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const content = contentRef.current
|
||||
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
handleContentChange()
|
||||
|
||||
if (typeof MutationObserver === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
handleContentChange()
|
||||
})
|
||||
|
||||
observer.observe(content, { childList: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [handleContentChange])
|
||||
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current
|
||||
|
||||
if (!content || typeof ResizeObserver === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
// Coalesce into rAF: handleResize mutates the spacer inside this observed
|
||||
// element, and resizing an observed element during delivery fires
|
||||
// "ResizeObserver loop completed with undelivered notifications".
|
||||
let frame = 0
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(handleResize)
|
||||
})
|
||||
|
||||
observer.observe(content)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [handleResize])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setContentRef}
|
||||
role={role ?? "log"}
|
||||
aria-relevant={ariaRelevant ?? "additions"}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={setSpacerElement}
|
||||
aria-hidden="true"
|
||||
data-message-scroller-spacer=""
|
||||
hidden
|
||||
className={spacerClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageScrollerItem({
|
||||
messageId,
|
||||
ref,
|
||||
scrollAnchor = false,
|
||||
...props
|
||||
}: MessageScrollerItemProps) {
|
||||
const registerMessage = useMessageScrollerItemContext()
|
||||
const elementRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const setItemRef = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
const previousElement = elementRef.current
|
||||
|
||||
elementRef.current = element
|
||||
|
||||
if (messageId) {
|
||||
registerMessage(messageId, element, previousElement)
|
||||
}
|
||||
|
||||
composeRefs(ref)?.(element)
|
||||
},
|
||||
[messageId, ref, registerMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setItemRef}
|
||||
data-message-id={messageId}
|
||||
data-scroll-anchor={scrollAnchor ? "true" : "false"}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageScrollerButton({
|
||||
behavior = "smooth",
|
||||
children,
|
||||
direction = "end",
|
||||
onClick,
|
||||
render,
|
||||
tabIndex,
|
||||
type = "button",
|
||||
...props
|
||||
}: MessageScrollerButtonProps) {
|
||||
const { scrollToEnd, scrollToStart, stateStore } = useMessageScrollerContext()
|
||||
const onClickRef = useLatest(onClick)
|
||||
const subscribe = React.useCallback(
|
||||
(listener: () => void) => stateStore.subscribe(listener),
|
||||
[stateStore]
|
||||
)
|
||||
const getSnapshot = React.useCallback(() => {
|
||||
const state = stateStore.getSnapshot()
|
||||
|
||||
return direction === "start" ? state.start : state.end
|
||||
}, [direction, stateStore])
|
||||
const isActive = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getSnapshot
|
||||
)
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
onClickRef.current?.(event)
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
event.currentTarget.blur()
|
||||
|
||||
if (direction === "start") {
|
||||
scrollToStart({ behavior })
|
||||
} else {
|
||||
scrollToEnd({ behavior })
|
||||
}
|
||||
}
|
||||
},
|
||||
[behavior, direction, isActive, onClickRef, scrollToEnd, scrollToStart]
|
||||
)
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
type,
|
||||
inert: !isActive,
|
||||
tabIndex: isActive ? tabIndex : -1,
|
||||
children: children ?? <span>Scroll to {direction}</span>,
|
||||
onClick: handleClick,
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
active: isActive,
|
||||
direction,
|
||||
},
|
||||
stateAttributesMapping: {
|
||||
active: (value) => ({
|
||||
"data-active": value ? "true" : "false",
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
useMessageScrollerVisibility,
|
||||
}
|
||||
451
packages/react/src/message-scroller/geometry.test.ts
Normal file
451
packages/react/src/message-scroller/geometry.test.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
getContentBottom,
|
||||
getElementScrollTop,
|
||||
getLastScrollAnchor,
|
||||
getMessageScrollerScrollable,
|
||||
getNewScrollAnchor,
|
||||
getUnanchoredScrollAnchor,
|
||||
} from "./geometry"
|
||||
|
||||
// jsdom does not compute layout, so each element's rect, scroll position, and
|
||||
// dimensions are stubbed directly. Rects are expressed in viewport (client)
|
||||
// coordinates and shift with the owning viewport's scrollTop.
|
||||
|
||||
type RectInput = {
|
||||
top: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length > 0) {
|
||||
cleanups.pop()?.()
|
||||
}
|
||||
})
|
||||
|
||||
// Override window.getComputedStyle once per test so padding stubs read from
|
||||
// data attributes set on the element.
|
||||
function stubComputedStyle() {
|
||||
const original = window.getComputedStyle
|
||||
|
||||
window.getComputedStyle = ((element: Element) => {
|
||||
const style = original(element)
|
||||
|
||||
if (element instanceof HTMLElement && element.dataset.paddingStart) {
|
||||
return {
|
||||
...style,
|
||||
gap: "0px",
|
||||
rowGap: "0px",
|
||||
paddingBlockStart: `${element.dataset.paddingStart}px`,
|
||||
paddingTop: `${element.dataset.paddingStart}px`,
|
||||
paddingBlockEnd: `${element.dataset.paddingEnd ?? "0"}px`,
|
||||
paddingBottom: `${element.dataset.paddingEnd ?? "0"}px`,
|
||||
} as CSSStyleDeclaration
|
||||
}
|
||||
|
||||
return style
|
||||
}) as typeof window.getComputedStyle
|
||||
|
||||
cleanups.push(() => {
|
||||
window.getComputedStyle = original
|
||||
})
|
||||
}
|
||||
|
||||
function setRect(element: HTMLElement, rect: RectInput) {
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
top: rect.top,
|
||||
bottom: rect.top + rect.height,
|
||||
height: rect.height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: rect.top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
}
|
||||
|
||||
function setClientHeight(element: HTMLElement, height: number) {
|
||||
Object.defineProperty(element, "clientHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
})
|
||||
}
|
||||
|
||||
// Build a viewport/content/items fixture. The viewport rect is anchored at
|
||||
// `viewportTop` in client space; item rects are given in viewport-relative
|
||||
// terms and offset by viewportTop so getElementTop math lines up with scrollTop.
|
||||
function createFixture(options: {
|
||||
viewportTop?: number
|
||||
viewportHeight: number
|
||||
scrollTop?: number
|
||||
scrollHeight?: number
|
||||
contentPaddingStart?: number
|
||||
contentPaddingEnd?: number
|
||||
items: Array<{
|
||||
messageId?: string
|
||||
scrollAnchor?: boolean
|
||||
top: number
|
||||
height: number
|
||||
}>
|
||||
}) {
|
||||
const viewportTop = options.viewportTop ?? 0
|
||||
const scrollTop = options.scrollTop ?? 0
|
||||
|
||||
const viewport = document.createElement("div")
|
||||
const content = document.createElement("div")
|
||||
const spacer = document.createElement("div")
|
||||
|
||||
viewport.appendChild(content)
|
||||
content.appendChild(spacer)
|
||||
|
||||
setRect(viewport, { top: viewportTop, height: options.viewportHeight })
|
||||
setClientHeight(viewport, options.viewportHeight)
|
||||
viewport.scrollTop = scrollTop
|
||||
|
||||
if (typeof options.scrollHeight === "number") {
|
||||
Object.defineProperty(viewport, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => options.scrollHeight as number,
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof options.contentPaddingStart === "number") {
|
||||
content.dataset.paddingStart = String(options.contentPaddingStart)
|
||||
content.dataset.paddingEnd = String(options.contentPaddingEnd ?? 0)
|
||||
}
|
||||
|
||||
const items = options.items.map((item) => {
|
||||
const element = document.createElement("div")
|
||||
|
||||
if (item.messageId) {
|
||||
element.dataset.messageId = item.messageId
|
||||
}
|
||||
|
||||
element.dataset.scrollAnchor = item.scrollAnchor ? "true" : "false"
|
||||
// Item rects are in client coordinates: viewport-relative top shifted by the
|
||||
// viewport's own top so getElementTop adds back scrollTop correctly.
|
||||
setRect(element, { top: viewportTop + item.top, height: item.height })
|
||||
content.insertBefore(element, spacer)
|
||||
|
||||
return element
|
||||
})
|
||||
|
||||
return { viewport, content, spacer, items }
|
||||
}
|
||||
|
||||
function createItems(
|
||||
specs: Array<{ id: string; anchor?: boolean }>
|
||||
): HTMLElement[] {
|
||||
return specs.map((spec) => {
|
||||
const element = document.createElement("div")
|
||||
element.dataset.messageId = spec.id
|
||||
element.dataset.scrollAnchor = spec.anchor ? "true" : "false"
|
||||
return element
|
||||
})
|
||||
}
|
||||
|
||||
describe("getElementScrollTop", () => {
|
||||
it("aligns to start by subtracting content padding and margin", () => {
|
||||
stubComputedStyle()
|
||||
const { items, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
scrollTop: 0,
|
||||
contentPaddingStart: 10,
|
||||
contentPaddingEnd: 10,
|
||||
items: [{ messageId: "a", top: 100, height: 50 }],
|
||||
})
|
||||
|
||||
// elementTop = 100; minus padding.start (10) minus scrollMargin (5).
|
||||
expect(
|
||||
getElementScrollTop({
|
||||
align: "start",
|
||||
element: items[0],
|
||||
scrollMargin: 5,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
).toBe(85)
|
||||
})
|
||||
|
||||
it("centers the element within the padded inset", () => {
|
||||
stubComputedStyle()
|
||||
const { items, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
scrollTop: 0,
|
||||
contentPaddingStart: 20,
|
||||
contentPaddingEnd: 20,
|
||||
items: [{ messageId: "a", top: 300, height: 40 }],
|
||||
})
|
||||
|
||||
// insetHeight = 200 - 20 - 20 = 160; offset = (160 - 40) / 2 = 60.
|
||||
// elementTop (300) - padding.start (20) - 60 - scrollMargin (0) = 220.
|
||||
expect(
|
||||
getElementScrollTop({
|
||||
align: "center",
|
||||
element: items[0],
|
||||
scrollMargin: 0,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
).toBe(220)
|
||||
})
|
||||
|
||||
it("aligns to end against the viewport bottom", () => {
|
||||
stubComputedStyle()
|
||||
const { items, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
scrollTop: 0,
|
||||
contentPaddingStart: 0,
|
||||
contentPaddingEnd: 8,
|
||||
items: [{ messageId: "a", top: 300, height: 50 }],
|
||||
})
|
||||
|
||||
// elementTop (300) - clientHeight (200) + height (50) + padding.end (8)
|
||||
// + scrollMargin (0) = 158.
|
||||
expect(
|
||||
getElementScrollTop({
|
||||
align: "end",
|
||||
element: items[0],
|
||||
scrollMargin: 0,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
).toBe(158)
|
||||
})
|
||||
|
||||
it("returns the current scrollTop when nearest and already visible", () => {
|
||||
stubComputedStyle()
|
||||
const { items, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
viewportTop: 0,
|
||||
scrollTop: 50,
|
||||
contentPaddingStart: 0,
|
||||
contentPaddingEnd: 0,
|
||||
// Element top relative to viewport is 60, so in content space it sits at
|
||||
// scrollTop (50) + 60 = 110, fully inside [50, 250].
|
||||
items: [{ messageId: "a", top: 60, height: 40 }],
|
||||
})
|
||||
|
||||
expect(
|
||||
getElementScrollTop({
|
||||
align: "nearest",
|
||||
element: items[0],
|
||||
scrollMargin: 0,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
).toBe(50)
|
||||
})
|
||||
|
||||
it("scrolls up to reveal an element above the viewport when nearest", () => {
|
||||
stubComputedStyle()
|
||||
const { items, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
viewportTop: 0,
|
||||
scrollTop: 300,
|
||||
contentPaddingStart: 0,
|
||||
contentPaddingEnd: 0,
|
||||
// Relative top -100 means content-space top is 300 + (-100) = 200, which
|
||||
// is above viewportTop (scrollTop 300), so it must scroll up to 200.
|
||||
items: [{ messageId: "a", top: -100, height: 40 }],
|
||||
})
|
||||
|
||||
expect(
|
||||
getElementScrollTop({
|
||||
align: "nearest",
|
||||
element: items[0],
|
||||
scrollMargin: 0,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getContentBottom", () => {
|
||||
it("returns padding sum when there are no items", () => {
|
||||
stubComputedStyle()
|
||||
const { content, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
scrollTop: 0,
|
||||
contentPaddingStart: 12,
|
||||
contentPaddingEnd: 8,
|
||||
items: [],
|
||||
})
|
||||
|
||||
expect(getContentBottom({ content, spacer, viewport })).toBe(20)
|
||||
})
|
||||
|
||||
it("measures from the lowest item bottom plus end padding", () => {
|
||||
stubComputedStyle()
|
||||
const { content, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
viewportTop: 0,
|
||||
scrollTop: 30,
|
||||
contentPaddingStart: 0,
|
||||
contentPaddingEnd: 16,
|
||||
items: [
|
||||
{ messageId: "a", top: 0, height: 100 },
|
||||
{ messageId: "b", top: 100, height: 150 },
|
||||
],
|
||||
})
|
||||
|
||||
// Lowest item bottom in client space is 250; minus viewportRect.top (0)
|
||||
// plus scrollTop (30) plus padding.end (16) = 296.
|
||||
expect(getContentBottom({ content, spacer, viewport })).toBe(296)
|
||||
})
|
||||
|
||||
it("excludes the spacer from the measurement", () => {
|
||||
stubComputedStyle()
|
||||
const { content, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
scrollTop: 0,
|
||||
contentPaddingStart: 0,
|
||||
contentPaddingEnd: 0,
|
||||
items: [{ messageId: "a", top: 0, height: 80 }],
|
||||
})
|
||||
|
||||
// Give the spacer a tall rect; it must not raise contentBottom past 80.
|
||||
setRect(spacer, { top: 0, height: 9999 })
|
||||
|
||||
expect(getContentBottom({ content, spacer, viewport })).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getNewScrollAnchor / getLastScrollAnchor", () => {
|
||||
it("finds the first anchor at or after the previous item count", () => {
|
||||
const items = createItems([
|
||||
{ id: "a", anchor: true },
|
||||
{ id: "b" },
|
||||
{ id: "c", anchor: true },
|
||||
{ id: "d", anchor: true },
|
||||
])
|
||||
|
||||
// Starting from index 1 skips the anchor at index 0.
|
||||
expect(getNewScrollAnchor(items, 1)).toBe(items[2])
|
||||
})
|
||||
|
||||
it("returns null when no anchor exists after the boundary", () => {
|
||||
const items = createItems([
|
||||
{ id: "a", anchor: true },
|
||||
{ id: "b" },
|
||||
{ id: "c" },
|
||||
])
|
||||
|
||||
expect(getNewScrollAnchor(items, 1)).toBeNull()
|
||||
})
|
||||
|
||||
it("finds the last anchor scanning from the end", () => {
|
||||
const items = createItems([
|
||||
{ id: "a", anchor: true },
|
||||
{ id: "b", anchor: true },
|
||||
{ id: "c" },
|
||||
])
|
||||
|
||||
expect(getLastScrollAnchor(items)).toBe(items[1])
|
||||
})
|
||||
|
||||
it("returns null when there are no anchors", () => {
|
||||
const items = createItems([{ id: "a" }, { id: "b" }])
|
||||
|
||||
expect(getLastScrollAnchor(items)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUnanchoredScrollAnchor", () => {
|
||||
it("returns the first anchor that has not been handled yet", () => {
|
||||
const items = createItems([
|
||||
{ id: "a", anchor: true },
|
||||
{ id: "b", anchor: true },
|
||||
])
|
||||
const handled = new WeakSet<HTMLElement>()
|
||||
|
||||
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[0])
|
||||
|
||||
handled.add(items[0]!)
|
||||
|
||||
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[1])
|
||||
})
|
||||
|
||||
it("returns null when every anchor has already been handled", () => {
|
||||
const items = createItems([{ id: "a", anchor: true }])
|
||||
const handled = new WeakSet<HTMLElement>([items[0]!])
|
||||
|
||||
expect(getUnanchoredScrollAnchor(items, handled)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMessageScrollerScrollable", () => {
|
||||
it("cannot scroll toward either edge when viewport or content is missing", () => {
|
||||
const scrollable = getMessageScrollerScrollable({
|
||||
content: null,
|
||||
scrollEdgeThreshold: 8,
|
||||
spacer: null,
|
||||
viewport: null,
|
||||
})
|
||||
|
||||
expect(scrollable).toMatchObject({
|
||||
start: false,
|
||||
end: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("treats sub-threshold gaps at both edges as not scrollable", () => {
|
||||
stubComputedStyle()
|
||||
const { content, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
viewportTop: 0,
|
||||
// scrollTop 5 <= threshold 8, so it cannot scroll toward the start.
|
||||
scrollTop: 5,
|
||||
contentPaddingStart: 0,
|
||||
contentPaddingEnd: 0,
|
||||
// contentBottom 204; 204 - 5 - 200 = -1 <= 8, so it cannot scroll toward the end.
|
||||
items: [{ messageId: "a", top: 199, height: 0 }],
|
||||
})
|
||||
|
||||
const scrollable = getMessageScrollerScrollable({
|
||||
content,
|
||||
scrollEdgeThreshold: 8,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
|
||||
expect(scrollable).toMatchObject({
|
||||
start: false,
|
||||
end: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("can scroll toward both edges past the threshold", () => {
|
||||
stubComputedStyle()
|
||||
const { content, spacer, viewport } = createFixture({
|
||||
viewportHeight: 200,
|
||||
viewportTop: 0,
|
||||
// scrollTop 100 > threshold 8, so it can scroll toward the start.
|
||||
scrollTop: 100,
|
||||
contentPaddingStart: 0,
|
||||
contentPaddingEnd: 0,
|
||||
// contentBottom 1000; 1000 - 100 - 200 = 700 > 8, so it can scroll toward the end.
|
||||
items: [{ messageId: "a", top: 900, height: 100 }],
|
||||
})
|
||||
|
||||
const scrollable = getMessageScrollerScrollable({
|
||||
content,
|
||||
scrollEdgeThreshold: 8,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
|
||||
expect(scrollable).toMatchObject({
|
||||
start: true,
|
||||
end: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
387
packages/react/src/message-scroller/geometry.ts
Normal file
387
packages/react/src/message-scroller/geometry.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
|
||||
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
|
||||
SCROLL_POSITION_EPSILON,
|
||||
} from "./types"
|
||||
import type {
|
||||
MessageScrollerScrollable,
|
||||
MessageScrollerScrollAlign,
|
||||
MessageScrollerVisibilityState,
|
||||
} from "./types"
|
||||
|
||||
function getMessageScrollerScrollable({
|
||||
content,
|
||||
scrollEdgeThreshold,
|
||||
spacer,
|
||||
viewport,
|
||||
}: {
|
||||
content: HTMLElement | null
|
||||
scrollEdgeThreshold: number
|
||||
spacer: HTMLElement | null
|
||||
viewport: HTMLElement | null
|
||||
}): MessageScrollerScrollable {
|
||||
if (!viewport || !content) {
|
||||
return EMPTY_MESSAGE_SCROLLER_SCROLLABLE
|
||||
}
|
||||
|
||||
const contentBottom = getContentBottom({ content, spacer, viewport })
|
||||
|
||||
return {
|
||||
start: viewport.scrollTop > scrollEdgeThreshold,
|
||||
end:
|
||||
contentBottom - viewport.scrollTop - viewport.clientHeight >
|
||||
scrollEdgeThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageScrollerVisibilityState({
|
||||
content,
|
||||
scrollMargin,
|
||||
scrollPreviousItemPeek,
|
||||
spacer,
|
||||
viewport,
|
||||
visibleMessageIds,
|
||||
}: {
|
||||
content: HTMLElement | null
|
||||
scrollMargin: number
|
||||
scrollPreviousItemPeek: number
|
||||
spacer: HTMLElement | null
|
||||
viewport: HTMLElement | null
|
||||
visibleMessageIds: Set<string>
|
||||
}): MessageScrollerVisibilityState {
|
||||
if (!content || !viewport) {
|
||||
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
|
||||
}
|
||||
|
||||
const viewportRect = viewport.getBoundingClientRect()
|
||||
// The reading line sits scrollPreviousItemPeek below scrollMargin: anchored
|
||||
// turns land there with the previous turn peeking above. A row only peeking in
|
||||
// that band has not been read down to yet, so it counts as neither visible nor
|
||||
// current.
|
||||
const lineTop = viewportRect.top + scrollMargin + scrollPreviousItemPeek
|
||||
const trackByLayout = typeof IntersectionObserver === "undefined"
|
||||
|
||||
const visible: string[] = []
|
||||
let currentAnchorId: string | null = null
|
||||
|
||||
// Walk rows in document order so visible ids come out top-to-bottom.
|
||||
for (const item of getMessageScrollerItems(content, spacer)) {
|
||||
const messageId = item.dataset.messageId
|
||||
|
||||
if (!messageId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const isAnchor = item.dataset.scrollAnchor === "true"
|
||||
// Anchors need a rect to place the current line; non-anchors lean on the
|
||||
// observer set (or a rect in the no-observer fallback).
|
||||
const rect = isAnchor || trackByLayout ? item.getBoundingClientRect() : null
|
||||
|
||||
const isVisible =
|
||||
trackByLayout && rect
|
||||
? rect.bottom > lineTop && rect.top < viewportRect.bottom
|
||||
: visibleMessageIds.has(messageId)
|
||||
|
||||
if (isVisible) {
|
||||
visible.push(messageId)
|
||||
}
|
||||
|
||||
// Current is the last anchor to have reached the reading line: the turn you
|
||||
// scrolled to (placed at the line) wins over newer turns lower down, the
|
||||
// previous turn peeking above the line has been passed, and it stays current
|
||||
// even after its header scrolls above the viewport.
|
||||
if (isAnchor && rect && rect.top <= lineTop + SCROLL_POSITION_EPSILON) {
|
||||
currentAnchorId = messageId
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.length === 0 && currentAnchorId === null) {
|
||||
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
|
||||
}
|
||||
|
||||
return {
|
||||
currentAnchorId,
|
||||
visibleMessageIds: visible,
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageScrollerItems(
|
||||
content: HTMLElement,
|
||||
spacer: HTMLElement | null
|
||||
) {
|
||||
return Array.from(content.children).filter(
|
||||
(child): child is HTMLElement =>
|
||||
child instanceof HTMLElement && child !== spacer
|
||||
)
|
||||
}
|
||||
|
||||
function getNewScrollAnchor(items: HTMLElement[], previousItemCount: number) {
|
||||
for (let index = previousItemCount; index < items.length; index++) {
|
||||
const item = items[index]
|
||||
|
||||
if (item?.dataset.scrollAnchor === "true") {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getUnanchoredScrollAnchor(
|
||||
items: HTMLElement[],
|
||||
handledAnchors: { has(element: HTMLElement): boolean }
|
||||
) {
|
||||
for (const item of items) {
|
||||
if (item.dataset.scrollAnchor === "true" && !handledAnchors.has(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function hasMultipleNewScrollAnchors(
|
||||
items: HTMLElement[],
|
||||
previousItemCount: number
|
||||
) {
|
||||
let count = 0
|
||||
|
||||
for (let index = previousItemCount; index < items.length; index++) {
|
||||
const item = items[index]
|
||||
|
||||
if (item?.dataset.scrollAnchor !== "true") {
|
||||
continue
|
||||
}
|
||||
|
||||
count += 1
|
||||
|
||||
if (count > 1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getLastScrollAnchor(items: HTMLElement[]) {
|
||||
for (let index = items.length - 1; index >= 0; index--) {
|
||||
const item = items[index]
|
||||
|
||||
if (item?.dataset.scrollAnchor === "true") {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getFirstVisibleMessageItem({
|
||||
content,
|
||||
spacer,
|
||||
viewport,
|
||||
}: {
|
||||
content: HTMLElement
|
||||
spacer: HTMLElement | null
|
||||
viewport: HTMLElement
|
||||
}) {
|
||||
const viewportRect = viewport.getBoundingClientRect()
|
||||
|
||||
for (const item of getMessageScrollerItems(content, spacer)) {
|
||||
if (!item.dataset.messageId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rect = item.getBoundingClientRect()
|
||||
|
||||
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getElementScrollTop({
|
||||
align,
|
||||
element,
|
||||
scrollMargin,
|
||||
spacer,
|
||||
viewport,
|
||||
}: {
|
||||
align: MessageScrollerScrollAlign
|
||||
element: HTMLElement
|
||||
scrollMargin: number
|
||||
spacer: HTMLElement | null
|
||||
viewport: HTMLElement
|
||||
}) {
|
||||
const elementTop = getElementTop(element, viewport)
|
||||
const elementHeight = element.getBoundingClientRect().height
|
||||
const contentPadding = getContentBlockPadding(spacer)
|
||||
|
||||
if (align === "center") {
|
||||
const insetHeight = Math.max(
|
||||
0,
|
||||
viewport.clientHeight - contentPadding.start - contentPadding.end
|
||||
)
|
||||
|
||||
return (
|
||||
elementTop -
|
||||
contentPadding.start -
|
||||
(insetHeight - elementHeight) / 2 -
|
||||
scrollMargin
|
||||
)
|
||||
}
|
||||
|
||||
if (align === "end") {
|
||||
return (
|
||||
elementTop -
|
||||
viewport.clientHeight +
|
||||
elementHeight +
|
||||
contentPadding.end +
|
||||
scrollMargin
|
||||
)
|
||||
}
|
||||
|
||||
if (align === "nearest") {
|
||||
const elementBottom = elementTop + elementHeight
|
||||
const viewportTop = viewport.scrollTop + contentPadding.start
|
||||
const viewportBottom =
|
||||
viewport.scrollTop + viewport.clientHeight - contentPadding.end
|
||||
|
||||
if (elementTop >= viewportTop && elementBottom <= viewportBottom) {
|
||||
return viewport.scrollTop
|
||||
}
|
||||
|
||||
if (elementTop < viewportTop) {
|
||||
return elementTop - contentPadding.start - scrollMargin
|
||||
}
|
||||
|
||||
return (
|
||||
elementBottom - viewport.clientHeight + contentPadding.end + scrollMargin
|
||||
)
|
||||
}
|
||||
|
||||
return elementTop - contentPadding.start - scrollMargin
|
||||
}
|
||||
|
||||
function getElementTop(element: HTMLElement, viewport: HTMLElement) {
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
const viewportRect = viewport.getBoundingClientRect()
|
||||
|
||||
return elementRect.top - viewportRect.top + viewport.scrollTop
|
||||
}
|
||||
|
||||
function getElementViewportTop(element: HTMLElement, viewport: HTMLElement) {
|
||||
return (
|
||||
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
||||
)
|
||||
}
|
||||
|
||||
function getTailSpacerHeight({
|
||||
content,
|
||||
scrollTop,
|
||||
spacer,
|
||||
viewport,
|
||||
}: {
|
||||
content: HTMLElement
|
||||
scrollTop: number
|
||||
spacer: HTMLElement | null
|
||||
viewport: HTMLElement
|
||||
}) {
|
||||
const contentBottom = getContentBottom({ content, spacer, viewport })
|
||||
|
||||
return scrollTop + viewport.clientHeight - contentBottom
|
||||
}
|
||||
|
||||
function getContentBottom({
|
||||
content,
|
||||
spacer,
|
||||
viewport,
|
||||
}: {
|
||||
content: HTMLElement
|
||||
spacer: HTMLElement | null
|
||||
viewport: HTMLElement
|
||||
}) {
|
||||
const items = getMessageScrollerItems(content, spacer)
|
||||
const padding = getBlockPadding(content)
|
||||
const viewportRect = viewport.getBoundingClientRect()
|
||||
const scrollTop = viewport.scrollTop
|
||||
let contentBottom = padding.start + padding.end
|
||||
|
||||
for (const item of items) {
|
||||
const rect = item.getBoundingClientRect()
|
||||
|
||||
contentBottom = Math.max(
|
||||
contentBottom,
|
||||
rect.bottom - viewportRect.top + scrollTop + padding.end
|
||||
)
|
||||
}
|
||||
|
||||
return contentBottom
|
||||
}
|
||||
|
||||
function getMaxScrollTop(viewport: HTMLElement) {
|
||||
return Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
}
|
||||
|
||||
function getBlockPadding(element: HTMLElement) {
|
||||
const style = window.getComputedStyle(element)
|
||||
|
||||
return {
|
||||
end: readCssPixel(style.paddingBlockEnd || style.paddingBottom),
|
||||
start: readCssPixel(style.paddingBlockStart || style.paddingTop),
|
||||
}
|
||||
}
|
||||
|
||||
function getContentBlockPadding(spacer: HTMLElement | null) {
|
||||
const content = spacer?.parentElement
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
end: 0,
|
||||
start: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return getBlockPadding(content)
|
||||
}
|
||||
|
||||
function getFlexGap(element: HTMLElement | null) {
|
||||
if (!element) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
const gap = style.rowGap === "normal" ? style.gap : style.rowGap
|
||||
|
||||
return readCssPixel(gap)
|
||||
}
|
||||
|
||||
function readCssPixel(value: string | undefined) {
|
||||
if (!value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const number = Number.parseFloat(value)
|
||||
|
||||
return Number.isFinite(number) ? number : 0
|
||||
}
|
||||
|
||||
export {
|
||||
getContentBlockPadding,
|
||||
getContentBottom,
|
||||
getElementScrollTop,
|
||||
getElementTop,
|
||||
getElementViewportTop,
|
||||
getFirstVisibleMessageItem,
|
||||
getFlexGap,
|
||||
getLastScrollAnchor,
|
||||
getMaxScrollTop,
|
||||
getMessageScrollerItems,
|
||||
getMessageScrollerScrollable,
|
||||
getMessageScrollerVisibilityState,
|
||||
getNewScrollAnchor,
|
||||
getTailSpacerHeight,
|
||||
getUnanchoredScrollAnchor,
|
||||
hasMultipleNewScrollAnchors,
|
||||
}
|
||||
31
packages/react/src/message-scroller/index.ts
Normal file
31
packages/react/src/message-scroller/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
MessageScrollerButton as Button,
|
||||
MessageScrollerContent as Content,
|
||||
MessageScrollerItem as Item,
|
||||
MessageScrollerProvider as Provider,
|
||||
MessageScroller as Root,
|
||||
MessageScrollerViewport as Viewport,
|
||||
} from "./components"
|
||||
|
||||
export const MessageScroller = {
|
||||
Provider,
|
||||
Root,
|
||||
Viewport,
|
||||
Content,
|
||||
Item,
|
||||
Button,
|
||||
}
|
||||
|
||||
export {
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
useMessageScrollerVisibility,
|
||||
} from "./components"
|
||||
|
||||
export type {
|
||||
MessageScrollerDefaultScrollPosition,
|
||||
MessageScrollerScrollAlign,
|
||||
MessageScrollerScrollOptions,
|
||||
MessageScrollerScrollable,
|
||||
MessageScrollerVisibilityState,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,698 @@
|
||||
import * as React from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { createRoot, type Root } from "react-dom/client"
|
||||
import { afterEach, expect, test } from "vitest"
|
||||
|
||||
import {
|
||||
MessageScroller as MessageScrollerPrimitive,
|
||||
useMessageScroller,
|
||||
useMessageScrollerVisibility,
|
||||
} from "."
|
||||
|
||||
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
|
||||
const MessageScroller = MessageScrollerPrimitive.Root
|
||||
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
|
||||
const MessageScrollerContent = MessageScrollerPrimitive.Content
|
||||
const MessageScrollerItem = MessageScrollerPrimitive.Item
|
||||
const MessageScrollerButton = MessageScrollerPrimitive.Button
|
||||
|
||||
// Real-browser regression for the prepend double-compensation bug. In Chromium
|
||||
// native scroll anchoring shifts scrollTop on prepend; the component's restore
|
||||
// must NOT compensate again. This is invisible to jsdom: with no native
|
||||
// anchoring there, the viewport-relative and content-relative measurements
|
||||
// produce the same number, so the bug and the fix look identical. Only a real
|
||||
// engine with scroll anchoring can tell them apart.
|
||||
|
||||
// This component is driven by the event loop — requestAnimationFrame,
|
||||
// ResizeObserver, IntersectionObserver — none of which act() can wrap. The suite
|
||||
// flushes renders synchronously with flushSync and settles the async scroll work
|
||||
// on real frames via settle(), so it runs OUTSIDE the act environment that
|
||||
// vitest.browser.setup.ts enables globally (vitest isolates files, so other
|
||||
// suites keep it on). Without this, the rAF-driven visibility subscriber logs
|
||||
// spurious "not wrapped in act" warnings even though the assertions, which read
|
||||
// only after settle(), are correct.
|
||||
|
||||
;(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = false
|
||||
|
||||
const ITEM_HEIGHT = 80
|
||||
const VIEWPORT_HEIGHT = 200
|
||||
|
||||
type TestItem = {
|
||||
height?: number
|
||||
id: string
|
||||
scrollAnchor?: boolean
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
afterEach(() => {
|
||||
root?.unmount()
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
function Thread({
|
||||
autoScroll,
|
||||
defaultScrollPosition,
|
||||
items,
|
||||
scrollPreviousItemPeek,
|
||||
showButton = false,
|
||||
showJumpButton = false,
|
||||
showVisibility = false,
|
||||
}: {
|
||||
autoScroll?: boolean
|
||||
defaultScrollPosition?: React.ComponentProps<
|
||||
typeof MessageScrollerProvider
|
||||
>["defaultScrollPosition"]
|
||||
items: TestItem[]
|
||||
scrollPreviousItemPeek?: number
|
||||
showButton?: boolean
|
||||
showJumpButton?: boolean
|
||||
showVisibility?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MessageScrollerProvider
|
||||
autoScroll={autoScroll}
|
||||
defaultScrollPosition={defaultScrollPosition}
|
||||
scrollPreviousItemPeek={scrollPreviousItemPeek}
|
||||
>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport
|
||||
aria-label="viewport"
|
||||
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
|
||||
>
|
||||
<MessageScrollerContent
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<MessageScrollerItem
|
||||
key={item.id}
|
||||
messageId={item.id}
|
||||
scrollAnchor={item.scrollAnchor}
|
||||
style={{
|
||||
height: item.height ?? ITEM_HEIGHT,
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
{item.id}
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
{showButton ? (
|
||||
<MessageScrollerButton behavior="auto">
|
||||
Scroll to end
|
||||
</MessageScrollerButton>
|
||||
) : null}
|
||||
{showJumpButton ? <JumpButton messageId="m5" /> : null}
|
||||
{showVisibility ? <VisibilityProbe /> : null}
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function VisibilityProbe() {
|
||||
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="visibility"
|
||||
data-current-anchor={currentAnchorId ?? ""}
|
||||
data-visible={visibleMessageIds.join(",")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const MemoMessageItem = React.memo(function MemoMessageItem({
|
||||
id,
|
||||
anchor,
|
||||
}: {
|
||||
id: string
|
||||
anchor: boolean
|
||||
}) {
|
||||
return (
|
||||
<MessageScrollerItem
|
||||
messageId={id}
|
||||
scrollAnchor={anchor}
|
||||
style={{ height: ITEM_HEIGHT, flex: "none" }}
|
||||
>
|
||||
{id}
|
||||
</MessageScrollerItem>
|
||||
)
|
||||
})
|
||||
|
||||
function JumpButton({ messageId }: { messageId: string }) {
|
||||
const { scrollToMessage } = useMessageScroller()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
scrollToMessage(messageId, { align: "start", behavior: "auto" })
|
||||
}}
|
||||
>
|
||||
Jump to message
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve after a few real animation frames so the component's rAF-scheduled
|
||||
// scroll work and the browser's native anchoring both settle.
|
||||
function settle(frames = 4) {
|
||||
return new Promise<void>((resolve) => {
|
||||
let remaining = frames
|
||||
const tick = () =>
|
||||
remaining-- <= 0 ? resolve() : requestAnimationFrame(tick)
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
}
|
||||
|
||||
function viewportOffsetOf(messageId: string, viewport: HTMLElement) {
|
||||
const item = document.querySelector(
|
||||
`[data-message-id="${messageId}"]`
|
||||
) as HTMLElement
|
||||
return Math.round(
|
||||
item.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
||||
)
|
||||
}
|
||||
|
||||
function getViewport() {
|
||||
return document.querySelector('[aria-label="viewport"]') as HTMLElement
|
||||
}
|
||||
|
||||
function getDistanceToBottom(viewport: HTMLElement) {
|
||||
return Math.round(
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
|
||||
)
|
||||
}
|
||||
|
||||
function getScrollTop(viewport: HTMLElement) {
|
||||
return Math.round(viewport.scrollTop)
|
||||
}
|
||||
|
||||
function getCurrentAnchor() {
|
||||
return document
|
||||
.querySelector('[data-testid="visibility"]')!
|
||||
.getAttribute("data-current-anchor")
|
||||
}
|
||||
|
||||
function getVisibleIds() {
|
||||
const value =
|
||||
document
|
||||
.querySelector('[data-testid="visibility"]')!
|
||||
.getAttribute("data-visible") ?? ""
|
||||
return value ? value.split(",") : []
|
||||
}
|
||||
|
||||
async function renderThread(props: React.ComponentProps<typeof Thread>) {
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
flushSync(() => {
|
||||
root!.render(<Thread {...props} />)
|
||||
})
|
||||
await settle()
|
||||
}
|
||||
|
||||
function createItems(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
}))
|
||||
}
|
||||
|
||||
test("keeps the visible message in place when older messages are prepended", async () => {
|
||||
const initial = createItems(8)
|
||||
|
||||
await renderThread({ items: initial })
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
// Scroll into the middle so there is content above and below; m3 lands at the
|
||||
// top of the viewport and becomes the preserved anchor.
|
||||
viewport.scrollTop = 3 * ITEM_HEIGHT
|
||||
await settle()
|
||||
|
||||
const offsetBefore = viewportOffsetOf("m3", viewport)
|
||||
|
||||
// Prepend three older rows above the current scroll position.
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<Thread items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]} />
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
const offsetAfter = viewportOffsetOf("m3", viewport)
|
||||
|
||||
// The tracked message must not move within the viewport. With the old
|
||||
// content-relative restore it jumps up by the prepended height (~240px).
|
||||
expect(Math.abs(offsetAfter - offsetBefore)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("opens at the bottom by default", async () => {
|
||||
await renderThread({ items: createItems(8) })
|
||||
|
||||
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("keeps auto-scroll pinned when the final message grows", async () => {
|
||||
const initial = createItems(6)
|
||||
|
||||
await renderThread({ autoScroll: true, items: initial })
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<Thread
|
||||
autoScroll
|
||||
items={initial.map((item, index) =>
|
||||
index === initial.length - 1 ? { ...item, height: 240 } : item
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("keeps the end pinned when bulk appending anchored turns with autoScroll", async () => {
|
||||
const initial = createItems(6)
|
||||
|
||||
await renderThread({ autoScroll: true, items: initial })
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<Thread
|
||||
autoScroll
|
||||
items={[
|
||||
...initial,
|
||||
{ id: "new-user-1", scrollAnchor: true },
|
||||
{ id: "new-assistant-1" },
|
||||
{ id: "new-user-2", scrollAnchor: true },
|
||||
{ id: "new-assistant-2" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("does not keep the end pinned when appending after the default bottom open", async () => {
|
||||
const initial = createItems(6)
|
||||
|
||||
await renderThread({ items: initial })
|
||||
|
||||
const viewport = getViewport()
|
||||
const scrollTop = getScrollTop(viewport)
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(<Thread items={[...initial, { id: "new" }]} />)
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(getScrollTop(viewport)).toBe(scrollTop)
|
||||
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("scroll button moves the viewport to the end", async () => {
|
||||
await renderThread({
|
||||
defaultScrollPosition: "start",
|
||||
items: createItems(8),
|
||||
showButton: true,
|
||||
})
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
expect(getScrollTop(viewport)).toBe(0)
|
||||
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
|
||||
|
||||
const button = document.querySelector("button") as HTMLButtonElement
|
||||
|
||||
button.click()
|
||||
await settle()
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("restores the last scroll anchor when the final turn overflows", async () => {
|
||||
await renderThread({
|
||||
defaultScrollPosition: "last-anchor",
|
||||
items: [
|
||||
{ id: "m0" },
|
||||
{ id: "m1" },
|
||||
{ id: "last-user", scrollAnchor: true },
|
||||
{ id: "last-assistant", height: 360 },
|
||||
],
|
||||
scrollPreviousItemPeek: 0,
|
||||
})
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
expect(viewportOffsetOf("last-user", viewport)).toBeLessThanOrEqual(1)
|
||||
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("falls back to the end when the last anchored turn fits", async () => {
|
||||
await renderThread({
|
||||
defaultScrollPosition: "last-anchor",
|
||||
items: [
|
||||
...createItems(5),
|
||||
{ id: "last-user", scrollAnchor: true },
|
||||
{ id: "last-assistant" },
|
||||
],
|
||||
scrollPreviousItemPeek: 0,
|
||||
})
|
||||
|
||||
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("scrolls to a mounted message by id", async () => {
|
||||
await renderThread({
|
||||
defaultScrollPosition: "start",
|
||||
items: createItems(8),
|
||||
showJumpButton: true,
|
||||
})
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
const button = document.querySelector("button") as HTMLButtonElement
|
||||
|
||||
button.click()
|
||||
await settle()
|
||||
|
||||
expect(viewportOffsetOf("m5", viewport)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("preserves a scrolled-to turn across a prepend (command-path anchor)", async () => {
|
||||
// Characterization lock for the Phase-5 anchor-ref boundary: scrollToMessage
|
||||
// sets the preserve anchor OUTSIDE a content change (the command path), unlike
|
||||
// every other prepend test which scrolls directly. Jumping then prepending
|
||||
// must keep the jumped-to turn fixed, not lose its anchor.
|
||||
const initial = createItems(12)
|
||||
|
||||
await renderThread({
|
||||
defaultScrollPosition: "start",
|
||||
items: initial,
|
||||
showJumpButton: true,
|
||||
})
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
;(document.querySelector("button") as HTMLButtonElement).click()
|
||||
await settle()
|
||||
|
||||
const offsetBefore = viewportOffsetOf("m5", viewport)
|
||||
expect(offsetBefore).toBeLessThanOrEqual(1)
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<Thread
|
||||
defaultScrollPosition="start"
|
||||
items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]}
|
||||
showJumpButton
|
||||
/>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(
|
||||
Math.abs(viewportOffsetOf("m5", viewport) - offsetBefore)
|
||||
).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("user scroll intent cancels follow-bottom", async () => {
|
||||
const initial = createItems(8)
|
||||
|
||||
await renderThread({ autoScroll: true, items: initial })
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
|
||||
viewport.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, deltaY: -ITEM_HEIGHT })
|
||||
)
|
||||
viewport.scrollTop = 0
|
||||
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||
await settle()
|
||||
|
||||
expect(getScrollTop(viewport)).toBe(0)
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(<Thread autoScroll items={[...initial, { id: "new" }]} />)
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(getScrollTop(viewport)).toBe(0)
|
||||
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("tracks the current anchor as it scrolls above the viewport", async () => {
|
||||
// Every tenth row is a turn-start anchor: m0, m10, m20.
|
||||
const items = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
scrollAnchor: index % 10 === 0,
|
||||
}))
|
||||
|
||||
await renderThread({
|
||||
defaultScrollPosition: "start",
|
||||
items,
|
||||
showVisibility: true,
|
||||
})
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
// Opened at the top → the first anchor is current. The subscribed probe
|
||||
// settles on a post-mount rAF, so the value is only correct after the settle()
|
||||
// that renderThread awaits.
|
||||
expect(getCurrentAnchor()).toBe("m0")
|
||||
|
||||
// Scroll until m10 is fully above the viewport. It must stay current even
|
||||
// though it is no longer visible — the behavior the old activeMessageId (and
|
||||
// jsdom) could not express.
|
||||
viewport.scrollTop = 900
|
||||
await settle()
|
||||
|
||||
expect(getCurrentAnchor()).toBe("m10")
|
||||
expect(getVisibleIds()).not.toContain("m10")
|
||||
|
||||
// Scrolling past the next anchor advances the cursor.
|
||||
viewport.scrollTop = 1700
|
||||
await settle()
|
||||
|
||||
expect(getCurrentAnchor()).toBe("m20")
|
||||
})
|
||||
|
||||
test("keeps the anchor at the reading line current over lower visible anchors", async () => {
|
||||
// Every row is a turn-start anchor, so several are on screen at once — the
|
||||
// scrollToMessage case. Jumping to a turn must keep IT current even though
|
||||
// newer anchors sit below it and are still visible (the regression where the
|
||||
// lowest visible anchor wrongly stole "current").
|
||||
const items = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
scrollAnchor: true,
|
||||
}))
|
||||
|
||||
await renderThread({
|
||||
defaultScrollPosition: "start",
|
||||
items,
|
||||
showVisibility: true,
|
||||
})
|
||||
|
||||
const viewport = getViewport()
|
||||
|
||||
// Bring m5 up to the reading line; m6 and m7 sit below it, also visible.
|
||||
viewport.scrollTop = 5 * ITEM_HEIGHT
|
||||
await settle()
|
||||
|
||||
expect(getCurrentAnchor()).toBe("m5")
|
||||
// m6 is on screen but below the line — it must not steal current.
|
||||
expect(getVisibleIds()).toContain("m6")
|
||||
})
|
||||
|
||||
test("visibility populates under StrictMode (frame ref + lifecycle survive remount)", async () => {
|
||||
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
|
||||
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
// StrictMode double-invokes mount → unmount → remount on the same refs, which
|
||||
// is what wedges the rAF scheduler and tears the subscription in Next dev.
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<React.StrictMode>
|
||||
<MessageScrollerProvider defaultScrollPosition="start">
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport
|
||||
aria-label="viewport"
|
||||
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
|
||||
>
|
||||
<MessageScrollerContent
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{ids.map((id, index) => (
|
||||
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<VisibilityProbe />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
// Force a recompute after StrictMode's mount churn.
|
||||
getViewport().scrollTop = 0
|
||||
getViewport().dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||
await settle()
|
||||
|
||||
expect(getCurrentAnchor()).toBe("m0")
|
||||
expect(getVisibleIds().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("tracks visibility through memoized item components", async () => {
|
||||
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
|
||||
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<MessageScrollerProvider defaultScrollPosition="start">
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport
|
||||
aria-label="viewport"
|
||||
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
|
||||
>
|
||||
<MessageScrollerContent
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{ids.map((id, index) => (
|
||||
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<VisibilityProbe />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
// currentAnchorId scans the DOM (memo-independent). visibleMessageIds rides
|
||||
// registration + the real IntersectionObserver — the path memo could break.
|
||||
expect(getCurrentAnchor()).toBe("m0")
|
||||
expect(getVisibleIds().length).toBeGreaterThan(0)
|
||||
expect(getVisibleIds()).toContain("m0")
|
||||
})
|
||||
|
||||
test("an anchored turn holds at the top when content below it collapses", async () => {
|
||||
const peek = 32
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
const base = [
|
||||
{ id: "m0", height: 300 },
|
||||
{ id: "m1", height: 300 },
|
||||
{ id: "m2", height: 300 },
|
||||
]
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(<Thread items={base} scrollPreviousItemPeek={peek} />)
|
||||
})
|
||||
await settle()
|
||||
|
||||
// A new turn (anchor) arrives with a transient marker below it — like the
|
||||
// demo's "Thinking..." placeholder — so it has content below to reach the top.
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<Thread
|
||||
scrollPreviousItemPeek={peek}
|
||||
items={[
|
||||
...base,
|
||||
{ id: "turn", height: 80, scrollAnchor: true },
|
||||
{ id: "marker", height: 100 },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
|
||||
|
||||
// The marker is replaced by an empty reply that will stream in: the content
|
||||
// below the turn collapses. The turn must stay pinned at the top, not drop as
|
||||
// the browser clamps scrollTop to the shorter content.
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<Thread
|
||||
scrollPreviousItemPeek={peek}
|
||||
items={[
|
||||
...base,
|
||||
{ id: "turn", height: 80, scrollAnchor: true },
|
||||
{ id: "reply", height: 0 },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
await settle()
|
||||
|
||||
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
|
||||
})
|
||||
|
||||
test("auto-scroll and content updates survive a StrictMode remount", async () => {
|
||||
// StrictMode mounts, unmounts, then remounts on the same refs. The existing
|
||||
// StrictMode test covers the visibility frame; this one covers the rest of the
|
||||
// machinery — the state + scroll frame refs must reset on the remount so
|
||||
// follow-bottom keeps working, not just on first mount.
|
||||
const items = createItems(6)
|
||||
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<React.StrictMode>
|
||||
<Thread autoScroll items={items} showVisibility />
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
const viewport = getViewport()
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
|
||||
// A new message after the remount still drives the content-change + scroll path.
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<React.StrictMode>
|
||||
<Thread autoScroll items={[...items, { id: "new" }]} showVisibility />
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
|
||||
expect(getVisibleIds().length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -0,0 +1,841 @@
|
||||
import * as React from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { createRoot, type Root } from "react-dom/client"
|
||||
import { afterEach, expect, test } from "vitest"
|
||||
|
||||
import {
|
||||
MessageScroller as MessageScrollerPrimitive,
|
||||
useMessageScrollerVisibility,
|
||||
} from "."
|
||||
import { getMessageScrollerScrollable } from "./geometry"
|
||||
|
||||
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
|
||||
const MessageScroller = MessageScrollerPrimitive.Root
|
||||
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
|
||||
const MessageScrollerContent = MessageScrollerPrimitive.Content
|
||||
const MessageScrollerItem = MessageScrollerPrimitive.Item
|
||||
|
||||
// Performance proof for MessageScroller at scale. Two layers:
|
||||
//
|
||||
// Layer 1 isolates the algorithm: getMessageScrollerScrollable() drives the O(n)
|
||||
// getContentBottom() scan that runs on every scroll commit. We measure its cost
|
||||
// against a real laid-out DOM as message count grows, forcing a reflow each
|
||||
// iteration the way real scrolling does.
|
||||
//
|
||||
// Layer 2 is the integration proof: a real top-to-bottom scroll on a large
|
||||
// transcript, timing the synchronous scroll handler (where the geometry runs)
|
||||
// and the resulting frame cadence, with both scroll state and visibility
|
||||
// subscribed like a real chat app.
|
||||
//
|
||||
// jsdom cannot run either: it stubs getBoundingClientRect to a free property
|
||||
// read, so the forced layout that IS the cost here would not exist.
|
||||
|
||||
const ITEM_HEIGHT = 48
|
||||
const VIEWPORT_HEIGHT = 480
|
||||
const FRAME_BUDGET = 16 // One 60fps frame in ms.
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
// Performance benchmarks are not React act() tests: they drive real
|
||||
// requestAnimationFrame / IntersectionObserver and measure wall-clock, and never
|
||||
// assert on rendered output. Opt this file out of the act environment that
|
||||
// vitest.browser.setup.ts enables globally — vitest isolates files, so the
|
||||
// behavior tests keep it on. Renders we time are flushed synchronously with
|
||||
// flushSync (which runs layout effects in-band); everything else settles on real
|
||||
// frames. Without this flag, the live visibility subscriber's rAF/IO-driven store
|
||||
// updates — which act() cannot wrap — would log spurious "not wrapped in act"
|
||||
// warnings.
|
||||
;(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = false
|
||||
|
||||
afterEach(() => {
|
||||
root?.unmount()
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
function VisibilityProbe() {
|
||||
// Subscribing turns on the visibility recompute path, so Layer 2 reflects a
|
||||
// real app that reads currentAnchorId, not just the scroll-state path.
|
||||
const { currentAnchorId } = useMessageScrollerVisibility()
|
||||
|
||||
return (
|
||||
<div data-testid="visibility" data-current-anchor={currentAnchorId ?? ""} />
|
||||
)
|
||||
}
|
||||
|
||||
function Thread({
|
||||
count,
|
||||
showVisibility = false,
|
||||
}: {
|
||||
count: number
|
||||
showVisibility?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MessageScrollerProvider defaultScrollPosition="start">
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport
|
||||
aria-label="viewport"
|
||||
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
|
||||
>
|
||||
<MessageScrollerContent
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<MessageScrollerItem
|
||||
key={index}
|
||||
messageId={`m${index}`}
|
||||
// Every eighth row is a turn-start anchor, like real transcripts.
|
||||
scrollAnchor={index % 8 === 0}
|
||||
style={{ height: ITEM_HEIGHT, flex: "none" }}
|
||||
>
|
||||
{`m${index}`}
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
{showVisibility ? <VisibilityProbe /> : null}
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
async function mountThread(count: number, showVisibility = false) {
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
flushSync(() => {
|
||||
root!.render(<Thread count={count} showVisibility={showVisibility} />)
|
||||
})
|
||||
// Let the component's mount-time scroll work settle before measuring.
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
|
||||
const viewport = document.querySelector(
|
||||
'[aria-label="viewport"]'
|
||||
) as HTMLElement
|
||||
const content = viewport.querySelector('[role="log"]') as HTMLElement
|
||||
const spacer = content.querySelector(
|
||||
"[data-message-scroller-spacer]"
|
||||
) as HTMLElement
|
||||
|
||||
return { content, spacer, viewport }
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
function percentile(values: number[], p: number) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[
|
||||
Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))
|
||||
]
|
||||
}
|
||||
|
||||
function round(value: number) {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
test("Layer 1 — getContentBottom scan stays sub-frame as message count grows", async () => {
|
||||
const counts = [100, 500, 1000, 2000]
|
||||
const iterations = 200
|
||||
const rows: Array<{
|
||||
n: number
|
||||
medianMs: number
|
||||
p95Ms: number
|
||||
maxMs: number
|
||||
}> = []
|
||||
|
||||
for (const n of counts) {
|
||||
const { content, spacer, viewport } = await mountThread(n)
|
||||
const samples: number[] = []
|
||||
|
||||
// Warm up so JIT and first-layout costs do not pollute the samples.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
getMessageScrollerScrollable({
|
||||
content,
|
||||
scrollEdgeThreshold: 8,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// Toggle scrollTop to dirty layout, so the getBoundingClientRect calls
|
||||
// inside getMessageScrollerScrollable force a real reflow — exactly what
|
||||
// happens frame-to-frame during a real scroll. Without this the browser
|
||||
// would serve cached rects and the benchmark would measure nothing.
|
||||
viewport.scrollTop = 1000 + (i % 2)
|
||||
|
||||
const start = performance.now()
|
||||
getMessageScrollerScrollable({
|
||||
content,
|
||||
scrollEdgeThreshold: 8,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
samples.push(performance.now() - start)
|
||||
}
|
||||
|
||||
rows.push({
|
||||
n,
|
||||
medianMs: round(median(samples)),
|
||||
p95Ms: round(percentile(samples, 95)),
|
||||
maxMs: round(Math.max(...samples)),
|
||||
})
|
||||
|
||||
root?.unmount()
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"\n[Layer 1] getMessageScrollerScrollable cost per scroll commit (real reflow):\n" +
|
||||
rows
|
||||
.map(
|
||||
(r) =>
|
||||
` n=${String(r.n).padStart(4)} median ${String(
|
||||
r.medianMs
|
||||
).padStart(
|
||||
6
|
||||
)}ms p95 ${String(r.p95Ms).padStart(6)}ms max ${String(
|
||||
r.maxMs
|
||||
).padStart(6)}ms`
|
||||
)
|
||||
.join("\n") +
|
||||
"\n"
|
||||
)
|
||||
|
||||
// Proof: even at 2000 rendered rows a single commit stays under one frame,
|
||||
// with generous CI slack. The logged median is the real headline number.
|
||||
const worst = rows[rows.length - 1]
|
||||
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
|
||||
})
|
||||
|
||||
test("Layer 2 — real top-to-bottom scroll on 1000 messages holds frame budget", async () => {
|
||||
const COUNT = 1000
|
||||
const STEPS = 120
|
||||
|
||||
const { viewport } = await mountThread(COUNT, true)
|
||||
|
||||
// Capture any main-thread long task (>50ms by spec) during the scroll.
|
||||
const longTasks: number[] = []
|
||||
let observer: PerformanceObserver | null = null
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
longTasks.push(entry.duration)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ["longtask"] })
|
||||
} catch {
|
||||
// longtask not supported — frame timing below is still the primary proof.
|
||||
}
|
||||
|
||||
const max = viewport.scrollHeight - viewport.clientHeight
|
||||
const handlerCosts: number[] = []
|
||||
const frameDeltas: number[] = []
|
||||
|
||||
let last = performance.now()
|
||||
|
||||
for (let i = 1; i <= STEPS; i++) {
|
||||
viewport.scrollTop = Math.round((max * i) / STEPS)
|
||||
|
||||
// The scroll handler runs synchronously inside dispatch:
|
||||
// syncAfterScroll -> commitScrollState -> getContentBottom. Timing the
|
||||
// dispatch is timing the exact main-thread block a user scroll causes.
|
||||
const start = performance.now()
|
||||
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||
handlerCosts.push(performance.now() - start)
|
||||
|
||||
await nextFrame()
|
||||
const now = performance.now()
|
||||
frameDeltas.push(now - last)
|
||||
last = now
|
||||
}
|
||||
|
||||
observer?.disconnect()
|
||||
|
||||
const stats = {
|
||||
handlerMedianMs: round(median(handlerCosts)),
|
||||
handlerP95Ms: round(percentile(handlerCosts, 95)),
|
||||
handlerMaxMs: round(Math.max(...handlerCosts)),
|
||||
frameMedianMs: round(median(frameDeltas)),
|
||||
frameP95Ms: round(percentile(frameDeltas, 95)),
|
||||
frameMaxMs: round(Math.max(...frameDeltas)),
|
||||
longTasks: longTasks.length,
|
||||
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`\n[Layer 2] scrolling ${COUNT} messages top-to-bottom over ${STEPS} steps` +
|
||||
" (scroll state + visibility live):\n" +
|
||||
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
|
||||
` frame interval median ${stats.frameMedianMs}ms p95 ${stats.frameP95Ms}ms max ${stats.frameMaxMs}ms\n` +
|
||||
` long tasks ${stats.longTasks}${
|
||||
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
|
||||
}\n`
|
||||
)
|
||||
|
||||
// Proof: the synchronous scroll handler never spends a whole frame, so it
|
||||
// cannot drop one. Median is the headline; p95 carries generous CI slack.
|
||||
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
|
||||
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
|
||||
})
|
||||
|
||||
test("Regression guard — one scroll commit does O(items) layout reads, not O(items^2)", async () => {
|
||||
// Timing budgets are a coarse, hardware-dependent backstop. THIS is the real
|
||||
// regression guard: the number of forced layout reads per scroll commit is
|
||||
// deterministic and machine-independent. getContentBottom reads one rect per
|
||||
// item (plus a small constant), so the ratio stays ~O(1) per item. An O(n^2)
|
||||
// regression — e.g. a nested rect loop — would push it far past the bound.
|
||||
const ITEMS = 2000
|
||||
const { viewport } = await mountThread(ITEMS)
|
||||
|
||||
const originalRect = Element.prototype.getBoundingClientRect
|
||||
let calls = 0
|
||||
Element.prototype.getBoundingClientRect = function getBoundingClientRectSpy(
|
||||
this: Element
|
||||
) {
|
||||
calls++
|
||||
return originalRect.call(this)
|
||||
}
|
||||
|
||||
try {
|
||||
// One synchronous scroll commit (handleScroll runs inside dispatch).
|
||||
viewport.scrollTop = 1234
|
||||
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||
} finally {
|
||||
Element.prototype.getBoundingClientRect = originalRect
|
||||
}
|
||||
|
||||
const perItem = round(calls / ITEMS)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`\n[Regression guard] ${calls} getBoundingClientRect calls for ${ITEMS} items` +
|
||||
` (${perItem}/item) in one scroll commit\n`
|
||||
)
|
||||
|
||||
// A handful of rect reads per item is fine; anything that scales with item
|
||||
// count (O(n) per item -> O(n^2) total) blows past this at 2000 items.
|
||||
expect(perItem).toBeLessThan(4)
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Layer 3 — streaming. The one path where heavy DOM should actually bite: an
|
||||
// assistant reply GROWS token-by-token at the bottom of a long transcript.
|
||||
// Growth dirties layout, so handleContentChange + the ResizeObserver-driven
|
||||
// handleResize run while a full reflow is genuinely required — unlike steady
|
||||
// scroll, which never dirties layout.
|
||||
//
|
||||
// To isolate the SCROLLER's cost from the markdown's own render cost (they
|
||||
// interleave in one React commit), we run the identical growth twice: once
|
||||
// inside MessageScroller (auto-follow) and once inside a plain scroll div
|
||||
// (manual follow). Prior history is memoized in both, like a real app, so only
|
||||
// the growing reply re-renders. The delta is what the scroller adds per token.
|
||||
// ===========================================================================
|
||||
|
||||
const PRIOR_TURNS = 150
|
||||
const STREAM_CHUNKS = 60
|
||||
|
||||
function StreamingReplyBody({ chunks }: { chunks: number }) {
|
||||
// Each token batch appends a block, so the reply grows and dirties layout.
|
||||
return (
|
||||
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
|
||||
{Array.from({ length: chunks }, (_, index) =>
|
||||
index % 5 === 4 ? (
|
||||
<CodeBlock key={index} lines={6} />
|
||||
) : (
|
||||
<p key={index} style={{ margin: "0 0 8px" }}>
|
||||
{`Streaming sentence ${index} that adds height to the reply as it grows. `.repeat(
|
||||
2
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoized prior history — stable across token renders, so streaming only
|
||||
// re-renders the growing reply, exactly like a real memoized message list.
|
||||
const PriorTurnsScroller = React.memo(function PriorTurnsScroller() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<MessageScrollerItem
|
||||
messageId={`u${index}`}
|
||||
scrollAnchor
|
||||
style={{
|
||||
flex: "none",
|
||||
padding: "12px 16px",
|
||||
background: "#fafafa",
|
||||
}}
|
||||
>
|
||||
{`Question ${index}`}
|
||||
</MessageScrollerItem>
|
||||
<MessageScrollerItem messageId={`a${index}`} style={{ flex: "none" }}>
|
||||
<HeavyAssistantMessage seed={index} />
|
||||
</MessageScrollerItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const PriorTurnsPlain = React.memo(function PriorTurnsPlain() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div style={{ padding: "12px 16px", background: "#fafafa" }}>
|
||||
{`Question ${index}`}
|
||||
</div>
|
||||
<div>
|
||||
<HeavyAssistantMessage seed={index} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function ScrollerStream({ chunks }: { chunks: number }) {
|
||||
return (
|
||||
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport
|
||||
aria-label="viewport"
|
||||
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
|
||||
>
|
||||
<MessageScrollerContent
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<PriorTurnsScroller />
|
||||
<MessageScrollerItem messageId="streaming" style={{ flex: "none" }}>
|
||||
<StreamingReplyBody chunks={chunks} />
|
||||
</MessageScrollerItem>
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function PlainStream({ chunks }: { chunks: number }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="plain-viewport"
|
||||
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<PriorTurnsPlain />
|
||||
<div>
|
||||
<StreamingReplyBody chunks={chunks} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function runStream(
|
||||
renderTree: (chunks: number) => React.ReactElement,
|
||||
follow?: (viewport: HTMLElement) => void
|
||||
) {
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
flushSync(() => {
|
||||
root!.render(renderTree(1))
|
||||
})
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
|
||||
const viewport = (container.querySelector('[aria-label="viewport"]') ??
|
||||
container.querySelector('[data-testid="plain-viewport"]')) as HTMLElement
|
||||
|
||||
const longTasks: number[] = []
|
||||
let observer: PerformanceObserver | null = null
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
longTasks.push(entry.duration)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ["longtask"] })
|
||||
} catch {
|
||||
// longtask unsupported.
|
||||
}
|
||||
|
||||
const commits: number[] = []
|
||||
|
||||
for (let chunk = 2; chunk <= STREAM_CHUNKS; chunk++) {
|
||||
// Time the synchronous React commit: render + layout effects, where the
|
||||
// scroller's handleContentChange runs. The reply is one chunk taller, so
|
||||
// layout is dirty going in.
|
||||
const start = performance.now()
|
||||
flushSync(() => {
|
||||
root!.render(renderTree(chunk))
|
||||
})
|
||||
commits.push(performance.now() - start)
|
||||
|
||||
follow?.(viewport)
|
||||
await nextFrame()
|
||||
}
|
||||
|
||||
observer?.disconnect()
|
||||
|
||||
root?.unmount()
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
|
||||
return {
|
||||
commitMedianMs: round(median(commits)),
|
||||
commitP95Ms: round(percentile(commits, 95)),
|
||||
commitMaxMs: round(Math.max(...commits)),
|
||||
longTasks: longTasks.length,
|
||||
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
test("Layer 3 — streaming a heavy reply: scroller overhead vs plain baseline", async () => {
|
||||
// Baseline: same growth in a plain scroll div, manually following the bottom.
|
||||
const baseline = await runStream(
|
||||
(chunks) => <PlainStream chunks={chunks} />,
|
||||
(viewport) => {
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
}
|
||||
)
|
||||
|
||||
// Scroller: identical growth, auto-follow does the work.
|
||||
const scroller = await runStream((chunks) => (
|
||||
<ScrollerStream chunks={chunks} />
|
||||
))
|
||||
|
||||
const overheadMedian = round(
|
||||
scroller.commitMedianMs - baseline.commitMedianMs
|
||||
)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`\n[Layer 3] streaming a reply to ${STREAM_CHUNKS} chunks below ${PRIOR_TURNS} memoized heavy turns,` +
|
||||
" per-token commit (render + scroller content-change handling):\n" +
|
||||
` plain div baseline median ${baseline.commitMedianMs}ms p95 ${baseline.commitP95Ms}ms max ${baseline.commitMaxMs}ms longTasks ${baseline.longTasks}\n` +
|
||||
` MessageScroller median ${scroller.commitMedianMs}ms p95 ${scroller.commitP95Ms}ms max ${scroller.commitMaxMs}ms longTasks ${scroller.longTasks}\n` +
|
||||
` scroller overhead median ${overheadMedian}ms per token\n`
|
||||
)
|
||||
|
||||
// Proof: the scroller's per-token streaming work fits in a frame, and the
|
||||
// overhead it adds over a plain scroll div is a small fraction of one.
|
||||
expect(scroller.commitMedianMs).toBeLessThan(FRAME_BUDGET)
|
||||
expect(overheadMedian).toBeLessThan(FRAME_BUDGET)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heavy-content variant. Real chat turns are not one text node — they are full
|
||||
// markdown subtrees: headings, paragraphs, tokenized code blocks, tables and
|
||||
// lists, hundreds of DOM nodes each. The scroller's forced reflow recomputes
|
||||
// layout over that whole subtree, so this is the honest stress for the geometry
|
||||
// hot path. (This does NOT measure the markdown library's own render cost —
|
||||
// that is the content's concern, not the scroller's.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CodeBlock({ lines }: { lines: number }) {
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: "pre",
|
||||
overflowX: "auto",
|
||||
background: "#f5f5f5",
|
||||
padding: 12,
|
||||
margin: "8px 0",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{Array.from({ length: lines }, (_, line) => (
|
||||
<div key={line}>
|
||||
{Array.from({ length: 8 }, (_, token) => (
|
||||
<span key={token} style={{ color: token % 2 ? "#07a" : "#905" }}>
|
||||
{`tok${token} `}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownTable({ rows }: { rows: number }) {
|
||||
return (
|
||||
<table
|
||||
style={{ borderCollapse: "collapse", margin: "8px 0", width: "100%" }}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{["Name", "Type", "Default", "Description"].map((header) => (
|
||||
<th key={header} style={{ border: "1px solid #ddd", padding: 6 }}>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }, (_, row) => (
|
||||
<tr key={row}>
|
||||
{Array.from({ length: 4 }, (_, cell) => (
|
||||
<td key={cell} style={{ border: "1px solid #ddd", padding: 6 }}>
|
||||
{`r${row}c${cell}`}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function HeavyAssistantMessage({ seed }: { seed: number }) {
|
||||
// Vary the sizes by turn so heights are non-uniform like real replies.
|
||||
const paragraphs = 2 + (seed % 3)
|
||||
const codeLines = 12 + (seed % 14)
|
||||
const tableRows = 3 + (seed % 5)
|
||||
|
||||
return (
|
||||
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
|
||||
<h2 style={{ fontSize: 18, margin: "0 0 8px" }}>{`Reply ${seed}`}</h2>
|
||||
{Array.from({ length: paragraphs }, (_, index) => (
|
||||
<p key={index} style={{ margin: "0 0 8px" }}>
|
||||
{`This is paragraph ${index} of a fairly long assistant reply that wraps across multiple lines so the layout engine has real flow work to do when the surrounding scroll position changes during a scroll. `.repeat(
|
||||
2
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: 20 }}>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<li key={index}>{`List item ${index} with some inline text.`}</li>
|
||||
))}
|
||||
</ul>
|
||||
<CodeBlock lines={codeLines} />
|
||||
<MarkdownTable rows={tableRows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeavyThread({ turns }: { turns: number }) {
|
||||
return (
|
||||
<MessageScrollerProvider defaultScrollPosition="start">
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport
|
||||
aria-label="viewport"
|
||||
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
|
||||
>
|
||||
<MessageScrollerContent
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{Array.from({ length: turns }, (_, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{/* User turn: short, and the scroll anchor. */}
|
||||
<MessageScrollerItem
|
||||
messageId={`u${index}`}
|
||||
scrollAnchor
|
||||
style={{
|
||||
flex: "none",
|
||||
padding: "12px 16px",
|
||||
background: "#fafafa",
|
||||
}}
|
||||
>
|
||||
{`Question ${index}: how does this work?`}
|
||||
</MessageScrollerItem>
|
||||
{/* Assistant turn: heavy markdown subtree. */}
|
||||
<MessageScrollerItem
|
||||
messageId={`a${index}`}
|
||||
style={{ flex: "none" }}
|
||||
>
|
||||
<HeavyAssistantMessage seed={index} />
|
||||
</MessageScrollerItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<VisibilityProbe />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function mountHeavyThread(turns: number) {
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
flushSync(() => {
|
||||
root!.render(<HeavyThread turns={turns} />)
|
||||
})
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
|
||||
const viewport = document.querySelector(
|
||||
'[aria-label="viewport"]'
|
||||
) as HTMLElement
|
||||
const content = viewport.querySelector('[role="log"]') as HTMLElement
|
||||
const spacer = content.querySelector(
|
||||
"[data-message-scroller-spacer]"
|
||||
) as HTMLElement
|
||||
const nodeCount = content.querySelectorAll("*").length
|
||||
|
||||
return { content, nodeCount, spacer, viewport }
|
||||
}
|
||||
|
||||
test("Layer 1 (heavy markdown) — commit cost over full markdown subtrees", async () => {
|
||||
const turnCounts = [50, 150, 300]
|
||||
const iterations = 150
|
||||
const rows: Array<{
|
||||
turns: number
|
||||
items: number
|
||||
nodes: number
|
||||
medianMs: number
|
||||
p95Ms: number
|
||||
maxMs: number
|
||||
}> = []
|
||||
|
||||
for (const turns of turnCounts) {
|
||||
const { content, nodeCount, spacer, viewport } =
|
||||
await mountHeavyThread(turns)
|
||||
const samples: number[] = []
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
getMessageScrollerScrollable({
|
||||
content,
|
||||
scrollEdgeThreshold: 8,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
viewport.scrollTop = 1000 + (i % 2)
|
||||
|
||||
const start = performance.now()
|
||||
getMessageScrollerScrollable({
|
||||
content,
|
||||
scrollEdgeThreshold: 8,
|
||||
spacer,
|
||||
viewport,
|
||||
})
|
||||
samples.push(performance.now() - start)
|
||||
}
|
||||
|
||||
rows.push({
|
||||
turns,
|
||||
items: turns * 2,
|
||||
nodes: nodeCount,
|
||||
medianMs: round(median(samples)),
|
||||
p95Ms: round(percentile(samples, 95)),
|
||||
maxMs: round(Math.max(...samples)),
|
||||
})
|
||||
|
||||
root?.unmount()
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"\n[Layer 1 · heavy markdown] getMessageScrollerScrollable cost per commit:\n" +
|
||||
rows
|
||||
.map(
|
||||
(r) =>
|
||||
` turns=${String(r.turns).padStart(3)} items=${String(
|
||||
r.items
|
||||
).padStart(3)} domNodes=${String(r.nodes).padStart(
|
||||
6
|
||||
)} median ${String(r.medianMs).padStart(6)}ms p95 ${String(
|
||||
r.p95Ms
|
||||
).padStart(6)}ms max ${String(r.maxMs).padStart(6)}ms`
|
||||
)
|
||||
.join("\n") +
|
||||
"\n"
|
||||
)
|
||||
|
||||
const worst = rows[rows.length - 1]
|
||||
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
|
||||
})
|
||||
|
||||
test("Layer 2 (heavy markdown) — real scroll over 300 markdown turns", async () => {
|
||||
const TURNS = 300
|
||||
const STEPS = 120
|
||||
|
||||
const { nodeCount, viewport } = await mountHeavyThread(TURNS)
|
||||
|
||||
const longTasks: number[] = []
|
||||
let observer: PerformanceObserver | null = null
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
longTasks.push(entry.duration)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ["longtask"] })
|
||||
} catch {
|
||||
// longtask unsupported — handler timing below is still the proof.
|
||||
}
|
||||
|
||||
const max = viewport.scrollHeight - viewport.clientHeight
|
||||
const handlerCosts: number[] = []
|
||||
|
||||
for (let i = 1; i <= STEPS; i++) {
|
||||
viewport.scrollTop = Math.round((max * i) / STEPS)
|
||||
|
||||
const start = performance.now()
|
||||
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||
handlerCosts.push(performance.now() - start)
|
||||
|
||||
await nextFrame()
|
||||
}
|
||||
|
||||
observer?.disconnect()
|
||||
|
||||
const stats = {
|
||||
handlerMedianMs: round(median(handlerCosts)),
|
||||
handlerP95Ms: round(percentile(handlerCosts, 95)),
|
||||
handlerMaxMs: round(Math.max(...handlerCosts)),
|
||||
longTasks: longTasks.length,
|
||||
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`\n[Layer 2 · heavy markdown] scrolling ${TURNS} turns (${
|
||||
TURNS * 2
|
||||
} messages, ${nodeCount} DOM nodes) top-to-bottom:\n` +
|
||||
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
|
||||
` long tasks ${stats.longTasks}${
|
||||
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
|
||||
}\n`
|
||||
)
|
||||
|
||||
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
|
||||
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
|
||||
})
|
||||
1971
packages/react/src/message-scroller/message-scroller.test.tsx
Normal file
1971
packages/react/src/message-scroller/message-scroller.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
97
packages/react/src/message-scroller/stores.ts
Normal file
97
packages/react/src/message-scroller/stores.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE } from "./types"
|
||||
import type {
|
||||
MessageScrollerScrollable,
|
||||
MessageScrollerStore,
|
||||
MessageScrollerVisibilityState,
|
||||
MessageScrollerVisibilityStore,
|
||||
} from "./types"
|
||||
|
||||
// Generic useSyncExternalStore backing: a stable snapshot (referentially equal
|
||||
// while isEqual holds, so subscribers only re-render on real transitions) plus
|
||||
// optional reference-counting via the subscribe-time lifecycle callbacks.
|
||||
function createExternalStore<T>(
|
||||
initialSnapshot: T,
|
||||
isEqual: (a: T, b: T) => boolean
|
||||
) {
|
||||
let snapshot = initialSnapshot
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
hasListeners: () => listeners.size > 0,
|
||||
setSnapshot: (nextSnapshot: T) => {
|
||||
if (isEqual(snapshot, nextSnapshot)) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot = nextSnapshot
|
||||
listeners.forEach((listener) => listener())
|
||||
},
|
||||
subscribe: (
|
||||
listener: () => void,
|
||||
onFirstSubscribe?: () => void,
|
||||
onLastUnsubscribe?: () => void
|
||||
) => {
|
||||
const wasEmpty = listeners.size === 0
|
||||
|
||||
listeners.add(listener)
|
||||
|
||||
if (wasEmpty) {
|
||||
onFirstSubscribe?.()
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
|
||||
if (listeners.size === 0) {
|
||||
onLastUnsubscribe?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createMessageScrollerStore<T>(
|
||||
initialSnapshot: T,
|
||||
isEqual: (a: T, b: T) => boolean
|
||||
): MessageScrollerStore<T> {
|
||||
return createExternalStore(initialSnapshot, isEqual)
|
||||
}
|
||||
|
||||
function createMessageScrollerVisibilityStore(): MessageScrollerVisibilityStore {
|
||||
return createExternalStore(
|
||||
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
|
||||
areVisibilityStatesEqual
|
||||
)
|
||||
}
|
||||
|
||||
function areScrollStatesEqual(
|
||||
current: MessageScrollerScrollable,
|
||||
next: MessageScrollerScrollable
|
||||
) {
|
||||
return current.start === next.start && current.end === next.end
|
||||
}
|
||||
|
||||
function areVisibilityStatesEqual(
|
||||
current: MessageScrollerVisibilityState,
|
||||
next: MessageScrollerVisibilityState
|
||||
) {
|
||||
if (current.currentAnchorId !== next.currentAnchorId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (current.visibleMessageIds.length !== next.visibleMessageIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return current.visibleMessageIds.every(
|
||||
(messageId, index) => messageId === next.visibleMessageIds[index]
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
areScrollStatesEqual,
|
||||
areVisibilityStatesEqual,
|
||||
createMessageScrollerStore,
|
||||
createMessageScrollerVisibilityStore,
|
||||
}
|
||||
240
packages/react/src/message-scroller/types.ts
Normal file
240
packages/react/src/message-scroller/types.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { UseRenderComponentProps } from "../use-render"
|
||||
|
||||
// Default scrollEdgeThreshold. Sub-pixel tolerance so edge detection does not
|
||||
// flicker across engines that round scrollTop differently.
|
||||
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8
|
||||
|
||||
// Default scrollPreviousItemPeek. Pixels of the previous item kept visible above
|
||||
// a newly anchored row.
|
||||
const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64
|
||||
|
||||
// Default scrollMargin for scrollToMessage and programmatic targets.
|
||||
const DEFAULT_SCROLL_MARGIN = 0
|
||||
|
||||
// Two fractional scrollTop values within this range are treated as equal, to
|
||||
// absorb zoom and HiDPI rounding drift.
|
||||
const SCROLL_POSITION_EPSILON = 0.5
|
||||
|
||||
// How long (ms) data-autoscrolling stays set during a programmatic smooth scroll
|
||||
// before clearing.
|
||||
const AUTOSCROLLING_CLEAR_DELAY = 180
|
||||
|
||||
// Viewport keys that count as deliberate scroll intent and release follow-bottom.
|
||||
const USER_SCROLL_KEYS = new Set([
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
" ", // Space key.
|
||||
])
|
||||
|
||||
// Internal scroll mode. Derived from intent and commands; decides how the
|
||||
// viewport reacts to content and resize.
|
||||
type MessageScrollerMode =
|
||||
| "following-bottom" // autoScroll on, pinned to the latest message.
|
||||
| "free-scrolling" // reader scrolled away; position left alone (prepends still preserved).
|
||||
| "anchored-to-message" // holding a turn at the reading line while it streams.
|
||||
| "settling-jump" // a programmatic jump is animating; intent detection suppressed until it settles.
|
||||
|
||||
// Where a saved transcript opens on the first non-empty render.
|
||||
type MessageScrollerDefaultScrollPosition = "start" | "end" | "last-anchor"
|
||||
|
||||
// Which transcript edge MessageScrollerButton scrolls toward.
|
||||
type MessageScrollerButtonDirection = "start" | "end"
|
||||
|
||||
// Viewport alignment for scrollToMessage and programmatic jumps.
|
||||
type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest"
|
||||
|
||||
// Options for scrollToMessage, scrollToEnd, and scrollToStart.
|
||||
type MessageScrollerScrollOptions = {
|
||||
// Viewport edge or center to align the target to.
|
||||
align?: MessageScrollerScrollAlign
|
||||
// Native scroll behavior.
|
||||
behavior?: ScrollBehavior
|
||||
// Margin on the aligned edge, in pixels. Defaults to the provider scrollMargin.
|
||||
scrollMargin?: number
|
||||
}
|
||||
|
||||
// Scroll snapshot from useMessageScrollerScrollable: which edges the viewport can
|
||||
// still scroll toward.
|
||||
type MessageScrollerScrollable = {
|
||||
// The viewport can scroll toward the start (content is hidden above).
|
||||
start: boolean
|
||||
// The viewport can scroll toward the end (content is hidden below).
|
||||
end: boolean
|
||||
}
|
||||
|
||||
// Visibility snapshot from useMessageScrollerVisibility.
|
||||
type MessageScrollerVisibilityState = {
|
||||
// The anchored turn the reader is in, or null. Stays set after the anchor
|
||||
// scrolls above the viewport.
|
||||
currentAnchorId: string | null
|
||||
// messageId values intersecting the viewport, in document order.
|
||||
visibleMessageIds: string[]
|
||||
}
|
||||
|
||||
// Headless provider for a chat transcript scroller. Owns scroll behavior and
|
||||
// state; renders no DOM.
|
||||
type MessageScrollerProviderProps = {
|
||||
children?: React.ReactNode
|
||||
// Follow new content at the bottom while the viewport is already at the end.
|
||||
autoScroll?: boolean
|
||||
// Opening position on the first non-empty render, applied once.
|
||||
defaultScrollPosition?: MessageScrollerDefaultScrollPosition
|
||||
// Distance from an edge that still counts as at-top/at-bottom. Defaults to 8.
|
||||
scrollEdgeThreshold?: number
|
||||
// Extra top margin for a newly anchored row, added to scrollMargin. Defaults to 64.
|
||||
scrollPreviousItemPeek?: number
|
||||
// Default margin on the aligned edge for commands and visibility. Defaults to 0.
|
||||
scrollMargin?: number
|
||||
}
|
||||
|
||||
// Frame container for a chat transcript scroller. Must render inside a
|
||||
// MessageScrollerProvider.
|
||||
type MessageScrollerProps = React.ComponentProps<"div">
|
||||
|
||||
// Scrollable viewport. Owns native scroll events and prepend preservation.
|
||||
type MessageScrollerViewportProps = React.ComponentProps<"div"> & {
|
||||
// Keep the first visible messageId row stable on prepend. Defaults to true.
|
||||
preserveScrollOnPrepend?: boolean
|
||||
}
|
||||
|
||||
// Transcript row container. Every direct child should be a MessageScrollerItem.
|
||||
type MessageScrollerContentProps = React.ComponentProps<"div"> & {
|
||||
// Class name for the internal tail spacer used when anchoring turns near the top.
|
||||
spacerClassName?: string
|
||||
}
|
||||
|
||||
// One transcript row: a message, marker, typing row, separator, or load-more row.
|
||||
type MessageScrollerItemProps = React.ComponentProps<"div"> & {
|
||||
// Stable row id for scrollToMessage, visibility, and prepend preservation.
|
||||
messageId?: string
|
||||
// Marks a turn boundary that newly appended anchors and last-anchor restore use.
|
||||
scrollAnchor?: boolean
|
||||
}
|
||||
|
||||
// State passed to MessageScrollerButton's render prop.
|
||||
type MessageScrollerButtonRenderState = {
|
||||
// Whether overflow exists toward this button's direction.
|
||||
active: boolean
|
||||
direction: MessageScrollerButtonDirection
|
||||
}
|
||||
|
||||
// Scroll control for the start or end of the transcript.
|
||||
type MessageScrollerButtonProps = UseRenderComponentProps<
|
||||
"button",
|
||||
MessageScrollerButtonRenderState
|
||||
> & {
|
||||
// Native scroll behavior when clicked. Defaults to "smooth".
|
||||
behavior?: ScrollBehavior
|
||||
// Transcript edge to scroll toward. Defaults to "end".
|
||||
direction?: MessageScrollerButtonDirection
|
||||
}
|
||||
|
||||
// Minimal external store backing useMessageScrollerScrollable, consumed via
|
||||
// useSyncExternalStore.
|
||||
type MessageScrollerStore<T> = {
|
||||
getSnapshot: () => T
|
||||
setSnapshot: (nextSnapshot: T) => void
|
||||
subscribe: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
// External store backing useMessageScrollerVisibility. Adds reference-counting so
|
||||
// tracking stays lazy.
|
||||
type MessageScrollerVisibilityStore = {
|
||||
getSnapshot: () => MessageScrollerVisibilityState
|
||||
hasListeners: () => boolean
|
||||
setSnapshot: (nextSnapshot: MessageScrollerVisibilityState) => void
|
||||
subscribe: (
|
||||
listener: () => void,
|
||||
onFirstSubscribe: () => void,
|
||||
onLastUnsubscribe: () => void
|
||||
) => () => void
|
||||
}
|
||||
|
||||
// Registers (or, with removedElement, unregisters) a MessageScrollerItem node by
|
||||
// messageId.
|
||||
type MessageScrollerRegisterMessage = (
|
||||
messageId: string,
|
||||
element: HTMLElement | null,
|
||||
removedElement?: HTMLElement | null
|
||||
) => void
|
||||
|
||||
// Internal context wiring the parts together. Not part of the public API.
|
||||
type MessageScrollerContextValue = {
|
||||
handleContentChange: () => void
|
||||
handleResize: () => void
|
||||
observeVisibility: () => void
|
||||
preserveScrollOnPrependRef: React.RefObject<boolean>
|
||||
scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean
|
||||
scrollToMessage: (
|
||||
messageId: string,
|
||||
options?: MessageScrollerScrollOptions
|
||||
) => boolean
|
||||
scrollToStart: (options?: MessageScrollerScrollOptions) => boolean
|
||||
setContentElement: (element: HTMLDivElement | null) => void
|
||||
setRootElement: (element: HTMLDivElement | null) => void
|
||||
setSpacerElement: (element: HTMLDivElement | null) => void
|
||||
setViewportElement: (element: HTMLDivElement | null) => void
|
||||
pendingDefaultScrollStore: MessageScrollerStore<boolean>
|
||||
stateStore: MessageScrollerStore<MessageScrollerScrollable>
|
||||
syncAfterScroll: () => void
|
||||
unobserveVisibility: () => void
|
||||
userScrollIntent: () => void
|
||||
viewportRef: React.RefObject<HTMLDivElement | null>
|
||||
visibilityStore: MessageScrollerVisibilityStore
|
||||
}
|
||||
|
||||
// Initial MessageScrollerScrollable before measurement. Stable reference for the
|
||||
// server and first-render snapshot.
|
||||
const EMPTY_MESSAGE_SCROLLER_SCROLLABLE: MessageScrollerScrollable = {
|
||||
start: false,
|
||||
end: false,
|
||||
}
|
||||
|
||||
// Shared empty array so empty visibility snapshots stay referentially stable.
|
||||
const EMPTY_VISIBLE_MESSAGE_IDS: string[] = []
|
||||
|
||||
// Initial MessageScrollerVisibilityState. Nothing tracked, no current anchor.
|
||||
const EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE: MessageScrollerVisibilityState =
|
||||
{
|
||||
currentAnchorId: null,
|
||||
visibleMessageIds: EMPTY_VISIBLE_MESSAGE_IDS,
|
||||
}
|
||||
|
||||
export {
|
||||
AUTOSCROLLING_CLEAR_DELAY,
|
||||
DEFAULT_SCROLL_EDGE_THRESHOLD,
|
||||
DEFAULT_SCROLL_MARGIN,
|
||||
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
|
||||
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
|
||||
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
|
||||
EMPTY_VISIBLE_MESSAGE_IDS,
|
||||
SCROLL_POSITION_EPSILON,
|
||||
USER_SCROLL_KEYS,
|
||||
}
|
||||
|
||||
export type {
|
||||
MessageScrollerButtonDirection,
|
||||
MessageScrollerButtonProps,
|
||||
MessageScrollerButtonRenderState,
|
||||
MessageScrollerContentProps,
|
||||
MessageScrollerContextValue,
|
||||
MessageScrollerDefaultScrollPosition,
|
||||
MessageScrollerItemProps,
|
||||
MessageScrollerMode,
|
||||
MessageScrollerProps,
|
||||
MessageScrollerProviderProps,
|
||||
MessageScrollerRegisterMessage,
|
||||
MessageScrollerScrollAlign,
|
||||
MessageScrollerScrollOptions,
|
||||
MessageScrollerScrollable,
|
||||
MessageScrollerStore,
|
||||
MessageScrollerViewportProps,
|
||||
MessageScrollerVisibilityState,
|
||||
MessageScrollerVisibilityStore,
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
getElementScrollTop,
|
||||
getElementViewportTop,
|
||||
getMaxScrollTop,
|
||||
getTailSpacerHeight,
|
||||
} from "./geometry"
|
||||
import { AUTOSCROLLING_CLEAR_DELAY, SCROLL_POSITION_EPSILON } from "./types"
|
||||
import type { MessageScrollerScrollOptions } from "./types"
|
||||
import {
|
||||
markDefaultScrollPositionApplied,
|
||||
type MessageScrollerRefs,
|
||||
} from "./use-message-scroller-refs"
|
||||
|
||||
// Imperative scroll primitives, split from the controller so the move mechanics
|
||||
// live apart from the policy that decides when to run them. Each command resolves
|
||||
// a target scrollTop and returns false when the viewport is not mounted yet.
|
||||
function useMessageScrollerCommands({
|
||||
refs,
|
||||
commitScrollState,
|
||||
scheduleStateCommit,
|
||||
scheduleVisibilitySync,
|
||||
}: {
|
||||
refs: MessageScrollerRefs
|
||||
commitScrollState: () => void
|
||||
scheduleStateCommit: () => void
|
||||
scheduleVisibilitySync: () => void
|
||||
}) {
|
||||
const {
|
||||
streamingTurnRef,
|
||||
autoScrollRef,
|
||||
autoscrollingRef,
|
||||
autoscrollingTimeoutRef,
|
||||
contentRef,
|
||||
itemCountRef,
|
||||
messageElementsRef,
|
||||
modeRef,
|
||||
pendingScrollToMessageRef,
|
||||
prependRestoreRef,
|
||||
scrollMarginRef,
|
||||
scrollPreviousItemPeekRef,
|
||||
spacerGapRef,
|
||||
spacerHeightRef,
|
||||
spacerRef,
|
||||
viewportRef,
|
||||
} = refs
|
||||
|
||||
const setAutoScrolling = React.useCallback(
|
||||
(autoscrolling: boolean) => {
|
||||
if (autoscrollingTimeoutRef.current !== null) {
|
||||
window.clearTimeout(autoscrollingTimeoutRef.current)
|
||||
autoscrollingTimeoutRef.current = null
|
||||
}
|
||||
|
||||
if (autoscrollingRef.current !== autoscrolling) {
|
||||
autoscrollingRef.current = autoscrolling
|
||||
commitScrollState()
|
||||
}
|
||||
|
||||
if (autoscrolling) {
|
||||
autoscrollingTimeoutRef.current = window.setTimeout(() => {
|
||||
autoscrollingTimeoutRef.current = null
|
||||
autoscrollingRef.current = false
|
||||
commitScrollState()
|
||||
}, AUTOSCROLLING_CLEAR_DELAY)
|
||||
}
|
||||
},
|
||||
[commitScrollState]
|
||||
)
|
||||
|
||||
const setTailSpacerHeight = React.useCallback((height: number) => {
|
||||
const spacer = spacerRef.current
|
||||
|
||||
if (!spacer) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextHeight = Math.max(0, Math.ceil(height))
|
||||
|
||||
if (spacerHeightRef.current === nextHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
spacerHeightRef.current = nextHeight
|
||||
spacer.hidden = nextHeight === 0
|
||||
spacer.style.height = `${nextHeight}px`
|
||||
spacer.style.marginTop = nextHeight > 0 ? `${-spacerGapRef.current}px` : ""
|
||||
}, [])
|
||||
|
||||
const scrollToPosition = React.useCallback(
|
||||
(
|
||||
scrollTop: number,
|
||||
{
|
||||
behavior = "auto",
|
||||
autoscrolling = false,
|
||||
}: {
|
||||
behavior?: ScrollBehavior
|
||||
autoscrolling?: boolean
|
||||
} = {}
|
||||
) => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextScrollTop = Math.max(0, scrollTop)
|
||||
|
||||
if (
|
||||
Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON
|
||||
) {
|
||||
viewport.scrollTop = nextScrollTop
|
||||
commitScrollState()
|
||||
return
|
||||
}
|
||||
|
||||
if (autoscrolling) {
|
||||
setAutoScrolling(true)
|
||||
}
|
||||
|
||||
viewport.scrollTo({
|
||||
top: nextScrollTop,
|
||||
behavior,
|
||||
})
|
||||
scheduleStateCommit()
|
||||
},
|
||||
[commitScrollState, scheduleStateCommit, setAutoScrolling]
|
||||
)
|
||||
|
||||
const scrollToStart = React.useCallback(
|
||||
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
|
||||
if (!viewportRef.current) {
|
||||
return false
|
||||
}
|
||||
|
||||
setTailSpacerHeight(0)
|
||||
streamingTurnRef.current = null
|
||||
modeRef.current = "free-scrolling"
|
||||
scrollToPosition(0, { behavior })
|
||||
scheduleVisibilitySync()
|
||||
|
||||
return true
|
||||
},
|
||||
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
|
||||
)
|
||||
|
||||
const scrollToEnd = React.useCallback(
|
||||
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) {
|
||||
return false
|
||||
}
|
||||
|
||||
setTailSpacerHeight(0)
|
||||
streamingTurnRef.current = null
|
||||
modeRef.current = autoScrollRef.current
|
||||
? "following-bottom"
|
||||
: "free-scrolling"
|
||||
scrollToPosition(getMaxScrollTop(viewport), {
|
||||
autoscrolling: true,
|
||||
behavior,
|
||||
})
|
||||
scheduleVisibilitySync()
|
||||
|
||||
return true
|
||||
},
|
||||
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
|
||||
)
|
||||
|
||||
const scrollToElement = React.useCallback(
|
||||
(
|
||||
element: HTMLElement,
|
||||
{
|
||||
align = "start",
|
||||
behavior = "auto",
|
||||
scrollMargin = scrollMarginRef.current,
|
||||
}: MessageScrollerScrollOptions = {},
|
||||
{
|
||||
keepPreviousPeek = false,
|
||||
}: {
|
||||
keepPreviousPeek?: boolean
|
||||
} = {}
|
||||
) => {
|
||||
const content = contentRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!content || !viewport || !content.contains(element)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollTop = getElementScrollTop({
|
||||
align,
|
||||
element,
|
||||
scrollMargin: keepPreviousPeek
|
||||
? scrollMargin + scrollPreviousItemPeekRef.current
|
||||
: scrollMargin,
|
||||
spacer: spacerRef.current,
|
||||
viewport,
|
||||
})
|
||||
|
||||
const nextSpacerHeight = getTailSpacerHeight({
|
||||
content,
|
||||
scrollTop,
|
||||
spacer: spacerRef.current,
|
||||
viewport,
|
||||
})
|
||||
|
||||
setTailSpacerHeight(nextSpacerHeight)
|
||||
// Seed the prepend anchor with the jump target so a prepend that lands
|
||||
// before this scroll settles still preserves the jumped-to row; once it
|
||||
// settles, syncAfterScroll's capturePrependAnchor re-captures it from the
|
||||
// first visible row.
|
||||
prependRestoreRef.current = {
|
||||
element,
|
||||
viewportTop: getElementViewportTop(element, viewport),
|
||||
}
|
||||
|
||||
modeRef.current = keepPreviousPeek
|
||||
? "anchored-to-message"
|
||||
: "settling-jump"
|
||||
streamingTurnRef.current = keepPreviousPeek ? element : null
|
||||
|
||||
scrollToPosition(scrollTop, { behavior })
|
||||
scheduleVisibilitySync()
|
||||
|
||||
return true
|
||||
},
|
||||
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
|
||||
)
|
||||
|
||||
const reanchorToAnchoredMessage = React.useCallback(() => {
|
||||
const element = streamingTurnRef.current
|
||||
|
||||
if (
|
||||
!element ||
|
||||
!element.isConnected ||
|
||||
modeRef.current !== "anchored-to-message"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Re-run the placement so the tail spacer is recomputed for the new content
|
||||
// height and the turn is held at the reading line.
|
||||
return scrollToElement(
|
||||
element,
|
||||
{ align: "start" },
|
||||
{ keepPreviousPeek: true }
|
||||
)
|
||||
}, [scrollToElement])
|
||||
|
||||
// The target row may not be mounted yet (e.g. an async-loaded transcript).
|
||||
// When it is missing the request is queued in pendingScrollToMessageRef and
|
||||
// flushed later — on registerMessage for that id, or on the next content
|
||||
// change. An explicit jump also marks the mount default as applied, so
|
||||
// defaultScrollPosition does not override it.
|
||||
const scrollToMessage = React.useCallback(
|
||||
(messageId: string, options?: MessageScrollerScrollOptions) => {
|
||||
const element = messageElementsRef.current.get(messageId)
|
||||
|
||||
if (!element) {
|
||||
if (itemCountRef.current === 0) {
|
||||
pendingScrollToMessageRef.current = {
|
||||
messageId,
|
||||
options,
|
||||
}
|
||||
markDefaultScrollPositionApplied(refs)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
markDefaultScrollPositionApplied(refs)
|
||||
|
||||
if (scrollToElement(element, options)) {
|
||||
pendingScrollToMessageRef.current = null
|
||||
return true
|
||||
}
|
||||
|
||||
pendingScrollToMessageRef.current = {
|
||||
messageId,
|
||||
options,
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[scrollToElement]
|
||||
)
|
||||
|
||||
const flushPendingScrollToMessage = React.useCallback(() => {
|
||||
const pending = pendingScrollToMessageRef.current
|
||||
|
||||
if (!pending) {
|
||||
return false
|
||||
}
|
||||
|
||||
const element = messageElementsRef.current.get(pending.messageId)
|
||||
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
const handled = scrollToElement(element, pending.options)
|
||||
|
||||
if (!handled) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingScrollToMessageRef.current = null
|
||||
markDefaultScrollPositionApplied(refs)
|
||||
|
||||
return true
|
||||
}, [scrollToElement])
|
||||
|
||||
return {
|
||||
flushPendingScrollToMessage,
|
||||
reanchorToAnchoredMessage,
|
||||
scrollToElement,
|
||||
scrollToEnd,
|
||||
scrollToMessage,
|
||||
scrollToStart,
|
||||
}
|
||||
}
|
||||
|
||||
export { useMessageScrollerCommands }
|
||||
@@ -0,0 +1,774 @@
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
getContentBottom,
|
||||
getElementTop,
|
||||
getElementViewportTop,
|
||||
getFirstVisibleMessageItem,
|
||||
getFlexGap,
|
||||
getLastScrollAnchor,
|
||||
getMessageScrollerItems,
|
||||
getMessageScrollerScrollable,
|
||||
getMessageScrollerVisibilityState,
|
||||
getNewScrollAnchor,
|
||||
getUnanchoredScrollAnchor,
|
||||
hasMultipleNewScrollAnchors,
|
||||
} from "./geometry"
|
||||
import {
|
||||
DEFAULT_SCROLL_EDGE_THRESHOLD,
|
||||
DEFAULT_SCROLL_MARGIN,
|
||||
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
|
||||
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
|
||||
SCROLL_POSITION_EPSILON,
|
||||
} from "./types"
|
||||
import type {
|
||||
MessageScrollerContextValue,
|
||||
MessageScrollerProviderProps,
|
||||
MessageScrollerRegisterMessage,
|
||||
MessageScrollerScrollable,
|
||||
} from "./types"
|
||||
import { useMessageScrollerCommands } from "./use-message-scroller-commands"
|
||||
import {
|
||||
clearPendingDefaultScroll,
|
||||
markDefaultScrollPositionApplied,
|
||||
useMessageScrollerRefs,
|
||||
} from "./use-message-scroller-refs"
|
||||
|
||||
// Builds a ref callback that stores the node and runs onMount once it attaches.
|
||||
function useElementRef(
|
||||
elementRef: React.RefObject<HTMLDivElement | null>,
|
||||
onMount: () => void
|
||||
) {
|
||||
return React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
elementRef.current = element
|
||||
|
||||
if (element) {
|
||||
onMount()
|
||||
}
|
||||
},
|
||||
[elementRef, onMount]
|
||||
)
|
||||
}
|
||||
|
||||
// Orchestrator hook. Decides when to scroll and delegates the moves to
|
||||
// useMessageScrollerCommands; state and visibility commits are coalesced on a
|
||||
// requestAnimationFrame and torn down on cleanup for StrictMode safety.
|
||||
function useMessageScrollerController({
|
||||
autoScroll = false,
|
||||
defaultScrollPosition = "end",
|
||||
scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD,
|
||||
scrollPreviousItemPeek = DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
|
||||
scrollMargin = DEFAULT_SCROLL_MARGIN,
|
||||
}: MessageScrollerProviderProps) {
|
||||
const refs = useMessageScrollerRefs({
|
||||
autoScroll,
|
||||
defaultScrollPosition,
|
||||
scrollEdgeThreshold,
|
||||
scrollMargin,
|
||||
scrollPreviousItemPeek,
|
||||
})
|
||||
|
||||
const {
|
||||
streamingTurnRef,
|
||||
autoScrollRef,
|
||||
autoscrollingRef,
|
||||
autoscrollingTimeoutRef,
|
||||
contentRef,
|
||||
defaultScrollPositionAppliedRef,
|
||||
firstItemRef,
|
||||
itemCountRef,
|
||||
lastScrollTopRef,
|
||||
messageElementsRef,
|
||||
modeRef,
|
||||
pendingScrollFrameRef,
|
||||
pendingScrollToMessageRef,
|
||||
prependRestoreRef,
|
||||
preserveScrollOnPrependRef,
|
||||
pendingDefaultScrollStore,
|
||||
rootRef,
|
||||
scrollEdgeThresholdRef,
|
||||
scrollMarginRef,
|
||||
scrollPreviousItemPeekRef,
|
||||
spacerGapRef,
|
||||
spacerHeightRef,
|
||||
spacerRef,
|
||||
stateFrameRef,
|
||||
stateStore,
|
||||
viewportRef,
|
||||
visibilityFrameRef,
|
||||
visibilityObserverRef,
|
||||
visibilityStore,
|
||||
visibleMessageIdsRef,
|
||||
handledScrollAnchorsRef,
|
||||
} = refs
|
||||
|
||||
const previousDefaultScrollPositionRef = React.useRef(defaultScrollPosition)
|
||||
|
||||
if (previousDefaultScrollPositionRef.current !== defaultScrollPosition) {
|
||||
previousDefaultScrollPositionRef.current = defaultScrollPosition
|
||||
defaultScrollPositionAppliedRef.current = false
|
||||
// The pending-scroll hold is mount-only. A live prop change re-applies the
|
||||
// opening position in layout; it does not hide the viewport again.
|
||||
}
|
||||
|
||||
const writeStateAttributes = React.useCallback(
|
||||
(state: MessageScrollerScrollable) => {
|
||||
const root = rootRef.current
|
||||
const viewport = viewportRef.current
|
||||
const scrollable = [state.start && "start", state.end && "end"]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
const autoScrolling = autoscrollingRef.current
|
||||
|
||||
for (const element of [root, viewport]) {
|
||||
if (!element) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (scrollable) {
|
||||
element.setAttribute("data-scrollable", scrollable)
|
||||
} else {
|
||||
element.removeAttribute("data-scrollable")
|
||||
}
|
||||
|
||||
element.toggleAttribute("data-autoscrolling", autoScrolling)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Owns the one follow-bottom transition: arm at the bottom, release on any
|
||||
// scroll away (including a scrollbar drag), suppressed during a programmatic
|
||||
// scroll so the auto-scroll animation cannot release itself. Arming also
|
||||
// skips the anchored-to-message hold: the tail spacer makes a freshly
|
||||
// anchored turn read as "at the end", and re-arming there would let the
|
||||
// first streamed chunk yank the reader off the anchor. The hold hands back
|
||||
// to following in handleResize, once the reply consumes the tail spacer.
|
||||
const reconcileFollowMode = React.useCallback(
|
||||
(scrollable: MessageScrollerScrollable) => {
|
||||
const scrollTop = viewportRef.current?.scrollTop ?? 0
|
||||
// Content growing past the live edge also reads as "not at the end", but
|
||||
// only a scrollbar drag moves scrollTop up. Growth must not release
|
||||
// follow-output: the resize handler is coalesced onto a frame, so a state
|
||||
// commit can observe the grown content before follow catches up.
|
||||
const scrolledUp =
|
||||
scrollTop < lastScrollTopRef.current - SCROLL_POSITION_EPSILON
|
||||
|
||||
lastScrollTopRef.current = scrollTop
|
||||
|
||||
if (
|
||||
autoScrollRef.current &&
|
||||
!scrollable.end &&
|
||||
modeRef.current !== "settling-jump" &&
|
||||
modeRef.current !== "anchored-to-message"
|
||||
) {
|
||||
modeRef.current = "following-bottom"
|
||||
} else if (
|
||||
modeRef.current === "following-bottom" &&
|
||||
scrollable.end &&
|
||||
scrolledUp &&
|
||||
!autoscrollingRef.current
|
||||
) {
|
||||
modeRef.current = "free-scrolling"
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const commitScrollState = React.useCallback(() => {
|
||||
const nextState = getMessageScrollerScrollable({
|
||||
content: contentRef.current,
|
||||
scrollEdgeThreshold: scrollEdgeThresholdRef.current,
|
||||
spacer: spacerRef.current,
|
||||
viewport: viewportRef.current,
|
||||
})
|
||||
|
||||
reconcileFollowMode(nextState)
|
||||
|
||||
// While follow-output is engaged the scroller is already closing any gap a
|
||||
// streamed chunk just opened, so publishing it as scrollable toward the
|
||||
// end would strobe the scroll button once per chunk. Reconcile runs on the
|
||||
// raw geometry first, so a commit that releases follow still publishes the
|
||||
// gap it released over.
|
||||
const publishedState =
|
||||
modeRef.current === "following-bottom"
|
||||
? { ...nextState, end: false }
|
||||
: nextState
|
||||
|
||||
writeStateAttributes(publishedState)
|
||||
stateStore.setSnapshot(publishedState)
|
||||
}, [reconcileFollowMode, stateStore, writeStateAttributes])
|
||||
|
||||
const scheduleStateCommit = React.useCallback(() => {
|
||||
if (stateFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
stateFrameRef.current = window.requestAnimationFrame(() => {
|
||||
stateFrameRef.current = null
|
||||
commitScrollState()
|
||||
})
|
||||
}, [commitScrollState])
|
||||
|
||||
const scheduleVisibilitySync = React.useCallback(() => {
|
||||
if (!visibilityStore.hasListeners()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (visibilityFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
visibilityFrameRef.current = window.requestAnimationFrame(() => {
|
||||
visibilityFrameRef.current = null
|
||||
|
||||
// A frame can outlive the last unsubscribe. Recomputing here would
|
||||
// overwrite the EMPTY snapshot that teardown just wrote, leaving a stale
|
||||
// value for the next subscriber to read.
|
||||
if (!visibilityStore.hasListeners()) {
|
||||
return
|
||||
}
|
||||
|
||||
visibilityStore.setSnapshot(
|
||||
getMessageScrollerVisibilityState({
|
||||
content: contentRef.current,
|
||||
scrollMargin: scrollMarginRef.current,
|
||||
scrollPreviousItemPeek: scrollPreviousItemPeekRef.current,
|
||||
spacer: spacerRef.current,
|
||||
viewport: viewportRef.current,
|
||||
visibleMessageIds: visibleMessageIdsRef.current,
|
||||
})
|
||||
)
|
||||
})
|
||||
}, [visibilityStore])
|
||||
|
||||
const {
|
||||
flushPendingScrollToMessage,
|
||||
reanchorToAnchoredMessage,
|
||||
scrollToElement,
|
||||
scrollToEnd,
|
||||
scrollToMessage,
|
||||
scrollToStart,
|
||||
} = useMessageScrollerCommands({
|
||||
refs,
|
||||
commitScrollState,
|
||||
scheduleStateCommit,
|
||||
scheduleVisibilitySync,
|
||||
})
|
||||
|
||||
const restorePrependedAnchor = React.useCallback(() => {
|
||||
const anchor = prependRestoreRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!anchor || !viewport || !anchor.element.isConnected) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare the anchor relative to the viewport, not to the content. Native
|
||||
// scroll anchoring leaves the viewport-relative position unchanged, so this
|
||||
// is a no-op where the browser already handled the prepend and only corrects
|
||||
// the scroll where it did not (e.g. Safari) — without trusting a capability
|
||||
// flag, which some engines report incorrectly.
|
||||
const nextViewportTop = getElementViewportTop(anchor.element, viewport)
|
||||
const delta = nextViewportTop - anchor.viewportTop
|
||||
|
||||
if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) {
|
||||
return false
|
||||
}
|
||||
|
||||
viewport.scrollTop += delta
|
||||
anchor.viewportTop = getElementViewportTop(anchor.element, viewport)
|
||||
scheduleStateCommit()
|
||||
scheduleVisibilitySync()
|
||||
|
||||
return true
|
||||
}, [scheduleStateCommit, scheduleVisibilitySync])
|
||||
|
||||
const capturePrependAnchor = React.useCallback(() => {
|
||||
const content = contentRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!content || !viewport) {
|
||||
prependRestoreRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = getFirstVisibleMessageItem({
|
||||
content,
|
||||
spacer: spacerRef.current,
|
||||
viewport,
|
||||
})
|
||||
|
||||
prependRestoreRef.current = anchor
|
||||
? {
|
||||
element: anchor,
|
||||
viewportTop: getElementViewportTop(anchor, viewport),
|
||||
}
|
||||
: null
|
||||
}, [])
|
||||
|
||||
const schedulePendingScrollToMessageFlush = React.useCallback(() => {
|
||||
if (pendingScrollFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingScrollFrameRef.current = null
|
||||
|
||||
if (flushPendingScrollToMessage()) {
|
||||
capturePrependAnchor()
|
||||
}
|
||||
})
|
||||
}, [capturePrependAnchor, flushPendingScrollToMessage])
|
||||
|
||||
const applyDefaultScrollPosition = React.useCallback(() => {
|
||||
if (
|
||||
!defaultScrollPosition ||
|
||||
defaultScrollPositionAppliedRef.current ||
|
||||
itemCountRef.current === 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
let handled = false
|
||||
|
||||
if (defaultScrollPosition === "last-anchor") {
|
||||
const content = contentRef.current
|
||||
const viewport = viewportRef.current
|
||||
const anchor =
|
||||
content && viewport
|
||||
? getLastScrollAnchor(
|
||||
getMessageScrollerItems(content, spacerRef.current)
|
||||
)
|
||||
: null
|
||||
|
||||
if (!content || !viewport || !anchor) {
|
||||
handled = scrollToEnd({ behavior: "auto" })
|
||||
} else {
|
||||
const anchorTop = getElementTop(anchor, viewport)
|
||||
const contentBottom = getContentBottom({
|
||||
content,
|
||||
spacer: spacerRef.current,
|
||||
viewport,
|
||||
})
|
||||
// A short last turn already fits below the anchor, so opening at the end
|
||||
// shows the whole turn without leaving a blank gap beneath it.
|
||||
const lastTurnFits = contentBottom - anchorTop <= viewport.clientHeight
|
||||
|
||||
handled = lastTurnFits
|
||||
? scrollToEnd({ behavior: "auto" })
|
||||
: scrollToElement(
|
||||
anchor,
|
||||
{ align: "start" },
|
||||
{ keepPreviousPeek: true }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
handled =
|
||||
defaultScrollPosition === "end"
|
||||
? scrollToEnd({ behavior: "auto" })
|
||||
: scrollToStart({ behavior: "auto" })
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
return false
|
||||
}
|
||||
|
||||
markDefaultScrollPositionApplied(refs)
|
||||
|
||||
return true
|
||||
}, [defaultScrollPosition, scrollToElement, scrollToEnd, scrollToStart])
|
||||
|
||||
const handleContentChange = React.useCallback(() => {
|
||||
const content = contentRef.current
|
||||
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
const items = getMessageScrollerItems(content, spacerRef.current)
|
||||
const previousItemCount = itemCountRef.current
|
||||
const previousFirstItem = firstItemRef.current
|
||||
|
||||
itemCountRef.current = items.length
|
||||
firstItemRef.current = items[0] ?? null
|
||||
|
||||
// Reconcile the scroll position with the new content. Every path re-captures
|
||||
// the prepend anchor afterward, so each branch just returns.
|
||||
//
|
||||
// Branch order is load-bearing: first-content, prepended, appended, updated.
|
||||
const reconcileScrollPosition = () => {
|
||||
if (flushPendingScrollToMessage()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (previousItemCount === 0) {
|
||||
if (applyDefaultScrollPosition()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
items.length > 0 &&
|
||||
autoScrollRef.current &&
|
||||
scrollToEnd({ behavior: "auto" })
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
commitScrollState()
|
||||
scheduleVisibilitySync()
|
||||
return
|
||||
}
|
||||
|
||||
const previousFirstItemIndex = previousFirstItem
|
||||
? items.indexOf(previousFirstItem)
|
||||
: -1
|
||||
const didPrepend =
|
||||
preserveScrollOnPrependRef.current && previousFirstItemIndex > 0
|
||||
|
||||
if (didPrepend) {
|
||||
// Prepended rows are not new appends. Restore the prior scroll position.
|
||||
// The restore is a no-op where native scroll anchoring already did it.
|
||||
restorePrependedAnchor()
|
||||
return
|
||||
}
|
||||
|
||||
if (items.length > previousItemCount) {
|
||||
const anchor = getNewScrollAnchor(items, previousItemCount)
|
||||
|
||||
if (anchor) {
|
||||
// While the reader is following the live end, a batch of several
|
||||
// anchored turns arriving at once should keep following the end — not
|
||||
// yank back to anchor the first turn of the batch. A single new anchor
|
||||
// still moves to the top as usual.
|
||||
if (
|
||||
autoScrollRef.current &&
|
||||
modeRef.current === "following-bottom" &&
|
||||
hasMultipleNewScrollAnchors(items, previousItemCount)
|
||||
) {
|
||||
scrollToEnd({ behavior: "auto" })
|
||||
return
|
||||
}
|
||||
|
||||
scrollToElement(
|
||||
anchor,
|
||||
{ align: "start" },
|
||||
{ keepPreviousPeek: true }
|
||||
)
|
||||
handledScrollAnchorsRef.current.add(anchor)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === previousItemCount) {
|
||||
const anchor = getUnanchoredScrollAnchor(
|
||||
items,
|
||||
handledScrollAnchorsRef.current
|
||||
)
|
||||
|
||||
if (anchor) {
|
||||
scrollToElement(
|
||||
anchor,
|
||||
{ align: "start" },
|
||||
{ keepPreviousPeek: true }
|
||||
)
|
||||
handledScrollAnchorsRef.current.add(anchor)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Appends with no new anchor (and content-only updates) fall through here:
|
||||
// keep following the end if we still are, otherwise just recommit state.
|
||||
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
|
||||
scrollToEnd({ behavior: "auto" })
|
||||
} else {
|
||||
commitScrollState()
|
||||
scheduleVisibilitySync()
|
||||
}
|
||||
}
|
||||
|
||||
reconcileScrollPosition()
|
||||
capturePrependAnchor()
|
||||
}, [
|
||||
applyDefaultScrollPosition,
|
||||
capturePrependAnchor,
|
||||
commitScrollState,
|
||||
flushPendingScrollToMessage,
|
||||
restorePrependedAnchor,
|
||||
scheduleVisibilitySync,
|
||||
scrollToElement,
|
||||
scrollToEnd,
|
||||
])
|
||||
|
||||
const handleResize = React.useCallback(() => {
|
||||
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
|
||||
scrollToEnd({ behavior: "auto" })
|
||||
return
|
||||
}
|
||||
|
||||
// Hold the anchored turn in place as content below it resizes (a reply
|
||||
// streaming in, or a transient marker collapsing) — otherwise the shrinking
|
||||
// content lets the browser clamp scrollTop and the turn drops.
|
||||
const previousSpacerHeight = spacerHeightRef.current
|
||||
|
||||
if (reanchorToAnchoredMessage()) {
|
||||
// The reply streaming below the anchor consumes the tail spacer as it
|
||||
// grows. Once the last of it is gone the reply has filled the viewport
|
||||
// and the reader is genuinely at the live edge, so autoScroll hands off
|
||||
// from the anchor hold to following the bottom. Requiring the >0 → 0
|
||||
// transition keeps a turn taller than the viewport (placed with no
|
||||
// spacer) held instead of yanked to the end.
|
||||
if (
|
||||
autoScrollRef.current &&
|
||||
previousSpacerHeight > 0 &&
|
||||
spacerHeightRef.current === 0
|
||||
) {
|
||||
scrollToEnd({ behavior: "auto" })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scheduleStateCommit()
|
||||
scheduleVisibilitySync()
|
||||
}, [
|
||||
reanchorToAnchoredMessage,
|
||||
scheduleStateCommit,
|
||||
scheduleVisibilitySync,
|
||||
scrollToEnd,
|
||||
])
|
||||
|
||||
const observeVisibility = React.useCallback(() => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport || !visibilityStore.hasListeners()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
scheduleVisibilitySync()
|
||||
return
|
||||
}
|
||||
|
||||
if (!visibilityObserverRef.current) {
|
||||
visibilityObserverRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const messageId = (entry.target as HTMLElement).dataset.messageId
|
||||
|
||||
if (!messageId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
visibleMessageIdsRef.current.add(messageId)
|
||||
} else {
|
||||
visibleMessageIdsRef.current.delete(messageId)
|
||||
}
|
||||
}
|
||||
|
||||
scheduleVisibilitySync()
|
||||
},
|
||||
{
|
||||
root: viewport,
|
||||
// Shrink the root's top edge to the anchoring line so a previous turn
|
||||
// peeking in the scrollMargin + peek band is not reported as visible,
|
||||
// keeping visibleMessageIds consistent with currentAnchorId. Captured
|
||||
// at observe time; a prop change rebuilds the observer on resubscribe.
|
||||
rootMargin: `${-(
|
||||
scrollMarginRef.current + scrollPreviousItemPeekRef.current
|
||||
)}px 0px 0px 0px`,
|
||||
threshold: [0, 0.01, 0.5, 1],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
messageElementsRef.current.forEach((element) => {
|
||||
visibilityObserverRef.current?.observe(element)
|
||||
})
|
||||
scheduleVisibilitySync()
|
||||
}, [scheduleVisibilitySync, visibilityStore])
|
||||
|
||||
const unobserveVisibility = React.useCallback(() => {
|
||||
if (visibilityFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(visibilityFrameRef.current)
|
||||
visibilityFrameRef.current = null
|
||||
}
|
||||
|
||||
visibilityObserverRef.current?.disconnect()
|
||||
visibilityObserverRef.current = null
|
||||
visibleMessageIdsRef.current.clear()
|
||||
visibilityStore.setSnapshot(EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE)
|
||||
}, [visibilityStore])
|
||||
|
||||
const registerMessage = React.useCallback<MessageScrollerRegisterMessage>(
|
||||
(messageId, element, removedElement) => {
|
||||
if (element) {
|
||||
messageElementsRef.current.set(messageId, element)
|
||||
visibilityObserverRef.current?.observe(element)
|
||||
scheduleVisibilitySync()
|
||||
|
||||
if (pendingScrollToMessageRef.current?.messageId === messageId) {
|
||||
schedulePendingScrollToMessageFlush()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
removedElement &&
|
||||
messageElementsRef.current.get(messageId) === removedElement
|
||||
) {
|
||||
messageElementsRef.current.delete(messageId)
|
||||
visibleMessageIdsRef.current.delete(messageId)
|
||||
visibilityObserverRef.current?.unobserve(removedElement)
|
||||
scheduleVisibilitySync()
|
||||
}
|
||||
},
|
||||
[schedulePendingScrollToMessageFlush, scheduleVisibilitySync]
|
||||
)
|
||||
|
||||
const userScrollIntent = React.useCallback(() => {
|
||||
if (
|
||||
modeRef.current === "following-bottom" ||
|
||||
modeRef.current === "anchored-to-message" ||
|
||||
modeRef.current === "settling-jump"
|
||||
) {
|
||||
// A deliberate gesture releases auto-follow, turn-anchoring, and an in-flight
|
||||
// programmatic jump so re-pinning (and re-arming) never fights the reader.
|
||||
streamingTurnRef.current = null
|
||||
modeRef.current = "free-scrolling"
|
||||
}
|
||||
}, [])
|
||||
|
||||
const mirrorStateAttributes = React.useCallback(
|
||||
() => writeStateAttributes(stateStore.getSnapshot()),
|
||||
[stateStore, writeStateAttributes]
|
||||
)
|
||||
|
||||
const setRootElement = useElementRef(rootRef, mirrorStateAttributes)
|
||||
const setViewportElement = useElementRef(viewportRef, mirrorStateAttributes)
|
||||
|
||||
const setContentElement = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
contentRef.current = element
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const setSpacerElement = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
spacerRef.current = element
|
||||
spacerGapRef.current = getFlexGap(element?.parentElement ?? null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const syncAfterScroll = React.useCallback(() => {
|
||||
commitScrollState()
|
||||
scheduleVisibilitySync()
|
||||
capturePrependAnchor()
|
||||
}, [capturePrependAnchor, commitScrollState, scheduleVisibilitySync])
|
||||
|
||||
const context = React.useMemo<MessageScrollerContextValue>(
|
||||
() => ({
|
||||
handleContentChange,
|
||||
handleResize,
|
||||
observeVisibility,
|
||||
pendingDefaultScrollStore,
|
||||
preserveScrollOnPrependRef,
|
||||
scrollToEnd,
|
||||
scrollToMessage,
|
||||
scrollToStart,
|
||||
setContentElement,
|
||||
setRootElement,
|
||||
setSpacerElement,
|
||||
setViewportElement,
|
||||
stateStore,
|
||||
syncAfterScroll,
|
||||
unobserveVisibility,
|
||||
userScrollIntent,
|
||||
viewportRef,
|
||||
visibilityStore,
|
||||
}),
|
||||
[
|
||||
handleContentChange,
|
||||
handleResize,
|
||||
observeVisibility,
|
||||
pendingDefaultScrollStore,
|
||||
scrollToEnd,
|
||||
scrollToMessage,
|
||||
scrollToStart,
|
||||
setContentElement,
|
||||
setRootElement,
|
||||
setSpacerElement,
|
||||
setViewportElement,
|
||||
stateStore,
|
||||
syncAfterScroll,
|
||||
unobserveVisibility,
|
||||
userScrollIntent,
|
||||
visibilityStore,
|
||||
]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (applyDefaultScrollPosition()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (itemCountRef.current === 0) {
|
||||
clearPendingDefaultScroll(refs)
|
||||
}
|
||||
}, [applyDefaultScrollPosition])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Reset every ref after cancelling. StrictMode replays effects on the same
|
||||
// refs (unmount then remount), so a frame id left non-null here makes the
|
||||
// scheduler on remount think a frame is still pending and never reschedule.
|
||||
if (stateFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(stateFrameRef.current)
|
||||
stateFrameRef.current = null
|
||||
}
|
||||
|
||||
if (visibilityFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(visibilityFrameRef.current)
|
||||
visibilityFrameRef.current = null
|
||||
}
|
||||
|
||||
if (autoscrollingTimeoutRef.current !== null) {
|
||||
window.clearTimeout(autoscrollingTimeoutRef.current)
|
||||
autoscrollingTimeoutRef.current = null
|
||||
}
|
||||
|
||||
if (pendingScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(pendingScrollFrameRef.current)
|
||||
pendingScrollFrameRef.current = null
|
||||
}
|
||||
|
||||
visibilityObserverRef.current?.disconnect()
|
||||
visibilityObserverRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (
|
||||
autoScroll &&
|
||||
modeRef.current === "following-bottom" &&
|
||||
itemCountRef.current > 0
|
||||
) {
|
||||
scrollToEnd({ behavior: "auto" })
|
||||
return
|
||||
}
|
||||
|
||||
commitScrollState()
|
||||
}, [autoScroll, commitScrollState, scrollToEnd])
|
||||
|
||||
return {
|
||||
context,
|
||||
registerMessage,
|
||||
}
|
||||
}
|
||||
|
||||
export { useMessageScrollerController }
|
||||
200
packages/react/src/message-scroller/use-message-scroller-refs.ts
Normal file
200
packages/react/src/message-scroller/use-message-scroller-refs.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
areScrollStatesEqual,
|
||||
createMessageScrollerStore,
|
||||
createMessageScrollerVisibilityStore,
|
||||
} from "./stores"
|
||||
import { EMPTY_MESSAGE_SCROLLER_SCROLLABLE } from "./types"
|
||||
import type {
|
||||
MessageScrollerDefaultScrollPosition,
|
||||
MessageScrollerMode,
|
||||
MessageScrollerScrollable,
|
||||
MessageScrollerScrollOptions,
|
||||
MessageScrollerStore,
|
||||
MessageScrollerVisibilityStore,
|
||||
} from "./types"
|
||||
|
||||
// Shared mutable ref bag for one MessageScroller, closed over by both the
|
||||
// controller and the commands so writes are visible across them without prop
|
||||
// threading. stateStore and visibilityStore fan out via useSyncExternalStore.
|
||||
type MessageScrollerRefs = {
|
||||
autoScrollRef: React.RefObject<boolean>
|
||||
autoscrollingRef: React.RefObject<boolean>
|
||||
autoscrollingTimeoutRef: React.RefObject<number | null>
|
||||
streamingTurnRef: React.RefObject<HTMLElement | null>
|
||||
contentRef: React.RefObject<HTMLDivElement | null>
|
||||
defaultScrollPositionAppliedRef: React.RefObject<boolean>
|
||||
firstItemRef: React.RefObject<HTMLElement | null>
|
||||
itemCountRef: React.RefObject<number>
|
||||
lastScrollTopRef: React.RefObject<number>
|
||||
messageElementsRef: React.RefObject<Map<string, HTMLElement>>
|
||||
modeRef: React.RefObject<MessageScrollerMode>
|
||||
pendingScrollFrameRef: React.RefObject<number | null>
|
||||
pendingScrollToMessageRef: React.RefObject<{
|
||||
messageId: string
|
||||
options?: MessageScrollerScrollOptions
|
||||
} | null>
|
||||
prependRestoreRef: React.RefObject<{
|
||||
element: HTMLElement
|
||||
viewportTop: number
|
||||
} | null>
|
||||
preserveScrollOnPrependRef: React.RefObject<boolean>
|
||||
pendingDefaultScrollStore: MessageScrollerStore<boolean>
|
||||
rootRef: React.RefObject<HTMLDivElement | null>
|
||||
scrollEdgeThresholdRef: React.RefObject<number>
|
||||
scrollMarginRef: React.RefObject<number>
|
||||
scrollPreviousItemPeekRef: React.RefObject<number>
|
||||
spacerGapRef: React.RefObject<number>
|
||||
spacerHeightRef: React.RefObject<number>
|
||||
spacerRef: React.RefObject<HTMLDivElement | null>
|
||||
stateFrameRef: React.RefObject<number | null>
|
||||
stateStore: MessageScrollerStore<MessageScrollerScrollable>
|
||||
viewportRef: React.RefObject<HTMLDivElement | null>
|
||||
visibilityFrameRef: React.RefObject<number | null>
|
||||
visibilityObserverRef: React.RefObject<IntersectionObserver | null>
|
||||
visibilityStore: MessageScrollerVisibilityStore
|
||||
visibleMessageIdsRef: React.RefObject<Set<string>>
|
||||
handledScrollAnchorsRef: React.RefObject<WeakSet<HTMLElement>>
|
||||
}
|
||||
|
||||
// Builds the per-instance ref bag: the two external stores constructed once, and
|
||||
// the latest prop values mirrored onto refs each render so callbacks stay stable.
|
||||
function useMessageScrollerRefs({
|
||||
autoScroll,
|
||||
defaultScrollPosition,
|
||||
scrollEdgeThreshold,
|
||||
scrollMargin,
|
||||
scrollPreviousItemPeek,
|
||||
}: {
|
||||
autoScroll: boolean
|
||||
defaultScrollPosition: MessageScrollerDefaultScrollPosition
|
||||
scrollEdgeThreshold: number
|
||||
scrollMargin: number
|
||||
scrollPreviousItemPeek: number
|
||||
}): MessageScrollerRefs {
|
||||
const autoScrollRef = React.useRef(autoScroll)
|
||||
const autoscrollingRef = React.useRef(false)
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const defaultScrollPositionAppliedRef = React.useRef(false)
|
||||
const scrollEdgeThresholdRef = React.useRef(scrollEdgeThreshold)
|
||||
const itemCountRef = React.useRef(0)
|
||||
const firstItemRef = React.useRef<HTMLElement | null>(null)
|
||||
// The scrollTop seen by the previous state commit, so follow-release can tell
|
||||
// a reader scrolling up from content growing past the live edge.
|
||||
const lastScrollTopRef = React.useRef(0)
|
||||
const modeRef = React.useRef<MessageScrollerMode>(
|
||||
autoScroll ? "following-bottom" : "free-scrolling"
|
||||
)
|
||||
const messageElementsRef = React.useRef(new Map<string, HTMLElement>())
|
||||
const pendingScrollToMessageRef = React.useRef<{
|
||||
messageId: string
|
||||
options?: MessageScrollerScrollOptions
|
||||
} | null>(null)
|
||||
// The row to hold steady on the next prepend: the first visible row, or a jump
|
||||
// target seeded by scrollToElement. restorePrependedAnchor reads only this.
|
||||
const prependRestoreRef = React.useRef<{
|
||||
element: HTMLElement
|
||||
viewportTop: number
|
||||
} | null>(null)
|
||||
// The turn held at the reading line so a reply streaming in below it can re-pin
|
||||
// it instead of letting scrollTop clamp it loose.
|
||||
const streamingTurnRef = React.useRef<HTMLElement | null>(null)
|
||||
const scrollPreviousItemPeekRef = React.useRef(scrollPreviousItemPeek)
|
||||
const preserveScrollOnPrependRef = React.useRef(true)
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const scrollMarginRef = React.useRef(scrollMargin)
|
||||
const pendingScrollFrameRef = React.useRef<number | null>(null)
|
||||
const spacerGapRef = React.useRef(0)
|
||||
const spacerHeightRef = React.useRef(0)
|
||||
const spacerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const stateFrameRef = React.useRef<number | null>(null)
|
||||
const pendingDefaultScrollStoreRef =
|
||||
React.useRef<MessageScrollerStore<boolean> | null>(null)
|
||||
const stateStoreRef =
|
||||
React.useRef<MessageScrollerStore<MessageScrollerScrollable> | null>(null)
|
||||
const autoscrollingTimeoutRef = React.useRef<number | null>(null)
|
||||
const viewportRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const visibilityFrameRef = React.useRef<number | null>(null)
|
||||
const visibilityObserverRef = React.useRef<IntersectionObserver | null>(null)
|
||||
const visibilityStoreRef =
|
||||
React.useRef<MessageScrollerVisibilityStore | null>(null)
|
||||
const visibleMessageIdsRef = React.useRef(new Set<string>())
|
||||
const handledScrollAnchorsRef = React.useRef(new WeakSet<HTMLElement>())
|
||||
|
||||
if (pendingDefaultScrollStoreRef.current === null) {
|
||||
pendingDefaultScrollStoreRef.current = createMessageScrollerStore(
|
||||
defaultScrollPosition === "end" ||
|
||||
defaultScrollPosition === "last-anchor",
|
||||
(current, next) => current === next
|
||||
)
|
||||
}
|
||||
|
||||
if (stateStoreRef.current === null) {
|
||||
stateStoreRef.current = createMessageScrollerStore(
|
||||
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
|
||||
areScrollStatesEqual
|
||||
)
|
||||
}
|
||||
|
||||
if (visibilityStoreRef.current === null) {
|
||||
visibilityStoreRef.current = createMessageScrollerVisibilityStore()
|
||||
}
|
||||
|
||||
// Track the latest prop values on every render so callbacks read fresh values
|
||||
// without being recreated (the useLatest pattern).
|
||||
autoScrollRef.current = autoScroll
|
||||
scrollEdgeThresholdRef.current = scrollEdgeThreshold
|
||||
scrollMarginRef.current = scrollMargin
|
||||
scrollPreviousItemPeekRef.current = scrollPreviousItemPeek
|
||||
|
||||
return {
|
||||
autoScrollRef,
|
||||
autoscrollingRef,
|
||||
autoscrollingTimeoutRef,
|
||||
streamingTurnRef,
|
||||
contentRef,
|
||||
defaultScrollPositionAppliedRef,
|
||||
firstItemRef,
|
||||
itemCountRef,
|
||||
lastScrollTopRef,
|
||||
messageElementsRef,
|
||||
modeRef,
|
||||
pendingScrollFrameRef,
|
||||
pendingScrollToMessageRef,
|
||||
prependRestoreRef,
|
||||
preserveScrollOnPrependRef,
|
||||
pendingDefaultScrollStore: pendingDefaultScrollStoreRef.current,
|
||||
rootRef,
|
||||
scrollEdgeThresholdRef,
|
||||
scrollMarginRef,
|
||||
scrollPreviousItemPeekRef,
|
||||
spacerGapRef,
|
||||
spacerHeightRef,
|
||||
spacerRef,
|
||||
stateFrameRef,
|
||||
stateStore: stateStoreRef.current,
|
||||
viewportRef,
|
||||
visibilityFrameRef,
|
||||
visibilityObserverRef,
|
||||
visibilityStore: visibilityStoreRef.current,
|
||||
visibleMessageIdsRef,
|
||||
handledScrollAnchorsRef,
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingDefaultScroll(refs: MessageScrollerRefs) {
|
||||
refs.pendingDefaultScrollStore.setSnapshot(false)
|
||||
}
|
||||
|
||||
function markDefaultScrollPositionApplied(refs: MessageScrollerRefs) {
|
||||
refs.defaultScrollPositionAppliedRef.current = true
|
||||
clearPendingDefaultScroll(refs)
|
||||
}
|
||||
|
||||
export {
|
||||
clearPendingDefaultScroll,
|
||||
markDefaultScrollPositionApplied,
|
||||
useMessageScrollerRefs,
|
||||
}
|
||||
export type { MessageScrollerRefs }
|
||||
11
packages/react/src/message-scroller/utils.ts
Normal file
11
packages/react/src/message-scroller/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from "react"
|
||||
|
||||
function useLatest<T>(value: T) {
|
||||
const ref = React.useRef(value)
|
||||
|
||||
ref.current = value
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
export { useLatest }
|
||||
98
packages/react/src/questionnaire/README.md
Normal file
98
packages/react/src/questionnaire/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Questionnaire
|
||||
|
||||
An unstyled, multi-step questionnaire for React. `Root` renders a native form,
|
||||
and answers are read with `FormData`.
|
||||
|
||||
Questionnaire supports server rendering. Pass `Root.items` to include complete
|
||||
collection-derived state in the initial HTML.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const items = [
|
||||
{
|
||||
name: "prototype",
|
||||
required: true,
|
||||
prompt: "What should we prototype next?",
|
||||
description: "Choose a direction or write your own.",
|
||||
choices: [
|
||||
{
|
||||
value: "delegation",
|
||||
label: "Delegation",
|
||||
description: "Show how work moves to a specialist.",
|
||||
},
|
||||
{ value: "questions", label: "Question prompts" },
|
||||
],
|
||||
input: { label: "Another answer", placeholder: "Type another answer…" },
|
||||
},
|
||||
{
|
||||
name: "detail",
|
||||
required: false,
|
||||
prompt: "How much detail?",
|
||||
description: "Skip this if you are not sure yet.",
|
||||
choices: [
|
||||
{ value: "focused", label: "Focused" },
|
||||
{ value: "complete", label: "Complete flow" },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Questionnaire } from "@shadcn/react/questionnaire"
|
||||
|
||||
export function ProjectQuestionnaire() {
|
||||
return (
|
||||
<Questionnaire.Root
|
||||
items={items}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const answers = new FormData(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<Questionnaire.Progress />
|
||||
{items.map((question) => (
|
||||
<Questionnaire.Item
|
||||
key={question.name}
|
||||
name={question.name}
|
||||
required={question.required}
|
||||
>
|
||||
<Questionnaire.Title>{question.prompt}</Questionnaire.Title>
|
||||
<Questionnaire.Description>
|
||||
{question.description}
|
||||
</Questionnaire.Description>
|
||||
<Questionnaire.Choices>
|
||||
{question.choices.map((choice) => (
|
||||
<Questionnaire.Choice key={choice.value} value={choice.value}>
|
||||
<Questionnaire.ChoiceInput />
|
||||
<Questionnaire.ChoiceLabel>
|
||||
<span>{choice.label}</span>
|
||||
{"description" in choice ? (
|
||||
<span>{choice.description}</span>
|
||||
) : null}
|
||||
</Questionnaire.ChoiceLabel>
|
||||
<Questionnaire.ChoiceShortcut />
|
||||
</Questionnaire.Choice>
|
||||
))}
|
||||
{"input" in question ? (
|
||||
<Questionnaire.Input
|
||||
aria-label={question.input.label}
|
||||
placeholder={question.input.placeholder}
|
||||
/>
|
||||
) : null}
|
||||
</Questionnaire.Choices>
|
||||
<Questionnaire.Error />
|
||||
</Questionnaire.Item>
|
||||
))}
|
||||
<Questionnaire.Previous />
|
||||
<Questionnaire.Skip />
|
||||
<Questionnaire.Next />
|
||||
<Questionnaire.Submit />
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Read the full docs at [ui.shadcn.com/docs/react/questionnaire](https://ui.shadcn.com/docs/react/questionnaire).
|
||||
234
packages/react/src/questionnaire/collection.ts
Normal file
234
packages/react/src/questionnaire/collection.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type {
|
||||
ItemRegistration,
|
||||
QuestionnaireItemDefinition,
|
||||
QuestionnaireShortcutMode,
|
||||
} from "./types"
|
||||
import { getShortcutKeys } from "./utils"
|
||||
|
||||
type QuestionnaireCollection = {
|
||||
enabledItems: readonly QuestionnaireItemDefinition[]
|
||||
itemByName: ReadonlyMap<string, QuestionnaireItemDefinition>
|
||||
items: readonly QuestionnaireItemDefinition[]
|
||||
}
|
||||
|
||||
function createQuestionnaireCollection(
|
||||
items: readonly QuestionnaireItemDefinition[] | undefined
|
||||
): QuestionnaireCollection | null {
|
||||
if (items === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
enabledItems: items.filter((item) => !item.disabled),
|
||||
itemByName: new Map(items.map((item) => [item.name, item])),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialItemName(
|
||||
collection: QuestionnaireCollection | null,
|
||||
defaultItem: string | undefined
|
||||
) {
|
||||
if (!collection) {
|
||||
return defaultItem ?? null
|
||||
}
|
||||
|
||||
const defaultDefinition = defaultItem
|
||||
? collection.itemByName.get(defaultItem)
|
||||
: undefined
|
||||
|
||||
if (defaultDefinition && !defaultDefinition.disabled) {
|
||||
return defaultDefinition.name
|
||||
}
|
||||
|
||||
return collection.enabledItems[0]?.name ?? null
|
||||
}
|
||||
|
||||
function getShortcutByChoiceValue(
|
||||
item: QuestionnaireItemDefinition | undefined,
|
||||
shortcuts: QuestionnaireShortcutMode | null
|
||||
) {
|
||||
const shortcutByChoiceValue = new Map<string, string>()
|
||||
|
||||
if (!item || !shortcuts) {
|
||||
return shortcutByChoiceValue
|
||||
}
|
||||
|
||||
const keys = getShortcutKeys(shortcuts)
|
||||
let shortcutIndex = 0
|
||||
|
||||
for (const choice of item.choices ?? []) {
|
||||
if (choice.disabled) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shortcut = keys[shortcutIndex]
|
||||
|
||||
if (!shortcut) {
|
||||
break
|
||||
}
|
||||
|
||||
shortcutByChoiceValue.set(choice.value, shortcut)
|
||||
shortcutIndex += 1
|
||||
}
|
||||
|
||||
return shortcutByChoiceValue
|
||||
}
|
||||
|
||||
function getCollectionDefinitionWarnings(
|
||||
collection: QuestionnaireCollection,
|
||||
defaultItem: string | undefined
|
||||
) {
|
||||
const warnings: string[] = []
|
||||
const itemNames = new Set<string>()
|
||||
|
||||
for (const item of collection.items) {
|
||||
if (itemNames.has(item.name)) {
|
||||
warnings.push(`Item name "${item.name}" is defined more than once.`)
|
||||
}
|
||||
|
||||
itemNames.add(item.name)
|
||||
|
||||
const choiceValues = new Set<string>()
|
||||
|
||||
for (const choice of item.choices ?? []) {
|
||||
if (choiceValues.has(choice.value)) {
|
||||
warnings.push(
|
||||
`Choice value "${choice.value}" is defined more than once in item "${item.name}".`
|
||||
)
|
||||
}
|
||||
|
||||
choiceValues.add(choice.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultItem) {
|
||||
const defaultDefinition = collection.itemByName.get(defaultItem)
|
||||
|
||||
if (!defaultDefinition || defaultDefinition.disabled) {
|
||||
warnings.push(
|
||||
`defaultItem "${defaultItem}" does not identify an enabled item. The first enabled item will be used instead.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
function getCollectionRegistrationWarnings(
|
||||
collection: QuestionnaireCollection,
|
||||
registrations: readonly ItemRegistration[],
|
||||
shortcuts: QuestionnaireShortcutMode | null
|
||||
) {
|
||||
const warnings: string[] = []
|
||||
const registrationByName = new Map(
|
||||
registrations.map((registration) => [registration.name, registration])
|
||||
)
|
||||
|
||||
for (const definition of collection.items) {
|
||||
const registration = registrationByName.get(definition.name)
|
||||
|
||||
if (!registration) {
|
||||
if (!definition.disabled) {
|
||||
warnings.push(
|
||||
`Item "${definition.name}" is defined but has no rendered Questionnaire.Item.`
|
||||
)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (registration.disabled !== Boolean(definition.disabled)) {
|
||||
warnings.push(
|
||||
`Item "${definition.name}" has different disabled values in Root.items and Questionnaire.Item.`
|
||||
)
|
||||
}
|
||||
|
||||
if (registration.required !== Boolean(definition.required)) {
|
||||
warnings.push(
|
||||
`Item "${definition.name}" has different required values in Root.items and Questionnaire.Item.`
|
||||
)
|
||||
}
|
||||
|
||||
const definedChoices = definition.choices ?? []
|
||||
const definedChoiceByValue = new Map(
|
||||
definedChoices.map((choice) => [choice.value, choice])
|
||||
)
|
||||
const registeredChoiceByValue = new Map(
|
||||
registration.choices.map((choice) => [choice.value, choice])
|
||||
)
|
||||
|
||||
for (const choice of definedChoices) {
|
||||
const registeredChoice = registeredChoiceByValue.get(choice.value)
|
||||
|
||||
if (!registeredChoice) {
|
||||
warnings.push(
|
||||
`Choice "${choice.value}" is defined for item "${definition.name}" but has no rendered Questionnaire.Choice.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (registeredChoice.disabled !== Boolean(choice.disabled)) {
|
||||
warnings.push(
|
||||
`Choice "${choice.value}" in item "${definition.name}" has different disabled values in Root.items and Questionnaire.Choice.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (shortcuts) {
|
||||
for (const choice of registration.choices) {
|
||||
if (!definedChoiceByValue.has(choice.value)) {
|
||||
warnings.push(
|
||||
`Rendered choice "${choice.value}" in item "${definition.name}" is missing from Root.items and will not receive a shortcut.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const definedOrder = definedChoices
|
||||
.filter((choice) => !choice.disabled)
|
||||
.map((choice) => choice.value)
|
||||
const registeredOrder = registration.choices
|
||||
.filter((choice) => !choice.disabled)
|
||||
.map((choice) => choice.value)
|
||||
|
||||
const sameChoices =
|
||||
definedOrder.length > 0 &&
|
||||
definedOrder.length === registeredOrder.length &&
|
||||
definedOrder.every((choiceValue) =>
|
||||
registeredOrder.includes(choiceValue)
|
||||
)
|
||||
|
||||
if (
|
||||
sameChoices &&
|
||||
definedOrder.some(
|
||||
(choiceValue, index) => choiceValue !== registeredOrder[index]
|
||||
)
|
||||
) {
|
||||
warnings.push(
|
||||
`Choice order for item "${definition.name}" differs between Root.items and the rendered Questionnaire.Choice elements.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const registration of registrations) {
|
||||
if (
|
||||
!registration.disabled &&
|
||||
!collection.itemByName.has(registration.name)
|
||||
) {
|
||||
warnings.push(
|
||||
`Rendered item "${registration.name}" is missing from Root.items and is excluded from the questionnaire collection.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
export {
|
||||
createQuestionnaireCollection,
|
||||
getCollectionDefinitionWarnings,
|
||||
getCollectionRegistrationWarnings,
|
||||
getInitialItemName,
|
||||
getShortcutByChoiceValue,
|
||||
}
|
||||
551
packages/react/src/questionnaire/components.tsx
Normal file
551
packages/react/src/questionnaire/components.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { mergeProps, useRender } from "../use-render"
|
||||
import {
|
||||
QuestionnaireChoiceContext,
|
||||
QuestionnaireContext,
|
||||
QuestionnaireItemContext,
|
||||
useQuestionnaireChoiceContext,
|
||||
useQuestionnaireContext,
|
||||
useQuestionnaireItemContext,
|
||||
} from "./context"
|
||||
import type {
|
||||
QuestionnaireChoiceInputProps,
|
||||
QuestionnaireChoiceLabelProps,
|
||||
QuestionnaireChoiceProps,
|
||||
QuestionnaireChoiceShortcutProps,
|
||||
QuestionnaireChoicesProps,
|
||||
QuestionnaireDescriptionProps,
|
||||
QuestionnaireErrorProps,
|
||||
QuestionnaireInputProps,
|
||||
QuestionnaireItemProps,
|
||||
QuestionnaireNavigationState,
|
||||
QuestionnaireNextProps,
|
||||
QuestionnairePreviousProps,
|
||||
QuestionnaireProgressProps,
|
||||
QuestionnaireRootProps,
|
||||
QuestionnaireSkipProps,
|
||||
QuestionnaireSubmitProps,
|
||||
QuestionnaireTitleProps,
|
||||
} from "./types"
|
||||
import { useQuestionnaireChoice } from "./use-questionnaire-choice"
|
||||
import { useQuestionnaireInput } from "./use-questionnaire-input"
|
||||
import { useQuestionnaireItem } from "./use-questionnaire-item"
|
||||
import { useQuestionnaireRoot } from "./use-questionnaire-root"
|
||||
|
||||
function QuestionnaireRoot({
|
||||
defaultItem,
|
||||
item,
|
||||
items,
|
||||
noValidate = true,
|
||||
onItemChange,
|
||||
onReset,
|
||||
onSubmit,
|
||||
ref,
|
||||
shortcuts,
|
||||
...props
|
||||
}: QuestionnaireRootProps) {
|
||||
const { context, rootProps, state } = useQuestionnaireRoot({
|
||||
defaultItem,
|
||||
item,
|
||||
items,
|
||||
noValidate,
|
||||
onItemChange,
|
||||
onReset,
|
||||
onSubmit,
|
||||
ref,
|
||||
shortcuts,
|
||||
})
|
||||
const element = useRender({
|
||||
defaultTagName: "form",
|
||||
props: mergeProps<"form">({ ...rootProps, noValidate }, props),
|
||||
state,
|
||||
})
|
||||
|
||||
return (
|
||||
<QuestionnaireContext.Provider value={context}>
|
||||
{element}
|
||||
</QuestionnaireContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionnaireProgress({
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: QuestionnaireProgressProps) {
|
||||
const { current, first, last, total } = useQuestionnaireContext(
|
||||
"Questionnaire.Progress"
|
||||
)
|
||||
const label = total ? `Question ${current} of ${total}` : undefined
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
"aria-label": "Questionnaire progress",
|
||||
"aria-live": "polite",
|
||||
"aria-valuemax": total || undefined,
|
||||
"aria-valuemin": total ? 1 : undefined,
|
||||
"aria-valuenow": total ? current : undefined,
|
||||
"aria-valuetext": label,
|
||||
children: children ?? label,
|
||||
role: "progressbar",
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: { current, first, last, total },
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireItem({
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
"aria-keyshortcuts": ariaKeyShortcuts,
|
||||
children,
|
||||
disabled = false,
|
||||
invalid = false,
|
||||
multiple = false,
|
||||
name,
|
||||
onStatusChange,
|
||||
ref,
|
||||
required = false,
|
||||
...props
|
||||
}: QuestionnaireItemProps) {
|
||||
const { context, itemProps, state } = useQuestionnaireItem({
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
"aria-keyshortcuts": ariaKeyShortcuts,
|
||||
disabled,
|
||||
invalid,
|
||||
multiple,
|
||||
name,
|
||||
onStatusChange,
|
||||
ref,
|
||||
required,
|
||||
})
|
||||
const element = useRender({
|
||||
defaultTagName: "fieldset",
|
||||
props: mergeProps<"fieldset">({ ...itemProps, children }, props),
|
||||
state,
|
||||
stateAttributesMapping: {
|
||||
active: (isActive) => ({
|
||||
"data-active": isActive ? "" : undefined,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<QuestionnaireItemContext.Provider value={context}>
|
||||
{element}
|
||||
</QuestionnaireItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionnaireTitle({ render, ...props }: QuestionnaireTitleProps) {
|
||||
useQuestionnaireItemContext("Questionnaire.Title")
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "legend",
|
||||
props,
|
||||
render,
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireDescription({
|
||||
id,
|
||||
render,
|
||||
...props
|
||||
}: QuestionnaireDescriptionProps) {
|
||||
const { registerDescription } = useQuestionnaireItemContext(
|
||||
"Questionnaire.Description"
|
||||
)
|
||||
const generatedId = React.useId()
|
||||
const descriptionId = id ?? generatedId
|
||||
|
||||
React.useLayoutEffect(
|
||||
() => registerDescription(descriptionId),
|
||||
[descriptionId, registerDescription]
|
||||
)
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "p",
|
||||
props: mergeProps<"p">({ id: descriptionId }, props),
|
||||
render,
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireChoices({ render, ...props }: QuestionnaireChoicesProps) {
|
||||
const { shortcuts } = useQuestionnaireItemContext("Questionnaire.Choices")
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props,
|
||||
render,
|
||||
state: { shortcuts },
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireChoice({
|
||||
checked,
|
||||
children,
|
||||
defaultChecked = false,
|
||||
disabled = false,
|
||||
onChange,
|
||||
render,
|
||||
value,
|
||||
...props
|
||||
}: QuestionnaireChoiceProps) {
|
||||
const { inputProps, state } = useQuestionnaireChoice({
|
||||
checked,
|
||||
defaultChecked,
|
||||
disabled,
|
||||
onChange,
|
||||
value,
|
||||
})
|
||||
const element = useRender({
|
||||
defaultTagName: "label",
|
||||
props: mergeProps<"label">({ children }, props),
|
||||
render,
|
||||
state,
|
||||
stateAttributesMapping: {
|
||||
checked: (isChecked) => ({
|
||||
"data-checked": isChecked ? "" : undefined,
|
||||
"data-unchecked": isChecked ? undefined : "",
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<QuestionnaireChoiceContext.Provider value={{ inputProps, state }}>
|
||||
{element}
|
||||
</QuestionnaireChoiceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionnaireChoiceInput({
|
||||
render,
|
||||
...props
|
||||
}: QuestionnaireChoiceInputProps) {
|
||||
const { inputProps, state } = useQuestionnaireChoiceContext(
|
||||
"Questionnaire.ChoiceInput"
|
||||
)
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "input",
|
||||
props: mergeProps<"input">(inputProps, props),
|
||||
render,
|
||||
state,
|
||||
stateAttributesMapping: {
|
||||
checked: (isChecked) => ({
|
||||
"data-checked": isChecked ? "" : undefined,
|
||||
"data-unchecked": isChecked ? undefined : "",
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireChoiceLabel({
|
||||
render,
|
||||
...props
|
||||
}: QuestionnaireChoiceLabelProps) {
|
||||
useQuestionnaireChoiceContext("Questionnaire.ChoiceLabel")
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props,
|
||||
render,
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireChoiceShortcut({
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: QuestionnaireChoiceShortcutProps) {
|
||||
const { state } = useQuestionnaireChoiceContext(
|
||||
"Questionnaire.ChoiceShortcut"
|
||||
)
|
||||
const shortcutState = { shortcut: state.shortcut }
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
"aria-hidden": true,
|
||||
children: children ?? state.shortcut,
|
||||
hidden: state.shortcut === null,
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: shortcutState,
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireInput({
|
||||
defaultValue,
|
||||
disabled = false,
|
||||
onChange,
|
||||
ref,
|
||||
render,
|
||||
type = "text",
|
||||
value,
|
||||
...props
|
||||
}: QuestionnaireInputProps) {
|
||||
const { inputProps, state } = useQuestionnaireInput({
|
||||
defaultValue,
|
||||
disabled,
|
||||
onChange,
|
||||
ref,
|
||||
type,
|
||||
value,
|
||||
})
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "input",
|
||||
props: mergeProps<"input">(inputProps, props),
|
||||
render,
|
||||
state,
|
||||
stateAttributesMapping: {
|
||||
filled: (isFilled) => ({
|
||||
"data-empty": isFilled ? undefined : "",
|
||||
"data-filled": isFilled ? "" : undefined,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireError({
|
||||
children,
|
||||
id,
|
||||
render,
|
||||
...props
|
||||
}: QuestionnaireErrorProps) {
|
||||
const { invalid, registerError, required } = useQuestionnaireItemContext(
|
||||
"Questionnaire.Error"
|
||||
)
|
||||
const generatedId = React.useId()
|
||||
const errorId = id ?? generatedId
|
||||
|
||||
React.useLayoutEffect(() => registerError(errorId), [errorId, registerError])
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "p",
|
||||
props: mergeProps<"p">(
|
||||
{
|
||||
children:
|
||||
children ??
|
||||
(required
|
||||
? "Choose an answer to continue."
|
||||
: "Choose an answer or skip this question."),
|
||||
hidden: !invalid,
|
||||
id: errorId,
|
||||
role: invalid ? "alert" : undefined,
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: { invalid },
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnairePrevious({
|
||||
children,
|
||||
disabled: disabledProp = false,
|
||||
onClick,
|
||||
render,
|
||||
tabIndex,
|
||||
type = "button",
|
||||
...props
|
||||
}: QuestionnairePreviousProps) {
|
||||
const context = useQuestionnaireContext("Questionnaire.Previous")
|
||||
const visible = context.total > 1 && !context.first
|
||||
|
||||
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
context.goPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
return useRenderNavigationButton({
|
||||
children: children ?? "Previous",
|
||||
disabled: disabledProp,
|
||||
onClick: handleClick,
|
||||
props,
|
||||
render,
|
||||
status: context.activeItemStatus,
|
||||
tabIndex,
|
||||
type,
|
||||
visible,
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireSkip({
|
||||
children,
|
||||
disabled: disabledProp = false,
|
||||
onClick,
|
||||
render,
|
||||
tabIndex,
|
||||
type = "button",
|
||||
...props
|
||||
}: QuestionnaireSkipProps) {
|
||||
const context = useQuestionnaireContext("Questionnaire.Skip")
|
||||
const visible = context.activeItemRequired === false
|
||||
|
||||
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
context.skipCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
return useRenderNavigationButton({
|
||||
children: children ?? "Skip",
|
||||
disabled: disabledProp,
|
||||
onClick: handleClick,
|
||||
props,
|
||||
render,
|
||||
status: context.activeItemStatus,
|
||||
tabIndex,
|
||||
type,
|
||||
visible,
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireNext({
|
||||
children,
|
||||
disabled: disabledProp = false,
|
||||
onClick,
|
||||
render,
|
||||
tabIndex,
|
||||
type = "button",
|
||||
...props
|
||||
}: QuestionnaireNextProps) {
|
||||
const context = useQuestionnaireContext("Questionnaire.Next")
|
||||
const visible = context.total > 1 && !context.last
|
||||
|
||||
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
context.goNext()
|
||||
}
|
||||
}
|
||||
|
||||
return useRenderNavigationButton({
|
||||
children: children ?? "Next",
|
||||
disabled: disabledProp,
|
||||
onClick: handleClick,
|
||||
props,
|
||||
render,
|
||||
shortcut: "Enter",
|
||||
status: context.activeItemStatus,
|
||||
tabIndex,
|
||||
type,
|
||||
visible,
|
||||
})
|
||||
}
|
||||
|
||||
function QuestionnaireSubmit({
|
||||
children,
|
||||
disabled: disabledProp = false,
|
||||
render,
|
||||
tabIndex,
|
||||
type = "submit",
|
||||
...props
|
||||
}: QuestionnaireSubmitProps) {
|
||||
const context = useQuestionnaireContext("Questionnaire.Submit")
|
||||
const visible = context.total > 0 && context.last
|
||||
|
||||
return useRenderNavigationButton({
|
||||
children: children ?? "Submit",
|
||||
disabled: disabledProp,
|
||||
props,
|
||||
render,
|
||||
shortcut: "Enter",
|
||||
status: context.activeItemStatus,
|
||||
tabIndex,
|
||||
type,
|
||||
visible,
|
||||
})
|
||||
}
|
||||
|
||||
function useRenderNavigationButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
props,
|
||||
render,
|
||||
shortcut,
|
||||
status,
|
||||
tabIndex,
|
||||
type,
|
||||
visible,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled: boolean
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
props: React.ComponentPropsWithRef<"button">
|
||||
render: QuestionnaireNextProps["render"]
|
||||
shortcut?: "Enter"
|
||||
status: QuestionnaireNavigationState["status"]
|
||||
tabIndex: number | undefined
|
||||
type: "button" | "reset" | "submit"
|
||||
visible: boolean
|
||||
}) {
|
||||
const activeShortcut = visible && !disabled ? (shortcut ?? null) : null
|
||||
const state: QuestionnaireNavigationState = {
|
||||
disabled,
|
||||
shortcut: activeShortcut,
|
||||
status,
|
||||
visible,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
"aria-hidden": !visible || undefined,
|
||||
"aria-keyshortcuts": activeShortcut ?? undefined,
|
||||
children,
|
||||
disabled,
|
||||
hidden: !visible,
|
||||
inert: !visible,
|
||||
onClick,
|
||||
tabIndex: visible ? tabIndex : -1,
|
||||
type,
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state,
|
||||
stateAttributesMapping: {
|
||||
visible: (isVisible) => ({
|
||||
"data-hidden": isVisible ? undefined : "",
|
||||
"data-visible": isVisible ? "" : undefined,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
QuestionnaireChoice,
|
||||
QuestionnaireChoiceInput,
|
||||
QuestionnaireChoiceLabel,
|
||||
QuestionnaireChoiceShortcut,
|
||||
QuestionnaireChoices,
|
||||
QuestionnaireDescription,
|
||||
QuestionnaireError,
|
||||
QuestionnaireInput,
|
||||
QuestionnaireItem,
|
||||
QuestionnaireNext,
|
||||
QuestionnairePrevious,
|
||||
QuestionnaireProgress,
|
||||
QuestionnaireRoot,
|
||||
QuestionnaireSkip,
|
||||
QuestionnaireSubmit,
|
||||
QuestionnaireTitle,
|
||||
}
|
||||
59
packages/react/src/questionnaire/context.ts
Normal file
59
packages/react/src/questionnaire/context.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
QuestionnaireChoiceContextValue,
|
||||
QuestionnaireContextValue,
|
||||
QuestionnaireItemContextValue,
|
||||
} from "./types"
|
||||
|
||||
const QuestionnaireChoiceContext =
|
||||
React.createContext<QuestionnaireChoiceContextValue | null>(null)
|
||||
const QuestionnaireContext =
|
||||
React.createContext<QuestionnaireContextValue | null>(null)
|
||||
const QuestionnaireItemContext =
|
||||
React.createContext<QuestionnaireItemContextValue | null>(null)
|
||||
|
||||
function useQuestionnaireContext(component: string) {
|
||||
const context = React.useContext(QuestionnaireContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`${component} must be used within a Questionnaire.Root component.`
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function useQuestionnaireChoiceContext(component: string) {
|
||||
const context = React.useContext(QuestionnaireChoiceContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`${component} must be used within a Questionnaire.Choice component.`
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function useQuestionnaireItemContext(component: string) {
|
||||
const context = React.useContext(QuestionnaireItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`${component} must be used within a Questionnaire.Item component.`
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export {
|
||||
QuestionnaireChoiceContext,
|
||||
QuestionnaireContext,
|
||||
QuestionnaireItemContext,
|
||||
useQuestionnaireChoiceContext,
|
||||
useQuestionnaireContext,
|
||||
useQuestionnaireItemContext,
|
||||
}
|
||||
45
packages/react/src/questionnaire/index.ts
Normal file
45
packages/react/src/questionnaire/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
QuestionnaireChoice as Choice,
|
||||
QuestionnaireChoiceInput as ChoiceInput,
|
||||
QuestionnaireChoiceLabel as ChoiceLabel,
|
||||
QuestionnaireChoices as Choices,
|
||||
QuestionnaireChoiceShortcut as ChoiceShortcut,
|
||||
QuestionnaireDescription as Description,
|
||||
QuestionnaireError as Error,
|
||||
QuestionnaireInput as Input,
|
||||
QuestionnaireItem as Item,
|
||||
QuestionnaireNext as Next,
|
||||
QuestionnairePrevious as Previous,
|
||||
QuestionnaireProgress as Progress,
|
||||
QuestionnaireRoot as Root,
|
||||
QuestionnaireSkip as Skip,
|
||||
QuestionnaireSubmit as Submit,
|
||||
QuestionnaireTitle as Title,
|
||||
} from "./components"
|
||||
|
||||
export const Questionnaire = {
|
||||
Root,
|
||||
Progress,
|
||||
Item,
|
||||
Title,
|
||||
Description,
|
||||
Choices,
|
||||
Choice,
|
||||
ChoiceInput,
|
||||
ChoiceLabel,
|
||||
ChoiceShortcut,
|
||||
Input,
|
||||
Error,
|
||||
Previous,
|
||||
Skip,
|
||||
Next,
|
||||
Submit,
|
||||
}
|
||||
|
||||
export type {
|
||||
QuestionnaireChoiceDefinition,
|
||||
QuestionnaireInputType,
|
||||
QuestionnaireItemDefinition,
|
||||
QuestionnaireItemStatus,
|
||||
QuestionnaireShortcutMode,
|
||||
} from "./types"
|
||||
724
packages/react/src/questionnaire/questionnaire-ssr.test.tsx
Normal file
724
packages/react/src/questionnaire/questionnaire-ssr.test.tsx
Normal file
@@ -0,0 +1,724 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import * as React from "react"
|
||||
import { act } from "react"
|
||||
import { hydrateRoot, type Root } from "react-dom/client"
|
||||
import { renderToString } from "react-dom/server"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { Questionnaire } from "."
|
||||
import type { QuestionnaireItemDefinition } from "./types"
|
||||
|
||||
const items = [
|
||||
{
|
||||
choices: [
|
||||
{ value: "delegation" },
|
||||
{ disabled: true, value: "automatic" },
|
||||
{ value: "questions" },
|
||||
],
|
||||
name: "scope",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
choices: [{ value: "ignored" }],
|
||||
disabled: true,
|
||||
name: "disabled",
|
||||
},
|
||||
{
|
||||
choices: [{ value: "focused" }, { value: "complete" }],
|
||||
name: "detail",
|
||||
},
|
||||
] as const satisfies readonly QuestionnaireItemDefinition[]
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root | null
|
||||
|
||||
beforeEach(() => {
|
||||
;(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
root = null
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Questionnaire server rendering", () => {
|
||||
it("renders collection progress, active item, actions, and shortcuts", () => {
|
||||
renderServerQuestionnaire()
|
||||
|
||||
expect(form().dataset.current).toBe("1")
|
||||
expect(form().dataset.total).toBe("2")
|
||||
expect(form().hasAttribute("data-first")).toBe(true)
|
||||
expect(progress().textContent).toBe("Question 1 of 2")
|
||||
expect(progress().getAttribute("aria-valuenow")).toBe("1")
|
||||
expect(progress().getAttribute("aria-valuemax")).toBe("2")
|
||||
expect(item("scope").hasAttribute("data-active")).toBe(true)
|
||||
expect(item("scope").hidden).toBe(false)
|
||||
expect(item("disabled").hidden).toBe(true)
|
||||
expect(item("detail").hidden).toBe(true)
|
||||
expect(action("previous").hidden).toBe(true)
|
||||
expect(action("skip").hidden).toBe(true)
|
||||
expect(action("next").hidden).toBe(false)
|
||||
expect(action("next").dataset.status).toBe("unanswered")
|
||||
expect(action("submit").hidden).toBe(true)
|
||||
expect(choice("delegation").dataset.shortcut).toBe("A")
|
||||
expect(choiceInput("delegation").getAttribute("aria-keyshortcuts")).toBe(
|
||||
"A"
|
||||
)
|
||||
expect(choice("automatic").hasAttribute("data-shortcut")).toBe(false)
|
||||
expect(shortcut("automatic").hidden).toBe(true)
|
||||
expect(choice("questions").dataset.shortcut).toBe("B")
|
||||
})
|
||||
|
||||
it("renders a requested optional item and its applicable actions", () => {
|
||||
renderServerQuestionnaire({ defaultItem: "detail" })
|
||||
|
||||
expect(form().dataset.current).toBe("2")
|
||||
expect(form().hasAttribute("data-last")).toBe(true)
|
||||
expect(progress().textContent).toBe("Question 2 of 2")
|
||||
expect(item("scope").hidden).toBe(true)
|
||||
expect(item("detail").hasAttribute("data-active")).toBe(true)
|
||||
expect(action("previous").hidden).toBe(false)
|
||||
expect(action("skip").hidden).toBe(false)
|
||||
expect(action("next").hidden).toBe(true)
|
||||
expect(action("submit").hidden).toBe(false)
|
||||
})
|
||||
|
||||
it("renders a controlled item from the collection", () => {
|
||||
renderMarkup(<TestQuestionnaire item="detail" />)
|
||||
|
||||
expect(progress().textContent).toBe("Question 2 of 2")
|
||||
expect(item("scope").hidden).toBe(true)
|
||||
expect(item("detail").hasAttribute("data-active")).toBe(true)
|
||||
})
|
||||
|
||||
it.each(["missing", "disabled"])(
|
||||
"falls back from an invalid default item %s during render",
|
||||
(defaultItem) => {
|
||||
renderServerQuestionnaire({ defaultItem })
|
||||
|
||||
expect(progress().textContent).toBe("Question 1 of 2")
|
||||
expect(item("scope").hasAttribute("data-active")).toBe(true)
|
||||
expect(item("detail").hidden).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it("renders numeric shortcuts and leaves overflow choices unassigned", () => {
|
||||
const choices = Array.from({ length: 10 }, (_, index) => ({
|
||||
value: `choice-${index + 1}`,
|
||||
}))
|
||||
const numericItems = [{ choices, name: "numeric" }]
|
||||
|
||||
renderMarkup(
|
||||
<Questionnaire.Root items={numericItems} shortcuts="numbers">
|
||||
<Questionnaire.Item name="numeric">
|
||||
<Questionnaire.Title>Choose a number</Questionnaire.Title>
|
||||
{choices.map((choice) => (
|
||||
<TestChoice key={choice.value} value={choice.value} />
|
||||
))}
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
expect(choice("choice-1").dataset.shortcut).toBe("1")
|
||||
expect(choice("choice-9").dataset.shortcut).toBe("9")
|
||||
expect(choice("choice-10").hasAttribute("data-shortcut")).toBe(false)
|
||||
})
|
||||
|
||||
it("renders an input-only item without fixed-choice definitions", () => {
|
||||
renderMarkup(
|
||||
<Questionnaire.Root items={[{ name: "input" }]}>
|
||||
<Questionnaire.Progress data-testid="progress" />
|
||||
<Questionnaire.Item data-testid="input" name="input">
|
||||
<Questionnaire.Title>Describe the result</Questionnaire.Title>
|
||||
<Questionnaire.Input
|
||||
data-testid="input-control"
|
||||
aria-label="Result"
|
||||
/>
|
||||
</Questionnaire.Item>
|
||||
<Questionnaire.Submit data-testid="submit" />
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
expect(progress().textContent).toBe("Question 1 of 1")
|
||||
expect(item("input").hasAttribute("data-active")).toBe(true)
|
||||
expect(query<HTMLInputElement>("input-control").type).toBe("text")
|
||||
expect(action("submit").hidden).toBe(false)
|
||||
})
|
||||
|
||||
it("renders both navigation directions for a middle item", () => {
|
||||
const middleItems = [
|
||||
{ name: "first" },
|
||||
{ name: "middle" },
|
||||
{ name: "last" },
|
||||
]
|
||||
|
||||
renderMarkup(
|
||||
<Questionnaire.Root defaultItem="middle" items={middleItems}>
|
||||
<Questionnaire.Progress data-testid="progress" />
|
||||
{middleItems.map((definition) => (
|
||||
<Questionnaire.Item
|
||||
key={definition.name}
|
||||
data-testid={definition.name}
|
||||
name={definition.name}
|
||||
>
|
||||
<Questionnaire.Title>{definition.name}</Questionnaire.Title>
|
||||
</Questionnaire.Item>
|
||||
))}
|
||||
<Questionnaire.Previous data-testid="previous" />
|
||||
<Questionnaire.Next data-testid="next" />
|
||||
<Questionnaire.Submit data-testid="submit" />
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
expect(progress().textContent).toBe("Question 2 of 3")
|
||||
expect(action("previous").hidden).toBe(false)
|
||||
expect(action("next").hidden).toBe(false)
|
||||
expect(action("submit").hidden).toBe(true)
|
||||
})
|
||||
|
||||
it("pins the current defaultChecked server-rendering limitation", () => {
|
||||
const defaultItems = [{ choices: [{ value: "default" }], name: "answer" }]
|
||||
|
||||
renderMarkup(
|
||||
<Questionnaire.Root items={defaultItems}>
|
||||
<Questionnaire.Item name="answer">
|
||||
<TestChoice defaultChecked value="default" />
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
expect(choiceInput("default").checked).toBe(false)
|
||||
expect(choice("default").hasAttribute("data-checked")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Questionnaire hydration", () => {
|
||||
it("hydrates without changing collection-derived output", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const onRecoverableError = vi.fn()
|
||||
const questionnaire = <TestQuestionnaire />
|
||||
|
||||
renderMarkup(questionnaire)
|
||||
const initialMarkup = {
|
||||
actions: actionsMarkup(),
|
||||
progress: progress().outerHTML,
|
||||
root: form().outerHTML.match(/<form[^>]*>/)?.[0],
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire, { onRecoverableError })
|
||||
})
|
||||
|
||||
expect(onRecoverableError).not.toHaveBeenCalled()
|
||||
expect(consoleWarn).not.toHaveBeenCalled()
|
||||
expect(progress().outerHTML).toBe(initialMarkup.progress)
|
||||
expect(actionsMarkup()).toBe(initialMarkup.actions)
|
||||
expect(form().outerHTML.match(/<form[^>]*>/)?.[0]).toBe(initialMarkup.root)
|
||||
})
|
||||
|
||||
it("warns after falling back from an invalid default item", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const questionnaire = <TestQuestionnaire defaultItem="missing" />
|
||||
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
expect(progress().textContent).toBe("Question 1 of 2")
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'defaultItem "missing" does not identify an enabled item'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("joins logical navigation to matching runtime items", async () => {
|
||||
const questionnaire = <TestQuestionnaire />
|
||||
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
choiceInput("delegation").click()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
action("next").click()
|
||||
})
|
||||
|
||||
expect(progress().textContent).toBe("Question 2 of 2")
|
||||
expect(item("scope").hidden).toBe(true)
|
||||
expect(item("detail").hasAttribute("data-active")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps definition shortcut order authoritative after hydration", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const orderedItems = [
|
||||
{
|
||||
choices: [{ value: "second" }, { value: "first" }],
|
||||
name: "order",
|
||||
},
|
||||
]
|
||||
const questionnaire = (
|
||||
<Questionnaire.Root items={orderedItems} shortcuts="letters">
|
||||
<Questionnaire.Item data-testid="order" name="order">
|
||||
<TestChoice value="first" />
|
||||
<TestChoice value="second" />
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
expect(choice("first").dataset.shortcut).toBe("B")
|
||||
expect(choice("second").dataset.shortcut).toBe("A")
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
await keydown(item("order"), "A")
|
||||
|
||||
expect(choiceInput("second").checked).toBe(true)
|
||||
expect(choiceInput("first").checked).toBe(false)
|
||||
expect(choiceInput("second").getAttribute("aria-keyshortcuts")).toContain(
|
||||
"A"
|
||||
)
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Choice order for item "order" differs between Root.items'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("warns when rendered metadata differs from its definitions", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const questionnaire = (
|
||||
<Questionnaire.Root
|
||||
items={[
|
||||
{
|
||||
choices: [{ disabled: true, value: "fixed" }],
|
||||
name: "answer",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Questionnaire.Item name="answer">
|
||||
<TestChoice value="fixed" />
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledTimes(2)
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Item "answer" has different required values in Root.items'
|
||||
)
|
||||
)
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Choice "fixed" in item "answer" has different disabled values'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("warns about duplicate item names and choice values", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const questionnaire = (
|
||||
<Questionnaire.Root
|
||||
items={[
|
||||
{
|
||||
choices: [{ value: "fixed" }, { value: "fixed" }],
|
||||
name: "answer",
|
||||
},
|
||||
{ disabled: true, name: "answer" },
|
||||
]}
|
||||
>
|
||||
<Questionnaire.Item name="answer">
|
||||
<TestChoice value="fixed" />
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Item name "answer" is defined more than once')
|
||||
)
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Choice value "fixed" is defined more than once in item "answer"'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("does not assign post-hydration shortcuts to omitted definitions", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const questionnaire = (
|
||||
<Questionnaire.Root items={[{ name: "answer" }]} shortcuts="letters">
|
||||
<Questionnaire.Item name="answer">
|
||||
<TestChoice value="fixed" />
|
||||
<Questionnaire.Input aria-label="Another answer" />
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
expect(shortcut("fixed").hidden).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
expect(shortcut("fixed").hidden).toBe(true)
|
||||
expect(choiceInput("fixed").hasAttribute("aria-keyshortcuts")).toBe(false)
|
||||
expect(consoleWarn).toHaveBeenCalledTimes(1)
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Rendered choice "fixed" in item "answer" is missing from Root.items'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("updates authoritative order and falls back when the active item is disabled", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const onItemChange = vi.fn()
|
||||
const initialItems = [
|
||||
{ name: "first" },
|
||||
{ name: "second" },
|
||||
{ name: "third" },
|
||||
]
|
||||
|
||||
function DynamicQuestionnaire({
|
||||
definitions,
|
||||
}: {
|
||||
definitions: readonly QuestionnaireItemDefinition[]
|
||||
}) {
|
||||
return (
|
||||
<Questionnaire.Root
|
||||
data-testid="root"
|
||||
defaultItem="second"
|
||||
items={definitions}
|
||||
onItemChange={onItemChange}
|
||||
>
|
||||
<Questionnaire.Progress data-testid="progress" />
|
||||
{definitions.map((definition) => (
|
||||
<Questionnaire.Item
|
||||
key={definition.name}
|
||||
data-testid={definition.name}
|
||||
disabled={definition.disabled}
|
||||
name={definition.name}
|
||||
>
|
||||
<Questionnaire.Title>{definition.name}</Questionnaire.Title>
|
||||
</Questionnaire.Item>
|
||||
))}
|
||||
<Questionnaire.Previous data-testid="previous" />
|
||||
<Questionnaire.Next data-testid="next" />
|
||||
<Questionnaire.Submit data-testid="submit" />
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const questionnaire = <DynamicQuestionnaire definitions={initialItems} />
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
expect(progress().textContent).toBe("Question 2 of 3")
|
||||
|
||||
const reorderedItems = [
|
||||
{ name: "second" },
|
||||
{ name: "first" },
|
||||
{ name: "third" },
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<DynamicQuestionnaire definitions={reorderedItems} />)
|
||||
})
|
||||
|
||||
expect(progress().textContent).toBe("Question 1 of 3")
|
||||
expect(action("previous").hidden).toBe(true)
|
||||
|
||||
const disabledItems = [
|
||||
{ disabled: true, name: "second" },
|
||||
{ name: "first" },
|
||||
{ name: "third" },
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<DynamicQuestionnaire definitions={disabledItems} />)
|
||||
})
|
||||
|
||||
expect(progress().textContent).toBe("Question 1 of 2")
|
||||
expect(item("first").hasAttribute("data-active")).toBe(true)
|
||||
expect(onItemChange).toHaveBeenLastCalledWith("first")
|
||||
expect(consoleWarn).toHaveBeenCalledTimes(1)
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'defaultItem "second" does not identify an enabled item'
|
||||
)
|
||||
)
|
||||
|
||||
const expandedItems = [
|
||||
{ name: "first" },
|
||||
{ name: "third" },
|
||||
{ name: "fourth" },
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<DynamicQuestionnaire definitions={expandedItems} />)
|
||||
})
|
||||
|
||||
expect(progress().textContent).toBe("Question 1 of 3")
|
||||
expect(action("next").hidden).toBe(false)
|
||||
expect(item("fourth").hidden).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps a controlled item aligned with collection updates", async () => {
|
||||
function ControlledQuestionnaire({ item: activeItem }: { item: string }) {
|
||||
return (
|
||||
<Questionnaire.Root item={activeItem} items={items}>
|
||||
<Questionnaire.Progress data-testid="progress" />
|
||||
<Questionnaire.Item data-testid="scope" name="scope" required>
|
||||
<Questionnaire.Title>Scope</Questionnaire.Title>
|
||||
<TestChoice value="delegation" />
|
||||
<TestChoice disabled value="automatic" />
|
||||
<TestChoice value="questions" />
|
||||
</Questionnaire.Item>
|
||||
<Questionnaire.Item data-testid="detail" name="detail">
|
||||
<Questionnaire.Title>Detail</Questionnaire.Title>
|
||||
<TestChoice value="focused" />
|
||||
<TestChoice value="complete" />
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const questionnaire = <ControlledQuestionnaire item="scope" />
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<ControlledQuestionnaire item="detail" />)
|
||||
})
|
||||
|
||||
expect(progress().textContent).toBe("Question 2 of 2")
|
||||
expect(item("scope").hidden).toBe(true)
|
||||
expect(item("detail").hasAttribute("data-active")).toBe(true)
|
||||
})
|
||||
|
||||
it("reports a mismatch again after it resolves and recurs", async () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
|
||||
function WarningQuestionnaire({
|
||||
includeChoice,
|
||||
}: {
|
||||
includeChoice: boolean
|
||||
}) {
|
||||
const definitions = [
|
||||
{
|
||||
choices: includeChoice ? [{ value: "fixed" }] : undefined,
|
||||
name: "answer",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Questionnaire.Root items={definitions} shortcuts="letters">
|
||||
<Questionnaire.Item name="answer">
|
||||
<TestChoice value="fixed" />
|
||||
</Questionnaire.Item>
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const questionnaire = <WarningQuestionnaire includeChoice />
|
||||
renderMarkup(questionnaire)
|
||||
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, questionnaire)
|
||||
})
|
||||
|
||||
expect(consoleWarn).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<WarningQuestionnaire includeChoice={false} />)
|
||||
})
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<WarningQuestionnaire includeChoice />)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<WarningQuestionnaire includeChoice={false} />)
|
||||
})
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
function TestQuestionnaire({
|
||||
defaultItem,
|
||||
item: controlledItem,
|
||||
}: {
|
||||
defaultItem?: string
|
||||
item?: string
|
||||
}) {
|
||||
return (
|
||||
<Questionnaire.Root
|
||||
data-testid="root"
|
||||
defaultItem={defaultItem}
|
||||
item={controlledItem}
|
||||
items={items}
|
||||
shortcuts="letters"
|
||||
>
|
||||
<Questionnaire.Progress data-testid="progress" />
|
||||
|
||||
<Questionnaire.Item data-testid="scope" name="scope" required>
|
||||
<Questionnaire.Title>Choose the scope</Questionnaire.Title>
|
||||
<TestChoice value="delegation" />
|
||||
<TestChoice disabled value="automatic" />
|
||||
<TestChoice value="questions" />
|
||||
</Questionnaire.Item>
|
||||
|
||||
<Questionnaire.Item data-testid="disabled" disabled name="disabled">
|
||||
<Questionnaire.Title>Disabled question</Questionnaire.Title>
|
||||
<TestChoice value="ignored" />
|
||||
</Questionnaire.Item>
|
||||
|
||||
<Questionnaire.Item data-testid="detail" name="detail">
|
||||
<Questionnaire.Title>Choose the detail</Questionnaire.Title>
|
||||
<TestChoice value="focused" />
|
||||
<TestChoice value="complete" />
|
||||
</Questionnaire.Item>
|
||||
|
||||
<Questionnaire.Previous data-testid="previous" />
|
||||
<Questionnaire.Skip data-testid="skip" />
|
||||
<Questionnaire.Next data-testid="next" />
|
||||
<Questionnaire.Submit data-testid="submit" />
|
||||
</Questionnaire.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function TestChoice({
|
||||
value,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Questionnaire.Choice>, "children">) {
|
||||
return (
|
||||
<Questionnaire.Choice
|
||||
data-testid={`choice-${value}`}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
<Questionnaire.ChoiceInput data-testid={`input-${value}`} />
|
||||
<Questionnaire.ChoiceLabel>{value}</Questionnaire.ChoiceLabel>
|
||||
<Questionnaire.ChoiceShortcut data-testid={`shortcut-${value}`} />
|
||||
</Questionnaire.Choice>
|
||||
)
|
||||
}
|
||||
|
||||
function renderServerQuestionnaire({
|
||||
defaultItem,
|
||||
}: {
|
||||
defaultItem?: string
|
||||
} = {}) {
|
||||
renderMarkup(<TestQuestionnaire defaultItem={defaultItem} />)
|
||||
}
|
||||
|
||||
function renderMarkup(element: React.ReactNode) {
|
||||
container.innerHTML = renderToString(element)
|
||||
}
|
||||
|
||||
function form() {
|
||||
return query<HTMLFormElement>("root")
|
||||
}
|
||||
|
||||
function progress() {
|
||||
return query<HTMLElement>("progress")
|
||||
}
|
||||
|
||||
function item(name: string) {
|
||||
return query<HTMLFieldSetElement>(name)
|
||||
}
|
||||
|
||||
function choice(value: string) {
|
||||
return query<HTMLLabelElement>(`choice-${value}`)
|
||||
}
|
||||
|
||||
function choiceInput(value: string) {
|
||||
return query<HTMLInputElement>(`input-${value}`)
|
||||
}
|
||||
|
||||
function shortcut(value: string) {
|
||||
return query<HTMLElement>(`shortcut-${value}`)
|
||||
}
|
||||
|
||||
function action(name: "next" | "previous" | "skip" | "submit") {
|
||||
return query<HTMLButtonElement>(name)
|
||||
}
|
||||
|
||||
function actionsMarkup() {
|
||||
return ["previous", "skip", "next", "submit"]
|
||||
.map(
|
||||
(name) =>
|
||||
action(name as "next" | "previous" | "skip" | "submit").outerHTML
|
||||
)
|
||||
.join("")
|
||||
}
|
||||
|
||||
function query<ElementType extends Element>(testId: string) {
|
||||
const element = container.querySelector<ElementType>(
|
||||
`[data-testid="${testId}"]`
|
||||
)
|
||||
|
||||
if (!element) {
|
||||
throw new Error(`Missing test element: ${testId}`)
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
async function keydown(element: Element, key: string) {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key })
|
||||
)
|
||||
})
|
||||
}
|
||||
1421
packages/react/src/questionnaire/questionnaire.browser.test.tsx
Normal file
1421
packages/react/src/questionnaire/questionnaire.browser.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2183
packages/react/src/questionnaire/questionnaire.test.tsx
Normal file
2183
packages/react/src/questionnaire/questionnaire.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
305
packages/react/src/questionnaire/types.ts
Normal file
305
packages/react/src/questionnaire/types.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { UseRenderComponentProps } from "../use-render"
|
||||
|
||||
type QuestionnaireItemStatus = "unanswered" | "answered" | "skipped"
|
||||
type QuestionnaireShortcutMode = "letters" | "numbers"
|
||||
|
||||
type QuestionnaireChoiceDefinition = {
|
||||
disabled?: boolean
|
||||
value: string
|
||||
}
|
||||
|
||||
type QuestionnaireItemDefinition = {
|
||||
choices?: readonly QuestionnaireChoiceDefinition[]
|
||||
disabled?: boolean
|
||||
name: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
type QuestionnaireRootState = {
|
||||
current: number
|
||||
first: boolean
|
||||
last: boolean
|
||||
total: number
|
||||
}
|
||||
|
||||
type QuestionnaireRootProps = Omit<
|
||||
React.ComponentPropsWithRef<"form">,
|
||||
"defaultValue" | "value"
|
||||
> & {
|
||||
defaultItem?: string
|
||||
item?: string
|
||||
items?: readonly QuestionnaireItemDefinition[]
|
||||
onItemChange?: (item: string) => void
|
||||
shortcuts?: QuestionnaireShortcutMode
|
||||
}
|
||||
|
||||
type QuestionnaireProgressState = QuestionnaireRootState
|
||||
|
||||
type QuestionnaireProgressProps = UseRenderComponentProps<
|
||||
"div",
|
||||
QuestionnaireProgressState
|
||||
>
|
||||
|
||||
type QuestionnaireItemState = {
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
invalid: boolean
|
||||
multiple: boolean
|
||||
required: boolean
|
||||
status: QuestionnaireItemStatus
|
||||
}
|
||||
|
||||
type QuestionnaireItemProps = Omit<
|
||||
React.ComponentPropsWithRef<"fieldset">,
|
||||
"name" | "value"
|
||||
> & {
|
||||
invalid?: boolean
|
||||
name: string
|
||||
multiple?: boolean
|
||||
onStatusChange?: (status: QuestionnaireItemStatus) => void
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
type QuestionnaireTitleProps = UseRenderComponentProps<"legend">
|
||||
type QuestionnaireDescriptionProps = UseRenderComponentProps<"p">
|
||||
type QuestionnaireChoicesState = {
|
||||
shortcuts: QuestionnaireShortcutMode | null
|
||||
}
|
||||
|
||||
type QuestionnaireChoicesProps = UseRenderComponentProps<
|
||||
"div",
|
||||
QuestionnaireChoicesState
|
||||
>
|
||||
type QuestionnaireErrorProps = UseRenderComponentProps<
|
||||
"p",
|
||||
Pick<QuestionnaireItemState, "invalid">
|
||||
>
|
||||
|
||||
type QuestionnaireChoiceState = {
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
invalid: boolean
|
||||
shortcut: string | null
|
||||
type: "checkbox" | "radio"
|
||||
}
|
||||
|
||||
type QuestionnaireChoiceContextValue = {
|
||||
inputProps: React.ComponentPropsWithRef<"input">
|
||||
state: QuestionnaireChoiceState
|
||||
}
|
||||
|
||||
type QuestionnaireChoiceProps = Omit<
|
||||
UseRenderComponentProps<"label", QuestionnaireChoiceState>,
|
||||
"onChange"
|
||||
> & {
|
||||
checked?: boolean
|
||||
defaultChecked?: boolean
|
||||
disabled?: boolean
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
value: string
|
||||
}
|
||||
|
||||
type QuestionnaireChoiceInputProps = Omit<
|
||||
UseRenderComponentProps<"input", QuestionnaireChoiceState>,
|
||||
| "checked"
|
||||
| "defaultChecked"
|
||||
| "disabled"
|
||||
| "name"
|
||||
| "onChange"
|
||||
| "required"
|
||||
| "type"
|
||||
| "value"
|
||||
>
|
||||
|
||||
type QuestionnaireChoiceLabelProps = UseRenderComponentProps<"span">
|
||||
|
||||
type QuestionnaireChoiceShortcutState = Pick<
|
||||
QuestionnaireChoiceState,
|
||||
"shortcut"
|
||||
>
|
||||
|
||||
type QuestionnaireChoiceShortcutProps = UseRenderComponentProps<
|
||||
"span",
|
||||
QuestionnaireChoiceShortcutState
|
||||
>
|
||||
|
||||
type QuestionnaireInputState = {
|
||||
disabled: boolean
|
||||
filled: boolean
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
type QuestionnaireInputType =
|
||||
| "date"
|
||||
| "datetime-local"
|
||||
| "email"
|
||||
| "month"
|
||||
| "number"
|
||||
| "password"
|
||||
| "search"
|
||||
| "tel"
|
||||
| "text"
|
||||
| "time"
|
||||
| "url"
|
||||
| "week"
|
||||
|
||||
type QuestionnaireInputProps = Omit<
|
||||
UseRenderComponentProps<"input", QuestionnaireInputState>,
|
||||
"form" | "name" | "type"
|
||||
> & {
|
||||
type?: QuestionnaireInputType
|
||||
}
|
||||
|
||||
type QuestionnaireNavigationState = {
|
||||
disabled: boolean
|
||||
shortcut: "Enter" | null
|
||||
status: QuestionnaireItemStatus | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
type QuestionnairePreviousProps = UseRenderComponentProps<
|
||||
"button",
|
||||
QuestionnaireNavigationState
|
||||
>
|
||||
|
||||
type QuestionnaireSkipProps = UseRenderComponentProps<
|
||||
"button",
|
||||
QuestionnaireNavigationState
|
||||
>
|
||||
|
||||
type QuestionnaireNextProps = UseRenderComponentProps<
|
||||
"button",
|
||||
QuestionnaireNavigationState
|
||||
>
|
||||
|
||||
type QuestionnaireSubmitProps = UseRenderComponentProps<
|
||||
"button",
|
||||
QuestionnaireNavigationState
|
||||
>
|
||||
|
||||
type AnswerControlRegistration = {
|
||||
disabled: boolean
|
||||
element: HTMLInputElement
|
||||
id: string
|
||||
} & (
|
||||
| {
|
||||
ownDisabled: boolean
|
||||
type: "choice"
|
||||
value: string
|
||||
}
|
||||
| {
|
||||
type: "input"
|
||||
}
|
||||
)
|
||||
|
||||
type ChoiceRegistration = {
|
||||
disabled: boolean
|
||||
value: string
|
||||
}
|
||||
|
||||
type ItemRegistration = {
|
||||
choices: readonly ChoiceRegistration[]
|
||||
disabled: boolean
|
||||
element: HTMLFieldSetElement
|
||||
focus: () => void
|
||||
focusInvalid: () => void
|
||||
getAnswerByElement: (element: Element) => AnswerControlRegistration | null
|
||||
getAnswerByShortcut: (shortcut: string) => AnswerControlRegistration | null
|
||||
moveAnswerFocus: (element: Element, direction: "next" | "previous") => boolean
|
||||
name: string
|
||||
required: boolean
|
||||
reset: () => void
|
||||
skip: () => void
|
||||
status: QuestionnaireItemStatus
|
||||
validate: () => boolean
|
||||
}
|
||||
|
||||
type PendingFocus = {
|
||||
name: string
|
||||
target: "invalid" | "item"
|
||||
}
|
||||
|
||||
type QuestionnaireContextValue = QuestionnaireRootState & {
|
||||
activeItem: ItemRegistration | null
|
||||
activeItemName: string | null
|
||||
activeItemRequired: boolean | null
|
||||
activeItemStatus: QuestionnaireItemStatus | null
|
||||
domVersion: number
|
||||
goNext: () => void
|
||||
goPrevious: () => void
|
||||
nativeValidation: boolean
|
||||
itemDefinitionByName: ReadonlyMap<string, QuestionnaireItemDefinition> | null
|
||||
registerItem: (registration: ItemRegistration) => () => void
|
||||
shortcuts: QuestionnaireShortcutMode | null
|
||||
skipCurrent: () => void
|
||||
}
|
||||
|
||||
type QuestionnaireItemContextValue = {
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
hasInputAnswer: boolean
|
||||
invalid: boolean
|
||||
multiple: boolean
|
||||
name: string
|
||||
registerAnswerControl: (registration: AnswerControlRegistration) => () => void
|
||||
registerAnswerSelection: (
|
||||
answerId: string,
|
||||
defaultSelected: boolean
|
||||
) => () => void
|
||||
registerDescription: (descriptionId: string) => () => void
|
||||
registerError: (errorId: string) => () => void
|
||||
required: boolean
|
||||
resetVersion: number
|
||||
selectedAnswerIds: string[]
|
||||
setAnswerDefault: (answerId: string, defaultSelected: boolean) => void
|
||||
setAnswerSelectionFromInteraction: (
|
||||
answerId: string,
|
||||
selected: boolean
|
||||
) => void
|
||||
shortcutByAnswerId: ReadonlyMap<string, string>
|
||||
shortcutByChoiceValue: ReadonlyMap<string, string> | null
|
||||
shortcuts: QuestionnaireShortcutMode | null
|
||||
status: QuestionnaireItemStatus
|
||||
syncControlledAnswerSelection: (answerId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
export type {
|
||||
AnswerControlRegistration,
|
||||
ChoiceRegistration,
|
||||
ItemRegistration,
|
||||
PendingFocus,
|
||||
QuestionnaireChoiceContextValue,
|
||||
QuestionnaireChoiceDefinition,
|
||||
QuestionnaireChoiceInputProps,
|
||||
QuestionnaireChoiceLabelProps,
|
||||
QuestionnaireChoiceProps,
|
||||
QuestionnaireChoiceShortcutProps,
|
||||
QuestionnaireChoiceShortcutState,
|
||||
QuestionnaireChoiceState,
|
||||
QuestionnaireChoicesProps,
|
||||
QuestionnaireChoicesState,
|
||||
QuestionnaireContextValue,
|
||||
QuestionnaireDescriptionProps,
|
||||
QuestionnaireErrorProps,
|
||||
QuestionnaireInputProps,
|
||||
QuestionnaireInputState,
|
||||
QuestionnaireInputType,
|
||||
QuestionnaireItemContextValue,
|
||||
QuestionnaireItemDefinition,
|
||||
QuestionnaireItemProps,
|
||||
QuestionnaireItemState,
|
||||
QuestionnaireItemStatus,
|
||||
QuestionnaireNavigationState,
|
||||
QuestionnaireNextProps,
|
||||
QuestionnairePreviousProps,
|
||||
QuestionnaireProgressProps,
|
||||
QuestionnaireProgressState,
|
||||
QuestionnaireRootProps,
|
||||
QuestionnaireRootState,
|
||||
QuestionnaireShortcutMode,
|
||||
QuestionnaireSkipProps,
|
||||
QuestionnaireSubmitProps,
|
||||
QuestionnaireTitleProps,
|
||||
}
|
||||
170
packages/react/src/questionnaire/use-questionnaire-choice.ts
Normal file
170
packages/react/src/questionnaire/use-questionnaire-choice.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { useQuestionnaireItemContext } from "./context"
|
||||
import type {
|
||||
QuestionnaireChoiceProps,
|
||||
QuestionnaireChoiceState,
|
||||
} from "./types"
|
||||
import { getAnswerKeyShortcuts } from "./utils"
|
||||
|
||||
type UseQuestionnaireChoiceParameters = Pick<
|
||||
QuestionnaireChoiceProps,
|
||||
"checked" | "defaultChecked" | "disabled" | "onChange" | "value"
|
||||
>
|
||||
|
||||
function useQuestionnaireChoice({
|
||||
checked: controlledChecked,
|
||||
defaultChecked = false,
|
||||
disabled: choiceDisabled = false,
|
||||
onChange,
|
||||
value,
|
||||
}: UseQuestionnaireChoiceParameters) {
|
||||
const {
|
||||
disabled: itemDisabled,
|
||||
hasInputAnswer,
|
||||
invalid,
|
||||
multiple,
|
||||
name: itemName,
|
||||
registerAnswerControl,
|
||||
registerAnswerSelection,
|
||||
required,
|
||||
resetVersion,
|
||||
selectedAnswerIds,
|
||||
setAnswerDefault,
|
||||
setAnswerSelectionFromInteraction,
|
||||
shortcutByAnswerId,
|
||||
shortcutByChoiceValue,
|
||||
status,
|
||||
syncControlledAnswerSelection,
|
||||
} = useQuestionnaireItemContext("Questionnaire.Choice")
|
||||
const answerId = React.useId()
|
||||
const [inputElement, setInputElement] =
|
||||
React.useState<HTMLInputElement | null>(null)
|
||||
const initialDefaultCheckedRef = React.useRef(defaultChecked)
|
||||
const controlled = controlledChecked !== undefined
|
||||
const disabled = itemDisabled || choiceDisabled
|
||||
const selected = selectedAnswerIds.includes(answerId)
|
||||
const checked = controlled
|
||||
? status === "skipped"
|
||||
? false
|
||||
: controlledChecked
|
||||
: selected
|
||||
const type = multiple ? "checkbox" : "radio"
|
||||
const shortcut =
|
||||
shortcutByChoiceValue?.get(value) ??
|
||||
shortcutByAnswerId.get(answerId) ??
|
||||
null
|
||||
|
||||
React.useLayoutEffect(
|
||||
() => registerAnswerSelection(answerId, initialDefaultCheckedRef.current),
|
||||
[answerId, registerAnswerSelection]
|
||||
)
|
||||
React.useLayoutEffect(
|
||||
() => setAnswerDefault(answerId, defaultChecked),
|
||||
[answerId, defaultChecked, setAnswerDefault]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!inputElement) {
|
||||
return
|
||||
}
|
||||
|
||||
return registerAnswerControl({
|
||||
disabled,
|
||||
element: inputElement,
|
||||
id: answerId,
|
||||
ownDisabled: choiceDisabled,
|
||||
type: "choice",
|
||||
value,
|
||||
})
|
||||
}, [
|
||||
answerId,
|
||||
choiceDisabled,
|
||||
disabled,
|
||||
inputElement,
|
||||
registerAnswerControl,
|
||||
value,
|
||||
])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (controlled) {
|
||||
syncControlledAnswerSelection(answerId, controlledChecked)
|
||||
}
|
||||
}, [
|
||||
answerId,
|
||||
controlled,
|
||||
controlledChecked,
|
||||
resetVersion,
|
||||
syncControlledAnswerSelection,
|
||||
])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!inputElement) {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the native reset target aligned with Questionnaire's owned default,
|
||||
// including controlled choices whose checked prop remains authoritative.
|
||||
inputElement.defaultChecked = controlled
|
||||
? controlledChecked
|
||||
: defaultChecked
|
||||
|
||||
if (resetVersion > 0) {
|
||||
inputElement.checked = checked
|
||||
}
|
||||
}, [
|
||||
checked,
|
||||
controlled,
|
||||
controlledChecked,
|
||||
defaultChecked,
|
||||
inputElement,
|
||||
resetVersion,
|
||||
])
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
onChange?.(event)
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!controlled) {
|
||||
setAnswerSelectionFromInteraction(answerId, event.target.checked)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "skipped" && controlledChecked === event.target.checked) {
|
||||
setAnswerSelectionFromInteraction(answerId, controlledChecked)
|
||||
}
|
||||
}
|
||||
|
||||
const state: QuestionnaireChoiceState = {
|
||||
checked,
|
||||
disabled,
|
||||
invalid,
|
||||
shortcut,
|
||||
type,
|
||||
}
|
||||
|
||||
return {
|
||||
inputProps: {
|
||||
ref: setInputElement,
|
||||
"aria-invalid": invalid || undefined,
|
||||
"aria-keyshortcuts": getAnswerKeyShortcuts(
|
||||
shortcut,
|
||||
!disabled && checked
|
||||
),
|
||||
checked,
|
||||
disabled,
|
||||
id: answerId,
|
||||
name: status === "skipped" ? undefined : itemName,
|
||||
onChange: handleChange,
|
||||
required: required && !multiple && !hasInputAnswer,
|
||||
type,
|
||||
value,
|
||||
},
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export { useQuestionnaireChoice }
|
||||
149
packages/react/src/questionnaire/use-questionnaire-input.ts
Normal file
149
packages/react/src/questionnaire/use-questionnaire-input.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { composeRefs } from "../use-render"
|
||||
import { useQuestionnaireItemContext } from "./context"
|
||||
import type { QuestionnaireInputProps, QuestionnaireInputState } from "./types"
|
||||
import { getAnswerKeyShortcuts, hasInputValue } from "./utils"
|
||||
|
||||
type UseQuestionnaireInputParameters = Pick<
|
||||
QuestionnaireInputProps,
|
||||
"defaultValue" | "disabled" | "onChange" | "ref" | "type" | "value"
|
||||
>
|
||||
|
||||
function useQuestionnaireInput({
|
||||
defaultValue,
|
||||
disabled: inputDisabled = false,
|
||||
onChange,
|
||||
ref,
|
||||
type = "text",
|
||||
value: controlledValue,
|
||||
}: UseQuestionnaireInputParameters) {
|
||||
const {
|
||||
disabled: itemDisabled,
|
||||
invalid,
|
||||
name: itemName,
|
||||
registerAnswerControl,
|
||||
registerAnswerSelection,
|
||||
resetVersion,
|
||||
selectedAnswerIds,
|
||||
setAnswerDefault,
|
||||
setAnswerSelectionFromInteraction,
|
||||
syncControlledAnswerSelection,
|
||||
} = useQuestionnaireItemContext("Questionnaire.Input")
|
||||
const answerId = React.useId()
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
||||
const initialDefaultFilledRef = React.useRef(hasInputValue(defaultValue))
|
||||
const controlled = controlledValue !== undefined
|
||||
const defaultFilled = hasInputValue(defaultValue)
|
||||
const controlledFilled = hasInputValue(controlledValue)
|
||||
const [uncontrolledFilled, setUncontrolledFilled] =
|
||||
React.useState(defaultFilled)
|
||||
const disabled = itemDisabled || inputDisabled
|
||||
const filled = controlled ? controlledFilled : uncontrolledFilled
|
||||
const selected = selectedAnswerIds.includes(answerId)
|
||||
|
||||
React.useLayoutEffect(
|
||||
() => registerAnswerSelection(answerId, initialDefaultFilledRef.current),
|
||||
[answerId, registerAnswerSelection]
|
||||
)
|
||||
React.useLayoutEffect(
|
||||
() => setAnswerDefault(answerId, defaultFilled),
|
||||
[defaultFilled, answerId, setAnswerDefault]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const input = inputRef.current
|
||||
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
return registerAnswerControl({
|
||||
disabled,
|
||||
element: input,
|
||||
id: answerId,
|
||||
type: "input",
|
||||
})
|
||||
}, [disabled, answerId, registerAnswerControl])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (controlled) {
|
||||
syncControlledAnswerSelection(answerId, controlledFilled)
|
||||
return
|
||||
}
|
||||
|
||||
if (resetVersion > 0) {
|
||||
setUncontrolledFilled(defaultFilled)
|
||||
}
|
||||
}, [
|
||||
controlled,
|
||||
controlledFilled,
|
||||
controlledValue,
|
||||
defaultFilled,
|
||||
answerId,
|
||||
resetVersion,
|
||||
syncControlledAnswerSelection,
|
||||
])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const input = inputRef.current
|
||||
|
||||
if (!input || !controlled) {
|
||||
return
|
||||
}
|
||||
|
||||
input.defaultValue = String(controlledValue)
|
||||
}, [controlled, controlledValue])
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
onChange?.(event)
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextFilled = event.target.value.trim().length > 0
|
||||
|
||||
if (controlled) {
|
||||
return
|
||||
}
|
||||
|
||||
setUncontrolledFilled(nextFilled)
|
||||
setAnswerSelectionFromInteraction(answerId, nextFilled)
|
||||
}
|
||||
|
||||
const state: QuestionnaireInputState = {
|
||||
disabled,
|
||||
filled,
|
||||
invalid,
|
||||
}
|
||||
const setInputRef = React.useCallback(
|
||||
(element: HTMLInputElement | null) => {
|
||||
inputRef.current = element
|
||||
composeRefs(ref)?.(element)
|
||||
},
|
||||
[ref]
|
||||
)
|
||||
|
||||
return {
|
||||
inputProps: {
|
||||
"aria-invalid": invalid || undefined,
|
||||
"aria-keyshortcuts": getAnswerKeyShortcuts(
|
||||
null,
|
||||
!disabled && filled && selected
|
||||
),
|
||||
defaultValue: controlled ? undefined : defaultValue,
|
||||
disabled,
|
||||
form: selected ? undefined : "",
|
||||
id: answerId,
|
||||
name: selected ? itemName : undefined,
|
||||
onChange: handleChange,
|
||||
ref: setInputRef,
|
||||
type,
|
||||
value: controlled ? controlledValue : undefined,
|
||||
},
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export { useQuestionnaireInput }
|
||||
571
packages/react/src/questionnaire/use-questionnaire-item.ts
Normal file
571
packages/react/src/questionnaire/use-questionnaire-item.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { composeRefs } from "../use-render"
|
||||
import { getShortcutByChoiceValue } from "./collection"
|
||||
import { useQuestionnaireContext } from "./context"
|
||||
import type {
|
||||
AnswerControlRegistration,
|
||||
QuestionnaireItemContextValue,
|
||||
QuestionnaireItemProps,
|
||||
QuestionnaireItemState,
|
||||
QuestionnaireItemStatus,
|
||||
} from "./types"
|
||||
import {
|
||||
compareAnswerOrder,
|
||||
getShortcutKeys,
|
||||
isAnswerFilled,
|
||||
isEmptyNavigableInput,
|
||||
isRadioTarget,
|
||||
isTextEntryTarget,
|
||||
} from "./utils"
|
||||
|
||||
type UseQuestionnaireItemParameters = Pick<
|
||||
QuestionnaireItemProps,
|
||||
| "aria-describedby"
|
||||
| "aria-keyshortcuts"
|
||||
| "disabled"
|
||||
| "invalid"
|
||||
| "multiple"
|
||||
| "name"
|
||||
| "onStatusChange"
|
||||
| "ref"
|
||||
| "required"
|
||||
>
|
||||
|
||||
function useQuestionnaireItem({
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
"aria-keyshortcuts": ariaKeyShortcuts,
|
||||
disabled = false,
|
||||
invalid: externallyInvalid = false,
|
||||
multiple = false,
|
||||
name,
|
||||
onStatusChange,
|
||||
ref,
|
||||
required = false,
|
||||
}: UseQuestionnaireItemParameters) {
|
||||
const {
|
||||
activeItemName,
|
||||
domVersion,
|
||||
first,
|
||||
itemDefinitionByName,
|
||||
last,
|
||||
nativeValidation,
|
||||
registerItem,
|
||||
shortcuts,
|
||||
} = useQuestionnaireContext("Questionnaire.Item")
|
||||
const [element, setElement] = React.useState<HTMLFieldSetElement | null>(null)
|
||||
const [answerControlRegistrations, setAnswerControlRegistrations] =
|
||||
React.useState<AnswerControlRegistration[]>([])
|
||||
const [validationAttempted, setValidationAttempted] = React.useState(false)
|
||||
const [selectedAnswerIds, setSelectedAnswerIds] = React.useState<string[]>([])
|
||||
const [skipped, setSkipped] = React.useState(false)
|
||||
const [resetVersion, setResetVersion] = React.useState(0)
|
||||
const [descriptionIds, setDescriptionIds] = React.useState<string[]>([])
|
||||
const [errorIds, setErrorIds] = React.useState<string[]>([])
|
||||
const defaultSelectedAnswerIdsRef = React.useRef<string[]>([])
|
||||
const multipleRef = React.useRef(multiple)
|
||||
const previousMultipleRef = React.useRef(multiple)
|
||||
multipleRef.current = multiple
|
||||
const active = !disabled && activeItemName === name
|
||||
const answerControls = React.useMemo(
|
||||
() => [...answerControlRegistrations].sort(compareAnswerOrder),
|
||||
[answerControlRegistrations, domVersion]
|
||||
)
|
||||
const answers = React.useMemo(
|
||||
() => answerControls.filter((registration) => !registration.disabled),
|
||||
[answerControls]
|
||||
)
|
||||
const answered = answers.some((answer) =>
|
||||
selectedAnswerIds.includes(answer.id)
|
||||
)
|
||||
const status: QuestionnaireItemStatus = skipped
|
||||
? "skipped"
|
||||
: answered
|
||||
? "answered"
|
||||
: "unanswered"
|
||||
const intentionallySkipped = status === "skipped" && !required
|
||||
const valid =
|
||||
disabled ||
|
||||
intentionallySkipped ||
|
||||
(!externallyInvalid && status === "answered")
|
||||
const invalid =
|
||||
!disabled &&
|
||||
!intentionallySkipped &&
|
||||
(externallyInvalid || (validationAttempted && !valid))
|
||||
const hasInputAnswer = answers.some((answer) => answer.type === "input")
|
||||
const previousStatusRef = React.useRef(status)
|
||||
const itemDefinition = itemDefinitionByName?.get(name)
|
||||
const shortcutByChoiceValue = React.useMemo(
|
||||
() =>
|
||||
itemDefinitionByName
|
||||
? getShortcutByChoiceValue(itemDefinition, shortcuts)
|
||||
: null,
|
||||
[itemDefinition, itemDefinitionByName, shortcuts]
|
||||
)
|
||||
const shortcutByAnswerId = React.useMemo(() => {
|
||||
if (shortcutByChoiceValue) {
|
||||
return new Map<string, string>()
|
||||
}
|
||||
|
||||
const keys = getShortcutKeys(shortcuts)
|
||||
const shortcutAnswers = answers.filter((answer) => answer.type === "choice")
|
||||
|
||||
return new Map(
|
||||
shortcutAnswers
|
||||
.slice(0, keys.length)
|
||||
.map((answer, index) => [answer.id, keys[index]])
|
||||
)
|
||||
}, [answers, shortcutByChoiceValue, shortcuts])
|
||||
|
||||
const registerAnswerControl = React.useCallback(
|
||||
(registration: AnswerControlRegistration) => {
|
||||
setAnswerControlRegistrations((currentRegistrations) => [
|
||||
...currentRegistrations.filter(
|
||||
(currentRegistration) =>
|
||||
currentRegistration.element !== registration.element &&
|
||||
currentRegistration.id !== registration.id
|
||||
),
|
||||
registration,
|
||||
])
|
||||
|
||||
return () => {
|
||||
setAnswerControlRegistrations((currentRegistrations) =>
|
||||
currentRegistrations.filter(
|
||||
(currentRegistration) => currentRegistration !== registration
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (previousStatusRef.current === status) {
|
||||
return
|
||||
}
|
||||
|
||||
previousStatusRef.current = status
|
||||
|
||||
onStatusChange?.(status)
|
||||
}, [onStatusChange, status])
|
||||
|
||||
const updateAnswerSelected = React.useCallback(
|
||||
(answerId: string, selected: boolean) => {
|
||||
setSelectedAnswerIds((currentAnswerIds) => {
|
||||
if (!selected) {
|
||||
return currentAnswerIds.filter(
|
||||
(currentAnswerId) => currentAnswerId !== answerId
|
||||
)
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
return [answerId]
|
||||
}
|
||||
|
||||
return currentAnswerIds.includes(answerId)
|
||||
? currentAnswerIds
|
||||
: [...currentAnswerIds, answerId]
|
||||
})
|
||||
},
|
||||
[multiple]
|
||||
)
|
||||
const setAnswerSelectionFromInteraction = React.useCallback(
|
||||
(answerId: string, selected: boolean) => {
|
||||
setSkipped(false)
|
||||
updateAnswerSelected(answerId, selected)
|
||||
},
|
||||
[updateAnswerSelected]
|
||||
)
|
||||
const syncControlledAnswerSelection = React.useCallback(
|
||||
(answerId: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setSkipped(false)
|
||||
}
|
||||
|
||||
updateAnswerSelected(answerId, selected)
|
||||
},
|
||||
[updateAnswerSelected]
|
||||
)
|
||||
|
||||
const registerAnswerSelection = React.useCallback(
|
||||
(answerId: string, defaultSelected: boolean) => {
|
||||
if (defaultSelected) {
|
||||
defaultSelectedAnswerIdsRef.current = [
|
||||
...defaultSelectedAnswerIdsRef.current.filter(
|
||||
(currentAnswerId) => currentAnswerId !== answerId
|
||||
),
|
||||
answerId,
|
||||
]
|
||||
setSelectedAnswerIds((currentAnswerIds) => {
|
||||
if (!multipleRef.current) {
|
||||
return currentAnswerIds.length ? currentAnswerIds : [answerId]
|
||||
}
|
||||
|
||||
return currentAnswerIds.includes(answerId)
|
||||
? currentAnswerIds
|
||||
: [...currentAnswerIds, answerId]
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
defaultSelectedAnswerIdsRef.current =
|
||||
defaultSelectedAnswerIdsRef.current.filter(
|
||||
(currentAnswerId) => currentAnswerId !== answerId
|
||||
)
|
||||
setSelectedAnswerIds((currentAnswerIds) =>
|
||||
currentAnswerIds.filter(
|
||||
(currentAnswerId) => currentAnswerId !== answerId
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
const setAnswerDefault = React.useCallback(
|
||||
(answerId: string, defaultSelected: boolean) => {
|
||||
if (defaultSelected) {
|
||||
defaultSelectedAnswerIdsRef.current =
|
||||
defaultSelectedAnswerIdsRef.current.includes(answerId)
|
||||
? defaultSelectedAnswerIdsRef.current
|
||||
: [...defaultSelectedAnswerIdsRef.current, answerId]
|
||||
return
|
||||
}
|
||||
|
||||
defaultSelectedAnswerIdsRef.current =
|
||||
defaultSelectedAnswerIdsRef.current.filter(
|
||||
(currentAnswerId) => currentAnswerId !== answerId
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const registerDescription = React.useCallback(
|
||||
(registeredDescriptionId: string) => {
|
||||
setDescriptionIds((currentDescriptionIds) =>
|
||||
currentDescriptionIds.includes(registeredDescriptionId)
|
||||
? currentDescriptionIds
|
||||
: [...currentDescriptionIds, registeredDescriptionId]
|
||||
)
|
||||
|
||||
return () => {
|
||||
setDescriptionIds((currentDescriptionIds) =>
|
||||
currentDescriptionIds.filter(
|
||||
(currentDescriptionId) =>
|
||||
currentDescriptionId !== registeredDescriptionId
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const registerError = React.useCallback((registeredErrorId: string) => {
|
||||
setErrorIds((currentErrorIds) =>
|
||||
currentErrorIds.includes(registeredErrorId)
|
||||
? currentErrorIds
|
||||
: [...currentErrorIds, registeredErrorId]
|
||||
)
|
||||
|
||||
return () => {
|
||||
setErrorIds((currentErrorIds) =>
|
||||
currentErrorIds.filter(
|
||||
(currentErrorId) => currentErrorId !== registeredErrorId
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validate = React.useCallback(() => {
|
||||
setValidationAttempted(true)
|
||||
|
||||
if (!valid) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!nativeValidation) {
|
||||
return true
|
||||
}
|
||||
|
||||
const invalidAnswer = answers.find(
|
||||
(answer) =>
|
||||
isAnswerFilled(answer) &&
|
||||
answer.element.willValidate &&
|
||||
!answer.element.validity.valid
|
||||
)
|
||||
|
||||
if (!invalidAnswer) {
|
||||
return true
|
||||
}
|
||||
|
||||
invalidAnswer.element.focus()
|
||||
invalidAnswer.element.reportValidity()
|
||||
|
||||
return false
|
||||
}, [answers, nativeValidation, valid])
|
||||
|
||||
const focus = React.useCallback(() => {
|
||||
element?.focus()
|
||||
}, [element])
|
||||
|
||||
const focusInvalid = React.useCallback(() => {
|
||||
const selectedInput = element?.querySelector<HTMLInputElement>(
|
||||
"input[data-filled][name]:not(:disabled)"
|
||||
)
|
||||
const firstControl = element?.querySelector<HTMLElement>(
|
||||
"input:not([type=hidden]):not(:disabled), textarea:not(:disabled)"
|
||||
)
|
||||
|
||||
;(selectedInput ?? firstControl ?? element)?.focus()
|
||||
}, [element])
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setValidationAttempted(false)
|
||||
setSkipped(false)
|
||||
setSelectedAnswerIds(
|
||||
multiple
|
||||
? [...defaultSelectedAnswerIdsRef.current]
|
||||
: defaultSelectedAnswerIdsRef.current.slice(0, 1)
|
||||
)
|
||||
setResetVersion((version) => version + 1)
|
||||
}, [multiple])
|
||||
|
||||
const skip = React.useCallback(() => {
|
||||
if (required) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedAnswerIds([])
|
||||
setSkipped(true)
|
||||
}, [required])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const wasMultiple = previousMultipleRef.current
|
||||
previousMultipleRef.current = multiple
|
||||
|
||||
if (!wasMultiple || multiple) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedAnswerIds((currentAnswerIds) => {
|
||||
const selectedAnswer = answers.find((answer) =>
|
||||
currentAnswerIds.includes(answer.id)
|
||||
)
|
||||
|
||||
return selectedAnswer ? [selectedAnswer.id] : []
|
||||
})
|
||||
}, [answers, multiple])
|
||||
|
||||
const getAnswerByElement = React.useCallback(
|
||||
(answerElement: Element) =>
|
||||
answers.find((answer) => answer.element === answerElement) ?? null,
|
||||
[answers]
|
||||
)
|
||||
const getAnswerByShortcut = React.useCallback(
|
||||
(shortcut: string) => {
|
||||
if (shortcutByChoiceValue) {
|
||||
const choiceValue = Array.from(shortcutByChoiceValue.entries()).find(
|
||||
([, choiceShortcut]) => choiceShortcut === shortcut
|
||||
)?.[0]
|
||||
|
||||
return (
|
||||
answers.find(
|
||||
(answer) => answer.type === "choice" && answer.value === choiceValue
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
const answerId = Array.from(shortcutByAnswerId.entries()).find(
|
||||
([, answerShortcut]) => answerShortcut === shortcut
|
||||
)?.[0]
|
||||
|
||||
return answers.find((answer) => answer.id === answerId) ?? null
|
||||
},
|
||||
[answers, shortcutByAnswerId, shortcutByChoiceValue]
|
||||
)
|
||||
const moveAnswerFocus = React.useCallback(
|
||||
(currentElement: Element, direction: "next" | "previous") => {
|
||||
const currentIndex = answers.findIndex(
|
||||
(answer) => answer.element === currentElement
|
||||
)
|
||||
const currentAnswer =
|
||||
currentIndex < 0 ? null : (answers[currentIndex] ?? null)
|
||||
|
||||
if (
|
||||
!answers.length ||
|
||||
(isTextEntryTarget(currentElement) &&
|
||||
!isEmptyNavigableInput(currentAnswer)) ||
|
||||
(currentIndex < 0 && currentElement !== element)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextAnswer =
|
||||
currentIndex < 0
|
||||
? (answers.find(isAnswerFilled) ??
|
||||
(direction === "next" ? answers[0] : answers[answers.length - 1]))
|
||||
: answers[
|
||||
(currentIndex +
|
||||
(direction === "next" ? 1 : -1) +
|
||||
answers.length) %
|
||||
answers.length
|
||||
]
|
||||
|
||||
if (!nextAnswer || nextAnswer.element === currentElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
currentIndex >= 0 &&
|
||||
isRadioTarget(currentElement) &&
|
||||
isRadioTarget(nextAnswer.element)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
nextAnswer.element.focus()
|
||||
|
||||
if (nextAnswer.type === "choice" && isRadioTarget(nextAnswer.element)) {
|
||||
nextAnswer.element.click()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[answers, element]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
return registerItem({
|
||||
choices: answerControls.flatMap((answer) =>
|
||||
answer.type === "choice"
|
||||
? [{ disabled: answer.ownDisabled, value: answer.value }]
|
||||
: []
|
||||
),
|
||||
disabled,
|
||||
element,
|
||||
focus,
|
||||
focusInvalid,
|
||||
getAnswerByElement,
|
||||
getAnswerByShortcut,
|
||||
moveAnswerFocus,
|
||||
name,
|
||||
required,
|
||||
reset,
|
||||
skip,
|
||||
status,
|
||||
validate,
|
||||
})
|
||||
}, [
|
||||
answerControls,
|
||||
disabled,
|
||||
element,
|
||||
focus,
|
||||
focusInvalid,
|
||||
getAnswerByElement,
|
||||
getAnswerByShortcut,
|
||||
moveAnswerFocus,
|
||||
name,
|
||||
registerItem,
|
||||
required,
|
||||
reset,
|
||||
skip,
|
||||
status,
|
||||
validate,
|
||||
])
|
||||
|
||||
const context = React.useMemo<QuestionnaireItemContextValue>(
|
||||
() => ({
|
||||
active,
|
||||
disabled,
|
||||
hasInputAnswer,
|
||||
invalid,
|
||||
multiple,
|
||||
name,
|
||||
registerAnswerControl,
|
||||
registerAnswerSelection,
|
||||
registerDescription,
|
||||
registerError,
|
||||
required,
|
||||
resetVersion,
|
||||
selectedAnswerIds,
|
||||
setAnswerDefault,
|
||||
setAnswerSelectionFromInteraction,
|
||||
shortcutByAnswerId,
|
||||
shortcutByChoiceValue,
|
||||
shortcuts,
|
||||
status,
|
||||
syncControlledAnswerSelection,
|
||||
}),
|
||||
[
|
||||
active,
|
||||
disabled,
|
||||
hasInputAnswer,
|
||||
invalid,
|
||||
multiple,
|
||||
name,
|
||||
registerAnswerControl,
|
||||
registerAnswerSelection,
|
||||
registerDescription,
|
||||
registerError,
|
||||
required,
|
||||
resetVersion,
|
||||
selectedAnswerIds,
|
||||
setAnswerDefault,
|
||||
setAnswerSelectionFromInteraction,
|
||||
shortcutByAnswerId,
|
||||
shortcutByChoiceValue,
|
||||
shortcuts,
|
||||
status,
|
||||
syncControlledAnswerSelection,
|
||||
]
|
||||
)
|
||||
const setItemRef = React.useCallback(
|
||||
(nextElement: HTMLFieldSetElement | null) => {
|
||||
setElement(nextElement)
|
||||
composeRefs(ref)?.(nextElement)
|
||||
},
|
||||
[ref]
|
||||
)
|
||||
const describedBy =
|
||||
[...descriptionIds, ...(invalid ? errorIds : []), ariaDescribedBy]
|
||||
.filter(Boolean)
|
||||
.join(" ") || undefined
|
||||
const keyShortcuts =
|
||||
[
|
||||
ariaKeyShortcuts,
|
||||
active ? "Meta+Enter Control+Enter" : undefined,
|
||||
active && answers.length ? "ArrowUp ArrowDown" : undefined,
|
||||
active && !first ? "ArrowLeft" : undefined,
|
||||
active && !last && status !== "unanswered" ? "ArrowRight" : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") || undefined
|
||||
const state: QuestionnaireItemState = {
|
||||
active,
|
||||
disabled,
|
||||
invalid,
|
||||
multiple,
|
||||
required,
|
||||
status,
|
||||
}
|
||||
|
||||
return {
|
||||
context,
|
||||
itemProps: {
|
||||
"aria-describedby": describedBy,
|
||||
"aria-invalid": invalid || undefined,
|
||||
"aria-keyshortcuts": keyShortcuts,
|
||||
disabled,
|
||||
hidden: !active,
|
||||
inert: !active,
|
||||
ref: setItemRef,
|
||||
tabIndex: -1,
|
||||
},
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export { useQuestionnaireItem }
|
||||
525
packages/react/src/questionnaire/use-questionnaire-root.ts
Normal file
525
packages/react/src/questionnaire/use-questionnaire-root.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { composeRefs } from "../use-render"
|
||||
import {
|
||||
createQuestionnaireCollection,
|
||||
getCollectionDefinitionWarnings,
|
||||
getCollectionRegistrationWarnings,
|
||||
getInitialItemName,
|
||||
} from "./collection"
|
||||
import type {
|
||||
ItemRegistration,
|
||||
PendingFocus,
|
||||
QuestionnaireContextValue,
|
||||
QuestionnaireRootProps,
|
||||
QuestionnaireRootState,
|
||||
} from "./types"
|
||||
import {
|
||||
compareItemOrder,
|
||||
getShortcutFromKey,
|
||||
isAnswerFilled,
|
||||
isRadioTarget,
|
||||
isTextEntryTarget,
|
||||
} from "./utils"
|
||||
|
||||
type UseQuestionnaireRootParameters = Pick<
|
||||
QuestionnaireRootProps,
|
||||
| "defaultItem"
|
||||
| "item"
|
||||
| "items"
|
||||
| "noValidate"
|
||||
| "onItemChange"
|
||||
| "onReset"
|
||||
| "onSubmit"
|
||||
| "ref"
|
||||
| "shortcuts"
|
||||
>
|
||||
|
||||
function useQuestionnaireRoot({
|
||||
defaultItem,
|
||||
item: controlledItem,
|
||||
items: itemDefinitions,
|
||||
noValidate,
|
||||
onItemChange,
|
||||
onReset,
|
||||
onSubmit,
|
||||
ref,
|
||||
shortcuts: shortcutMode,
|
||||
}: UseQuestionnaireRootParameters) {
|
||||
const collection = React.useMemo(
|
||||
() => createQuestionnaireCollection(itemDefinitions),
|
||||
[itemDefinitions]
|
||||
)
|
||||
const [registrations, setRegistrations] = React.useState<ItemRegistration[]>(
|
||||
[]
|
||||
)
|
||||
const [uncontrolledItem, setUncontrolledItem] = React.useState<string | null>(
|
||||
() => getInitialItemName(collection, defaultItem)
|
||||
)
|
||||
const [rootElement, setRootElement] = React.useState<HTMLFormElement | null>(
|
||||
null
|
||||
)
|
||||
const [domVersion, setDomVersion] = React.useState(0)
|
||||
const pendingFocusRef = React.useRef<PendingFocus | null>(null)
|
||||
const controlled = controlledItem !== undefined
|
||||
const activeItemName = controlled ? controlledItem : uncontrolledItem
|
||||
const previousActiveItemNameRef = React.useRef(activeItemName)
|
||||
const nativeValidation = noValidate === false
|
||||
const shortcuts = shortcutMode ?? null
|
||||
|
||||
const activeWarningsRef = React.useRef(new Set<string>())
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!collection || !rootElement) {
|
||||
activeWarningsRef.current.clear()
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const warnings = [
|
||||
...getCollectionDefinitionWarnings(collection, defaultItem),
|
||||
...getCollectionRegistrationWarnings(
|
||||
collection,
|
||||
registrations,
|
||||
shortcuts
|
||||
),
|
||||
]
|
||||
const activeWarnings = new Set(warnings)
|
||||
|
||||
activeWarnings.forEach((warning) => {
|
||||
if (!activeWarningsRef.current.has(warning)) {
|
||||
console.warn(`[Questionnaire] ${warning}`)
|
||||
}
|
||||
})
|
||||
|
||||
activeWarningsRef.current = activeWarnings
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [collection, defaultItem, registrations, rootElement, shortcuts])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!rootElement || typeof MutationObserver === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setDomVersion((version) => version + 1)
|
||||
})
|
||||
|
||||
observer.observe(rootElement, { childList: true, subtree: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [rootElement])
|
||||
|
||||
const runtimeItems = React.useMemo(
|
||||
() =>
|
||||
registrations
|
||||
.filter((registration) => !registration.disabled)
|
||||
.sort(compareItemOrder),
|
||||
[domVersion, registrations]
|
||||
)
|
||||
const runtimeItemByName = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
runtimeItems.map((runtimeItem) => [runtimeItem.name, runtimeItem])
|
||||
),
|
||||
[runtimeItems]
|
||||
)
|
||||
const logicalItems = collection?.enabledItems ?? runtimeItems
|
||||
const currentIndex = logicalItems.findIndex(
|
||||
(logicalItem) => logicalItem.name === activeItemName
|
||||
)
|
||||
const activeItem =
|
||||
currentIndex < 0 || !activeItemName
|
||||
? null
|
||||
: (runtimeItemByName.get(activeItemName) ?? null)
|
||||
const activeDefinition = activeItemName
|
||||
? collection?.itemByName.get(activeItemName)
|
||||
: undefined
|
||||
const activeItemRequired =
|
||||
currentIndex < 0
|
||||
? null
|
||||
: activeDefinition
|
||||
? Boolean(activeDefinition.required)
|
||||
: (activeItem?.required ?? false)
|
||||
const activeItemStatus =
|
||||
currentIndex < 0
|
||||
? null
|
||||
: (activeItem?.status ?? (activeItemName ? "unanswered" : null))
|
||||
const orderedRegistrations = React.useMemo(
|
||||
() =>
|
||||
collection
|
||||
? collection.enabledItems.flatMap((definition) => {
|
||||
const registration = runtimeItemByName.get(definition.name)
|
||||
|
||||
return registration ? [registration] : []
|
||||
})
|
||||
: runtimeItems,
|
||||
[collection, runtimeItemByName, runtimeItems]
|
||||
)
|
||||
const total = logicalItems.length
|
||||
const current = currentIndex < 0 ? 0 : currentIndex + 1
|
||||
const first = total > 0 && currentIndex === 0
|
||||
const last = total > 0 && currentIndex === total - 1
|
||||
|
||||
const setItem = React.useCallback(
|
||||
(nextItem: string, focusTarget: PendingFocus["target"] = "item") => {
|
||||
if (nextItem === activeItemName) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingFocusRef.current = {
|
||||
name: nextItem,
|
||||
target: focusTarget,
|
||||
}
|
||||
|
||||
if (!controlled) {
|
||||
setUncontrolledItem(nextItem)
|
||||
}
|
||||
|
||||
onItemChange?.(nextItem)
|
||||
},
|
||||
[activeItemName, controlled, onItemChange]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (total === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentIndex < 0) {
|
||||
if (!controlled && activeItemName === null) {
|
||||
setUncontrolledItem(logicalItems[0].name)
|
||||
return
|
||||
}
|
||||
|
||||
setItem(logicalItems[0].name)
|
||||
return
|
||||
}
|
||||
|
||||
const pendingFocus = pendingFocusRef.current
|
||||
const activeItemChanged =
|
||||
previousActiveItemNameRef.current !== activeItemName
|
||||
|
||||
previousActiveItemNameRef.current = activeItemName
|
||||
|
||||
if (!pendingFocus || pendingFocus.name !== activeItemName) {
|
||||
if (controlled && activeItemChanged) {
|
||||
pendingFocusRef.current = null
|
||||
activeItem?.focus()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingFocus.target === "invalid") {
|
||||
activeItem?.focusInvalid()
|
||||
} else {
|
||||
activeItem?.focus()
|
||||
}
|
||||
|
||||
pendingFocusRef.current = null
|
||||
}, [
|
||||
activeItem,
|
||||
activeItemName,
|
||||
controlled,
|
||||
currentIndex,
|
||||
logicalItems,
|
||||
setItem,
|
||||
total,
|
||||
])
|
||||
|
||||
const registerItem = React.useCallback((registration: ItemRegistration) => {
|
||||
setRegistrations((currentRegistrations) => [
|
||||
...currentRegistrations.filter(
|
||||
(currentRegistration) =>
|
||||
currentRegistration.element !== registration.element &&
|
||||
currentRegistration.name !== registration.name
|
||||
),
|
||||
registration,
|
||||
])
|
||||
|
||||
return () => {
|
||||
setRegistrations((currentRegistrations) =>
|
||||
currentRegistrations.filter(
|
||||
(currentRegistration) => currentRegistration !== registration
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const goPrevious = React.useCallback(() => {
|
||||
if (currentIndex <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setItem(logicalItems[currentIndex - 1].name)
|
||||
}, [currentIndex, logicalItems, setItem])
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
if (!activeItem || currentIndex >= total - 1) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeItem.validate()) {
|
||||
activeItem.focusInvalid()
|
||||
return
|
||||
}
|
||||
|
||||
setItem(logicalItems[currentIndex + 1].name)
|
||||
}, [activeItem, currentIndex, logicalItems, setItem, total])
|
||||
|
||||
const confirmCurrent = React.useCallback(() => {
|
||||
if (!activeItem) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeItem.validate()) {
|
||||
activeItem.focusInvalid()
|
||||
return
|
||||
}
|
||||
|
||||
if (last) {
|
||||
rootElement?.requestSubmit()
|
||||
return
|
||||
}
|
||||
|
||||
setItem(logicalItems[currentIndex + 1].name)
|
||||
}, [activeItem, currentIndex, last, logicalItems, rootElement, setItem])
|
||||
|
||||
const skipCurrent = React.useCallback(() => {
|
||||
if (!activeItem || activeItem.required) {
|
||||
return
|
||||
}
|
||||
|
||||
activeItem.skip()
|
||||
|
||||
if (!last) {
|
||||
setItem(logicalItems[currentIndex + 1].name)
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
rootElement?.requestSubmit()
|
||||
})
|
||||
}, [activeItem, currentIndex, last, logicalItems, rootElement, setItem])
|
||||
|
||||
function handleReset(event: React.FormEvent<HTMLFormElement>) {
|
||||
onReset?.(event)
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const registration of registrations) {
|
||||
registration.reset()
|
||||
}
|
||||
|
||||
const resetItemName = collection
|
||||
? getInitialItemName(collection, defaultItem)
|
||||
: (runtimeItems.find((registration) => registration.name === defaultItem)
|
||||
?.name ?? runtimeItems[0]?.name)
|
||||
|
||||
if (resetItemName) {
|
||||
setItem(resetItemName)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
const firstInvalidItem = orderedRegistrations.find(
|
||||
(registration) => !registration.validate()
|
||||
)
|
||||
|
||||
if (firstInvalidItem) {
|
||||
event.preventDefault()
|
||||
setItem(firstInvalidItem.name, "invalid")
|
||||
|
||||
if (firstInvalidItem.name === activeItemName) {
|
||||
firstInvalidItem.focusInvalid()
|
||||
pendingFocusRef.current = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit?.(event)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLFormElement>) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.keyCode === 229 ||
|
||||
!activeItem ||
|
||||
!(event.target instanceof Element)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!event.repeat) {
|
||||
confirmCurrent()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
const moved = activeItem.moveAnswerFocus(
|
||||
event.target,
|
||||
event.key === "ArrowDown" ? "next" : "previous"
|
||||
)
|
||||
|
||||
if (moved) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(event.key === "ArrowLeft" || event.key === "ArrowRight") &&
|
||||
!isTextEntryTarget(event.target) &&
|
||||
!isRadioTarget(event.target)
|
||||
) {
|
||||
event.preventDefault()
|
||||
|
||||
if (event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
goPrevious()
|
||||
} else if (activeItem.status !== "unanswered") {
|
||||
goNext()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const answer = activeItem.getAnswerByElement(event.target)
|
||||
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (!event.repeat && isAnswerFilled(answer)) {
|
||||
confirmCurrent()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!shortcuts || isTextEntryTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const shortcut = getShortcutFromKey(event.key, shortcuts)
|
||||
const answer = shortcut ? activeItem.getAnswerByShortcut(shortcut) : null
|
||||
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
answer.element.focus()
|
||||
|
||||
if (answer.type === "choice") {
|
||||
answer.element.click()
|
||||
}
|
||||
}
|
||||
|
||||
const state: QuestionnaireRootState = {
|
||||
current,
|
||||
first,
|
||||
last,
|
||||
total,
|
||||
}
|
||||
const context = React.useMemo<QuestionnaireContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
activeItem,
|
||||
activeItemName,
|
||||
activeItemRequired,
|
||||
activeItemStatus,
|
||||
domVersion,
|
||||
goNext,
|
||||
goPrevious,
|
||||
itemDefinitionByName: collection?.itemByName ?? null,
|
||||
nativeValidation,
|
||||
registerItem,
|
||||
shortcuts,
|
||||
skipCurrent,
|
||||
}),
|
||||
[
|
||||
activeItem,
|
||||
activeItemName,
|
||||
activeItemRequired,
|
||||
activeItemStatus,
|
||||
collection,
|
||||
current,
|
||||
domVersion,
|
||||
first,
|
||||
goNext,
|
||||
goPrevious,
|
||||
last,
|
||||
nativeValidation,
|
||||
registerItem,
|
||||
shortcuts,
|
||||
skipCurrent,
|
||||
total,
|
||||
]
|
||||
)
|
||||
const setRootRef = React.useCallback(
|
||||
(element: HTMLFormElement | null) => {
|
||||
setRootElement(element)
|
||||
composeRefs(ref)?.(element)
|
||||
},
|
||||
[ref]
|
||||
)
|
||||
|
||||
return {
|
||||
context,
|
||||
rootProps: {
|
||||
"data-shortcuts": shortcuts ?? undefined,
|
||||
onKeyDown: handleKeyDown,
|
||||
onReset: handleReset,
|
||||
onSubmit: handleSubmit,
|
||||
ref: setRootRef,
|
||||
},
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export { useQuestionnaireRoot }
|
||||
141
packages/react/src/questionnaire/utils.ts
Normal file
141
packages/react/src/questionnaire/utils.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
AnswerControlRegistration,
|
||||
ItemRegistration,
|
||||
QuestionnaireShortcutMode,
|
||||
} from "./types"
|
||||
|
||||
function hasInputValue(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => String(item).trim().length > 0)
|
||||
}
|
||||
|
||||
return (
|
||||
value !== undefined && value !== null && String(value).trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getShortcutKeys(shortcuts: QuestionnaireShortcutMode | null) {
|
||||
if (shortcuts === "letters") {
|
||||
return Array.from({ length: 26 }, (_, index) =>
|
||||
String.fromCharCode(65 + index)
|
||||
)
|
||||
}
|
||||
|
||||
if (shortcuts === "numbers") {
|
||||
return Array.from({ length: 9 }, (_, index) => String(index + 1))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getShortcutFromKey(key: string, shortcuts: QuestionnaireShortcutMode) {
|
||||
const normalizedKey = shortcuts === "letters" ? key.toUpperCase() : key
|
||||
|
||||
return getShortcutKeys(shortcuts).includes(normalizedKey)
|
||||
? normalizedKey
|
||||
: null
|
||||
}
|
||||
|
||||
function getAnswerKeyShortcuts(shortcut: string | null, filled: boolean) {
|
||||
return (
|
||||
[shortcut, filled ? "Enter" : null].filter(Boolean).join(" ") || undefined
|
||||
)
|
||||
}
|
||||
|
||||
function isAnswerFilled(answer: AnswerControlRegistration) {
|
||||
if (answer.type === "choice") {
|
||||
return answer.element.checked
|
||||
}
|
||||
|
||||
return (
|
||||
answer.element.hasAttribute("name") && hasInputValue(answer.element.value)
|
||||
)
|
||||
}
|
||||
|
||||
function isEmptyNavigableInput(answer: AnswerControlRegistration | null) {
|
||||
return (
|
||||
answer?.type === "input" &&
|
||||
["email", "password", "search", "tel", "text", "url"].includes(
|
||||
answer.element.type
|
||||
) &&
|
||||
!hasInputValue(answer.element.value)
|
||||
)
|
||||
}
|
||||
|
||||
function isTextEntryTarget(element: Element) {
|
||||
if (
|
||||
element instanceof HTMLTextAreaElement ||
|
||||
element instanceof HTMLSelectElement
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (element instanceof HTMLInputElement) {
|
||||
return !["button", "checkbox", "radio", "reset", "submit"].includes(
|
||||
element.type
|
||||
)
|
||||
}
|
||||
|
||||
return element instanceof HTMLElement && element.isContentEditable
|
||||
}
|
||||
|
||||
function isRadioTarget(element: Element) {
|
||||
return element instanceof HTMLInputElement && element.type === "radio"
|
||||
}
|
||||
|
||||
function compareItemOrder(
|
||||
firstItem: ItemRegistration,
|
||||
secondItem: ItemRegistration
|
||||
) {
|
||||
if (firstItem.element === secondItem.element) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const position = firstItem.element.compareDocumentPosition(secondItem.element)
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function compareAnswerOrder(
|
||||
firstAnswer: AnswerControlRegistration,
|
||||
secondAnswer: AnswerControlRegistration
|
||||
) {
|
||||
if (firstAnswer.element === secondAnswer.element) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const position = firstAnswer.element.compareDocumentPosition(
|
||||
secondAnswer.element
|
||||
)
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export {
|
||||
compareAnswerOrder,
|
||||
compareItemOrder,
|
||||
getAnswerKeyShortcuts,
|
||||
getShortcutFromKey,
|
||||
getShortcutKeys,
|
||||
hasInputValue,
|
||||
isAnswerFilled,
|
||||
isEmptyNavigableInput,
|
||||
isRadioTarget,
|
||||
isTextEntryTarget,
|
||||
}
|
||||
16
packages/react/src/use-render/README.md
Normal file
16
packages/react/src/use-render/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# use-render
|
||||
|
||||
Internal polymorphic-render helper (`useRender`, `mergeProps`) that powers the
|
||||
`render` prop on `@shadcn/react` primitives. It lets a component render as a
|
||||
custom element while merging the primitive's props, refs, and state attributes
|
||||
onto it.
|
||||
|
||||
## Attribution
|
||||
|
||||
This code is adapted from [Base UI](https://github.com/mui/base-ui)'s
|
||||
`useRender` implementation (`@base-ui/react/use-render`), MIT licensed,
|
||||
© MUI. We keep a local copy so the primitives carry no runtime dependency on
|
||||
Base UI.
|
||||
|
||||
If Base UI publishes `useRender` as an independent, standalone package, we will
|
||||
switch to it and remove this copy.
|
||||
208
packages/react/src/use-render/index.ts
Normal file
208
packages/react/src/use-render/index.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// A poor man's version of base-ui useRender.
|
||||
// see https://base-ui.com/react/utils/use-render.
|
||||
// TODO: Replace if base-ui useRender is published as a package.
|
||||
import * as React from "react"
|
||||
|
||||
type RenderState = Record<string, unknown>
|
||||
|
||||
type RenderFunction<TState extends RenderState> = (
|
||||
props: Record<string, unknown>,
|
||||
state: TState
|
||||
) => React.ReactElement | null
|
||||
|
||||
type RenderProp<TState extends RenderState> =
|
||||
| React.ReactElement
|
||||
| RenderFunction<TState>
|
||||
|
||||
type StateAttributesMapping<TState extends RenderState> = Partial<{
|
||||
[K in keyof TState]: (
|
||||
value: TState[K]
|
||||
) => Record<string, string | undefined> | null | undefined
|
||||
}>
|
||||
|
||||
type UseRenderComponentProps<
|
||||
TElement extends React.ElementType,
|
||||
TState extends RenderState = RenderState,
|
||||
> = React.ComponentPropsWithRef<TElement> & {
|
||||
render?: RenderProp<TState>
|
||||
}
|
||||
|
||||
type UseRenderOptions<
|
||||
TElement extends React.ElementType,
|
||||
TState extends RenderState,
|
||||
> = {
|
||||
defaultTagName: TElement
|
||||
props?: React.ComponentPropsWithRef<TElement>
|
||||
render?: RenderProp<TState>
|
||||
state?: TState
|
||||
stateAttributesMapping?: StateAttributesMapping<TState>
|
||||
}
|
||||
|
||||
function useRender<
|
||||
TElement extends React.ElementType,
|
||||
TState extends RenderState = RenderState,
|
||||
>({
|
||||
defaultTagName,
|
||||
props,
|
||||
render,
|
||||
state = {} as TState,
|
||||
stateAttributesMapping,
|
||||
}: UseRenderOptions<TElement, TState>) {
|
||||
const elementProps = mergeProps<TElement>(
|
||||
getStateAttributes(state, stateAttributesMapping),
|
||||
props
|
||||
)
|
||||
|
||||
if (!render) {
|
||||
return React.createElement(defaultTagName, elementProps)
|
||||
}
|
||||
|
||||
if (typeof render === "function") {
|
||||
return render(elementProps, state)
|
||||
}
|
||||
|
||||
if (!React.isValidElement(render)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderProps = render.props as Record<string, unknown>
|
||||
const propsWithRenderProps = mergeProps<TElement>(elementProps, renderProps)
|
||||
const propsWithRef = {
|
||||
...propsWithRenderProps,
|
||||
ref: composeRefs(
|
||||
elementProps.ref as React.Ref<unknown> | undefined,
|
||||
renderProps.ref as React.Ref<unknown> | undefined
|
||||
),
|
||||
}
|
||||
|
||||
return React.cloneElement(
|
||||
render,
|
||||
propsWithRef as React.Attributes & Record<string, unknown>
|
||||
)
|
||||
}
|
||||
|
||||
function mergeProps<TElement extends React.ElementType>(
|
||||
...sources: Array<
|
||||
React.ComponentPropsWithRef<TElement> | Record<string, unknown> | undefined
|
||||
>
|
||||
) {
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const source of sources) {
|
||||
if (!source) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceProps = source as Record<string, unknown>
|
||||
|
||||
for (const key of Object.keys(sourceProps)) {
|
||||
const value = sourceProps[key]
|
||||
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const current = result[key]
|
||||
|
||||
if (key === "className") {
|
||||
result[key] = [current, value].filter(Boolean).join(" ")
|
||||
} else if (key === "style") {
|
||||
result[key] = {
|
||||
...(current as React.CSSProperties | undefined),
|
||||
...(value as React.CSSProperties | undefined),
|
||||
}
|
||||
} else if (key === "ref") {
|
||||
result[key] = composeRefs(
|
||||
current as React.Ref<unknown> | undefined,
|
||||
value as React.Ref<unknown> | undefined
|
||||
)
|
||||
} else if (
|
||||
isEventHandler(key) &&
|
||||
typeof current === "function" &&
|
||||
typeof value === "function"
|
||||
) {
|
||||
result[key] = composeEventHandlers(
|
||||
value as EventHandler,
|
||||
current as EventHandler
|
||||
)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result as React.ComponentPropsWithRef<TElement>
|
||||
}
|
||||
|
||||
function getStateAttributes<TState extends RenderState>(
|
||||
state: TState,
|
||||
mapping?: StateAttributesMapping<TState>
|
||||
) {
|
||||
const props: Record<string, unknown> = {}
|
||||
|
||||
for (const key of Object.keys(state) as Array<keyof TState>) {
|
||||
const value = state[key]
|
||||
const attributes = mapping?.[key]?.(value)
|
||||
|
||||
if (attributes) {
|
||||
Object.assign(props, attributes)
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === "slot") {
|
||||
props["data-slot"] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const attribute = `data-${String(key).replace(
|
||||
/[A-Z]/g,
|
||||
(letter) => `-${letter.toLowerCase()}`
|
||||
)}`
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
props[attribute] = value ? "" : undefined
|
||||
} else if (value !== null && value !== undefined) {
|
||||
props[attribute] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
type EventHandler = (event: React.SyntheticEvent) => void
|
||||
|
||||
function composeEventHandlers(theirs: EventHandler, ours: EventHandler) {
|
||||
return function handleEvent(event: React.SyntheticEvent) {
|
||||
theirs(event)
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
ours(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEventHandler(key: string) {
|
||||
return /^on[A-Z]/.test(key)
|
||||
}
|
||||
|
||||
function composeRefs<T>(
|
||||
...refs: Array<React.Ref<T> | undefined>
|
||||
): React.RefCallback<T> | undefined {
|
||||
const validRefs = refs.filter(Boolean)
|
||||
|
||||
if (validRefs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (value) => {
|
||||
for (const ref of validRefs) {
|
||||
if (typeof ref === "function") {
|
||||
ref(value)
|
||||
} else if (ref) {
|
||||
ref.current = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { composeRefs, mergeProps, useRender, type UseRenderComponentProps }
|
||||
11
packages/react/tsconfig.json
Normal file
11
packages/react/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": false,
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
47
packages/react/tsup.config.ts
Normal file
47
packages/react/tsup.config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { readFileSync, writeFileSync } from "fs"
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
const CLIENT_UI_ENTRIES = [
|
||||
"dist/message-scroller/index.js",
|
||||
"dist/questionnaire/index.js",
|
||||
]
|
||||
|
||||
export default defineConfig((options) => ({
|
||||
clean: !options.watch,
|
||||
dts: true,
|
||||
entry: {
|
||||
"message-scroller/index": "src/message-scroller/index.ts",
|
||||
"questionnaire/index": "src/questionnaire/index.ts",
|
||||
},
|
||||
format: ["esm"],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
target: "esnext",
|
||||
outDir: "dist",
|
||||
treeshake: true,
|
||||
plugins: [useClientDirectivePlugin(CLIENT_UI_ENTRIES)],
|
||||
}))
|
||||
|
||||
// Prepends `"use client"` to listed dist entry files after the build. Bundlers
|
||||
// strip the directive from source, but RSC apps need it on the published module
|
||||
// so imports like `@shadcn/react/message-scroller` resolve as client boundaries.
|
||||
function useClientDirectivePlugin(entries: string[]) {
|
||||
return {
|
||||
name: "use-client-directive",
|
||||
buildEnd({ writtenFiles }: { writtenFiles: { name: string }[] }) {
|
||||
const uiFiles = writtenFiles.filter((file) =>
|
||||
entries.some((entry) => file.name.replace(/\\/g, "/").endsWith(entry))
|
||||
)
|
||||
|
||||
for (const file of uiFiles) {
|
||||
const code = readFileSync(file.name, "utf8")
|
||||
|
||||
if (/^["']use client["'];?/.test(code)) {
|
||||
continue
|
||||
}
|
||||
|
||||
writeFileSync(file.name, `"use client";\n${code}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
25
packages/react/vitest.browser.config.ts
Normal file
25
packages/react/vitest.browser.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import tsconfigPaths from "vite-tsconfig-paths"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
// Browser tests to cover what jsdom cannot.
|
||||
// for example native scroll anchoring on prepend.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.browser.test.{ts,tsx}"],
|
||||
setupFiles: ["./vitest.browser.setup.ts"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: "playwright",
|
||||
headless: true,
|
||||
instances: [{ browser: "chromium" }],
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["react", "react-dom/client", "react/jsx-dev-runtime"],
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
4
packages/react/vitest.browser.setup.ts
Normal file
4
packages/react/vitest.browser.setup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// React's act() needs this flag outside of a testing-library environment.
|
||||
;(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
19
packages/react/vitest.config.ts
Normal file
19
packages/react/vitest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import tsconfigPaths from "vite-tsconfig-paths"
|
||||
import { configDefaults, defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: [
|
||||
...configDefaults.exclude,
|
||||
"**/node_modules/**",
|
||||
// Browser-mode tests run via vitest.browser.config.ts, not in jsdom.
|
||||
"**/*.browser.test.*",
|
||||
],
|
||||
testTimeout: 8000,
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
3
packages/shadcn/.gitignore
vendored
Normal file
3
packages/shadcn/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
components
|
||||
dist
|
||||
.turbo
|
||||
1
packages/shadcn/.prettierignore
Normal file
1
packages/shadcn/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
1025
packages/shadcn/CHANGELOG.md
Normal file
1025
packages/shadcn/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
61
packages/shadcn/README.md
Normal file
61
packages/shadcn/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# shadcn
|
||||
|
||||
A CLI for adding components to your project.
|
||||
|
||||
## create
|
||||
|
||||
Use the `create` command to create a new project. You will be taken to a website to build your custom design system and choose your framework.
|
||||
|
||||
```bash
|
||||
npx shadcn create
|
||||
```
|
||||
|
||||
## init
|
||||
|
||||
Use the `init` command to initialize dependencies for a new project.
|
||||
|
||||
The `init` command installs dependencies, adds the `cn` util, configures Tailwind CSS, and CSS variables for the project.
|
||||
|
||||
```bash
|
||||
npx shadcn init
|
||||
```
|
||||
|
||||
## apply
|
||||
|
||||
Use the `apply` command to apply a preset to an existing project.
|
||||
|
||||
The `apply` command overwrites the current preset configuration, reinstalls detected UI components, and updates fonts and CSS variables to match the new preset.
|
||||
|
||||
```bash
|
||||
npx shadcn apply --preset a2r6bw
|
||||
```
|
||||
|
||||
## add
|
||||
|
||||
Use the `add` command to add components to your project.
|
||||
|
||||
The `add` command adds a component to your project and installs all required dependencies.
|
||||
|
||||
```bash
|
||||
npx shadcn add [component]
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
npx shadcn add alert-dialog
|
||||
```
|
||||
|
||||
You can also run the command without any arguments to view a list of all available components:
|
||||
|
||||
```bash
|
||||
npx shadcn add
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit https://ui.shadcn.com/docs/cli to view the documentation.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
|
||||
135
packages/shadcn/package.json
Normal file
135
packages/shadcn/package.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"name": "shadcn",
|
||||
"version": "4.21.0",
|
||||
"description": "Add components to your apps.",
|
||||
"homepage": "https://ui.shadcn.com",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "shadcn",
|
||||
"url": "https://twitter.com/shadcn"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shadcn-ui/ui.git",
|
||||
"directory": "packages/shadcn"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"components",
|
||||
"ui",
|
||||
"tailwind",
|
||||
"radix-ui",
|
||||
"base-ui",
|
||||
"shadcn"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./registry": {
|
||||
"types": "./dist/registry/index.d.ts",
|
||||
"default": "./dist/registry/index.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema/index.d.ts",
|
||||
"default": "./dist/schema/index.js"
|
||||
},
|
||||
"./mcp": {
|
||||
"types": "./dist/mcp/index.d.ts",
|
||||
"default": "./dist/mcp/index.js"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/utils/index.d.ts",
|
||||
"default": "./dist/utils/index.js"
|
||||
},
|
||||
"./icons": {
|
||||
"types": "./dist/icons/index.d.ts",
|
||||
"default": "./dist/icons/index.js"
|
||||
},
|
||||
"./preset": {
|
||||
"types": "./dist/preset/index.d.ts",
|
||||
"default": "./dist/preset/index.js"
|
||||
},
|
||||
"./tailwind.css": "./dist/tailwind.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
"./dist/tailwind.css"
|
||||
],
|
||||
"bin": "./dist/index.js",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsup --watch",
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf dist && rimraf components",
|
||||
"start:dev": "cross-env REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates node dist/index.js",
|
||||
"start:prod": "cross-env REGISTRY_URL=https://ui.shadcn.com/r node dist/index.js",
|
||||
"start": "node dist/index.js",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"release": "changeset version",
|
||||
"pub:beta": "pnpm build && pnpm publish --no-git-checks --access public --tag beta",
|
||||
"pub:rc": "pnpm build && pnpm publish --no-git-checks --access public --tag rc",
|
||||
"pub:next": "pnpm build && pnpm publish --no-git-checks --access public --tag next",
|
||||
"pub:release": "pnpm build && pnpm publish --access public",
|
||||
"test": "vitest run",
|
||||
"test:dev": "REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates vitest run",
|
||||
"mcp:inspect": "pnpm dlx @modelcontextprotocol/inspector node dist/index.js mcp"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/plugin-transform-typescript": "^7.28.0",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@types/validate-npm-package-name": "^4.0.2",
|
||||
"browserslist": "^4.26.2",
|
||||
"cn": "^0.2.4",
|
||||
"commander": "^14.0.0",
|
||||
"cosmiconfig": "^9.0.0",
|
||||
"dedent": "^1.6.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"diff": "^8.0.2",
|
||||
"execa": "^9.6.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fs-extra": "^11.3.1",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"kleur": "^4.1.5",
|
||||
"open": "^11.0.0",
|
||||
"ora": "^8.2.0",
|
||||
"postcss": "^8.5.23",
|
||||
"postcss-selector-parser": "^7.1.3",
|
||||
"prompts": "^2.4.2",
|
||||
"recast": "^0.23.11",
|
||||
"socks": "^2.8.8",
|
||||
"stringify-object": "^5.0.0",
|
||||
"ts-morph": "^26.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"undici": "^7.29.0",
|
||||
"validate-npm-package-name": "^7.0.1",
|
||||
"zod": "^3.24.1",
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/ni": "^25.0.0",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/stringify-object": "^4.0.5",
|
||||
"msw": "^2.10.4",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "^8.5.0",
|
||||
"type-fest": "^4.41.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
470
packages/shadcn/src/colors.ts
Normal file
470
packages/shadcn/src/colors.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
export const TAILWIND_COLOR_SCALES = [
|
||||
"50",
|
||||
"100",
|
||||
"200",
|
||||
"300",
|
||||
"400",
|
||||
"500",
|
||||
"600",
|
||||
"700",
|
||||
"800",
|
||||
"900",
|
||||
"950",
|
||||
] as const
|
||||
|
||||
export const TAILWIND_COLOR_FAMILIES = [
|
||||
"red",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"green",
|
||||
"emerald",
|
||||
"teal",
|
||||
"cyan",
|
||||
"sky",
|
||||
"blue",
|
||||
"indigo",
|
||||
"violet",
|
||||
"purple",
|
||||
"fuchsia",
|
||||
"pink",
|
||||
"rose",
|
||||
"slate",
|
||||
"gray",
|
||||
"zinc",
|
||||
"neutral",
|
||||
"stone",
|
||||
"mauve",
|
||||
"olive",
|
||||
"mist",
|
||||
"taupe",
|
||||
] as const
|
||||
|
||||
export type TailwindColorScale = (typeof TAILWIND_COLOR_SCALES)[number]
|
||||
export type TailwindColorFamily = (typeof TAILWIND_COLOR_FAMILIES)[number]
|
||||
|
||||
export const TAILWIND_COLORS: Record<
|
||||
TailwindColorFamily,
|
||||
Record<TailwindColorScale, string>
|
||||
> = {
|
||||
red: {
|
||||
"50": "oklch(97.1% 0.013 17.38)",
|
||||
"100": "oklch(93.6% 0.032 17.717)",
|
||||
"200": "oklch(88.5% 0.062 18.334)",
|
||||
"300": "oklch(80.8% 0.114 19.571)",
|
||||
"400": "oklch(70.4% 0.191 22.216)",
|
||||
"500": "oklch(63.7% 0.237 25.331)",
|
||||
"600": "oklch(57.7% 0.245 27.325)",
|
||||
"700": "oklch(50.5% 0.213 27.518)",
|
||||
"800": "oklch(44.4% 0.177 26.899)",
|
||||
"900": "oklch(39.6% 0.141 25.723)",
|
||||
"950": "oklch(25.8% 0.092 26.042)",
|
||||
},
|
||||
orange: {
|
||||
"50": "oklch(98% 0.016 73.684)",
|
||||
"100": "oklch(95.4% 0.038 75.164)",
|
||||
"200": "oklch(90.1% 0.076 70.697)",
|
||||
"300": "oklch(83.7% 0.128 66.29)",
|
||||
"400": "oklch(75% 0.183 55.934)",
|
||||
"500": "oklch(70.5% 0.213 47.604)",
|
||||
"600": "oklch(64.6% 0.222 41.116)",
|
||||
"700": "oklch(55.3% 0.195 38.402)",
|
||||
"800": "oklch(47% 0.157 37.304)",
|
||||
"900": "oklch(40.8% 0.123 38.172)",
|
||||
"950": "oklch(26.6% 0.079 36.259)",
|
||||
},
|
||||
amber: {
|
||||
"50": "oklch(98.7% 0.022 95.277)",
|
||||
"100": "oklch(96.2% 0.059 95.617)",
|
||||
"200": "oklch(92.4% 0.12 95.746)",
|
||||
"300": "oklch(87.9% 0.169 91.605)",
|
||||
"400": "oklch(82.8% 0.189 84.429)",
|
||||
"500": "oklch(76.9% 0.188 70.08)",
|
||||
"600": "oklch(66.6% 0.179 58.318)",
|
||||
"700": "oklch(55.5% 0.163 48.998)",
|
||||
"800": "oklch(47.3% 0.137 46.201)",
|
||||
"900": "oklch(41.4% 0.112 45.904)",
|
||||
"950": "oklch(27.9% 0.077 45.635)",
|
||||
},
|
||||
yellow: {
|
||||
"50": "oklch(98.7% 0.026 102.212)",
|
||||
"100": "oklch(97.3% 0.071 103.193)",
|
||||
"200": "oklch(94.5% 0.129 101.54)",
|
||||
"300": "oklch(90.5% 0.182 98.111)",
|
||||
"400": "oklch(85.2% 0.199 91.936)",
|
||||
"500": "oklch(79.5% 0.184 86.047)",
|
||||
"600": "oklch(68.1% 0.162 75.834)",
|
||||
"700": "oklch(55.4% 0.135 66.442)",
|
||||
"800": "oklch(47.6% 0.114 61.907)",
|
||||
"900": "oklch(42.1% 0.095 57.708)",
|
||||
"950": "oklch(28.6% 0.066 53.813)",
|
||||
},
|
||||
lime: {
|
||||
"50": "oklch(98.6% 0.031 120.757)",
|
||||
"100": "oklch(96.7% 0.067 122.328)",
|
||||
"200": "oklch(93.8% 0.127 124.321)",
|
||||
"300": "oklch(89.7% 0.196 126.665)",
|
||||
"400": "oklch(84.1% 0.238 128.85)",
|
||||
"500": "oklch(76.8% 0.233 130.85)",
|
||||
"600": "oklch(64.8% 0.2 131.684)",
|
||||
"700": "oklch(53.2% 0.157 131.589)",
|
||||
"800": "oklch(45.3% 0.124 130.933)",
|
||||
"900": "oklch(40.5% 0.101 131.063)",
|
||||
"950": "oklch(27.4% 0.072 132.109)",
|
||||
},
|
||||
green: {
|
||||
"50": "oklch(98.2% 0.018 155.826)",
|
||||
"100": "oklch(96.2% 0.044 156.743)",
|
||||
"200": "oklch(92.5% 0.084 155.995)",
|
||||
"300": "oklch(87.1% 0.15 154.449)",
|
||||
"400": "oklch(79.2% 0.209 151.711)",
|
||||
"500": "oklch(72.3% 0.219 149.579)",
|
||||
"600": "oklch(62.7% 0.194 149.214)",
|
||||
"700": "oklch(52.7% 0.154 150.069)",
|
||||
"800": "oklch(44.8% 0.119 151.328)",
|
||||
"900": "oklch(39.3% 0.095 152.535)",
|
||||
"950": "oklch(26.6% 0.065 152.934)",
|
||||
},
|
||||
emerald: {
|
||||
"50": "oklch(97.9% 0.021 166.113)",
|
||||
"100": "oklch(95% 0.052 163.051)",
|
||||
"200": "oklch(90.5% 0.093 164.15)",
|
||||
"300": "oklch(84.5% 0.143 164.978)",
|
||||
"400": "oklch(76.5% 0.177 163.223)",
|
||||
"500": "oklch(69.6% 0.17 162.48)",
|
||||
"600": "oklch(59.6% 0.145 163.225)",
|
||||
"700": "oklch(50.8% 0.118 165.612)",
|
||||
"800": "oklch(43.2% 0.095 166.913)",
|
||||
"900": "oklch(37.8% 0.077 168.94)",
|
||||
"950": "oklch(26.2% 0.051 172.552)",
|
||||
},
|
||||
teal: {
|
||||
"50": "oklch(98.4% 0.014 180.72)",
|
||||
"100": "oklch(95.3% 0.051 180.801)",
|
||||
"200": "oklch(91% 0.096 180.426)",
|
||||
"300": "oklch(85.5% 0.138 181.071)",
|
||||
"400": "oklch(77.7% 0.152 181.912)",
|
||||
"500": "oklch(70.4% 0.14 182.503)",
|
||||
"600": "oklch(60% 0.118 184.704)",
|
||||
"700": "oklch(51.1% 0.096 186.391)",
|
||||
"800": "oklch(43.7% 0.078 188.216)",
|
||||
"900": "oklch(38.6% 0.063 188.416)",
|
||||
"950": "oklch(27.7% 0.046 192.524)",
|
||||
},
|
||||
cyan: {
|
||||
"50": "oklch(98.4% 0.019 200.873)",
|
||||
"100": "oklch(95.6% 0.045 203.388)",
|
||||
"200": "oklch(91.7% 0.08 205.041)",
|
||||
"300": "oklch(86.5% 0.127 207.078)",
|
||||
"400": "oklch(78.9% 0.154 211.53)",
|
||||
"500": "oklch(71.5% 0.143 215.221)",
|
||||
"600": "oklch(60.9% 0.126 221.723)",
|
||||
"700": "oklch(52% 0.105 223.128)",
|
||||
"800": "oklch(45% 0.085 224.283)",
|
||||
"900": "oklch(39.8% 0.07 227.392)",
|
||||
"950": "oklch(30.2% 0.056 229.695)",
|
||||
},
|
||||
sky: {
|
||||
"50": "oklch(97.7% 0.013 236.62)",
|
||||
"100": "oklch(95.1% 0.026 236.824)",
|
||||
"200": "oklch(90.1% 0.058 230.902)",
|
||||
"300": "oklch(82.8% 0.111 230.318)",
|
||||
"400": "oklch(74.6% 0.16 232.661)",
|
||||
"500": "oklch(68.5% 0.169 237.323)",
|
||||
"600": "oklch(58.8% 0.158 241.966)",
|
||||
"700": "oklch(50% 0.134 242.749)",
|
||||
"800": "oklch(44.3% 0.11 240.79)",
|
||||
"900": "oklch(39.1% 0.09 240.876)",
|
||||
"950": "oklch(29.3% 0.066 243.157)",
|
||||
},
|
||||
blue: {
|
||||
"50": "oklch(97% 0.014 254.604)",
|
||||
"100": "oklch(93.2% 0.032 255.585)",
|
||||
"200": "oklch(88.2% 0.059 254.128)",
|
||||
"300": "oklch(80.9% 0.105 251.813)",
|
||||
"400": "oklch(70.7% 0.165 254.624)",
|
||||
"500": "oklch(62.3% 0.214 259.815)",
|
||||
"600": "oklch(54.6% 0.245 262.881)",
|
||||
"700": "oklch(48.8% 0.243 264.376)",
|
||||
"800": "oklch(42.4% 0.199 265.638)",
|
||||
"900": "oklch(37.9% 0.146 265.522)",
|
||||
"950": "oklch(28.2% 0.091 267.935)",
|
||||
},
|
||||
indigo: {
|
||||
"50": "oklch(96.2% 0.018 272.314)",
|
||||
"100": "oklch(93% 0.034 272.788)",
|
||||
"200": "oklch(87% 0.065 274.039)",
|
||||
"300": "oklch(78.5% 0.115 274.713)",
|
||||
"400": "oklch(67.3% 0.182 276.935)",
|
||||
"500": "oklch(58.5% 0.233 277.117)",
|
||||
"600": "oklch(51.1% 0.262 276.966)",
|
||||
"700": "oklch(45.7% 0.24 277.023)",
|
||||
"800": "oklch(39.8% 0.195 277.366)",
|
||||
"900": "oklch(35.9% 0.144 278.697)",
|
||||
"950": "oklch(25.7% 0.09 281.288)",
|
||||
},
|
||||
violet: {
|
||||
"50": "oklch(96.9% 0.016 293.756)",
|
||||
"100": "oklch(94.3% 0.029 294.588)",
|
||||
"200": "oklch(89.4% 0.057 293.283)",
|
||||
"300": "oklch(81.1% 0.111 293.571)",
|
||||
"400": "oklch(70.2% 0.183 293.541)",
|
||||
"500": "oklch(60.6% 0.25 292.717)",
|
||||
"600": "oklch(54.1% 0.281 293.009)",
|
||||
"700": "oklch(49.1% 0.27 292.581)",
|
||||
"800": "oklch(43.2% 0.232 292.759)",
|
||||
"900": "oklch(38% 0.189 293.745)",
|
||||
"950": "oklch(28.3% 0.141 291.089)",
|
||||
},
|
||||
purple: {
|
||||
"50": "oklch(97.7% 0.014 308.299)",
|
||||
"100": "oklch(94.6% 0.033 307.174)",
|
||||
"200": "oklch(90.2% 0.063 306.703)",
|
||||
"300": "oklch(82.7% 0.119 306.383)",
|
||||
"400": "oklch(71.4% 0.203 305.504)",
|
||||
"500": "oklch(62.7% 0.265 303.9)",
|
||||
"600": "oklch(55.8% 0.288 302.321)",
|
||||
"700": "oklch(49.6% 0.265 301.924)",
|
||||
"800": "oklch(43.8% 0.218 303.724)",
|
||||
"900": "oklch(38.1% 0.176 304.987)",
|
||||
"950": "oklch(29.1% 0.149 302.717)",
|
||||
},
|
||||
fuchsia: {
|
||||
"50": "oklch(97.7% 0.017 320.058)",
|
||||
"100": "oklch(95.2% 0.037 318.852)",
|
||||
"200": "oklch(90.3% 0.076 319.62)",
|
||||
"300": "oklch(83.3% 0.145 321.434)",
|
||||
"400": "oklch(74% 0.238 322.16)",
|
||||
"500": "oklch(66.7% 0.295 322.15)",
|
||||
"600": "oklch(59.1% 0.293 322.896)",
|
||||
"700": "oklch(51.8% 0.253 323.949)",
|
||||
"800": "oklch(45.2% 0.211 324.591)",
|
||||
"900": "oklch(40.1% 0.17 325.612)",
|
||||
"950": "oklch(29.3% 0.136 325.661)",
|
||||
},
|
||||
pink: {
|
||||
"50": "oklch(97.1% 0.014 343.198)",
|
||||
"100": "oklch(94.8% 0.028 342.258)",
|
||||
"200": "oklch(89.9% 0.061 343.231)",
|
||||
"300": "oklch(82.3% 0.12 346.018)",
|
||||
"400": "oklch(71.8% 0.202 349.761)",
|
||||
"500": "oklch(65.6% 0.241 354.308)",
|
||||
"600": "oklch(59.2% 0.249 0.584)",
|
||||
"700": "oklch(52.5% 0.223 3.958)",
|
||||
"800": "oklch(45.9% 0.187 3.815)",
|
||||
"900": "oklch(40.8% 0.153 2.432)",
|
||||
"950": "oklch(28.4% 0.109 3.907)",
|
||||
},
|
||||
rose: {
|
||||
"50": "oklch(96.9% 0.015 12.422)",
|
||||
"100": "oklch(94.1% 0.03 12.58)",
|
||||
"200": "oklch(89.2% 0.058 10.001)",
|
||||
"300": "oklch(81% 0.117 11.638)",
|
||||
"400": "oklch(71.2% 0.194 13.428)",
|
||||
"500": "oklch(64.5% 0.246 16.439)",
|
||||
"600": "oklch(58.6% 0.253 17.585)",
|
||||
"700": "oklch(51.4% 0.222 16.935)",
|
||||
"800": "oklch(45.5% 0.188 13.697)",
|
||||
"900": "oklch(41% 0.159 10.272)",
|
||||
"950": "oklch(27.1% 0.105 12.094)",
|
||||
},
|
||||
slate: {
|
||||
"50": "oklch(98.4% 0.003 247.858)",
|
||||
"100": "oklch(96.8% 0.007 247.896)",
|
||||
"200": "oklch(92.9% 0.013 255.508)",
|
||||
"300": "oklch(86.9% 0.022 252.894)",
|
||||
"400": "oklch(70.4% 0.04 256.788)",
|
||||
"500": "oklch(55.4% 0.046 257.417)",
|
||||
"600": "oklch(44.6% 0.043 257.281)",
|
||||
"700": "oklch(37.2% 0.044 257.287)",
|
||||
"800": "oklch(27.9% 0.041 260.031)",
|
||||
"900": "oklch(20.8% 0.042 265.755)",
|
||||
"950": "oklch(12.9% 0.042 264.695)",
|
||||
},
|
||||
gray: {
|
||||
"50": "oklch(98.5% 0.002 247.839)",
|
||||
"100": "oklch(96.7% 0.003 264.542)",
|
||||
"200": "oklch(92.8% 0.006 264.531)",
|
||||
"300": "oklch(87.2% 0.01 258.338)",
|
||||
"400": "oklch(70.7% 0.022 261.325)",
|
||||
"500": "oklch(55.1% 0.027 264.364)",
|
||||
"600": "oklch(44.6% 0.03 256.802)",
|
||||
"700": "oklch(37.3% 0.034 259.733)",
|
||||
"800": "oklch(27.8% 0.033 256.848)",
|
||||
"900": "oklch(21% 0.034 264.665)",
|
||||
"950": "oklch(13% 0.028 261.692)",
|
||||
},
|
||||
zinc: {
|
||||
"50": "oklch(98.5% 0 0)",
|
||||
"100": "oklch(96.7% 0.001 286.375)",
|
||||
"200": "oklch(92% 0.004 286.32)",
|
||||
"300": "oklch(87.1% 0.006 286.286)",
|
||||
"400": "oklch(70.5% 0.015 286.067)",
|
||||
"500": "oklch(55.2% 0.016 285.938)",
|
||||
"600": "oklch(44.2% 0.017 285.786)",
|
||||
"700": "oklch(37% 0.013 285.805)",
|
||||
"800": "oklch(27.4% 0.006 286.033)",
|
||||
"900": "oklch(21% 0.006 285.885)",
|
||||
"950": "oklch(14.1% 0.005 285.823)",
|
||||
},
|
||||
neutral: {
|
||||
"50": "oklch(98.5% 0 0)",
|
||||
"100": "oklch(97% 0 0)",
|
||||
"200": "oklch(92.2% 0 0)",
|
||||
"300": "oklch(87% 0 0)",
|
||||
"400": "oklch(70.8% 0 0)",
|
||||
"500": "oklch(55.6% 0 0)",
|
||||
"600": "oklch(43.9% 0 0)",
|
||||
"700": "oklch(37.1% 0 0)",
|
||||
"800": "oklch(26.9% 0 0)",
|
||||
"900": "oklch(20.5% 0 0)",
|
||||
"950": "oklch(14.5% 0 0)",
|
||||
},
|
||||
stone: {
|
||||
"50": "oklch(98.5% 0.001 106.423)",
|
||||
"100": "oklch(97% 0.001 106.424)",
|
||||
"200": "oklch(92.3% 0.003 48.717)",
|
||||
"300": "oklch(86.9% 0.005 56.366)",
|
||||
"400": "oklch(70.9% 0.01 56.259)",
|
||||
"500": "oklch(55.3% 0.013 58.071)",
|
||||
"600": "oklch(44.4% 0.011 73.639)",
|
||||
"700": "oklch(37.4% 0.01 67.558)",
|
||||
"800": "oklch(26.8% 0.007 34.298)",
|
||||
"900": "oklch(21.6% 0.006 56.043)",
|
||||
"950": "oklch(14.7% 0.004 49.25)",
|
||||
},
|
||||
mauve: {
|
||||
"50": "oklch(98.5% 0 0)",
|
||||
"100": "oklch(96% 0.003 325.6)",
|
||||
"200": "oklch(92.2% 0.005 325.62)",
|
||||
"300": "oklch(86.5% 0.012 325.68)",
|
||||
"400": "oklch(71.1% 0.019 323.02)",
|
||||
"500": "oklch(54.2% 0.034 322.5)",
|
||||
"600": "oklch(43.5% 0.029 321.78)",
|
||||
"700": "oklch(36.4% 0.029 323.89)",
|
||||
"800": "oklch(26.3% 0.024 320.12)",
|
||||
"900": "oklch(21.2% 0.019 322.12)",
|
||||
"950": "oklch(14.5% 0.008 326)",
|
||||
},
|
||||
olive: {
|
||||
"50": "oklch(98.8% 0.003 106.5)",
|
||||
"100": "oklch(96.6% 0.005 106.5)",
|
||||
"200": "oklch(93% 0.007 106.5)",
|
||||
"300": "oklch(88% 0.011 106.6)",
|
||||
"400": "oklch(73.7% 0.021 106.9)",
|
||||
"500": "oklch(58% 0.031 107.3)",
|
||||
"600": "oklch(46.6% 0.025 107.3)",
|
||||
"700": "oklch(39.4% 0.023 107.4)",
|
||||
"800": "oklch(28.6% 0.016 107.4)",
|
||||
"900": "oklch(22.8% 0.013 107.4)",
|
||||
"950": "oklch(15.3% 0.006 107.1)",
|
||||
},
|
||||
mist: {
|
||||
"50": "oklch(98.7% 0.002 197.1)",
|
||||
"100": "oklch(96.3% 0.002 197.1)",
|
||||
"200": "oklch(92.5% 0.005 214.3)",
|
||||
"300": "oklch(87.2% 0.007 219.6)",
|
||||
"400": "oklch(72.3% 0.014 214.4)",
|
||||
"500": "oklch(56% 0.021 213.5)",
|
||||
"600": "oklch(45% 0.017 213.2)",
|
||||
"700": "oklch(37.8% 0.015 216)",
|
||||
"800": "oklch(27.5% 0.011 216.9)",
|
||||
"900": "oklch(21.8% 0.008 223.9)",
|
||||
"950": "oklch(14.8% 0.004 228.8)",
|
||||
},
|
||||
taupe: {
|
||||
"50": "oklch(98.6% 0.002 67.8)",
|
||||
"100": "oklch(96% 0.002 17.2)",
|
||||
"200": "oklch(92.2% 0.005 34.3)",
|
||||
"300": "oklch(86.8% 0.007 39.5)",
|
||||
"400": "oklch(71.4% 0.014 41.2)",
|
||||
"500": "oklch(54.7% 0.021 43.1)",
|
||||
"600": "oklch(43.8% 0.017 39.3)",
|
||||
"700": "oklch(36.7% 0.016 35.7)",
|
||||
"800": "oklch(26.8% 0.011 36.5)",
|
||||
"900": "oklch(21.4% 0.009 43.1)",
|
||||
"950": "oklch(14.7% 0.004 49.3)",
|
||||
},
|
||||
}
|
||||
|
||||
// Old preset codes only carried one theme value, so these legacy chart HSLs
|
||||
// should resolve to a neutral fallback family instead of a separate theme.
|
||||
const LEGACY_COLOR_FAMILY_ALIASES: Record<string, TailwindColorFamily> = {
|
||||
"220.9 39.3% 11%": "gray",
|
||||
"210 20% 98%": "gray",
|
||||
"12 76% 61%": "gray",
|
||||
"220 70% 50%": "gray",
|
||||
}
|
||||
|
||||
const TAILWIND_COLOR_VALUE_TO_FAMILY = new Map<string, TailwindColorFamily>()
|
||||
|
||||
for (const family of TAILWIND_COLOR_FAMILIES) {
|
||||
for (const scale of TAILWIND_COLOR_SCALES) {
|
||||
TAILWIND_COLOR_VALUE_TO_FAMILY.set(
|
||||
normalizeColorValue(TAILWIND_COLORS[family][scale]),
|
||||
family
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function findTailwindColorFamily(value: string | undefined) {
|
||||
const normalized = normalizeColorValue(value)
|
||||
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
TAILWIND_COLOR_VALUE_TO_FAMILY.get(normalized) ??
|
||||
LEGACY_COLOR_FAMILY_ALIASES[normalized] ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeColorValue(value: string | undefined) {
|
||||
if (!value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const normalized = value.trim().replace(/\s+/g, " ").toLowerCase()
|
||||
|
||||
if (!normalized.startsWith("oklch(") || !normalized.endsWith(")")) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const inner = normalized.slice(6, -1).trim()
|
||||
const [color] = inner.split(/\s*\/\s*/)
|
||||
const parts = color.split(/\s+/)
|
||||
|
||||
if (parts.length < 3) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const lightness = normalizeColorNumber(parts[0], { percentage: true })
|
||||
const chroma = normalizeColorNumber(parts[1])
|
||||
const hue = normalizeColorNumber(parts[2])
|
||||
|
||||
return `oklch(${lightness} ${chroma} ${hue})`
|
||||
}
|
||||
|
||||
function normalizeColorNumber(
|
||||
value: string,
|
||||
options: {
|
||||
percentage?: boolean
|
||||
} = {}
|
||||
) {
|
||||
if (options.percentage && value.endsWith("%")) {
|
||||
return formatColorNumber(Number.parseFloat(value) / 100)
|
||||
}
|
||||
|
||||
return formatColorNumber(Number.parseFloat(value))
|
||||
}
|
||||
|
||||
function formatColorNumber(value: number) {
|
||||
if (Number.isNaN(value)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return Number(value.toFixed(12)).toString()
|
||||
}
|
||||
739
packages/shadcn/src/commands/add.test.ts
Normal file
739
packages/shadcn/src/commands/add.test.ts
Normal file
@@ -0,0 +1,739 @@
|
||||
import path from "path"
|
||||
import { runInit } from "@/src/commands/init"
|
||||
import { REGISTRY_URL, SHADCN_URL } from "@/src/registry/constants"
|
||||
import { getFixturesDir, withTempDir } from "@/src/test-helpers"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { createProject } from "@/src/utils/create-project"
|
||||
import { dryRunComponents } from "@/src/utils/dry-run"
|
||||
import { updateAppIndex } from "@/src/utils/update-app-index"
|
||||
import fs from "fs-extra"
|
||||
import { http, HttpResponse } from "msw"
|
||||
import { setupServer } from "msw/node"
|
||||
import prompts from "prompts"
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest"
|
||||
|
||||
import { add } from "./add"
|
||||
|
||||
// --- Mocked seams (the collaborators add.ts delegates to). ---
|
||||
|
||||
vi.mock("@/src/utils/add-components", () => ({
|
||||
addComponents: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/commands/init", () => ({
|
||||
runInit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/create-project", () => ({
|
||||
createProject: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/update-app-index", () => ({
|
||||
updateAppIndex: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/dry-run", () => ({
|
||||
dryRunComponents: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("prompts", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/spinner", () => ({
|
||||
spinner: vi.fn(() => ({
|
||||
start: vi.fn().mockReturnThis(),
|
||||
stop: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
info: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/logger", () => ({
|
||||
logger: {
|
||||
break: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/handle-error", () => ({
|
||||
handleError: vi.fn((error) => {
|
||||
throw error
|
||||
}),
|
||||
}))
|
||||
|
||||
// --- MSW registry server. ---
|
||||
//
|
||||
// Serves a minimal registry:ui item for most component names, plus
|
||||
// dedicated handlers for the registry:style, registry:theme and
|
||||
// universal (registry:item) branches, the /init endpoint hit by
|
||||
// resolveRegistryBaseConfig, and a direct-URL v0 "chat" item for the
|
||||
// updateAppIndex branch.
|
||||
|
||||
function uiItem(name: string) {
|
||||
return {
|
||||
name,
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: `registry/new-york/ui/${name}.tsx`,
|
||||
type: "registry:ui",
|
||||
content: `export function ${name}() {}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const CHAT_ITEM_URL = "https://v0.dev/chat/b/xyz"
|
||||
|
||||
// Registered under both "new-york" (used by fixtures with a non-empty
|
||||
// tailwind.config, e.g. config-full) and "new-york-v4" (the fallback style
|
||||
// resolved by configWithDefaults whenever tailwind.config is "", which is
|
||||
// both createConfig's default and the vite-with-tailwind fixture's value).
|
||||
const STYLES = ["new-york", "new-york-v4", "base-nova"]
|
||||
|
||||
function registerItem(name: string, body: Record<string, unknown>) {
|
||||
return STYLES.map((style) =>
|
||||
http.get(`${REGISTRY_URL}/styles/${style}/${name}.json`, () =>
|
||||
HttpResponse.json(body)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const server = setupServer(
|
||||
http.get(`${REGISTRY_URL}/index.json`, () =>
|
||||
HttpResponse.json([
|
||||
uiItem("button"),
|
||||
uiItem("sonner"),
|
||||
uiItem("toast"),
|
||||
uiItem("toaster"),
|
||||
])
|
||||
),
|
||||
...registerItem("button", uiItem("button")),
|
||||
...registerItem("card", uiItem("card")),
|
||||
...registerItem("sonner", uiItem("sonner")),
|
||||
...registerItem("toast", uiItem("toast")),
|
||||
...registerItem("toaster", uiItem("toaster")),
|
||||
...registerItem("style-item", {
|
||||
name: "style-item",
|
||||
type: "registry:style",
|
||||
}),
|
||||
...registerItem("theme-item", {
|
||||
name: "theme-item",
|
||||
type: "registry:theme",
|
||||
}),
|
||||
...registerItem("universal-item", {
|
||||
name: "universal-item",
|
||||
type: "registry:item",
|
||||
}),
|
||||
http.get(`${SHADCN_URL}/init`, () =>
|
||||
HttpResponse.json({
|
||||
name: "base",
|
||||
type: "registry:base",
|
||||
extends: "none",
|
||||
})
|
||||
),
|
||||
// resolveRegistryUrl appends "/json" for /chat/b/ URLs (the v0 registry
|
||||
// convention), so the handler must match the rewritten address.
|
||||
http.get(`${CHAT_ITEM_URL}/json`, () =>
|
||||
HttpResponse.json({
|
||||
name: "chat-item",
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
// --- Test helpers. ---
|
||||
|
||||
async function withFixtureCopy<T>(
|
||||
fixtureName: string,
|
||||
fn: (dir: string) => Promise<T>
|
||||
) {
|
||||
return withTempDir(async (dir) => {
|
||||
await fs.copy(getFixturesDir(fixtureName), dir)
|
||||
return fn(dir)
|
||||
})
|
||||
}
|
||||
|
||||
async function runAdd(args: string[], cwd: string) {
|
||||
return add.parseAsync(["node", "shadcn", ...args, "--cwd", cwd], {
|
||||
from: "node",
|
||||
})
|
||||
}
|
||||
|
||||
async function setConfigStyle(cwd: string, style: string) {
|
||||
const configPath = path.join(cwd, "components.json")
|
||||
const config = await fs.readJson(configPath)
|
||||
await fs.writeJson(configPath, {
|
||||
...config,
|
||||
style,
|
||||
})
|
||||
}
|
||||
|
||||
function mockProcessExit() {
|
||||
return vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit:${code}`)
|
||||
}) as any
|
||||
}
|
||||
|
||||
function mockPrompts(answers: Record<string, unknown>) {
|
||||
vi.mocked(prompts).mockImplementation(async (opts: any) => {
|
||||
const name = Array.isArray(opts) ? opts[0]?.name : opts?.name
|
||||
if (name && name in answers) {
|
||||
return { [name]: answers[name] }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
function baseConfig(cwd: string) {
|
||||
return {
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: "new-york",
|
||||
rsc: false,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "",
|
||||
baseColor: "neutral",
|
||||
cssVariables: true,
|
||||
prefix: "",
|
||||
},
|
||||
iconLibrary: "lucide",
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
ui: "@/components/ui",
|
||||
hooks: "@/hooks",
|
||||
lib: "@/lib",
|
||||
utils: "@/lib/utils",
|
||||
},
|
||||
registries: {},
|
||||
resolvedPaths: {
|
||||
cwd,
|
||||
tailwindConfig: "",
|
||||
tailwindCss: "",
|
||||
utils: "",
|
||||
components: "",
|
||||
lib: "",
|
||||
hooks: "",
|
||||
ui: "",
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
describe("add command", () => {
|
||||
let exit: ReturnType<typeof mockProcessExit>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
exit = mockProcessExit()
|
||||
mockPrompts({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
describe("happy path", () => {
|
||||
it("installs components via addComponents when a valid project exists", async () => {
|
||||
await withFixtureCopy("config-full", async (cwd) => {
|
||||
await runAdd(["button"], cwd)
|
||||
|
||||
expect(addComponents).toHaveBeenCalledTimes(1)
|
||||
expect(addComponents).toHaveBeenCalledWith(
|
||||
["button"],
|
||||
expect.any(Object),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(runInit).not.toHaveBeenCalled()
|
||||
expect(createProject).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("dry run", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(dryRunComponents).mockResolvedValue({
|
||||
files: [],
|
||||
dependencies: [],
|
||||
devDependencies: [],
|
||||
css: null,
|
||||
envVars: null,
|
||||
fonts: [],
|
||||
docs: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("previews with --dry-run and never calls addComponents", async () => {
|
||||
await withFixtureCopy("config-full", async (cwd) => {
|
||||
await runAdd(["button", "--dry-run"], cwd)
|
||||
|
||||
expect(dryRunComponents).toHaveBeenCalledTimes(1)
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("implies dry-run with --diff", async () => {
|
||||
await withFixtureCopy("config-full", async (cwd) => {
|
||||
await runAdd(["button", "--diff"], cwd)
|
||||
|
||||
expect(dryRunComponents).toHaveBeenCalledTimes(1)
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("implies dry-run with --view", async () => {
|
||||
await withFixtureCopy("config-full", async (cwd) => {
|
||||
await runAdd(["button", "--view"], cwd)
|
||||
|
||||
expect(dryRunComponents).toHaveBeenCalledTimes(1)
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("missing components.json (init flow)", () => {
|
||||
it("runs init with the documented flags when the user proceeds", async () => {
|
||||
await withTempDir(async (cwd) => {
|
||||
await fs.writeJson(cwd + "/package.json", {
|
||||
name: "no-config-project",
|
||||
version: "1.0.0",
|
||||
})
|
||||
|
||||
mockPrompts({
|
||||
proceed: true,
|
||||
base: "radix",
|
||||
selectedPreset: "nova",
|
||||
})
|
||||
vi.mocked(runInit).mockResolvedValue(baseConfig(cwd))
|
||||
|
||||
await runAdd(["button"], cwd)
|
||||
|
||||
expect(runInit).toHaveBeenCalledTimes(1)
|
||||
const callArgs = vi.mocked(runInit).mock.calls[0][0]
|
||||
expect(callArgs.cwd).toBe(cwd)
|
||||
expect(callArgs.yes).toBe(true)
|
||||
expect(callArgs.force).toBe(true)
|
||||
expect(callArgs.isNewProject).toBe(false)
|
||||
expect(callArgs.skipPreflight).toBe(false)
|
||||
// initHasRun guard: addComponents must NOT run again after init.
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("exits 1 without running init when the user declines", async () => {
|
||||
await withTempDir(async (cwd) => {
|
||||
await fs.writeJson(cwd + "/package.json", {
|
||||
name: "no-config-project",
|
||||
version: "1.0.0",
|
||||
})
|
||||
|
||||
mockPrompts({ proceed: false })
|
||||
|
||||
await expect(runAdd(["button"], cwd)).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(runInit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("allows toast after selecting Base UI", async () => {
|
||||
await withTempDir(async (cwd) => {
|
||||
await fs.writeJson(path.join(cwd, "package.json"), {
|
||||
name: "no-config-project",
|
||||
version: "1.0.0",
|
||||
})
|
||||
|
||||
mockPrompts({
|
||||
proceed: true,
|
||||
base: "base",
|
||||
selectedPreset: "nova",
|
||||
})
|
||||
vi.mocked(runInit).mockResolvedValue({
|
||||
...baseConfig(cwd),
|
||||
style: "base-nova",
|
||||
})
|
||||
|
||||
await runAdd(["toast"], cwd)
|
||||
|
||||
expect(runInit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
components: expect.arrayContaining(["toast"]),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("warns about toast after selecting a non-Base UI library", async () => {
|
||||
await withTempDir(async (cwd) => {
|
||||
await fs.writeJson(path.join(cwd, "package.json"), {
|
||||
name: "no-config-project",
|
||||
version: "1.0.0",
|
||||
})
|
||||
const { logger } = await import("@/src/utils/logger")
|
||||
|
||||
mockPrompts({
|
||||
proceed: true,
|
||||
base: "radix",
|
||||
})
|
||||
|
||||
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("only available for Base UI projects")
|
||||
)
|
||||
expect(runInit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty/missing project (create-project flow)", () => {
|
||||
it("does not scaffold before warning about toast for a non-Base UI library", async () => {
|
||||
await withTempDir(async (emptyDir) => {
|
||||
const { logger } = await import("@/src/utils/logger")
|
||||
|
||||
mockPrompts({
|
||||
base: "radix",
|
||||
})
|
||||
|
||||
await expect(runAdd(["toast"], emptyDir)).rejects.toThrow(
|
||||
"process.exit:1"
|
||||
)
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("only available for Base UI projects")
|
||||
)
|
||||
expect(createProject).not.toHaveBeenCalled()
|
||||
expect(runInit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("runs init with isNewProject/skipPreflight after scaffolding a project", async () => {
|
||||
await withTempDir(async (emptyDir) => {
|
||||
await withTempDir(async (projectPath) => {
|
||||
mockPrompts({
|
||||
base: "radix",
|
||||
selectedPreset: "nova",
|
||||
})
|
||||
vi.mocked(createProject).mockResolvedValue({
|
||||
projectPath,
|
||||
projectName: "app",
|
||||
template: "next",
|
||||
} as any)
|
||||
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
|
||||
|
||||
await runAdd(["button"], emptyDir)
|
||||
|
||||
expect(createProject).toHaveBeenCalledTimes(1)
|
||||
expect(runInit).toHaveBeenCalledTimes(1)
|
||||
const callArgs = vi.mocked(runInit).mock.calls[0][0]
|
||||
expect(callArgs.cwd).toBe(projectPath)
|
||||
expect(callArgs.yes).toBe(true)
|
||||
expect(callArgs.force).toBe(true)
|
||||
expect(callArgs.isNewProject).toBe(true)
|
||||
expect(callArgs.skipPreflight).toBe(true)
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("exits 1 when createProject cannot resolve a project path", async () => {
|
||||
await withTempDir(async (emptyDir) => {
|
||||
vi.mocked(createProject).mockResolvedValue({
|
||||
projectPath: null,
|
||||
projectName: null,
|
||||
template: "next",
|
||||
} as any)
|
||||
|
||||
await expect(runAdd(["button"], emptyDir)).rejects.toThrow(
|
||||
"process.exit:1"
|
||||
)
|
||||
|
||||
expect(runInit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("updates the app index for a single v0 chat component", async () => {
|
||||
await withTempDir(async (emptyDir) => {
|
||||
await withTempDir(async (projectPath) => {
|
||||
mockPrompts({
|
||||
base: "radix",
|
||||
selectedPreset: "nova",
|
||||
})
|
||||
vi.mocked(createProject).mockResolvedValue({
|
||||
projectPath,
|
||||
projectName: "app",
|
||||
template: "next",
|
||||
} as any)
|
||||
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
|
||||
|
||||
await runAdd([CHAT_ITEM_URL], emptyDir)
|
||||
|
||||
expect(updateAppIndex).toHaveBeenCalledTimes(1)
|
||||
expect(updateAppIndex).toHaveBeenCalledWith(
|
||||
CHAT_ITEM_URL,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("does not update the app index for multiple components", async () => {
|
||||
await withTempDir(async (emptyDir) => {
|
||||
await withTempDir(async (projectPath) => {
|
||||
mockPrompts({
|
||||
base: "radix",
|
||||
selectedPreset: "nova",
|
||||
})
|
||||
vi.mocked(createProject).mockResolvedValue({
|
||||
projectPath,
|
||||
projectName: "app",
|
||||
template: "next",
|
||||
} as any)
|
||||
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
|
||||
|
||||
await runAdd(["button", "card"], emptyDir)
|
||||
|
||||
expect(updateAppIndex).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("does not update the app index for a non-matching single component", async () => {
|
||||
await withTempDir(async (emptyDir) => {
|
||||
await withTempDir(async (projectPath) => {
|
||||
mockPrompts({
|
||||
base: "radix",
|
||||
selectedPreset: "nova",
|
||||
})
|
||||
vi.mocked(createProject).mockResolvedValue({
|
||||
projectPath,
|
||||
projectName: "app",
|
||||
template: "next",
|
||||
} as any)
|
||||
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
|
||||
|
||||
await runAdd(["button"], emptyDir)
|
||||
|
||||
expect(updateAppIndex).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("registry:style / registry:theme overwrite confirm", () => {
|
||||
it("exits 1 when the user declines the overwrite confirm", async () => {
|
||||
await withTempDir(async (cwd) => {
|
||||
mockPrompts({ confirm: false })
|
||||
|
||||
await expect(runAdd(["style-item"], cwd)).rejects.toThrow(
|
||||
"process.exit:1"
|
||||
)
|
||||
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("proceeds when the user confirms the overwrite", async () => {
|
||||
await withFixtureCopy("config-full", async (cwd) => {
|
||||
mockPrompts({ confirm: true })
|
||||
|
||||
await runAdd(["style-item"], cwd)
|
||||
|
||||
expect(addComponents).toHaveBeenCalledTimes(1)
|
||||
expect(addComponents).toHaveBeenCalledWith(
|
||||
["style-item"],
|
||||
expect.any(Object),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("skips the confirm prompt entirely with --yes", async () => {
|
||||
await withFixtureCopy("config-full", async (cwd) => {
|
||||
await runAdd(["style-item", "--yes"], cwd)
|
||||
|
||||
expect(prompts).not.toHaveBeenCalled()
|
||||
expect(addComponents).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it("also prompts for registry:theme items", async () => {
|
||||
await withTempDir(async (cwd) => {
|
||||
mockPrompts({ confirm: false })
|
||||
|
||||
await expect(runAdd(["theme-item"], cwd)).rejects.toThrow(
|
||||
"process.exit:1"
|
||||
)
|
||||
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("universal registry item short-circuit", () => {
|
||||
it("calls addComponents directly without running preflight/init", async () => {
|
||||
await withTempDir(async (cwd) => {
|
||||
await runAdd(["universal-item"], cwd)
|
||||
|
||||
expect(addComponents).toHaveBeenCalledTimes(1)
|
||||
expect(addComponents).toHaveBeenCalledWith(
|
||||
["universal-item"],
|
||||
expect.any(Object),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(runInit).not.toHaveBeenCalled()
|
||||
expect(prompts).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("deprecated components guard (Tailwind v4)", () => {
|
||||
it("exits 1 and warns when adding toaster", async () => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
const { logger } = await import("@/src/utils/logger")
|
||||
|
||||
await expect(runAdd(["toaster"], cwd)).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("toaster component is deprecated")
|
||||
)
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it("exits 1 and warns when adding toast to a legacy style", async () => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
const { logger } = await import("@/src/utils/logger")
|
||||
|
||||
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("only available for Base UI projects")
|
||||
)
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it.each(["radix-nova", "aria-nova"])(
|
||||
"exits 1 and warns when adding toast to %s",
|
||||
async (style) => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
const { logger } = await import("@/src/utils/logger")
|
||||
await setConfigStyle(cwd, style)
|
||||
|
||||
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("only available for Base UI projects")
|
||||
)
|
||||
expect(addComponents).not.toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it("allows adding toast to a Base UI style", async () => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
await setConfigStyle(cwd, "base-nova")
|
||||
|
||||
await runAdd(["toast"], cwd)
|
||||
|
||||
expect(addComponents).toHaveBeenCalledWith(
|
||||
["toast"],
|
||||
expect.objectContaining({ style: "base-nova" }),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("allows explicitly adding sonner to a Base UI style", async () => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
await setConfigStyle(cwd, "base-nova")
|
||||
|
||||
await runAdd(["sonner"], cwd)
|
||||
|
||||
expect(addComponents).toHaveBeenCalledWith(
|
||||
["sonner"],
|
||||
expect.objectContaining({ style: "base-nova" }),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("filters toast from --all for legacy styles", async () => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
await runAdd(["--all"], cwd)
|
||||
|
||||
expect(addComponents).toHaveBeenCalledWith(
|
||||
["button", "sonner"],
|
||||
expect.any(Object),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("includes toast in --all for Base UI styles", async () => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
await setConfigStyle(cwd, "base-nova")
|
||||
|
||||
await runAdd(["--all"], cwd)
|
||||
|
||||
expect(addComponents).toHaveBeenCalledWith(
|
||||
["button", "toast"],
|
||||
expect.objectContaining({ style: "base-nova" }),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ style: "new-york-v4", includesToast: false },
|
||||
{ style: "base-nova", includesToast: true },
|
||||
])(
|
||||
"filters the interactive picker for $style",
|
||||
async ({ style, includesToast }) => {
|
||||
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
|
||||
await setConfigStyle(cwd, style)
|
||||
mockPrompts({ components: ["button"] })
|
||||
|
||||
await runAdd([], cwd)
|
||||
|
||||
const componentPrompt = vi
|
||||
.mocked(prompts)
|
||||
.mock.calls.map(([options]) => options)
|
||||
.find(
|
||||
(options) =>
|
||||
!Array.isArray(options) && options.name === "components"
|
||||
) as
|
||||
| { choices?: Array<{ value: string }>; name?: string }
|
||||
| undefined
|
||||
const choices = (componentPrompt?.choices ?? []).map(
|
||||
(choice) => choice.value
|
||||
)
|
||||
|
||||
expect(componentPrompt).toBeDefined()
|
||||
expect(choices.includes("toast")).toBe(includesToast)
|
||||
expect(choices.includes("sonner")).toBe(!includesToast)
|
||||
expect(choices).not.toContain("toaster")
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
430
packages/shadcn/src/commands/add.ts
Normal file
430
packages/shadcn/src/commands/add.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import path from "path"
|
||||
import { runInit } from "@/src/commands/init"
|
||||
import { preFlightAdd } from "@/src/preflights/preflight-add"
|
||||
import { parsePresetStyle, type PresetBase } from "@/src/preset/preset"
|
||||
import {
|
||||
promptForBase,
|
||||
promptForPreset,
|
||||
resolveRegistryBaseConfig,
|
||||
} from "@/src/preset/presets"
|
||||
import { getRegistryItems, getShadcnRegistryIndex } from "@/src/registry/api"
|
||||
import {
|
||||
COMPONENTS_HIDDEN_FROM_SELECTION,
|
||||
DEPRECATED_COMPONENTS,
|
||||
} from "@/src/registry/constants"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { registryItemTypeSchema } from "@/src/registry/schema"
|
||||
import { isUniversalRegistryItem } from "@/src/registry/utils"
|
||||
import { getTemplateForFramework } from "@/src/templates/index"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { createProject } from "@/src/utils/create-project"
|
||||
import { dryRunComponents } from "@/src/utils/dry-run"
|
||||
import { formatDryRunResult } from "@/src/utils/dry-run-formatter"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import { createConfig, getConfig } from "@/src/utils/get-config"
|
||||
import { getProjectInfo } from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { updateAppIndex } from "@/src/utils/update-app-index"
|
||||
import { Command } from "commander"
|
||||
import prompts from "prompts"
|
||||
import { z } from "zod"
|
||||
|
||||
export const addOptionsSchema = z.object({
|
||||
components: z.array(z.string()).optional(),
|
||||
yes: z.boolean(),
|
||||
overwrite: z.boolean(),
|
||||
cwd: z.string(),
|
||||
all: z.boolean(),
|
||||
path: z.string().optional(),
|
||||
silent: z.boolean(),
|
||||
dryRun: z.boolean(),
|
||||
diff: z.union([z.string(), z.literal(true)]).optional(),
|
||||
view: z.union([z.string(), z.literal(true)]).optional(),
|
||||
})
|
||||
|
||||
export const add = new Command()
|
||||
.name("add")
|
||||
.description("add a component to your project")
|
||||
.argument("[components...]", "item addresses to add")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option("-o, --overwrite", "overwrite existing files.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("-a, --all", "add all available components", false)
|
||||
.option("-p, --path <path>", "the path to add the component to.")
|
||||
.option("-s, --silent", "mute output.", false)
|
||||
.option("--dry-run", "preview changes without writing files.", false)
|
||||
.option("--diff [path]", "show diff for a file.")
|
||||
.option("--view [path]", "show file contents.")
|
||||
.action(async (components, opts) => {
|
||||
try {
|
||||
const options = addOptionsSchema.parse({
|
||||
components,
|
||||
...opts,
|
||||
cwd: path.resolve(opts.cwd),
|
||||
})
|
||||
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
const isDryRun = options.dryRun || options.diff || options.view
|
||||
|
||||
let initialConfig = await getConfig(options.cwd)
|
||||
const hasExistingConfig = !!initialConfig
|
||||
if (!initialConfig) {
|
||||
initialConfig = createConfig({
|
||||
style: "new-york",
|
||||
resolvedPaths: {
|
||||
cwd: options.cwd,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let hasNewRegistries = false
|
||||
if (components.length > 0) {
|
||||
const { config: updatedConfig, newRegistries } =
|
||||
await ensureRegistriesInConfig(components, initialConfig, {
|
||||
silent: options.silent,
|
||||
writeFile: false,
|
||||
})
|
||||
initialConfig = updatedConfig
|
||||
hasNewRegistries = newRegistries.length > 0
|
||||
}
|
||||
|
||||
const projectInfo = await getProjectInfo(options.cwd)
|
||||
const { base: configuredBase } = parsePresetStyle(initialConfig.style)
|
||||
|
||||
if (hasExistingConfig && projectInfo?.tailwindVersion === "v4") {
|
||||
warnForDeprecatedComponents(components, configuredBase)
|
||||
}
|
||||
|
||||
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
|
||||
let shouldInstallStyleIndex = true
|
||||
const shouldResolveInitialItem =
|
||||
components.length > 0 &&
|
||||
(hasExistingConfig ||
|
||||
!DEPRECATED_COMPONENTS.some(
|
||||
(component) => component.name === components[0]
|
||||
))
|
||||
|
||||
if (shouldResolveInitialItem) {
|
||||
const [registryItem] = await getRegistryItems([components[0]], {
|
||||
config: initialConfig,
|
||||
})
|
||||
itemType = registryItem?.type
|
||||
shouldInstallStyleIndex =
|
||||
itemType !== "registry:theme" &&
|
||||
itemType !== "registry:style" &&
|
||||
itemType !== "registry:base"
|
||||
|
||||
if (isUniversalRegistryItem(registryItem) && !isDryRun) {
|
||||
await addComponents(components, initialConfig, options)
|
||||
return
|
||||
}
|
||||
if (
|
||||
!options.yes &&
|
||||
!isDryRun &&
|
||||
(itemType === "registry:style" || itemType === "registry:theme")
|
||||
) {
|
||||
logger.break()
|
||||
const { confirm } = await prompts({
|
||||
type: "confirm",
|
||||
name: "confirm",
|
||||
message: highlighter.warn(
|
||||
`You are about to install a new ${itemType.replace(
|
||||
"registry:",
|
||||
""
|
||||
)}. \nExisting CSS variables and components will be overwritten. Continue?`
|
||||
),
|
||||
})
|
||||
if (!confirm) {
|
||||
logger.break()
|
||||
logger.log(`Installation cancelled.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.components?.length) {
|
||||
options.components = await promptForRegistryComponents(
|
||||
options,
|
||||
initialConfig.style
|
||||
)
|
||||
}
|
||||
|
||||
if (!components.length && projectInfo?.tailwindVersion === "v4") {
|
||||
warnForDeprecatedComponents(options.components ?? [], configuredBase)
|
||||
}
|
||||
|
||||
let { errors, config } = await preFlightAdd(options)
|
||||
|
||||
// No components.json file. Prompt the user to run init.
|
||||
let initHasRun = false
|
||||
if (errors[ERRORS.MISSING_CONFIG]) {
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: `You need to create a ${highlighter.info(
|
||||
"components.json"
|
||||
)} file to add components. Proceed?`,
|
||||
initial: true,
|
||||
})
|
||||
|
||||
if (!proceed) {
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Infer template from project framework.
|
||||
const inferredTemplate = getTemplateForFramework(
|
||||
projectInfo?.framework.name
|
||||
)
|
||||
|
||||
// Prompt for base and preset.
|
||||
const base = await promptForBase()
|
||||
warnForDeprecatedComponents(options.components ?? [], base)
|
||||
const { url: initUrl } = await promptForPreset({
|
||||
rtl: false,
|
||||
base,
|
||||
template: inferredTemplate,
|
||||
})
|
||||
|
||||
// Resolve registry:base config.
|
||||
const {
|
||||
registryBaseConfig,
|
||||
installStyleIndex,
|
||||
url: cleanInitUrl,
|
||||
} = await resolveRegistryBaseConfig(initUrl, options.cwd)
|
||||
|
||||
config = await runInit({
|
||||
cwd: options.cwd,
|
||||
yes: true,
|
||||
force: true,
|
||||
defaults: false,
|
||||
skipPreflight: false,
|
||||
silent: options.silent && !hasNewRegistries,
|
||||
isNewProject: false,
|
||||
cssVariables: true,
|
||||
rtl: false,
|
||||
installStyleIndex,
|
||||
components: [cleanInitUrl, ...(options.components ?? [])],
|
||||
registryBaseConfig,
|
||||
})
|
||||
initHasRun = true
|
||||
}
|
||||
|
||||
let shouldUpdateAppIndex = false
|
||||
|
||||
if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
|
||||
const selectedBase = await promptForBase()
|
||||
warnForDeprecatedComponents(options.components ?? [], selectedBase)
|
||||
|
||||
const { projectPath, template } = await createProject({
|
||||
cwd: options.cwd,
|
||||
force: options.overwrite,
|
||||
components: options.components,
|
||||
})
|
||||
if (!projectPath) {
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
options.cwd = projectPath
|
||||
|
||||
const { url: initUrl } = await promptForPreset({
|
||||
rtl: false,
|
||||
base: selectedBase,
|
||||
template,
|
||||
})
|
||||
const {
|
||||
registryBaseConfig,
|
||||
installStyleIndex,
|
||||
url: cleanInitUrl,
|
||||
} = await resolveRegistryBaseConfig(initUrl, options.cwd)
|
||||
|
||||
config = await runInit({
|
||||
cwd: options.cwd,
|
||||
yes: true,
|
||||
force: true,
|
||||
defaults: false,
|
||||
skipPreflight: true,
|
||||
silent: !hasNewRegistries && options.silent,
|
||||
isNewProject: true,
|
||||
cssVariables: true,
|
||||
rtl: false,
|
||||
installStyleIndex,
|
||||
components: [cleanInitUrl, ...(options.components ?? [])],
|
||||
registryBaseConfig,
|
||||
})
|
||||
initHasRun = true
|
||||
|
||||
shouldUpdateAppIndex =
|
||||
options.components?.length === 1 &&
|
||||
!!options.components[0].match(/\/chat\/b\//)
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
`Failed to read config at ${highlighter.info(options.cwd)}.`
|
||||
)
|
||||
}
|
||||
|
||||
const { config: updatedConfig } = await ensureRegistriesInConfig(
|
||||
options.components,
|
||||
config,
|
||||
{
|
||||
silent: options.silent || hasNewRegistries,
|
||||
writeFile: !isDryRun,
|
||||
}
|
||||
)
|
||||
config = updatedConfig
|
||||
|
||||
// Dry-run mode: preview changes without writing files.
|
||||
// --diff and --view imply --dry-run.
|
||||
if (isDryRun) {
|
||||
const dryRunSpinner = spinner("Resolving items.", {
|
||||
silent: options.silent,
|
||||
}).start()
|
||||
const dryRunResult = await dryRunComponents(
|
||||
options.components,
|
||||
config,
|
||||
{
|
||||
overwrite: options.overwrite,
|
||||
}
|
||||
)
|
||||
dryRunSpinner.stop()
|
||||
|
||||
logger.log(
|
||||
formatDryRunResult(dryRunResult, options.components, {
|
||||
diff: options.diff,
|
||||
view: options.view,
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!initHasRun) {
|
||||
await addComponents(options.components, config, options)
|
||||
}
|
||||
|
||||
// If we're adding a single component and it's from the v0 registry,
|
||||
// let's update the app/page.tsx file to import the component.
|
||||
if (shouldUpdateAppIndex) {
|
||||
await updateAppIndex(options.components[0], config)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
handleError(error)
|
||||
} finally {
|
||||
clearRegistryContext()
|
||||
}
|
||||
})
|
||||
|
||||
async function promptForRegistryComponents(
|
||||
options: z.infer<typeof addOptionsSchema>,
|
||||
style: string
|
||||
) {
|
||||
const registryIndex = await getShadcnRegistryIndex()
|
||||
if (!registryIndex) {
|
||||
logger.break()
|
||||
handleError(new Error("Failed to fetch registry index."))
|
||||
return []
|
||||
}
|
||||
|
||||
const { base } = parsePresetStyle(style)
|
||||
|
||||
if (options.all) {
|
||||
return registryIndex
|
||||
.map((entry) => entry.name)
|
||||
.filter((component) => isComponentSelectable(component, base))
|
||||
}
|
||||
|
||||
if (options.components?.length) {
|
||||
return options.components
|
||||
}
|
||||
|
||||
const { components } = await prompts({
|
||||
type: "multiselect",
|
||||
name: "components",
|
||||
message: "Which components would you like to add?",
|
||||
hint: "Space to select. A to toggle all. Enter to submit.",
|
||||
instructions: false,
|
||||
choices: registryIndex
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.type === "registry:ui" &&
|
||||
isComponentSelectable(entry.name, base)
|
||||
)
|
||||
.map((entry) => ({
|
||||
title: entry.name,
|
||||
value: entry.name,
|
||||
selected: options.all ? true : options.components?.includes(entry.name),
|
||||
})),
|
||||
})
|
||||
|
||||
if (!components?.length) {
|
||||
logger.warn("No components selected. Exiting.")
|
||||
logger.info("")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const result = z.array(z.string()).safeParse(components)
|
||||
if (!result.success) {
|
||||
logger.error("")
|
||||
handleError(new Error("Something went wrong. Please try again."))
|
||||
return []
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
function warnForDeprecatedComponents(
|
||||
components: string[],
|
||||
base: PresetBase | undefined
|
||||
) {
|
||||
const deprecatedComponents = DEPRECATED_COMPONENTS.filter(
|
||||
(component) =>
|
||||
components.includes(component.name) &&
|
||||
isDeprecatedComponent(component.name, base)
|
||||
)
|
||||
|
||||
if (!deprecatedComponents.length) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.break()
|
||||
deprecatedComponents.forEach((component) => {
|
||||
logger.warn(highlighter.warn(component.message))
|
||||
})
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function isDeprecatedComponent(name: string, base: PresetBase | undefined) {
|
||||
const component = DEPRECATED_COMPONENTS.find(
|
||||
(component) => component.name === name
|
||||
)
|
||||
|
||||
return (
|
||||
!!component &&
|
||||
(!component.availableIn || !base || !component.availableIn.includes(base))
|
||||
)
|
||||
}
|
||||
|
||||
function isComponentSelectable(name: string, base: PresetBase | undefined) {
|
||||
if (isDeprecatedComponent(name, base)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const component = COMPONENTS_HIDDEN_FROM_SELECTION.find(
|
||||
(component) => component.name === name
|
||||
)
|
||||
|
||||
return !component || !base || !component.hiddenIn.includes(base)
|
||||
}
|
||||
186
packages/shadcn/src/commands/apply.test.ts
Normal file
186
packages/shadcn/src/commands/apply.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { REGISTRY_URL } from "@/src/registry/constants"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
getPresetUrlOnly,
|
||||
parseApplyOnlyParts,
|
||||
resolveApplyInitUrl,
|
||||
resolveApplyOnly,
|
||||
validateApplyOnlyPreset,
|
||||
} from "./apply"
|
||||
|
||||
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
|
||||
|
||||
describe("resolveApplyInitUrl", () => {
|
||||
it("should keep aria as the current base", () => {
|
||||
const initUrl = resolveApplyInitUrl("b0", "aria")
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("base")).toBe("aria")
|
||||
})
|
||||
|
||||
it("should include the inferred template for preset codes", () => {
|
||||
const initUrl = resolveApplyInitUrl("a0", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
|
||||
expect(parsed.searchParams.get("template")).toBe("next")
|
||||
expect(parsed.searchParams.get("preset")).toBe("a0")
|
||||
expect(parsed.searchParams.get("base")).toBe("base")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
|
||||
it("should include the inferred template for named presets", () => {
|
||||
const initUrl = resolveApplyInitUrl("lyra", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
|
||||
expect(parsed.searchParams.get("template")).toBe("next")
|
||||
expect(parsed.searchParams.get("base")).toBe("base")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
|
||||
it("should keep the current base for raw preset URLs without injecting a template", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
|
||||
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("template")).toBeNull()
|
||||
expect(parsed.searchParams.get("track")).toBe("1")
|
||||
expect(parsed.searchParams.get("base")).toBe("base")
|
||||
expect(parsed.searchParams.get("rtl")).toBe("true")
|
||||
})
|
||||
|
||||
it("should include only for preset codes", () => {
|
||||
const initUrl = resolveApplyInitUrl("a0", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
only: "theme,font",
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("theme,font")
|
||||
})
|
||||
|
||||
it("should include only for named presets", () => {
|
||||
const initUrl = resolveApplyInitUrl("lyra", "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
only: "font",
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("font")
|
||||
})
|
||||
|
||||
it("should include only for raw preset URLs", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
|
||||
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
only: "theme",
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("theme")
|
||||
})
|
||||
|
||||
it("should preserve only from raw preset URLs", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default&only=font`
|
||||
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
|
||||
template: "next",
|
||||
rtl: true,
|
||||
})
|
||||
const parsed = new URL(initUrl)
|
||||
|
||||
expect(parsed.searchParams.get("only")).toBe("font")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseApplyOnlyParts", () => {
|
||||
it("returns undefined when only is omitted", () => {
|
||||
expect(resolveApplyOnly(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects missing only values with allowed values", () => {
|
||||
expect(() => resolveApplyOnly(true)).toThrow(
|
||||
[
|
||||
"Missing value for --only.",
|
||||
"Use one or more of: theme, font.",
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
})
|
||||
|
||||
it("normalizes explicit values and aliases", () => {
|
||||
expect(resolveApplyOnly("font")).toEqual(["font"])
|
||||
expect(parseApplyOnlyParts("theme,font")).toEqual(["theme", "font"])
|
||||
})
|
||||
|
||||
it("dedupes and accepts plural aliases", () => {
|
||||
expect(parseApplyOnlyParts("theme,fonts,font")).toEqual(["theme", "font"])
|
||||
})
|
||||
|
||||
it("rejects invalid values", () => {
|
||||
expect(() => parseApplyOnlyParts("theme,colors")).toThrow(
|
||||
[
|
||||
"Invalid value for --only: theme,colors.",
|
||||
"Use one or more of: theme, font.",
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
expect(() => parseApplyOnlyParts("")).toThrow("Invalid value for --only")
|
||||
expect(() => parseApplyOnlyParts("icon")).toThrow(
|
||||
"Use one or more of: theme, font."
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPresetUrlOnly", () => {
|
||||
it("reads only from shadcn init URLs", () => {
|
||||
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&only=font`
|
||||
|
||||
expect(getPresetUrlOnly(presetUrl)).toBe("font")
|
||||
})
|
||||
|
||||
it("reads only from non-shadcn init URLs", () => {
|
||||
const presetUrl =
|
||||
"http://localhost:4000/init?base=radix&style=nova&only=font"
|
||||
|
||||
expect(getPresetUrlOnly(presetUrl)).toBe("font")
|
||||
expect(resolveApplyOnly(getPresetUrlOnly(presetUrl))).toEqual(["font"])
|
||||
})
|
||||
|
||||
it("ignores only on non-init URLs", () => {
|
||||
const presetUrl =
|
||||
"http://localhost:4000/r/styles/nova/button.json?only=font"
|
||||
|
||||
expect(getPresetUrlOnly(presetUrl)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateApplyOnlyPreset", () => {
|
||||
it("rejects only without a preset", () => {
|
||||
expect(() => validateApplyOnlyPreset({ only: ["theme"] })).toThrow(
|
||||
[
|
||||
"Missing preset for --only.",
|
||||
"Use: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
})
|
||||
|
||||
it("allows only with a preset", () => {
|
||||
expect(() =>
|
||||
validateApplyOnlyPreset({ preset: "a0", only: ["theme"] })
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
601
packages/shadcn/src/commands/apply.ts
Normal file
601
packages/shadcn/src/commands/apply.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
import path from "path"
|
||||
import { runInit } from "@/src/commands/init"
|
||||
import { preFlightApply } from "@/src/preflights/preflight-apply"
|
||||
import {
|
||||
decodePreset,
|
||||
isPresetCode,
|
||||
type PresetBase,
|
||||
} from "@/src/preset/preset"
|
||||
import {
|
||||
DEFAULT_PRESETS,
|
||||
promptToOpenPresetBuilder,
|
||||
resolveCreateUrl,
|
||||
resolveInitUrl,
|
||||
resolveRegistryBaseConfig,
|
||||
} from "@/src/preset/presets"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { registryConfigSchema } from "@/src/registry/schema"
|
||||
import { isUrl } from "@/src/registry/utils"
|
||||
import { getTemplateForFramework } from "@/src/templates/index"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import * as ERRORS from "@/src/utils/errors"
|
||||
import {
|
||||
createFileBackup,
|
||||
deleteFileBackup,
|
||||
FileBackupError,
|
||||
restoreFileBackup,
|
||||
withFileBackup,
|
||||
} from "@/src/utils/file-helper"
|
||||
import {
|
||||
getBase,
|
||||
getWorkspaceConfig,
|
||||
type Config,
|
||||
} from "@/src/utils/get-config"
|
||||
import {
|
||||
getProjectComponents,
|
||||
getProjectInfo,
|
||||
} from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { Command } from "commander"
|
||||
import fs from "fs-extra"
|
||||
import prompts from "prompts"
|
||||
import { z } from "zod"
|
||||
|
||||
export const applyOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
positionalPreset: z.string().optional(),
|
||||
preset: z.string().optional(),
|
||||
only: z.union([z.boolean(), z.string()]).optional(),
|
||||
yes: z.boolean(),
|
||||
silent: z.boolean(),
|
||||
})
|
||||
|
||||
const APPLY_ONLY_VALUES = ["theme", "font"] as const
|
||||
type ApplyOnlyValue = (typeof APPLY_ONLY_VALUES)[number]
|
||||
|
||||
class ApplyOnlyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "ApplyOnlyError"
|
||||
}
|
||||
}
|
||||
|
||||
class ApplyWorkspaceSyncError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "ApplyWorkspaceSyncError"
|
||||
}
|
||||
}
|
||||
|
||||
export const apply = new Command()
|
||||
.name("apply")
|
||||
.description("apply a preset to an existing project")
|
||||
.argument("[preset]", "the preset to apply")
|
||||
.option("--preset <preset>", "preset configuration to apply")
|
||||
.option("--only [parts]", "apply only parts of a preset: theme, font")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("-s, --silent", "mute output.", false)
|
||||
.action(async (positionalPreset, opts) => {
|
||||
try {
|
||||
const options = applyOptionsSchema.parse({
|
||||
...opts,
|
||||
cwd: path.resolve(opts.cwd),
|
||||
positionalPreset,
|
||||
})
|
||||
|
||||
const preset = resolveApplyPreset(options)
|
||||
const explicitOnly = resolveApplyOnly(options.only)
|
||||
validateApplyOnlyPreset({ preset, only: explicitOnly })
|
||||
|
||||
const preflight = await preFlightApply(options)
|
||||
|
||||
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`The ${highlighter.info(
|
||||
"apply"
|
||||
)} command only works in an existing project.`
|
||||
)
|
||||
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (preflight.errors[ERRORS.MISSING_CONFIG]) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`No ${highlighter.info("components.json")} found at ${highlighter.info(
|
||||
options.cwd
|
||||
)}.`
|
||||
)
|
||||
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const existingConfig = preflight.config
|
||||
if (!existingConfig) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rtl = existingConfig.rtl ?? false
|
||||
const template = await resolveApplyTemplate(options.cwd)
|
||||
|
||||
if (!preset) {
|
||||
const createUrl = resolveCreateUrl({
|
||||
command: "init",
|
||||
template,
|
||||
base: getBase(existingConfig.style),
|
||||
rtl,
|
||||
})
|
||||
|
||||
await promptToOpenPresetBuilder({
|
||||
createUrl,
|
||||
followUp: `Then run ${highlighter.info(
|
||||
"shadcn apply --preset <preset>"
|
||||
)} with the preset code or preset URL from ui.shadcn.com.`,
|
||||
prompt: !options.yes,
|
||||
})
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
validatePreset(preset)
|
||||
|
||||
const only = explicitOnly ?? resolveApplyOnly(getPresetUrlOnly(preset))
|
||||
const shouldReinstallComponents = !only
|
||||
const reinstallComponents = shouldReinstallComponents
|
||||
? await getProjectComponents(options.cwd)
|
||||
: []
|
||||
|
||||
if (!options.yes) {
|
||||
logger.break()
|
||||
if (!only) {
|
||||
logger.warn(
|
||||
highlighter.warn(
|
||||
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
|
||||
)
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
highlighter.warn(
|
||||
`Applying the selected preset parts will update your project configuration and styles.`
|
||||
)
|
||||
)
|
||||
}
|
||||
logger.warn(
|
||||
`Commit or stash your changes before continuing so you can easily go back.`
|
||||
)
|
||||
|
||||
if (shouldReinstallComponents) {
|
||||
logger.break()
|
||||
logger.log(" The following components will be re-installed:")
|
||||
if (reinstallComponents.length) {
|
||||
for (let i = 0; i < reinstallComponents.length; i += 8) {
|
||||
logger.log(
|
||||
` - ${reinstallComponents.slice(i, i + 8).join(", ")}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.log(" - No installed UI components were detected.")
|
||||
}
|
||||
}
|
||||
logger.break()
|
||||
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: "Would you like to continue?",
|
||||
initial: false,
|
||||
})
|
||||
|
||||
if (!proceed) {
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
const currentBase = getBase(existingConfig.style)
|
||||
const initUrl = resolveApplyInitUrl(preset, currentBase, {
|
||||
template,
|
||||
rtl,
|
||||
only: only?.join(","),
|
||||
})
|
||||
|
||||
const config = await withFileBackup(
|
||||
path.resolve(options.cwd, "components.json"),
|
||||
async () => {
|
||||
const {
|
||||
registryBaseConfig,
|
||||
installStyleIndex,
|
||||
url: cleanUrl,
|
||||
} = await resolveRegistryBaseConfig(initUrl, options.cwd, {
|
||||
registries: existingConfig.registries as
|
||||
| z.infer<typeof registryConfigSchema>
|
||||
| undefined,
|
||||
})
|
||||
|
||||
const applyRegistryBaseConfig = resolveApplyRegistryBaseConfig({
|
||||
registryBaseConfig,
|
||||
existingConfig,
|
||||
only,
|
||||
})
|
||||
|
||||
return await runInit({
|
||||
cwd: options.cwd,
|
||||
yes: true,
|
||||
force: false,
|
||||
reinstall: shouldReinstallComponents,
|
||||
defaults: false,
|
||||
silent: options.silent,
|
||||
isNewProject: false,
|
||||
cssVariables: true,
|
||||
installStyleIndex,
|
||||
registryBaseConfig: applyRegistryBaseConfig,
|
||||
existingConfig,
|
||||
components: [cleanUrl, ...reinstallComponents],
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await syncApplyWorkspaceConfigs(config, { only })
|
||||
|
||||
logger.break()
|
||||
logger.log("Preset applied successfully.")
|
||||
logger.break()
|
||||
} catch (error) {
|
||||
if (error instanceof ApplyOnlyError) {
|
||||
for (const line of error.message.split("\n")) {
|
||||
logger.error(line)
|
||||
}
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (error instanceof FileBackupError) {
|
||||
logger.error(
|
||||
`Could not back up ${highlighter.info("components.json")}. Aborting.`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (error instanceof ApplyWorkspaceSyncError) {
|
||||
logger.error(error.message)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
logger.break()
|
||||
handleError(error)
|
||||
} finally {
|
||||
clearRegistryContext()
|
||||
}
|
||||
})
|
||||
|
||||
function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
|
||||
const positionalPreset = options.positionalPreset?.trim()
|
||||
const flagPreset = options.preset?.trim()
|
||||
|
||||
if (positionalPreset && flagPreset && positionalPreset !== flagPreset) {
|
||||
logger.error(
|
||||
`Received two different preset values. Use either the positional preset or ${highlighter.info(
|
||||
"--preset"
|
||||
)}, or pass the same value to both.`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return flagPreset ?? positionalPreset
|
||||
}
|
||||
|
||||
export function getPresetUrlOnly(preset: string) {
|
||||
if (!isUrl(preset)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const url = new URL(preset)
|
||||
if (url.pathname !== "/init") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return url.searchParams.get("only") ?? undefined
|
||||
}
|
||||
|
||||
export function resolveApplyOnly(
|
||||
value: z.infer<typeof applyOptionsSchema>["only"]
|
||||
) {
|
||||
if (value === undefined || value === false) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value === true) {
|
||||
throw new ApplyOnlyError(
|
||||
[
|
||||
"Missing value for --only.",
|
||||
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
return parseApplyOnlyParts(value)
|
||||
}
|
||||
|
||||
export function parseApplyOnlyParts(value: string) {
|
||||
const aliases: Record<string, ApplyOnlyValue> = {
|
||||
theme: "theme",
|
||||
font: "font",
|
||||
fonts: "font",
|
||||
}
|
||||
const parts = value
|
||||
.split(",")
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
const invalid = parts.filter((part) => !aliases[part])
|
||||
|
||||
if (!parts.length || invalid.length) {
|
||||
throw new ApplyOnlyError(
|
||||
[
|
||||
`Invalid value for --only: ${value}.`,
|
||||
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
|
||||
"Example: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
return Array.from(new Set(parts.map((part) => aliases[part])))
|
||||
}
|
||||
|
||||
export function validateApplyOnlyPreset(options: {
|
||||
preset?: string
|
||||
only?: ApplyOnlyValue[]
|
||||
}) {
|
||||
if (!options.only || options.preset) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new ApplyOnlyError(
|
||||
[
|
||||
"Missing preset for --only.",
|
||||
"Use: shadcn apply <preset> --only theme,font.",
|
||||
].join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplyRegistryBaseConfig(options: {
|
||||
registryBaseConfig: Record<string, unknown> | undefined
|
||||
existingConfig: Record<string, unknown>
|
||||
only: ApplyOnlyValue[] | undefined
|
||||
}) {
|
||||
if (!options.only || options.only.includes("theme")) {
|
||||
return options.registryBaseConfig
|
||||
}
|
||||
|
||||
const existingTailwind =
|
||||
typeof options.existingConfig.tailwind === "object" &&
|
||||
options.existingConfig.tailwind !== null
|
||||
? options.existingConfig.tailwind
|
||||
: {}
|
||||
const registryTailwind =
|
||||
typeof options.registryBaseConfig?.tailwind === "object" &&
|
||||
options.registryBaseConfig.tailwind !== null
|
||||
? options.registryBaseConfig.tailwind
|
||||
: {}
|
||||
const config: Record<string, unknown> = {
|
||||
...options.registryBaseConfig,
|
||||
tailwind: {
|
||||
...existingTailwind,
|
||||
...registryTailwind,
|
||||
},
|
||||
}
|
||||
|
||||
if (options.existingConfig.menuColor) {
|
||||
config.menuColor = options.existingConfig.menuColor
|
||||
}
|
||||
|
||||
if (options.existingConfig.menuAccent) {
|
||||
config.menuAccent = options.existingConfig.menuAccent
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function validatePreset(preset: string) {
|
||||
if (isUrl(preset) || isPresetCode(preset)) {
|
||||
return
|
||||
}
|
||||
|
||||
const knownPresetNames = Object.keys(DEFAULT_PRESETS)
|
||||
|
||||
if (!knownPresetNames.includes(preset)) {
|
||||
logger.error(
|
||||
`Invalid preset: ${highlighter.info(
|
||||
preset
|
||||
)}.\nUse one of the available presets: ${knownPresetNames.join(", ")} \nor build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApplyTemplate(cwd: string) {
|
||||
const projectInfo = await getProjectInfo(cwd)
|
||||
return getTemplateForFramework(projectInfo?.framework.name)
|
||||
}
|
||||
|
||||
async function syncApplyWorkspaceConfigs(
|
||||
config: Config,
|
||||
options?: {
|
||||
only?: string[]
|
||||
}
|
||||
) {
|
||||
if (options?.only && !options.only.includes("theme")) {
|
||||
return
|
||||
}
|
||||
|
||||
const linkedConfigs = await getApplyWorkspaceConfigs(config)
|
||||
if (!linkedConfigs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const patch = {
|
||||
style: config.style,
|
||||
tailwind: {
|
||||
baseColor: config.tailwind.baseColor,
|
||||
cssVariables: config.tailwind.cssVariables,
|
||||
},
|
||||
...(config.iconLibrary ? { iconLibrary: config.iconLibrary } : {}),
|
||||
...(config.rtl !== undefined ? { rtl: config.rtl } : {}),
|
||||
...(config.menuColor ? { menuColor: config.menuColor } : {}),
|
||||
...(config.menuAccent ? { menuAccent: config.menuAccent } : {}),
|
||||
}
|
||||
|
||||
const workspaceConfigs = []
|
||||
|
||||
for (const linkedConfig of linkedConfigs) {
|
||||
const configPath = path.resolve(
|
||||
linkedConfig.resolvedPaths.cwd,
|
||||
"components.json"
|
||||
)
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
continue
|
||||
}
|
||||
|
||||
workspaceConfigs.push({
|
||||
configPath,
|
||||
existingConfig: await fs.readJson(configPath),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
for (const workspaceConfig of workspaceConfigs) {
|
||||
const backupPath = createFileBackup(workspaceConfig.configPath)
|
||||
if (!backupPath) {
|
||||
throw new FileBackupError(workspaceConfig.configPath)
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspaceConfig of workspaceConfigs) {
|
||||
await fs.writeJson(
|
||||
workspaceConfig.configPath,
|
||||
{
|
||||
...workspaceConfig.existingConfig,
|
||||
...patch,
|
||||
tailwind: {
|
||||
...workspaceConfig.existingConfig.tailwind,
|
||||
...patch.tailwind,
|
||||
},
|
||||
},
|
||||
{ spaces: 2 }
|
||||
)
|
||||
}
|
||||
|
||||
for (const workspaceConfig of workspaceConfigs) {
|
||||
deleteFileBackup(workspaceConfig.configPath)
|
||||
}
|
||||
} catch (error) {
|
||||
for (const workspaceConfig of [...workspaceConfigs].reverse()) {
|
||||
restoreFileBackup(workspaceConfig.configPath)
|
||||
}
|
||||
|
||||
throw new ApplyWorkspaceSyncError(
|
||||
`Failed to sync linked workspace configs.${error instanceof Error ? ` ${error.message}` : ""}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function getApplyWorkspaceConfigs(config: Config) {
|
||||
const workspaceConfig = await getWorkspaceConfig(config)
|
||||
if (!workspaceConfig) {
|
||||
return []
|
||||
}
|
||||
|
||||
const linkedConfigs = new Map<string, Config>()
|
||||
|
||||
for (const linkedConfig of Object.values(workspaceConfig)) {
|
||||
if (linkedConfig.resolvedPaths.cwd === config.resolvedPaths.cwd) {
|
||||
continue
|
||||
}
|
||||
|
||||
linkedConfigs.set(linkedConfig.resolvedPaths.cwd, linkedConfig)
|
||||
}
|
||||
|
||||
return Array.from(linkedConfigs.values()).sort((a, b) =>
|
||||
a.resolvedPaths.cwd.localeCompare(b.resolvedPaths.cwd)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveApplyInitUrl(
|
||||
preset: string,
|
||||
currentBase: PresetBase,
|
||||
options: { template?: string; rtl?: boolean; only?: string } = {}
|
||||
) {
|
||||
if (isUrl(preset)) {
|
||||
const url = new URL(preset)
|
||||
|
||||
if (url.pathname === "/init" && preset.startsWith(SHADCN_URL)) {
|
||||
url.searchParams.set("track", "1")
|
||||
}
|
||||
|
||||
url.searchParams.set("base", currentBase)
|
||||
url.searchParams.set("rtl", String(options.rtl ?? false))
|
||||
if (options.only) {
|
||||
url.searchParams.set("only", options.only)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
if (isPresetCode(preset)) {
|
||||
const decoded = decodePreset(preset)
|
||||
if (!decoded) {
|
||||
logger.error(`Invalid preset code: ${highlighter.info(preset)}`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return resolveInitUrl(
|
||||
{
|
||||
...decoded,
|
||||
base: currentBase,
|
||||
rtl: options.rtl ?? false,
|
||||
},
|
||||
{ preset, template: options.template, only: options.only }
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedPreset = DEFAULT_PRESETS[preset as keyof typeof DEFAULT_PRESETS]
|
||||
|
||||
return resolveInitUrl(
|
||||
{
|
||||
...resolvedPreset,
|
||||
base: currentBase,
|
||||
rtl: options.rtl ?? resolvedPreset.rtl,
|
||||
},
|
||||
{ template: options.template, only: options.only }
|
||||
)
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string) {
|
||||
return /[^A-Za-z0-9_./:-]/.test(value) ? JSON.stringify(value) : value
|
||||
}
|
||||
|
||||
function getInitCommand(preset?: string) {
|
||||
if (!preset) {
|
||||
return "shadcn init"
|
||||
}
|
||||
|
||||
return `shadcn init --preset ${quoteShellArg(preset)}`
|
||||
}
|
||||
142
packages/shadcn/src/commands/build.test.ts
Normal file
142
packages/shadcn/src/commands/build.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as fs from "fs/promises"
|
||||
import { tmpdir } from "os"
|
||||
import * as path from "path"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { build } from "./build"
|
||||
|
||||
vi.mock("@/src/utils/handle-error", () => ({
|
||||
handleError: vi.fn((error) => {
|
||||
throw error
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/spinner", () => ({
|
||||
spinner: () => ({
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("build command", () => {
|
||||
it("writes flattened registries for source registries that use include", async () => {
|
||||
const cwd = await createFixture({
|
||||
"registry.json": JSON.stringify({
|
||||
name: "example",
|
||||
homepage: "https://example.com",
|
||||
include: ["components/ui/registry.json"],
|
||||
}),
|
||||
"components/ui/registry.json": JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: "button.tsx",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
"components/ui/button.tsx": "export function Button() {}",
|
||||
})
|
||||
|
||||
await build.parseAsync(
|
||||
["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
|
||||
{ from: "node" }
|
||||
)
|
||||
|
||||
const outputDir = path.join(cwd, "public/r")
|
||||
const registry = JSON.parse(
|
||||
await fs.readFile(path.join(outputDir, "registry.json"), "utf-8")
|
||||
)
|
||||
const button = JSON.parse(
|
||||
await fs.readFile(path.join(outputDir, "button.json"), "utf-8")
|
||||
)
|
||||
|
||||
expect(registry).toMatchObject({
|
||||
name: "example",
|
||||
homepage: "https://example.com",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/button.tsx",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(registry).not.toHaveProperty("include")
|
||||
expect(registry.items[0].files[0]).not.toHaveProperty("content")
|
||||
expect(button).toMatchObject({
|
||||
$schema: "https://ui.shadcn.com/schema/registry-item.json",
|
||||
name: "button",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/button.tsx",
|
||||
content: "export function Button() {}",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("creates nested output directories for item names with path segments", async () => {
|
||||
const cwd = await createFixture({
|
||||
"registry.json": JSON.stringify({
|
||||
name: "example",
|
||||
homepage: "https://example.com",
|
||||
items: [
|
||||
{
|
||||
name: "extension/foo",
|
||||
type: "registry:item",
|
||||
files: [
|
||||
{
|
||||
path: "registry/extensions/foo.tsx",
|
||||
type: "registry:file",
|
||||
target: "extensions/foo.tsx",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
"registry/extensions/foo.tsx": "export function Foo() {}",
|
||||
})
|
||||
|
||||
await build.parseAsync(
|
||||
["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
|
||||
{ from: "node" }
|
||||
)
|
||||
|
||||
const item = JSON.parse(
|
||||
await fs.readFile(path.join(cwd, "public/r/extension/foo.json"), "utf-8")
|
||||
)
|
||||
|
||||
expect(item).toMatchObject({
|
||||
name: "extension/foo",
|
||||
files: [
|
||||
{
|
||||
path: "registry/extensions/foo.tsx",
|
||||
content: "export function Foo() {}",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function createFixture(files: Record<string, string>) {
|
||||
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-build-"))
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([filePath, content]) => {
|
||||
const targetPath = path.join(cwd, filePath)
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true })
|
||||
await fs.writeFile(targetPath, content)
|
||||
})
|
||||
)
|
||||
|
||||
return cwd
|
||||
}
|
||||
103
packages/shadcn/src/commands/build.ts
Normal file
103
packages/shadcn/src/commands/build.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { preFlightBuild } from "@/src/preflights/preflight-build"
|
||||
import {
|
||||
createRegistryCatalog,
|
||||
createRegistryItem,
|
||||
readRegistryWithIncludes,
|
||||
} from "@/src/registry/loader"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { Command } from "commander"
|
||||
import { z } from "zod"
|
||||
|
||||
export const buildOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
registryFile: z.string(),
|
||||
outputDir: z.string(),
|
||||
})
|
||||
|
||||
export const build = new Command()
|
||||
.name("build")
|
||||
.description("build components for a shadcn registry")
|
||||
.argument("[registry]", "path to registry.json file", "./registry.json")
|
||||
.option(
|
||||
"-o, --output <path>",
|
||||
"destination directory for json files",
|
||||
"./public/r"
|
||||
)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.action(async (registryFile: string, opts) => {
|
||||
try {
|
||||
const options = buildOptionsSchema.parse({
|
||||
cwd: path.resolve(opts.cwd),
|
||||
registryFile,
|
||||
outputDir: opts.output,
|
||||
})
|
||||
|
||||
const { resolvePaths } = await preFlightBuild(options)
|
||||
const registryResult = await readRegistryWithIncludes(
|
||||
resolvePaths.registryFile,
|
||||
{
|
||||
cwd: resolvePaths.cwd,
|
||||
}
|
||||
)
|
||||
const resolvedRegistry = registryResult.registry
|
||||
const registryRootDir = registryResult.usesInclude
|
||||
? path.dirname(resolvePaths.registryFile)
|
||||
: resolvePaths.cwd
|
||||
const registryCatalog = createRegistryCatalog(
|
||||
registryResult,
|
||||
registryRootDir,
|
||||
resolvePaths.cwd
|
||||
)
|
||||
|
||||
const buildSpinner = spinner("Building registry...")
|
||||
for (const registryItem of resolvedRegistry.items) {
|
||||
buildSpinner.start(`Building ${registryItem.name}...`)
|
||||
|
||||
const registryItemForBuild = await createRegistryItem(
|
||||
registryItem,
|
||||
registryResult,
|
||||
registryRootDir,
|
||||
resolvePaths.cwd
|
||||
)
|
||||
|
||||
// Write the registry item to the output directory.
|
||||
// Item names can contain path segments (e.g. "extension/foo"), so
|
||||
// ensure the nested output directory exists before writing.
|
||||
const outputPath = path.resolve(
|
||||
resolvePaths.outputDir,
|
||||
`${registryItemForBuild.name}.json`
|
||||
)
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
outputPath,
|
||||
JSON.stringify(registryItemForBuild, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
if (registryResult.usesInclude) {
|
||||
await fs.writeFile(
|
||||
path.resolve(resolvePaths.outputDir, "registry.json"),
|
||||
JSON.stringify(registryCatalog, null, 2)
|
||||
)
|
||||
} else {
|
||||
// Copy registry.json to the output directory.
|
||||
await fs.copyFile(
|
||||
resolvePaths.registryFile,
|
||||
path.resolve(resolvePaths.outputDir, "registry.json")
|
||||
)
|
||||
}
|
||||
|
||||
buildSpinner.succeed("Building registry.")
|
||||
} catch (error) {
|
||||
logger.break()
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
255
packages/shadcn/src/commands/diff.ts
Normal file
255
packages/shadcn/src/commands/diff.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import {
|
||||
fetchTree,
|
||||
getItemTargetPath,
|
||||
getRegistryBaseColor,
|
||||
getShadcnRegistryIndex,
|
||||
} from "@/src/registry/api"
|
||||
import { registryIndexSchema } from "@/src/schema"
|
||||
import { getSupportedFontMarkers } from "@/src/utils/font-markers"
|
||||
import { Config, getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { transform } from "@/src/utils/transformers"
|
||||
import { transformCleanup } from "@/src/utils/transformers/transform-cleanup"
|
||||
import { transformCssVars } from "@/src/utils/transformers/transform-css-vars"
|
||||
import { transformFont } from "@/src/utils/transformers/transform-font"
|
||||
import { transformIcons } from "@/src/utils/transformers/transform-icons"
|
||||
import { transformImport } from "@/src/utils/transformers/transform-import"
|
||||
import { transformMenu } from "@/src/utils/transformers/transform-menu"
|
||||
import { transformRsc } from "@/src/utils/transformers/transform-rsc"
|
||||
import { transformRtl } from "@/src/utils/transformers/transform-rtl"
|
||||
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
|
||||
import { Command } from "commander"
|
||||
import { diffLines, type Change } from "diff"
|
||||
import { z } from "zod"
|
||||
|
||||
const updateOptionsSchema = z.object({
|
||||
component: z.string().optional(),
|
||||
yes: z.boolean(),
|
||||
cwd: z.string(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
|
||||
export const diff = new Command()
|
||||
.name("diff")
|
||||
.description("[DEPRECATED] Use `add [component] --diff` instead.")
|
||||
.argument("[component]", "the component name")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.action(async (name, opts) => {
|
||||
try {
|
||||
const options = updateOptionsSchema.parse({
|
||||
component: name,
|
||||
...opts,
|
||||
})
|
||||
|
||||
const cwd = path.resolve(options.cwd)
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
logger.error(`The path ${cwd} does not exist. Please try again.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config = await getConfig(cwd)
|
||||
if (!config) {
|
||||
// Check if we're in a monorepo root.
|
||||
if (await isMonorepoRoot(cwd)) {
|
||||
const targets = await getMonorepoTargets(cwd)
|
||||
if (targets.length > 0) {
|
||||
formatMonorepoMessage("diff [component]", targets)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Configuration is missing. Please run ${highlighter.success(
|
||||
`init`
|
||||
)} to create a components.json file.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const registryIndex = await getShadcnRegistryIndex()
|
||||
|
||||
if (!registryIndex) {
|
||||
handleError(new Error("Failed to fetch registry index."))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!options.component) {
|
||||
const targetDir = config.resolvedPaths.components
|
||||
|
||||
// Find all components that exist in the project.
|
||||
const projectComponents = registryIndex.filter((item) => {
|
||||
for (const file of item.files ?? []) {
|
||||
const filePath = path.resolve(
|
||||
targetDir,
|
||||
typeof file === "string" ? file : file.path
|
||||
)
|
||||
if (existsSync(filePath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Check for updates.
|
||||
const componentsWithUpdates = []
|
||||
for (const component of projectComponents) {
|
||||
const changes = await diffComponent(component, config)
|
||||
if (changes.length) {
|
||||
componentsWithUpdates.push({
|
||||
name: component.name,
|
||||
changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!componentsWithUpdates.length) {
|
||||
logger.info("No updates found.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
logger.info("The following components have updates available:")
|
||||
for (const component of componentsWithUpdates) {
|
||||
logger.info(`- ${component.name}`)
|
||||
for (const change of component.changes) {
|
||||
logger.info(` - ${change.filePath}`)
|
||||
}
|
||||
}
|
||||
logger.break()
|
||||
logger.info(
|
||||
`Run ${highlighter.success(`diff <component>`)} to see the changes.`
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Show diff for a single component.
|
||||
const component = registryIndex.find(
|
||||
(item) => item.name === options.component
|
||||
)
|
||||
|
||||
if (!component) {
|
||||
logger.error(
|
||||
`The component ${highlighter.success(
|
||||
options.component
|
||||
)} does not exist.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const changes = await diffComponent(component, config)
|
||||
|
||||
if (!changes.length) {
|
||||
logger.info(`No updates found for ${options.component}.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
logger.info(`- ${change.filePath}`)
|
||||
await printDiff(change.patch)
|
||||
logger.info("")
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
async function diffComponent(
|
||||
component: z.infer<typeof registryIndexSchema>[number],
|
||||
config: Config
|
||||
) {
|
||||
const payload = await fetchTree(config.style, [component])
|
||||
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
|
||||
const supportedFontMarkers = getSupportedFontMarkers(payload)
|
||||
|
||||
if (!payload) {
|
||||
return []
|
||||
}
|
||||
|
||||
const changes = []
|
||||
|
||||
for (const item of payload) {
|
||||
const targetDir = await getItemTargetPath(config, item)
|
||||
|
||||
if (!targetDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of item.files ?? []) {
|
||||
const filePath = path.resolve(
|
||||
targetDir,
|
||||
typeof file === "string" ? file : file.path
|
||||
)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(filePath, "utf8")
|
||||
|
||||
if (typeof file === "string" || !file.content) {
|
||||
continue
|
||||
}
|
||||
|
||||
const registryContent = await transform(
|
||||
{
|
||||
filename: file.path,
|
||||
raw: file.content,
|
||||
config,
|
||||
baseColor,
|
||||
supportedFontMarkers,
|
||||
},
|
||||
[
|
||||
transformImport,
|
||||
transformRsc,
|
||||
transformCssVars,
|
||||
transformTwPrefixes,
|
||||
transformIcons,
|
||||
transformMenu,
|
||||
transformRtl,
|
||||
transformFont,
|
||||
transformCleanup,
|
||||
]
|
||||
)
|
||||
|
||||
const patch = diffLines(registryContent as string, fileContent)
|
||||
if (patch.length > 1) {
|
||||
changes.push({
|
||||
filePath,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
async function printDiff(diff: Change[]) {
|
||||
diff.forEach((part) => {
|
||||
if (part) {
|
||||
if (part.added) {
|
||||
return process.stdout.write(highlighter.success(part.value))
|
||||
}
|
||||
if (part.removed) {
|
||||
return process.stdout.write(highlighter.error(part.value))
|
||||
}
|
||||
|
||||
return process.stdout.write(part.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
20
packages/shadcn/src/commands/docs.test.ts
Normal file
20
packages/shadcn/src/commands/docs.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { docs, resolveDocsBase } from "./docs"
|
||||
|
||||
describe("resolveDocsBase", () => {
|
||||
it("accepts aria explicitly", () => {
|
||||
expect(resolveDocsBase("aria", undefined)).toBe("aria")
|
||||
expect(docs.helpInformation()).toContain("base, radix, or aria")
|
||||
})
|
||||
|
||||
it("infers aria from the project style", () => {
|
||||
expect(resolveDocsBase(undefined, "aria-nova")).toBe("aria")
|
||||
})
|
||||
|
||||
it("rejects unsupported bases", () => {
|
||||
expect(() => resolveDocsBase("unsupported", undefined)).toThrow(
|
||||
"Expected one of: radix, base, aria"
|
||||
)
|
||||
})
|
||||
})
|
||||
127
packages/shadcn/src/commands/docs.ts
Normal file
127
packages/shadcn/src/commands/docs.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import path from "path"
|
||||
import {
|
||||
isPresetBase,
|
||||
PRESET_BASES,
|
||||
type PresetBase,
|
||||
} from "@/src/preset/preset"
|
||||
import { getShadcnRegistryIndex } from "@/src/registry/api"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import { getBase, getConfig } from "@/src/utils/get-config"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { Command } from "commander"
|
||||
|
||||
const SHADCN_BASE_URL = "https://ui.shadcn.com"
|
||||
|
||||
export const docs = new Command()
|
||||
.name("docs")
|
||||
.description("get docs, api references and usage examples for components")
|
||||
.argument("<components...>", "component names")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option(
|
||||
"-b, --base <base>",
|
||||
"the base to use: base, radix, or aria. defaults to project base."
|
||||
)
|
||||
.option("--json", "output as JSON.", false)
|
||||
.action(async (components, opts) => {
|
||||
try {
|
||||
const cwd = path.resolve(opts.cwd)
|
||||
const config = await getConfig(cwd)
|
||||
const base = resolveDocsBase(opts.base, config?.style)
|
||||
|
||||
const index = await getShadcnRegistryIndex()
|
||||
|
||||
if (!index) {
|
||||
logger.error("Failed to fetch the registry index.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const results: {
|
||||
component: string
|
||||
base: PresetBase
|
||||
links: Record<string, string>
|
||||
}[] = []
|
||||
|
||||
for (const component of components) {
|
||||
const item = index.find((entry) => entry.name === component)
|
||||
|
||||
if (!item) {
|
||||
logger.error(
|
||||
`Component ${highlighter.info(
|
||||
component
|
||||
)} not found in the shadcn registry.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const links = (
|
||||
item.meta?.links as Record<string, Record<string, string>>
|
||||
)?.[base]
|
||||
|
||||
if (!links || Object.keys(links).length === 0) {
|
||||
logger.warn(
|
||||
`No documentation links available for ${highlighter.info(
|
||||
component
|
||||
)}.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
results.push({
|
||||
component,
|
||||
base,
|
||||
links: normalizeLinks(links),
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ base, results }, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
// Compute max key length across all results for consistent alignment.
|
||||
const maxKeyLength = Math.max(
|
||||
...results.flatMap((r) => Object.keys(r.links).map((k) => k.length))
|
||||
)
|
||||
|
||||
for (const { component, links } of results) {
|
||||
logger.log(highlighter.info(component))
|
||||
for (const [key, value] of Object.entries(links)) {
|
||||
logger.log(` - ${key.padEnd(maxKeyLength + 2)}${value}`)
|
||||
}
|
||||
logger.break()
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export function resolveDocsBase(base: unknown, style: string | undefined) {
|
||||
const resolvedBase = base ?? getBase(style)
|
||||
|
||||
if (!isPresetBase(resolvedBase)) {
|
||||
throw new Error(
|
||||
`Invalid base: ${String(resolvedBase)}. Expected one of: ${PRESET_BASES.join(
|
||||
", "
|
||||
)}.`
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedBase
|
||||
}
|
||||
|
||||
function normalizeLinks(links: Record<string, string>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(links).map(([key, value]) => [
|
||||
key,
|
||||
value.startsWith(SHADCN_BASE_URL)
|
||||
? `${SHADCN_URL}${value.slice(SHADCN_BASE_URL.length)}`
|
||||
: value,
|
||||
])
|
||||
)
|
||||
}
|
||||
262
packages/shadcn/src/commands/eject.test.ts
Normal file
262
packages/shadcn/src/commands/eject.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { getPackageManager } from "@/src/utils/get-package-manager"
|
||||
import { execa } from "execa"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { runEject, SHADCN_TAILWIND_IMPORT } from "./eject"
|
||||
|
||||
vi.mock("execa", () => ({
|
||||
execa: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-package-manager", () => ({
|
||||
getPackageManager: vi.fn().mockResolvedValue("pnpm"),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-monorepo-info", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/src/utils/get-monorepo-info")>()
|
||||
return {
|
||||
...actual,
|
||||
formatMonorepoMessage: vi.fn(),
|
||||
getMonorepoTargets: vi.fn().mockResolvedValue([]),
|
||||
isMonorepoRoot: vi.fn().mockResolvedValue(false),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/src/utils/spinner", () => ({
|
||||
spinner: vi.fn(() => ({
|
||||
start: vi.fn().mockReturnThis(),
|
||||
succeed: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/logger", () => ({
|
||||
logger: {
|
||||
break: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const fixtureConfig = {
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: "base-nova",
|
||||
rsc: true,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "app/globals.css",
|
||||
baseColor: "neutral",
|
||||
cssVariables: true,
|
||||
},
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
},
|
||||
}
|
||||
|
||||
const fixtureTsconfig = {
|
||||
compilerOptions: {
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"@/*": ["./*"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const baseCss = `@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
describe("runEject", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getPackageManager).mockResolvedValue("pnpm")
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), "shadcn-eject-"))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function setupProject(
|
||||
cssContent: string,
|
||||
packageJson: Record<string, unknown> = {
|
||||
name: "test-app",
|
||||
dependencies: {
|
||||
shadcn: "^4.8.3",
|
||||
},
|
||||
}
|
||||
) {
|
||||
await writeFile(
|
||||
path.join(tempDir, "components.json"),
|
||||
JSON.stringify(fixtureConfig, null, 2)
|
||||
)
|
||||
await writeFile(
|
||||
path.join(tempDir, "tsconfig.json"),
|
||||
JSON.stringify(fixtureTsconfig, null, 2)
|
||||
)
|
||||
await mkdir(path.join(tempDir, "app"), { recursive: true })
|
||||
await writeFile(path.join(tempDir, "app", "globals.css"), cssContent)
|
||||
await writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify(packageJson, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
it("inlines shadcn/tailwind.css and removes the dependency", async () => {
|
||||
await setupProject(baseCss)
|
||||
|
||||
await runEject({
|
||||
cwd: tempDir,
|
||||
yes: true,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const output = await readFile(
|
||||
path.join(tempDir, "app", "globals.css"),
|
||||
"utf8"
|
||||
)
|
||||
const shadcnCss = await readFile(
|
||||
path.join(process.cwd(), "src/tailwind.css"),
|
||||
"utf8"
|
||||
)
|
||||
|
||||
expect(output).not.toMatch(SHADCN_TAILWIND_IMPORT)
|
||||
expect(output).toContain('@import "tailwindcss";')
|
||||
expect(output).toContain('@import "tw-animate-css";')
|
||||
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
|
||||
expect(output).toContain(shadcnCss.trim())
|
||||
expect(output).toContain("@layer base")
|
||||
expect(output).toContain("@apply bg-background text-foreground;")
|
||||
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
|
||||
cwd: tempDir,
|
||||
})
|
||||
})
|
||||
|
||||
it("removes shadcn from devDependencies", async () => {
|
||||
await setupProject(baseCss, {
|
||||
name: "test-app",
|
||||
devDependencies: {
|
||||
shadcn: "^4.8.3",
|
||||
},
|
||||
})
|
||||
|
||||
await runEject({
|
||||
cwd: tempDir,
|
||||
yes: true,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const output = await readFile(
|
||||
path.join(tempDir, "app", "globals.css"),
|
||||
"utf8"
|
||||
)
|
||||
|
||||
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
|
||||
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
|
||||
cwd: tempDir,
|
||||
})
|
||||
})
|
||||
|
||||
it("skips dependency removal when shadcn is not in package.json", async () => {
|
||||
await setupProject(baseCss, {
|
||||
name: "test-app",
|
||||
dependencies: {},
|
||||
})
|
||||
|
||||
await runEject({
|
||||
cwd: tempDir,
|
||||
yes: true,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
expect(execa).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("removes shadcn from package.json for deno", async () => {
|
||||
vi.mocked(getPackageManager).mockResolvedValue("deno")
|
||||
await setupProject(baseCss)
|
||||
|
||||
await runEject({
|
||||
cwd: tempDir,
|
||||
yes: true,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(tempDir, "package.json"), "utf8")
|
||||
)
|
||||
|
||||
expect(packageJson.dependencies?.shadcn).toBeUndefined()
|
||||
expect(execa).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("exits when shadcn/tailwind.css is not imported", async () => {
|
||||
await setupProject(`@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
`)
|
||||
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit")
|
||||
}) as never)
|
||||
|
||||
await expect(
|
||||
runEject({
|
||||
cwd: tempDir,
|
||||
yes: true,
|
||||
silent: true,
|
||||
})
|
||||
).rejects.toThrow("process.exit")
|
||||
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("shows monorepo suggestions when run from a monorepo root", async () => {
|
||||
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
|
||||
vi.mocked(getMonorepoTargets).mockResolvedValue([
|
||||
{ name: "packages/ui", hasConfig: true },
|
||||
{ name: "apps/web", hasConfig: true },
|
||||
])
|
||||
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit")
|
||||
}) as never)
|
||||
|
||||
await expect(
|
||||
runEject({
|
||||
cwd: tempDir,
|
||||
yes: true,
|
||||
silent: true,
|
||||
})
|
||||
).rejects.toThrow("process.exit")
|
||||
|
||||
expect(formatMonorepoMessage).toHaveBeenCalledWith("eject", [
|
||||
{ name: "packages/ui", hasConfig: true },
|
||||
{ name: "apps/web", hasConfig: true },
|
||||
])
|
||||
|
||||
exit.mockRestore()
|
||||
})
|
||||
})
|
||||
248
packages/shadcn/src/commands/eject.ts
Normal file
248
packages/shadcn/src/commands/eject.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import { getPackageInfo } from "@/src/utils/get-package-info"
|
||||
import { getPackageManager } from "@/src/utils/get-package-manager"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { spinner } from "@/src/utils/spinner"
|
||||
import { Command } from "commander"
|
||||
import { execa } from "execa"
|
||||
import fsExtra from "fs-extra"
|
||||
import prompts from "prompts"
|
||||
import { z } from "zod"
|
||||
|
||||
export const SHADCN_TAILWIND_IMPORT =
|
||||
/@import\s+["']shadcn\/tailwind\.css["'];?\s*\n?/
|
||||
|
||||
export const ejectOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
yes: z.boolean(),
|
||||
silent: z.boolean(),
|
||||
})
|
||||
|
||||
export const eject = new Command()
|
||||
.name("eject")
|
||||
.description("inline shadcn/tailwind.css and remove the shadcn dependency")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option("-s, --silent", "mute output.", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const options = ejectOptionsSchema.parse({
|
||||
cwd: path.resolve(opts.cwd),
|
||||
yes: opts.yes,
|
||||
silent: opts.silent,
|
||||
})
|
||||
|
||||
await runEject(options)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export async function runEject(options: z.infer<typeof ejectOptionsSchema>) {
|
||||
if (!fsExtra.existsSync(path.resolve(options.cwd, "components.json"))) {
|
||||
if (await isMonorepoRoot(options.cwd)) {
|
||||
const targets = await getMonorepoTargets(options.cwd)
|
||||
if (targets.length > 0) {
|
||||
formatMonorepoMessage("eject", targets)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
logger.break()
|
||||
logger.error(
|
||||
`No ${highlighter.info("components.json")} found. Run ${highlighter.info("shadcn init")} first.`
|
||||
)
|
||||
logger.error(
|
||||
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config = await getConfig(options.cwd)
|
||||
if (!config?.resolvedPaths.tailwindCss) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
"Could not resolve the Tailwind CSS file from components.json."
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const cssFilepath = config.resolvedPaths.tailwindCss
|
||||
const cssFilepathRelative = path.relative(options.cwd, cssFilepath)
|
||||
let cssContent = await fs.readFile(cssFilepath, "utf8")
|
||||
|
||||
if (!SHADCN_TAILWIND_IMPORT.test(cssContent)) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`Could not find ${highlighter.info('@import "shadcn/tailwind.css"')} in ${highlighter.info(cssFilepathRelative)}.`
|
||||
)
|
||||
logger.error("Nothing to eject.")
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const packageInfo = getPackageInfo(options.cwd, false)
|
||||
const shadcnVersion = getShadcnVersion(packageInfo)
|
||||
const shadcnCssPath = resolveShadcnTailwindCss(options.cwd)
|
||||
const shadcnCssContent = await fs.readFile(shadcnCssPath, "utf8")
|
||||
|
||||
if (!options.silent) {
|
||||
logger.break()
|
||||
logger.warn(
|
||||
"This action is not reversible. Future shadcn CLI updates to tailwind.css will not apply automatically."
|
||||
)
|
||||
logger.break()
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
logger.log("This will:")
|
||||
logger.log(
|
||||
` - Inline ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}`
|
||||
)
|
||||
logger.log(` - Remove the ${highlighter.info("shadcn")} dependency`)
|
||||
logger.break()
|
||||
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: "Proceed?",
|
||||
initial: false,
|
||||
})
|
||||
|
||||
if (!proceed) {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
const ejectSpinner = spinner(
|
||||
`Inlining ${highlighter.info("shadcn/tailwind.css")}.`,
|
||||
{
|
||||
silent: options.silent,
|
||||
}
|
||||
)?.start()
|
||||
|
||||
cssContent = cssContent.replace(
|
||||
SHADCN_TAILWIND_IMPORT,
|
||||
() =>
|
||||
`/* ejected from shadcn@${shadcnVersion} */\n${shadcnCssContent.trim()}\n\n`
|
||||
)
|
||||
|
||||
await fs.writeFile(cssFilepath, cssContent, "utf8")
|
||||
ejectSpinner?.succeed()
|
||||
|
||||
if (hasShadcnDependency(packageInfo)) {
|
||||
const removeSpinner = spinner(`Removing ${highlighter.info("shadcn")}.`, {
|
||||
silent: options.silent,
|
||||
})?.start()
|
||||
|
||||
await removeShadcnDependency(options.cwd)
|
||||
removeSpinner?.succeed()
|
||||
} else if (!options.silent) {
|
||||
logger.warn(
|
||||
`The ${highlighter.info("shadcn")} package was not found in package.json. Skipped removal.`
|
||||
)
|
||||
}
|
||||
|
||||
logger.break()
|
||||
logger.log(
|
||||
`Ejected ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}.`
|
||||
)
|
||||
logger.break()
|
||||
}
|
||||
|
||||
function getShadcnVersion(packageInfo: ReturnType<typeof getPackageInfo>) {
|
||||
if (!packageInfo) {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return (
|
||||
packageInfo.dependencies?.shadcn ??
|
||||
packageInfo.devDependencies?.shadcn ??
|
||||
"unknown"
|
||||
)
|
||||
.replace(/^[\^~]/, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function hasShadcnDependency(packageInfo: ReturnType<typeof getPackageInfo>) {
|
||||
if (!packageInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
packageInfo.dependencies?.shadcn || packageInfo.devDependencies?.shadcn
|
||||
)
|
||||
}
|
||||
|
||||
function resolveShadcnTailwindCss(cwd: string) {
|
||||
const projectCss = path.join(cwd, "node_modules/shadcn/dist/tailwind.css")
|
||||
if (fsExtra.existsSync(projectCss)) {
|
||||
return projectCss
|
||||
}
|
||||
|
||||
const cliRoot = process.argv[1]
|
||||
? path.dirname(path.resolve(process.argv[1]))
|
||||
: cwd
|
||||
|
||||
for (const candidate of [
|
||||
path.join(cliRoot, "tailwind.css"),
|
||||
path.join(cliRoot, "dist", "tailwind.css"),
|
||||
path.join(cliRoot, "src", "tailwind.css"),
|
||||
path.join(process.cwd(), "src/tailwind.css"),
|
||||
path.join(process.cwd(), "dist/tailwind.css"),
|
||||
]) {
|
||||
if (fsExtra.existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not resolve shadcn/tailwind.css.")
|
||||
}
|
||||
|
||||
async function removeShadcnDependency(cwd: string) {
|
||||
const packageManager = await getPackageManager(cwd)
|
||||
|
||||
switch (packageManager) {
|
||||
case "npm":
|
||||
await execa("npm", ["uninstall", "shadcn"], { cwd })
|
||||
break
|
||||
case "pnpm":
|
||||
await execa("pnpm", ["remove", "shadcn"], { cwd })
|
||||
break
|
||||
case "yarn":
|
||||
await execa("yarn", ["remove", "shadcn"], { cwd })
|
||||
break
|
||||
case "bun":
|
||||
await execa("bun", ["remove", "shadcn"], { cwd })
|
||||
break
|
||||
case "deno": {
|
||||
const packageJsonPath = path.join(cwd, "package.json")
|
||||
const packageJson = await fsExtra.readJson(packageJsonPath)
|
||||
|
||||
for (const field of ["dependencies", "devDependencies"] as const) {
|
||||
if (packageJson[field]?.shadcn) {
|
||||
delete packageJson[field].shadcn
|
||||
}
|
||||
}
|
||||
|
||||
await fsExtra.writeJson(packageJsonPath, packageJson, { spaces: 2 })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
299
packages/shadcn/src/commands/info.ts
Normal file
299
packages/shadcn/src/commands/info.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { resolveProjectPreset } from "@/src/preset/resolve"
|
||||
import { SHADCN_URL } from "@/src/registry/constants"
|
||||
import { getBase, getConfig } from "@/src/utils/get-config"
|
||||
import {
|
||||
formatMonorepoMessage,
|
||||
getMonorepoTargets,
|
||||
isMonorepoRoot,
|
||||
} from "@/src/utils/get-monorepo-info"
|
||||
import {
|
||||
getProjectComponents,
|
||||
getProjectInfo,
|
||||
type ProjectInfo,
|
||||
} from "@/src/utils/get-project-info"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { Command } from "commander"
|
||||
|
||||
const CODE_BASE = `${SHADCN_URL}/code/apps/v4/registry/bases`
|
||||
|
||||
export const info = new Command()
|
||||
.name("info")
|
||||
.description("get information about your project")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("--json", "output as JSON.", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const cwd = path.resolve(opts.cwd)
|
||||
|
||||
// Check if we're in a monorepo root.
|
||||
if (
|
||||
!existsSync(path.resolve(cwd, "components.json")) &&
|
||||
(await isMonorepoRoot(cwd))
|
||||
) {
|
||||
const targets = await getMonorepoTargets(cwd)
|
||||
if (targets.length > 0) {
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: "monorepo_root",
|
||||
message:
|
||||
"You are running info from a monorepo root. Use the -c flag to specify a workspace.",
|
||||
targets: targets.map((t) => t.name),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
formatMonorepoMessage("info", targets)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const projectInfo = await getProjectInfo(cwd)
|
||||
const config = await getConfig(cwd)
|
||||
const components = await getProjectComponents(cwd)
|
||||
const base = getBase(config?.style)
|
||||
const data = await collectInfo(projectInfo, config, components, base)
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
printInfo(data)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
function getRegistries(
|
||||
registries: Record<string, string | { url: string }> | undefined
|
||||
) {
|
||||
if (!registries) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(registries)) {
|
||||
result[name] = typeof value === "string" ? value : value.url
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function collectInfo(
|
||||
projectInfo: ProjectInfo | null,
|
||||
config: Awaited<ReturnType<typeof getConfig>>,
|
||||
components: string[],
|
||||
base: string
|
||||
) {
|
||||
const preset = config ? await resolveProjectPreset(config, projectInfo) : null
|
||||
|
||||
return {
|
||||
project: projectInfo
|
||||
? {
|
||||
framework: projectInfo.framework.label,
|
||||
frameworkName: projectInfo.framework.name,
|
||||
frameworkVersion: projectInfo.frameworkVersion ?? null,
|
||||
srcDirectory: projectInfo.isSrcDir,
|
||||
rsc: projectInfo.isRSC,
|
||||
typescript: projectInfo.isTsx,
|
||||
tailwindVersion: projectInfo.tailwindVersion ?? null,
|
||||
tailwindConfig: projectInfo.tailwindConfigFile ?? null,
|
||||
tailwindCss: projectInfo.tailwindCssFile ?? null,
|
||||
importAlias: projectInfo.aliasPrefix ?? null,
|
||||
}
|
||||
: null,
|
||||
config: config
|
||||
? {
|
||||
style: config.style,
|
||||
base,
|
||||
rsc: config.rsc,
|
||||
typescript: config.tsx,
|
||||
iconLibrary: config.iconLibrary ?? null,
|
||||
rtl: config.rtl ?? false,
|
||||
menuColor: config.menuColor ?? null,
|
||||
menuAccent: config.menuAccent ?? null,
|
||||
aliases: {
|
||||
components: config.aliases.components,
|
||||
utils: config.aliases.utils,
|
||||
ui: config.aliases.ui ?? null,
|
||||
lib: config.aliases.lib ?? null,
|
||||
hooks: config.aliases.hooks ?? null,
|
||||
},
|
||||
resolvedPaths: {
|
||||
cwd: config.resolvedPaths.cwd,
|
||||
tailwindConfig: config.resolvedPaths.tailwindConfig || null,
|
||||
tailwindCss: config.resolvedPaths.tailwindCss || null,
|
||||
utils: config.resolvedPaths.utils,
|
||||
components: config.resolvedPaths.components,
|
||||
lib: config.resolvedPaths.lib,
|
||||
hooks: config.resolvedPaths.hooks,
|
||||
ui: config.resolvedPaths.ui,
|
||||
},
|
||||
registries: getRegistries(config.registries),
|
||||
}
|
||||
: null,
|
||||
preset,
|
||||
components,
|
||||
links: {
|
||||
docs: `${SHADCN_URL}/docs`,
|
||||
components: `${SHADCN_URL}/docs/components/${base}/[component].md`,
|
||||
ui: `${CODE_BASE}/${base}/ui/[component].tsx`,
|
||||
examples: `${CODE_BASE}/${base}/examples/[component]-example.tsx`,
|
||||
schema: `${SHADCN_URL}/schema.json`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
|
||||
// Project.
|
||||
logger.log(highlighter.info("Project"))
|
||||
if (data.project) {
|
||||
printEntries({
|
||||
framework: `${data.project.framework} (${data.project.frameworkName})`,
|
||||
frameworkVersion: data.project.frameworkVersion ?? "-",
|
||||
srcDirectory: data.project.srcDirectory ? "Yes" : "No",
|
||||
rsc: data.project.rsc ? "Yes" : "No",
|
||||
typescript: data.project.typescript ? "Yes" : "No",
|
||||
tailwindVersion: data.project.tailwindVersion ?? "-",
|
||||
tailwindConfig: data.project.tailwindConfig ?? "-",
|
||||
tailwindCss: data.project.tailwindCss ?? "-",
|
||||
importAlias: data.project.importAlias ?? "-",
|
||||
})
|
||||
} else {
|
||||
logger.log(" No project info detected.")
|
||||
}
|
||||
|
||||
// Config.
|
||||
logger.break()
|
||||
logger.log(highlighter.info("Configuration"))
|
||||
if (data.config) {
|
||||
printEntries({
|
||||
style: data.config.style,
|
||||
base: data.config.base,
|
||||
rsc: data.config.rsc ? "Yes" : "No",
|
||||
typescript: data.config.typescript ? "Yes" : "No",
|
||||
iconLibrary: data.config.iconLibrary ?? "-",
|
||||
rtl: data.config.rtl ? "Yes" : "No",
|
||||
menuColor: data.config.menuColor ?? "-",
|
||||
menuAccent: data.config.menuAccent ?? "-",
|
||||
})
|
||||
|
||||
logger.break()
|
||||
printPresetInfo(data.preset)
|
||||
|
||||
// Aliases.
|
||||
logger.break()
|
||||
logger.log(highlighter.info("Aliases"))
|
||||
printEntries({
|
||||
components: data.config.aliases.components,
|
||||
utils: data.config.aliases.utils,
|
||||
ui: data.config.aliases.ui ?? "-",
|
||||
lib: data.config.aliases.lib ?? "-",
|
||||
hooks: data.config.aliases.hooks ?? "-",
|
||||
})
|
||||
|
||||
// Resolved paths.
|
||||
logger.break()
|
||||
logger.log(highlighter.info("Resolved Paths"))
|
||||
printEntries({
|
||||
cwd: data.config.resolvedPaths.cwd,
|
||||
tailwindConfig: data.config.resolvedPaths.tailwindConfig ?? "-",
|
||||
tailwindCss: data.config.resolvedPaths.tailwindCss ?? "-",
|
||||
utils: data.config.resolvedPaths.utils,
|
||||
components: data.config.resolvedPaths.components,
|
||||
lib: data.config.resolvedPaths.lib,
|
||||
hooks: data.config.resolvedPaths.hooks,
|
||||
ui: data.config.resolvedPaths.ui,
|
||||
})
|
||||
|
||||
// Registries.
|
||||
if (Object.keys(data.config.registries).length > 0) {
|
||||
logger.break()
|
||||
logger.log("registries:")
|
||||
printEntries(data.config.registries)
|
||||
}
|
||||
} else {
|
||||
logger.log(" No components.json found.")
|
||||
}
|
||||
|
||||
// Installed components.
|
||||
logger.break()
|
||||
logger.log(highlighter.info("Installed Components"))
|
||||
if (data.components.length > 0) {
|
||||
logger.log(` ${data.components.join(", ")}`)
|
||||
} else {
|
||||
logger.log(" No components installed.")
|
||||
}
|
||||
|
||||
// Links.
|
||||
logger.break()
|
||||
logger.log(highlighter.info("Links"))
|
||||
printEntries(data.links)
|
||||
|
||||
logger.break()
|
||||
}
|
||||
|
||||
export function printPresetInfo(
|
||||
preset: Awaited<ReturnType<typeof collectInfo>>["preset"],
|
||||
options: {
|
||||
fallbackNote?: string
|
||||
} = {}
|
||||
) {
|
||||
logger.log(highlighter.info("Preset"))
|
||||
if (!preset?.code) {
|
||||
printEntries({
|
||||
code: "-",
|
||||
})
|
||||
} else {
|
||||
const fallbacks = preset.fallbacks ?? []
|
||||
const formatPresetValue = (key: string, value: string | undefined) => {
|
||||
const suffix = fallbacks.includes(key) ? "*" : ""
|
||||
return `${value ?? "-"}${suffix}`
|
||||
}
|
||||
|
||||
printEntries({
|
||||
code: preset.code,
|
||||
version: preset.code[0],
|
||||
style: preset.values?.style ?? "-",
|
||||
baseColor: formatPresetValue("baseColor", preset.values?.baseColor),
|
||||
theme: formatPresetValue("theme", preset.values?.theme),
|
||||
chartColor: formatPresetValue("chartColor", preset.values?.chartColor),
|
||||
iconLibrary: formatPresetValue("iconLibrary", preset.values?.iconLibrary),
|
||||
font: formatPresetValue("font", preset.values?.font),
|
||||
fontHeading: formatPresetValue("fontHeading", preset.values?.fontHeading),
|
||||
radius: formatPresetValue("radius", preset.values?.radius),
|
||||
menuAccent: formatPresetValue("menuAccent", preset.values?.menuAccent),
|
||||
menuColor: formatPresetValue("menuColor", preset.values?.menuColor),
|
||||
url: `${SHADCN_URL}/create?preset=${preset.code}`,
|
||||
})
|
||||
|
||||
if (fallbacks.length > 0) {
|
||||
logger.log("")
|
||||
logger.log(
|
||||
options.fallbackNote ??
|
||||
" * Uses preset defaults for values not available as options on shadcn/create."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printEntries(entries: Record<string, string>) {
|
||||
const maxKeyLength = Math.max(
|
||||
...Object.keys(entries).map((key) => key.length)
|
||||
)
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
logger.log(` ${key.padEnd(maxKeyLength + 2)}${value}`)
|
||||
}
|
||||
}
|
||||
298
packages/shadcn/src/commands/init.test.ts
Normal file
298
packages/shadcn/src/commands/init.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { mkdir, mkdtemp, rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { preFlightInit } from "@/src/preflights/preflight-init"
|
||||
import { templates } from "@/src/templates"
|
||||
import { addComponents } from "@/src/utils/add-components"
|
||||
import { createProject } from "@/src/utils/create-project"
|
||||
import { MISSING_DIR_OR_EMPTY_PROJECT } from "@/src/utils/errors"
|
||||
import {
|
||||
getProjectConfig,
|
||||
getProjectInfo,
|
||||
getProjectTailwindVersionFromConfig,
|
||||
} from "@/src/utils/get-project-info"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { applyInitUrlOptions, init, initOptionsSchema, runInit } from "./init"
|
||||
|
||||
vi.mock("@/src/preflights/preflight-init", () => ({
|
||||
preFlightInit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/create-project", () => ({
|
||||
createProject: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/add-components", () => ({
|
||||
addComponents: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/registries", () => ({
|
||||
ensureRegistriesInConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/registry/api", () => ({
|
||||
getRegistryBaseColors: vi.fn().mockResolvedValue([
|
||||
{
|
||||
label: "Zinc",
|
||||
name: "zinc",
|
||||
},
|
||||
]),
|
||||
getRegistryStyles: vi.fn().mockResolvedValue([
|
||||
{
|
||||
label: "New York",
|
||||
name: "new-york",
|
||||
},
|
||||
]),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-config", () => ({
|
||||
DEFAULT_COMPONENTS: "@/components",
|
||||
DEFAULT_TAILWIND_CONFIG: "tailwind.config.js",
|
||||
DEFAULT_TAILWIND_CSS: "app/globals.css",
|
||||
DEFAULT_UTILS: "@/lib/utils",
|
||||
explorer: {
|
||||
clearCaches: vi.fn(),
|
||||
},
|
||||
getConfig: vi.fn(),
|
||||
getWorkspaceConfig: vi.fn().mockResolvedValue(null),
|
||||
resolveConfigPaths: vi.fn(
|
||||
async (cwd: string, config: Record<string, unknown>) => ({
|
||||
...config,
|
||||
resolvedPaths: {
|
||||
cwd,
|
||||
tailwindConfig: path.resolve(cwd, "tailwind.config.js"),
|
||||
tailwindCss: path.resolve(cwd, "src/index.css"),
|
||||
utils: path.resolve(cwd, "src/lib/utils.ts"),
|
||||
components: path.resolve(cwd, "src/components"),
|
||||
lib: path.resolve(cwd, "src/lib"),
|
||||
hooks: path.resolve(cwd, "src/hooks"),
|
||||
ui: path.resolve(cwd, "src/components/ui"),
|
||||
},
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/get-project-info", () => ({
|
||||
getProjectComponents: vi.fn().mockResolvedValue([]),
|
||||
getProjectConfig: vi.fn(),
|
||||
getProjectInfo: vi.fn(),
|
||||
getProjectTailwindVersionFromConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/logger", () => ({
|
||||
logger: {
|
||||
break: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/spinner", () => ({
|
||||
spinner: vi.fn(() => ({
|
||||
fail: vi.fn(),
|
||||
start: vi.fn().mockReturnThis(),
|
||||
succeed: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/utils/highlighter", () => ({
|
||||
highlighter: {
|
||||
error: (value: string) => value,
|
||||
info: (value: string) => value,
|
||||
success: (value: string) => value,
|
||||
warn: (value: string) => value,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("prompts", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const projectInfo = {
|
||||
framework: {
|
||||
label: "Vite",
|
||||
links: {},
|
||||
name: "vite",
|
||||
},
|
||||
frameworkVersion: null,
|
||||
isRSC: false,
|
||||
isSrcDir: true,
|
||||
isTsx: true,
|
||||
tailwindConfigFile: null,
|
||||
tailwindCssFile: "src/index.css",
|
||||
tailwindVersion: "v4",
|
||||
aliasPrefix: "@",
|
||||
}
|
||||
|
||||
function createProjectConfig(cwd: string) {
|
||||
return {
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: "new-york",
|
||||
rsc: false,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "src/index.css",
|
||||
baseColor: "zinc",
|
||||
cssVariables: true,
|
||||
prefix: "",
|
||||
},
|
||||
iconLibrary: "lucide",
|
||||
rtl: false,
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
ui: "@/components/ui",
|
||||
hooks: "@/hooks",
|
||||
lib: "@/lib",
|
||||
utils: "@/lib/utils",
|
||||
},
|
||||
resolvedPaths: {
|
||||
cwd,
|
||||
tailwindConfig: "",
|
||||
tailwindCss: path.resolve(cwd, "src/index.css"),
|
||||
utils: path.resolve(cwd, "src/lib/utils.ts"),
|
||||
components: path.resolve(cwd, "src/components"),
|
||||
lib: path.resolve(cwd, "src/lib"),
|
||||
hooks: path.resolve(cwd, "src/hooks"),
|
||||
ui: path.resolve(cwd, "src/components/ui"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createInitOptions(cwd: string) {
|
||||
return {
|
||||
cwd,
|
||||
yes: true,
|
||||
defaults: true,
|
||||
force: false,
|
||||
reinstall: false,
|
||||
silent: true,
|
||||
isNewProject: false,
|
||||
cssVariables: true,
|
||||
installStyleIndex: true,
|
||||
template: "vite",
|
||||
} as Parameters<typeof runInit>[0]
|
||||
}
|
||||
|
||||
describe("runInit", () => {
|
||||
let cwd: string
|
||||
let originalPostInit: typeof templates.vite.postInit
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(path.join(os.tmpdir(), "shadcn-init-test-"))
|
||||
originalPostInit = templates.vite.postInit
|
||||
|
||||
vi.mocked(getProjectInfo).mockResolvedValue(projectInfo as any)
|
||||
vi.mocked(getProjectTailwindVersionFromConfig).mockResolvedValue("v4")
|
||||
vi.mocked(getProjectConfig).mockImplementation(async (projectCwd) =>
|
||||
createProjectConfig(projectCwd)
|
||||
)
|
||||
vi.mocked(ensureRegistriesInConfig).mockImplementation(
|
||||
async (_components, config) => ({
|
||||
config,
|
||||
newRegistries: [],
|
||||
discoveredRegistries: {},
|
||||
packageJsonRegistries: {},
|
||||
})
|
||||
)
|
||||
vi.mocked(addComponents).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
templates.vite.postInit = originalPostInit
|
||||
vi.clearAllMocks()
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("does not run template postInit for existing projects with an explicit template", async () => {
|
||||
const postInit = vi.fn()
|
||||
templates.vite.postInit = postInit
|
||||
vi.mocked(preFlightInit).mockResolvedValue({
|
||||
errors: {},
|
||||
projectInfo: projectInfo as any,
|
||||
})
|
||||
|
||||
await runInit(createInitOptions(cwd))
|
||||
|
||||
expect(postInit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("runs template postInit after creating a new project", async () => {
|
||||
const projectPath = path.join(cwd, "vite-app")
|
||||
await mkdir(projectPath)
|
||||
const postInit = vi.fn()
|
||||
templates.vite.postInit = postInit
|
||||
vi.mocked(preFlightInit).mockResolvedValue({
|
||||
errors: {
|
||||
[MISSING_DIR_OR_EMPTY_PROJECT]: true,
|
||||
},
|
||||
projectInfo: null,
|
||||
})
|
||||
vi.mocked(createProject).mockResolvedValue({
|
||||
projectPath,
|
||||
projectName: "vite-app",
|
||||
template: "vite",
|
||||
})
|
||||
|
||||
await runInit(createInitOptions(cwd))
|
||||
|
||||
expect(postInit).toHaveBeenCalledWith({ projectPath })
|
||||
})
|
||||
|
||||
it("does not run template postInit when isNewProject is true but createProject was skipped", async () => {
|
||||
const postInit = vi.fn()
|
||||
templates.vite.postInit = postInit
|
||||
|
||||
await runInit({
|
||||
...createInitOptions(cwd),
|
||||
skipPreflight: true,
|
||||
isNewProject: true,
|
||||
})
|
||||
|
||||
expect(createProject).not.toHaveBeenCalled()
|
||||
expect(postInit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("init options", () => {
|
||||
it("accepts aria as a base", () => {
|
||||
const result = initOptionsSchema.parse({
|
||||
...createInitOptions("/tmp/project"),
|
||||
base: "aria",
|
||||
})
|
||||
|
||||
expect(result.base).toBe("aria")
|
||||
expect(init.helpInformation()).toContain("(base, radix, aria)")
|
||||
})
|
||||
|
||||
it("parses pointer flags", () => {
|
||||
const result = initOptionsSchema.parse({
|
||||
...createInitOptions("/tmp/project"),
|
||||
pointer: true,
|
||||
})
|
||||
|
||||
expect(result.pointer).toBe(true)
|
||||
})
|
||||
|
||||
it("applies pointer to raw init urls", () => {
|
||||
const url = applyInitUrlOptions(
|
||||
new URL("https://ui.shadcn.com/init?preset=a0"),
|
||||
{ pointer: true }
|
||||
)
|
||||
|
||||
expect(url.searchParams.get("pointer")).toBe("true")
|
||||
})
|
||||
|
||||
it("removes pointer from raw init urls when disabled", () => {
|
||||
const url = applyInitUrlOptions(
|
||||
new URL("https://ui.shadcn.com/init?preset=a0&pointer=true"),
|
||||
{ pointer: false }
|
||||
)
|
||||
|
||||
expect(url.searchParams.has("pointer")).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user