diff --git a/eslint.config.js b/eslint.config.js index 595ecea0..6a887df0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ export default [ ignores: ["**/dist/**", "**/docs/**"], }, { - files: ["packages/*/src/**/*.ts"], + files: ["packages/*/src/**/*.{ts,tsx}"], languageOptions: { parser: tsParser, }, diff --git a/packages/platform/README.md b/packages/platform/README.md index 1bc34831..edf6883e 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -197,3 +197,33 @@ the first, and the chat watches it while `appId` is `null`. A sent prompt shows queue meanwhile. A question is a union on `kind` (`choice`, `secrets`, `approval`) and carries only the actions its kind allows, and declining is always one of them. As in Base44's builder, only the last assistant message makes the chat wait; a kind this release doesn't know is declined for you. Every shape has JSDoc in `src/react/chat.types.ts`. + +### Provider and parts + +`` calls the hook once and shares the chat through context. Any component +below it reads the chat with `useChatState()` (`items`, `status`, `connection`, `error`) or +`useChatActions()` (`send`, `create`, `clearError`), and `` draws an item with your parts. + +```tsx +import { Base44ChatProvider, Message, useChatState } from "@base44/platform/react"; + + + + + +function Messages() { + const { items } = useChatState(); + return items.map((item) =>
); +} +``` + +- **A part for every question kind is required.** TypeScript refuses a `components.question` + without `Choice`, `Secrets` and `Approval`, and a release that adds a kind adds a part, so an + upgrade tells you what to write. Until then the chat declines the new kind for you. +- **Message parts are optional.** `Text` and `Step` left out are drawn as plain HTML, with no + classes and a `data-base44` attribute for CSS. +- **A fixed order.** `` draws the text, then each step followed by its open question, if + it has one. It adds no element of its own. `` draws a question anywhere else below the provider. +- **Stable actions.** `useChatActions()` keeps its identity while the app stays the same, also with + an inline `backend` and `onAppCreated`, so a component that reads only actions doesn't re-render + when a message arrives. diff --git a/packages/platform/src/react/Base44ChatProvider.tsx b/packages/platform/src/react/Base44ChatProvider.tsx new file mode 100644 index 00000000..04c1abb0 --- /dev/null +++ b/packages/platform/src/react/Base44ChatProvider.tsx @@ -0,0 +1,81 @@ +"use client"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { Base44App } from "./chat.types.js"; +import type { Base44ChatProviderProps } from "./components.types.js"; +import { ActionsContext, ComponentsContext, StateContext, type ResolvedComponents } from "./context.js"; +import { fallbackMessageComponents } from "./fallbacks.js"; +import { useBase44Chat } from "./useBase44Chat.js"; + +/** + * Runs the AI chat for one app for the tree below it. It calls {@link useBase44Chat} once and + * shares the result through context, so any component below reads it with {@link useChatState} + * and {@link useChatActions}, and {@link Message} draws an item with your parts. + * + * Put it as high as the components that need the chat: a header that shows the status, a preview + * that waits for the build, and the message list all read the same chat. It renders nothing of its + * own. + * + * You pass a part for every question kind. The message parts, `Text` and `Step`, are optional: + * one left out is drawn as plain HTML with no classes and a `data-base44` attribute for CSS. + * + * @param props - The {@link useBase44Chat} options, the parts, and the children. + * @returns The children, inside the chat's contexts. + * + * @example + * ```tsx + * // The provider, a message list, and a composer of your own + * + * + * + * + * + * function Messages() { + * const { items } = useChatState(); + * return items.map((item) =>
); + * } + * + * function Composer() { + * const { status } = useChatState(); + * const { send } = useChatActions(); + * const [prompt, setPrompt] = useState(""); + * return ( + *
{ e.preventDefault(); send(prompt); setPrompt(""); }}> + * setPrompt(e.target.value)} /> + * + *
+ * ); + * } + * ``` + */ +export function Base44ChatProvider({ components, children, appId, backend, onAppCreated }: Base44ChatProviderProps) { + // The hook reads the backend through a ref already. The callback goes through one here, so an + // inline `onAppCreated` keeps `create`, and every reader of the actions, stable. + const onAppCreatedRef = useRef(onAppCreated); + useEffect(() => { + onAppCreatedRef.current = onAppCreated; + }); + const reportAppCreated = useCallback((app: Base44App) => onAppCreatedRef.current?.(app), []); + + const { items, status, connection, error, send, create, clearError } = useBase44Chat({ appId, backend, onAppCreated: reportAppCreated }); + const state = useMemo(() => ({ items, status, connection, error }), [items, status, connection, error]); + const actions = useMemo(() => ({ send, create, clearError }), [send, create, clearError]); + + // Merged part by part, so an inline `components={{…}}` with the same parts keeps the same identity. + const { Text, Step } = components.message ?? {}; + const { Choice, Secrets, Approval } = components.question; + const parts = useMemo( + () => ({ + message: { Text: Text ?? fallbackMessageComponents.Text, Step: Step ?? fallbackMessageComponents.Step }, + question: { Choice, Secrets, Approval }, + }), + [Text, Step, Choice, Secrets, Approval], + ); + + return ( + + + {children} + + + ); +} diff --git a/packages/platform/src/react/Message.tsx b/packages/platform/src/react/Message.tsx new file mode 100644 index 00000000..c6f68219 --- /dev/null +++ b/packages/platform/src/react/Message.tsx @@ -0,0 +1,67 @@ +"use client"; +import { Fragment } from "react"; +import type { MessageProps, QuestionProps } from "./components.types.js"; +import { useResolvedComponents } from "./context.js"; + +/** + * Draws one chat item with the parts from the nearest {@link Base44ChatProvider}, always in the + * same order: its text, then one `Step` per tool call, each followed by its open question, if it has + * one. Only the last assistant message carries questions. + * + * It adds no element of its own, so wrap it to lay messages out. + * + * @param props - The item to draw. + * @returns The item's parts. + * @throws {Error} When there is no provider above the component. + * + * @example + * ```tsx + * // Align by role in your own wrapper + *
+ * + *
+ * ``` + */ +export function Message({ item }: MessageProps) { + const { Text, Step } = useResolvedComponents("Message").message; + return ( + <> + {item.text && } + {item.steps.map(step => ( + + + {step.question && } + + ))} + + ); +} + +/** + * Draws an open question with the part for its kind, from the nearest {@link Base44ChatProvider}. + * The part receives the question with its bound actions. {@link Message} already draws a message's + * questions; use this to show one somewhere else. + * + * @param props - The question to draw. + * @returns The part for the question's kind. + * @throws {Error} When there is no provider above the component. + * + * @example + * ```tsx + * // The open questions, pinned above the composer + * const { items } = useChatState(); + * const open = items.flatMap((item) => item.steps.flatMap((step) => (step.question ? [step.question] : []))); + * return open.map((question, i) => ); + * ``` + */ +export function Question({ question }: QuestionProps) { + const { Choice, Secrets, Approval } = useResolvedComponents("Question").question; + switch (question.kind) { + case "choice": + return ; + case "secrets": + return ; + case "approval": + return ; + } +} diff --git a/packages/platform/src/react/components.types.ts b/packages/platform/src/react/components.types.ts new file mode 100644 index 00000000..c4c08ca5 --- /dev/null +++ b/packages/platform/src/react/components.types.ts @@ -0,0 +1,73 @@ +import type { ComponentType, ReactNode } from "react"; +import type { ApprovalQuestion, Base44Chat, Base44ChatOptions, ChatItem, ChatQuestion, ChatStep, ChoiceQuestion, SecretsQuestion } from "./chat.types.js"; + +/** Props of the `Text` part: one message's text. */ +export interface TextProps { + /** The message text, as raw markdown. Never empty: an item without text draws no `Text`. */ + text: string; + /** Who wrote the message, to align or color it. */ + role: ChatItem["role"]; + /** Whether this is a prompt Base44 hasn't confirmed yet. */ + pending: boolean; +} + +/** Props of the `Step` part: one tool call of a message. */ +export type StepProps = ChatStep; + +/** The parts of a message's own content. Each one left out is drawn as plain, unstyled HTML. */ +export interface MessageComponents { + /** Draws a message's text. */ + Text: ComponentType; + /** Draws one tool call as a line of activity. */ + Step: ComponentType; +} + +/** + * One part per question kind, all required. Each receives the question itself: its content and the + * actions bound to it, so a part can only answer the question it draws. + * + * A release that adds a kind adds a part here, so upgrading tells you at compile time which part to + * write. Until you upgrade, the chat declines the new kind for you. + */ +export interface QuestionComponents { + /** Draws a pick between options. Call `answer` with one list of labels per question. */ + Choice: ComponentType; + /** Draws a form for values such as API keys. Call `submit` with the values keyed by field name. */ + Secrets: ComponentType; + /** Draws a request to run an action. Call `approve` or `decline`. */ + Approval: ComponentType; +} + +/** The parts you pass to {@link Base44ChatProvider}: every question part, and the message parts you replace. */ +export interface ChatComponents { + /** Replacements for the message parts. */ + message?: Partial; + /** A part for every question kind. */ + question: QuestionComponents; +} + +/** What {@link useChatState} returns: everything that changes as the chat runs. */ +export type ChatState = Pick; + +/** What {@link useChatActions} returns. The actions keep their identity while the app stays the same. */ +export type ChatActions = Pick; + +/** Props of {@link Base44ChatProvider}: the {@link useBase44Chat} options, the parts, and the tree that reads them. */ +export interface Base44ChatProviderProps extends Base44ChatOptions { + /** The parts that draw the chat. Read part by part, so an inline object works. */ + components: ChatComponents; + /** The components that read the chat. */ + children?: ReactNode; +} + +/** Props of {@link Message}. */ +export interface MessageProps { + /** The item to draw, one of the `items` from {@link useChatState}. */ + item: ChatItem; +} + +/** Props of {@link Question}. */ +export interface QuestionProps { + /** The open question to draw, from {@link ChatStep.question}. */ + question: ChatQuestion; +} diff --git a/packages/platform/src/react/context.ts b/packages/platform/src/react/context.ts new file mode 100644 index 00000000..bb5d01aa --- /dev/null +++ b/packages/platform/src/react/context.ts @@ -0,0 +1,68 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { ChatActions, ChatState, MessageComponents, QuestionComponents } from "./components.types.js"; + +/** @internal The parts with the fallbacks applied, as {@link Base44ChatProvider} shares them. */ +export interface ResolvedComponents { + message: MessageComponents; + question: QuestionComponents; +} + +/** @internal Filled by {@link Base44ChatProvider}. State changes on every event; actions and parts do not. */ +export const StateContext = createContext(null); +/** @internal */ +export const ActionsContext = createContext(null); +/** @internal */ +export const ComponentsContext = createContext(null); + +/** + * Reads the chat's state from the nearest {@link Base44ChatProvider}: `items`, `status`, + * `connection` and `error`. A component that calls it re-renders on every live event. + * + * @returns The state of the chat the provider runs. + * @throws {Error} When there is no provider above the component. + * + * @example + * ```tsx + * // The message list + * function Messages() { + * const { items } = useChatState(); + * return items.map((item) =>
); + * } + * ``` + */ +export function useChatState(): ChatState { + return inside(useContext(StateContext), "useChatState"); +} + +/** + * Reads the chat's actions from the nearest {@link Base44ChatProvider}: `send`, `create` and + * `clearError`. They keep their identity while the app stays the same, also with an inline + * `backend` or `onAppCreated`, so a component that reads only actions doesn't re-render when a + * message arrives. + * + * @returns The actions of the chat the provider runs. + * @throws {Error} When there is no provider above the component. + * + * @example + * ```tsx + * // A button outside the chat that starts a new app + * function NewAppButton({ prompt }: { prompt: string }) { + * const { create } = useChatActions(); + * return ; + * } + * ``` + */ +export function useChatActions(): ChatActions { + return inside(useContext(ActionsContext), "useChatActions"); +} + +/** @internal The parts from the nearest provider. */ +export function useResolvedComponents(component: string): ResolvedComponents { + return inside(useContext(ComponentsContext), component); +} + +function inside(value: T | null, reader: string): T { + if (!value) throw new Error(`${reader} needs a above it`); + return value; +} diff --git a/packages/platform/src/react/fallbacks.tsx b/packages/platform/src/react/fallbacks.tsx new file mode 100644 index 00000000..1b01da5b --- /dev/null +++ b/packages/platform/src/react/fallbacks.tsx @@ -0,0 +1,25 @@ +"use client"; +import type { MessageComponents, StepProps, TextProps } from "./components.types.js"; + +// The message parts a partner does not replace: plain HTML, no classes, so the library has no look +// of its own. Each carries a `data-base44` attribute for CSS to hook on. Questions have no +// fallback: the partner supplies a part for every kind. + +function Text({ text, role, pending }: TextProps) { + return ( +

+ {text} +

+ ); +} + +function Step({ label, status }: StepProps) { + return ( +

+ {label} +

+ ); +} + +/** @internal */ +export const fallbackMessageComponents: MessageComponents = { Text, Step }; diff --git a/packages/platform/src/react/index.ts b/packages/platform/src/react/index.ts index 1b5a4af1..588e3a99 100644 --- a/packages/platform/src/react/index.ts +++ b/packages/platform/src/react/index.ts @@ -1,7 +1,14 @@ -/** The `@base44/platform/react` entry: the AI chat for one app, as a React hook. React is an optional peer, loaded only here. */ +/** The `@base44/platform/react` entry: the AI chat for one app, as a hook and as a provider that draws it with your parts. React is an optional peer, loaded only here. */ export { useBase44Chat } from "./useBase44Chat.js"; +export { Base44ChatProvider } from "./Base44ChatProvider.js"; +export { useChatActions, useChatState } from "./context.js"; +export { Message, Question } from "./Message.js"; export type { ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatBackend, ChatConnection, ChatError, ChatItem, ChatQuestion, ChatStatus, ChatStep, ChoiceEntry, ChoiceQuestion, SecretField, SecretsQuestion, ToolCallAnswer, } from "./chat.types.js"; +export type { + Base44ChatProviderProps, ChatActions, ChatComponents, ChatState, MessageComponents, MessageProps, QuestionComponents, QuestionProps, + StepProps, TextProps, +} from "./components.types.js"; export type { SocketSession } from "../client/modules/builder.types.js"; diff --git a/packages/platform/tests/package.test.mjs b/packages/platform/tests/package.test.mjs index 6d6bd1a5..db3e5a44 100644 --- a/packages/platform/tests/package.test.mjs +++ b/packages/platform/tests/package.test.mjs @@ -51,7 +51,7 @@ test("the server entrypoint resolves from the installed package", () => { }); test("the React modules that use hooks or context keep their \"use client\" directive", () => { - for (const file of ['dist/react/useBase44Chat.js']) { + for (const file of ['dist/react/useBase44Chat.js', 'dist/react/context.js', 'dist/react/Base44ChatProvider.js', 'dist/react/Message.js', 'dist/react/fallbacks.js']) { assert.match(readFileSync(path.join(installed, file), "utf8"), /^"use client";/, file); } }); @@ -62,8 +62,8 @@ test("react is an optional peer of the /react entry, never a dependency of the c assert.deepEqual(manifest.peerDependenciesMeta, { react: { optional: true } }); run(process.execPath, ["--input-type=module", "--eval", ` import assert from "node:assert/strict"; - import { useBase44Chat } from "@base44/platform/react"; - assert.equal(typeof useBase44Chat, "function"); + import { Base44ChatProvider, Message, Question, useBase44Chat, useChatActions, useChatState } from "@base44/platform/react"; + for (const fn of [Base44ChatProvider, Message, Question, useBase44Chat, useChatActions, useChatState]) assert.equal(typeof fn, "function"); `]); }); @@ -72,7 +72,7 @@ import { createPlatformServer, type PlatformServer } from "@base44/platform/serv const server: PlatformServer = createPlatformServer({ serverUrl: "https://example.test", apiKey: "key" }); void server; -import { useBase44Chat, type Base44ChatBackend, type ChatQuestion } from "@base44/platform/react"; +import { Base44ChatProvider, Message, useBase44Chat, useChatActions, useChatState, type Base44ChatBackend, type ChatComponents, type ChatQuestion } from "@base44/platform/react"; const backend: Base44ChatBackend = { async createApp(prompt) { return { id: "a".repeat(24), name: prompt }; }, async openLiveSession() { return { session_token: "token", socket_url: "https://example.test", socket_path: "/ws/socket.io/" }; }, @@ -83,7 +83,11 @@ declare const question: ChatQuestion; if (question.kind === "choice") void question.answer([["A"]]); // @ts-expect-error A choice is answered, not approved. if (question.kind === "choice") question.approve(); -void useBase44Chat; void backend; +// A question part receives the question itself, actions included. +const parts: ChatComponents = { question: { Choice: ({ answer }) => { void answer; return null; }, Secrets: ({ submit }) => { void submit; return null; }, Approval: ({ action, approve }) => { void action; void approve; return null; } } }; +// @ts-expect-error Every question kind needs a part. +const missing: ChatComponents = { question: { Approval: () => null } }; +void useBase44Chat; void backend; void parts; void missing; void Base44ChatProvider; void Message; void useChatState; void useChatActions; import { createPlatformClient, type PlatformEvent } from "@base44/platform"; const platform = createPlatformClient(); diff --git a/packages/platform/tests/types/react-provider.types.tsx b/packages/platform/tests/types/react-provider.types.tsx new file mode 100644 index 00000000..a83c9b16 --- /dev/null +++ b/packages/platform/tests/types/react-provider.types.tsx @@ -0,0 +1,37 @@ +import { Base44ChatProvider, Message, Question, useChatActions, useChatState, type ApprovalQuestion, type Base44ChatBackend, type ChatComponents, type ChatState, type QuestionComponents, type TextProps } from "@base44/platform/react"; + +declare const backend: Base44ChatBackend; +const question: QuestionComponents = { + Choice: ({ questions, answer, decline }) => , + Secrets: ({ fields, submit }) => , + Approval: ({ action, approve }) => , +}; +const Text = ({ text, role, pending }: TextProps) =>

{text}

; + +void ( + void app.id} components={{ message: { Text }, question }}> + + +); + +// @ts-expect-error The question parts are required. +void ; +// @ts-expect-error Every question kind needs a part; secrets is missing. +const partial: ChatComponents = { question: { Choice: question.Choice, Approval: question.Approval } }; +void partial; +// @ts-expect-error There is no fallback kind: an unknown question is declined by the chat. +const unknown: ChatComponents = { question: { ...question, Unknown: () => null } }; +void unknown; +// @ts-expect-error An approval part gets no answer action. +const wrongPart: ChatComponents = { question: { ...question, Approval: ({ answer }: ApprovalQuestion) => { void answer; return null; } } }; +void wrongPart; + +function Reader() { + const state: ChatState = useChatState(); + const { send, create, clearError } = useChatActions(); + // @ts-expect-error Actions are not state. + void state.send; + void send; void create; void clearError; + return state.items.flatMap(item => item.steps.flatMap(step => (step.question ? [] : []))); +} +void Reader; diff --git a/packages/platform/tests/unit/react/provider.test.tsx b/packages/platform/tests/unit/react/provider.test.tsx new file mode 100644 index 00000000..0cac3481 --- /dev/null +++ b/packages/platform/tests/unit/react/provider.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +import { act, memo } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { Message as BaseMessage } from "../../../src/client/modules/builder.events.generated.js"; +import type { PlatformSnapshot } from "../../../src/client/modules/builder.events.types.js"; +import type { SocketSession } from "../../../src/client/modules/builder.types.js"; +import { + Base44ChatProvider, Message, Question, useChatActions, useChatState, + type ApprovalQuestion, type Base44ChatBackend, type Base44ChatProviderProps, type ChoiceQuestion, type QuestionComponents, type SecretsQuestion, type StepProps, +} from "../../../src/react/index.js"; + +// The platform client is replaced by a recorder, as in the hook's own test. +type Sub = { onSnapshot(s: PlatformSnapshot): unknown; onEvent(e: unknown): unknown; onError(e: unknown): void }; +const fake = vi.hoisted(() => ({ subs: [] as Record[] })); +vi.mock("../../../src/client/client.js", () => ({ + createPlatformClient: () => ({ + builder: { + init() { + const subs: Record = {}; + fake.subs.push(subs); + return { connect: async () => {}, close() {}, subscribe(appId: string, opts: Sub) { subs[appId] = opts; return { appId, active: true, unsubscribe() {} }; } }; + }, + }, + }), +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const app = "a".repeat(24); +const message = (id: string, created: string, extra: Partial = {}): BaseMessage => ({ id, role: "assistant", metadata: { created_date: created }, ...extra }); +const makeBackend = () => ({ + createApp: vi.fn(async (prompt: string) => ({ id: "b".repeat(24), name: prompt })), + openLiveSession: vi.fn(async (): Promise => ({ session_token: "token", socket_url: "https://socket.test", socket_path: "/ws/socket.io/" })), + sendMessage: vi.fn(async () => {}), + submitToolCallInput: vi.fn(async () => {}), +}) satisfies Base44ChatBackend; + +// Only the last assistant message makes the chat wait, so it holds all three questions. +const conversation: BaseMessage[] = [ + message("m1", "1", { role: "user", content: "A habit app" }), + message("m2", "2", { + content: "A few things first.", + tool_calls: [ + { id: "t0", name: "write_file", status: "success", display: { file_paths: ["src/App.jsx"] } }, + { id: "t1", name: "ask_clarifying_questions", status: "waiting_for_user_input", waiting_on: { kind: "choice" }, arguments: { questions: [{ question: "Tracked how?", options: ["Daily", { label: "Weekly" }] }] } }, + { id: "t2", name: "send_email", status: "waiting_for_user_input", waiting_on: { kind: "approval" }, approval: { guard: "g", reason: "Sends mail" } }, + { id: "t3", name: "set_secrets", status: "waiting_for_user_input", waiting_on: { kind: "input" }, arguments: { secrets_schema: [{ secretName: "API_KEY" }] } }, + ], + } as Partial), +]; + +// The partner's question parts: each answers through the actions it was given. +const questionParts: QuestionComponents = { + Choice: ({ questions, answer }: ChoiceQuestion) => , + Secrets: ({ fields, submit }: SecretsQuestion) => , + Approval: ({ action, reason, decline }: ApprovalQuestion) => , +}; + +function Messages() { + const { items } = useChatState(); + return <>{items.map(item =>
)}; +} + +let container: HTMLDivElement; +let root: Root; +const settle = () => act(async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }); +async function render(node: React.ReactNode) { + await act(async () => root.render(node)); + await settle(); +} +const snapshot = (messages: BaseMessage[]) => act(async () => { await fake.subs.at(-1)![app].onSnapshot({ status: { state: "ready" }, messages } as PlatformSnapshot); }); +const event = (e: unknown) => act(async () => { await fake.subs.at(-1)![app].onEvent(e); }); +const $ = (selector: string) => container.querySelector(selector); +const click = (el: Element | null) => act(async () => { (el as HTMLElement).click(); }); + +beforeEach(() => { + fake.subs.length = 0; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +describe("drawing a message", () => { + test("text, then each step followed by its own question, with no element of its own", async () => { + await render(); + await snapshot(conversation); + const drawn = [...($('[data-item="m2"]')?.children ?? [])].map(el => el.getAttribute("data-base44") ?? `part:${el.getAttribute("data-part")}`); + expect(drawn).toEqual(["text", "step", "step", "part:choice", "step", "part:approval", "step", "part:secrets"]); + expect($('[data-item="m1"] [data-base44="text"]')?.dataset.role).toBe("user"); + const steps = [...container.querySelectorAll('[data-item="m2"] [data-base44="step"]')]; + expect(steps.map(step => [step.textContent, step.dataset.status])).toEqual([ + ["write_file src/App.jsx", "done"], ["ask_clarifying_questions", "waiting"], ["send_email", "waiting"], ["set_secrets", "waiting"], + ]); + expect($('[data-part="approval"]')?.textContent).toBe("send_email: Sends mail"); + }); + + test("each question part answers its own tool call", async () => { + const backend = makeBackend(); + await render(); + await snapshot(conversation); + await click($('[data-part="choice"]')); + expect(backend.submitToolCallInput).toHaveBeenLastCalledWith(app, { + toolCallId: "t1", messageId: "m2", approve: true, extraUserInput: { answers: [{ question_index: 0, selected_labels: ["Weekly"] }] }, + }); + expect($('[data-part="choice"]')).toBeNull(); + await click($('[data-part="secrets"]')); + expect(backend.submitToolCallInput).toHaveBeenLastCalledWith(app, { toolCallId: "t3", messageId: "m2", approve: true, extraUserInput: { secrets: { API_KEY: "secret" } } }); + await click($('[data-part="approval"]')); + expect(backend.submitToolCallInput).toHaveBeenLastCalledWith(app, { toolCallId: "t2", messageId: "m2", approve: false, extraUserInput: undefined }); + }); + + test("replaced message parts draw instead of the fallbacks", async () => { + const Step = ({ label }: StepProps) => {label}; + await render(); + await snapshot(conversation); + expect([...container.querySelectorAll('[data-part="step"]')].map(step => step.textContent)).toContain("write_file src/App.jsx"); + expect($('[data-base44="step"]')).toBeNull(); + expect($('[data-item="m1"] [data-base44="text"]')).not.toBeNull(); + }); + + test("a sent prompt draws as pending until Base44 has it", async () => { + let actions!: ReturnType; + function Grab() { actions = useChatActions(); return null; } + await render(); + await snapshot([]); + await act(async () => { await actions.send("Add a streak"); }); + expect($('[data-base44="text"]')?.dataset.pending).toBe("true"); + await event({ type: "message.updated", appId: app, data: { message: message("m9", "9", { role: "user", content: "Add a streak" }) } }); + expect($('[data-base44="text"]')?.dataset.pending).toBeUndefined(); + }); + + test(" draws a question anywhere below the provider", async () => { + function Pinned() { + const { items } = useChatState(); + const open = items.flatMap(item => item.steps.flatMap(step => (step.question ? [step] : []))); + return <>{open.map(step => )}; + } + await render(); + await snapshot(conversation); + expect([...container.querySelectorAll("[data-part]")].map(el => el.getAttribute("data-part"))).toEqual(["choice", "approval", "secrets"]); + }); +}); + +describe("reading the chat", () => { + test("the readers and parts fail clearly outside a provider", async () => { + const errors: string[] = []; + function Outside() { + for (const read of [useChatState, useChatActions]) { + try { read(); } catch (e) { errors.push((e as Error).message); } + } + return null; + } + await render(); + expect(errors).toEqual(["useChatState needs a above it", "useChatActions needs a above it"]); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(render()).rejects.toThrow("Message needs a above it"); + spy.mockRestore(); + }); + + test("state readers re-render on events, action readers do not", async () => { + const renders = { state: 0, actions: 0 }; + function StateReader() { renders.state++; useChatState(); return null; } + function ActionsReader() { renders.actions++; useChatActions(); return null; } + await render(); + const before = { ...renders }; + await snapshot([message("m1", "1", { content: "one" })]); + await event({ type: "message.updated", appId: app, data: { message: message("m2", "2", { content: "two" }) } }); + expect(renders.state).toBeGreaterThan(before.state); + expect(renders.actions).toBe(before.actions); + }); + + test("an inline backend, callback and parts keep the actions and the parts stable", async () => { + const renders = { actions: 0, parts: 0 }; + const ActionsReader = memo(function ActionsReader() { renders.actions++; useChatActions(); return null; }); + const PartsReader = memo(function PartsReader() { renders.parts++; return ; }); + const Step = ({ label }: StepProps) => {label}; + const backend = makeBackend(); + const tree = (n: number, props: Partial = {}) => ( + void n} components={{ message: { Step }, question: { ...questionParts } }} {...props}> + + + + ); + await render(tree(1)); + await render(tree(2)); + await render(tree(3)); + expect(renders).toEqual({ actions: 1, parts: 1 }); + // A part that really changes does reach the message. + await render(tree(4, { components: { message: { Text: ({ text }) => {text} }, question: questionParts } })); + expect(renders.actions).toBe(1); + expect($('[data-part="text"]')?.textContent).toBe("hi"); + }); + + test("actions from context reach the latest backend and callback", async () => { + const first = makeBackend(), second = makeBackend(); + const onAppCreated = vi.fn(); + let actions!: ReturnType; + function Grab() { actions = useChatActions(); return null; } + await render( {}} components={{ question: questionParts }}>); + const firstActions = actions; + await render(); + expect(actions).toBe(firstActions); + await act(async () => { await actions.create("A tiny app"); }); + expect(first.createApp).not.toHaveBeenCalled(); + expect(second.createApp).toHaveBeenCalledWith("A tiny app"); + expect(onAppCreated).toHaveBeenCalledWith({ id: "b".repeat(24), name: "A tiny app" }); + }); +}); diff --git a/packages/platform/tsconfig.json b/packages/platform/tsconfig.json index df59da57..4f01fc51 100644 --- a/packages/platform/tsconfig.json +++ b/packages/platform/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx" }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/packages/platform/tsconfig.type-tests.json b/packages/platform/tsconfig.type-tests.json index 7bb14cbe..7e2a9137 100644 --- a/packages/platform/tsconfig.type-tests.json +++ b/packages/platform/tsconfig.type-tests.json @@ -10,5 +10,5 @@ "@base44/platform/react": ["src/react/index.ts"] } }, - "include": ["src/**/*.ts", "tests/types/**/*.ts", "examples/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/types/**/*.ts", "tests/types/**/*.tsx", "examples/**/*.ts", "examples/**/*.tsx"] } diff --git a/packages/platform/vitest.config.ts b/packages/platform/vitest.config.ts index fbeac687..41544006 100644 --- a/packages/platform/vitest.config.ts +++ b/packages/platform/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["tests/unit/**/*.test.{js,ts}"], + include: ["tests/unit/**/*.test.{js,ts,tsx}"], }, });