Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const versions = { chatscope: ChatscopeChat, shadcn: ShadcnChat, "assistant-ui":
// For comparing: three chats that talk to Base44 by hand (without-sdk/), and one on the
// headless library (sdk/). Pick one with the tabs.
export default function Chat(props: ChatProps) {
const [version, setVersion] = useState<keyof typeof versions>("chatscope");
const [version, setVersion] = useState<keyof typeof versions>("headless");
const Version = versions[version];

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,61 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Message, useBase44Chat } from "../../../sdk";
import { Base44ChatProvider, Message, useChatActions, useChatState } from "../../../sdk";
import { server } from "./base44Server";
import { chatParts } from "./chatParts";
import { StatusLine, chatParts } from "./chatParts";
import type { ChatProps } from "../Chat";

// Only markup: the hook holds all the Base44 logic, and chatParts gives it Tiny's look.
// Option B, the provider: it runs the chat for the tree below it, and <Message> draws each item
// with Tiny's parts. The list and the composer read the chat from context.
export default function HeadlessChat({ app, onAppCreated }: ChatProps) {
const chat = useBase44Chat({ appId: app?.id ?? null, server });
const [prompt, setPrompt] = useState("");

return (
<div className="flex h-full flex-col text-sm">
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{chat.items.map((item) => (
<Message key={item.id} item={item} chat={chat} components={chatParts} />
))}
{chat.status && <p className="text-xs text-muted-foreground">{chat.status}</p>}
<Base44ChatProvider appId={app?.id ?? null} server={server} onAppCreated={onAppCreated} components={chatParts}>
<div className="flex h-full flex-col text-sm">
<Messages />
<Composer hasApp={!!app} />
</div>
<form
onSubmit={(e) => {
e.preventDefault();
chat.send(prompt).then((created) => created && onAppCreated(created));
setPrompt("");
}}
className="m-3 flex gap-2 rounded-2xl border p-2"
>
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Message…" className="flex-1 bg-transparent px-2 outline-none" />
<Button size="sm" disabled={!chat.canSend || !prompt.trim()}>Send</Button>
</form>
</Base44ChatProvider>
);
}

function Messages() {
const { items, phase, error } = useChatState();
const { clearError } = useChatActions();
return (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{items.map((item) => (
<div key={item.id} className="flex flex-col gap-1">
<Message item={item} />
</div>
))}
<StatusLine phase={phase} error={error} onDismiss={clearError} />
</div>
);
}

function Composer({ hasApp }: { hasApp: boolean }) {
const { canSend } = useChatState();
const { send, create } = useChatActions();
const [prompt, setPrompt] = useState("");

function submit() {
// No app yet: the first prompt creates one. Otherwise it goes to the selected app.
if (hasApp) send(prompt);
else create(prompt);
setPrompt("");
}

return (
<form onSubmit={(e) => { e.preventDefault(); submit(); }} className="m-3 flex gap-2 rounded-2xl border p-2">
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={hasApp ? "Ask for a change…" : "Describe your app…"}
className="flex-1 bg-transparent px-2 outline-none"
/>
{/* canSend is false while a question is open: Base44 drops a message sent into a stopped turn. */}
<Button size="sm" disabled={!canSend || !prompt.trim()}>Send</Button>
</form>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
import { useState } from "react";
import { Check, CircleHelp, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ApprovalProps, ChoiceProps, InputProps, MessageComponents, StepProps, TextProps } from "../../../sdk";
import type { ApprovalQuestion, ChatError, ChatPhase, ChoiceQuestion, InputQuestion, StepProps, TextProps, ChatComponentOverrides } from "../../../sdk";

// Tiny's look for the library's message parts: shadcn buttons, Tiny's theme, lucide icons.
// Every part is optional; anything left out uses the library's default.
// Tiny's look: shadcn buttons, Tiny's theme, lucide icons. Each part takes exactly the props
// the library hands it, and a question part gets the question with its bound actions.

function Text({ text, role }: TextProps) {
return <p className={role === "user" ? "rounded-2xl bg-muted px-4 py-2 whitespace-pre-wrap" : "whitespace-pre-wrap"}>{text}</p>;
export function Text({ text, role }: TextProps) {
return <p className={role === "user" ? "ml-auto max-w-[80%] rounded-2xl bg-muted px-4 py-2 whitespace-pre-wrap" : "whitespace-pre-wrap"}>{text}</p>;
}

const stepIcons = { running: Loader2, waiting: CircleHelp, done: Check, error: X };

function Step({ label, status }: StepProps) {
export function Step({ label, status }: StepProps) {
const Icon = stepIcons[status];
return (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
Expand All @@ -22,21 +22,27 @@ function Step({ label, status }: StepProps) {
);
}

function Choice({ questions, answer, decline }: ChoiceProps) {
const [picked, setPicked] = useState<string[][]>([]);
export function Choice({ questions, answer, decline }: ChoiceQuestion) {
// One list of picked labels per question, in the same order as `questions`.
const [picked, setPicked] = useState<string[][]>(questions.map(() => []));
function toggle(i: number, option: string, multi: boolean) {
const current = picked[i] ?? [];
const next = current.includes(option) ? current.filter((o) => o !== option) : multi ? [...current, option] : [option];
setPicked(Object.assign([...picked], { [i]: next }));
setPicked((all) =>
all.map((labels, j) => {
if (j !== i) return labels;
if (labels.includes(option)) return labels.filter((o) => o !== option);
return multi ? [...labels, option] : [option];
}),
);
}
return (
<div className="flex flex-col gap-3 rounded-xl border p-3">
{questions.map((q, i) => (
<div key={i} className="flex flex-col gap-1.5">
<p className="font-medium">{q.text}</p>
{q.description && <p className="text-xs text-muted-foreground">{q.description}</p>}
<div className="flex flex-wrap gap-1">
{q.options.map((option) => (
<Button key={option} size="sm" variant={picked[i]?.includes(option) ? "default" : "outline"} onClick={() => toggle(i, option, q.multi)}>
<Button key={option} size="sm" variant={picked[i].includes(option) ? "default" : "outline"} onClick={() => toggle(i, option, q.multi)}>
{option}
</Button>
))}
Expand All @@ -51,12 +57,19 @@ function Choice({ questions, answer, decline }: ChoiceProps) {
);
}

function Input({ fields, submit, decline }: InputProps) {
export function Secrets({ fields, submit, decline }: InputQuestion) {
const [values, setValues] = useState<Record<string, string>>({});
return (
<div className="flex flex-col gap-2 rounded-xl border p-3">
{fields.map((field) => (
<input key={field} type="password" placeholder={field} onChange={(e) => setValues({ ...values, [field]: e.target.value })} className="rounded-md border px-2 py-1" />
<input
key={field.name}
type="password"
placeholder={field.name}
title={field.description}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
className="rounded-md border px-2 py-1"
/>
))}
<div className="flex gap-2">
<Button size="sm" onClick={() => submit(values)}>Save</Button>
Expand All @@ -66,7 +79,7 @@ function Input({ fields, submit, decline }: InputProps) {
);
}

function Approval({ action, reason, approve, decline }: ApprovalProps) {
export function Approval({ action, reason, approve, decline }: ApprovalQuestion) {
return (
<div className="flex flex-col gap-2 rounded-xl border p-3">
<p className="font-medium">Allow {action}?</p>
Expand All @@ -79,4 +92,26 @@ function Approval({ action, reason, approve, decline }: ApprovalProps) {
);
}

export const chatParts: Partial<MessageComponents> = { Text, Step, Choice, Input, Approval };
// The wording for each phase is Tiny's, not the library's.
const phaseText: Record<ChatPhase, string> = {
idle: "",
creating: "Creating app…",
loading: "Loading the conversation…",
waiting: "Waiting for your answer",
building: "Building…",
};

export function StatusLine({ phase, error, onDismiss }: { phase: ChatPhase; error: ChatError | null; onDismiss: () => void }) {
if (error) {
return (
<p className="text-xs text-destructive">
{error.message} <Button size="xs" variant="ghost" onClick={onDismiss}>Dismiss</Button>
</p>
);
}
return <p className="text-xs text-muted-foreground">{phaseText[phase]}</p>;
}

// The same parts, grouped the way <Base44ChatProvider> takes them. Unknown is left out, so the
// library's unstyled fallback draws it.
export const chatParts: ChatComponentOverrides = { message: { Text, Step }, question: { Choice, Input: Secrets, Approval } };
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default function ToolQuestion({ appId, messageId, tool }: { appId: string
const [sent, setSent] = useState<"approved" | "declined" | null>(null);
const [secrets, setSecrets] = useState<Record<string, string>>({});

function answer(approve: boolean, extraUserInput?: object) {
function answer(approve: boolean, extraUserInput?: Record<string, unknown>) {
setSent(approve ? "approved" : "declined");
submitToolCallInput(appId, { toolCallId: tool.id!, messageId, approve, extraUserInput }).catch(() => setSent(null));
}
Expand Down
74 changes: 74 additions & 0 deletions examples/white-label-minimal/lean/sdk/Base44ChatProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";
import { useMemo } from "react";
import type { Base44ChatProviderProps, ChatComponents } from "./components.types";
import { ActionsContext, ComponentsContext, StateContext } from "./context";
import { fallbackComponents } from "./fallbacks";
import { useBase44Chat } from "./useBase44Chat";

/**
* Runs one app's builder chat 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} finds its parts.
*
* Put it as high as the components that need the chat: around a header that shows the phase,
* a preview that waits for the build, and the message list, they all read the same chat.
* Renders nothing of its own.
*
* @param props - The {@link useBase44Chat} options, the parts to replace, and the children.
* @returns The children, inside the chat's contexts.
*
* @example
* ```tsx
* // The provider, a message list, and a composer of your own
* <Base44ChatProvider appId={appId} server={server} onAppCreated={selectApp} components={{ question: { Approval: MyApproval } }}>
* <Messages />
* <Composer />
* </Base44ChatProvider>
*
* function Messages() {
* const { items } = useChatState();
* return items.map((item) => <div key={item.id}><Message item={item} /></div>);
* }
*
* function Composer() {
* const { canSend } = useChatState();
* const { send } = useChatActions();
* const [prompt, setPrompt] = useState("");
* return (
* <form onSubmit={(e) => { e.preventDefault(); send(prompt); setPrompt(""); }}>
* <input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
* <button disabled={!canSend}>Send</button>
* </form>
* );
* }
* ```
*/
export function Base44ChatProvider({ components, children, ...options }: Base44ChatProviderProps) {
const { items, phase, error, canSend, messages, send, create, clearError } = useBase44Chat(options);
const state = useMemo(() => ({ items, phase, error, canSend, messages }), [items, phase, error, canSend, messages]);
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, Input, Approval, Unknown } = components?.question ?? {};
const parts = useMemo<ChatComponents>(
() => ({
message: { Text: Text ?? fallbackComponents.message.Text, Step: Step ?? fallbackComponents.message.Step },
question: {
Choice: Choice ?? fallbackComponents.question.Choice,
Input: Input ?? fallbackComponents.question.Input,
Approval: Approval ?? fallbackComponents.question.Approval,
Unknown: Unknown ?? fallbackComponents.question.Unknown,
},
}),
[Text, Step, Choice, Input, Approval, Unknown],
);

return (
<ComponentsContext.Provider value={parts}>
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
</ComponentsContext.Provider>
);
}
85 changes: 51 additions & 34 deletions examples/white-label-minimal/lean/sdk/Message.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,61 @@
"use client";
import type { ComponentType } from "react";
import { Question, defaultQuestionComponents, type QuestionComponents } from "./Question";
import type { Base44Chat, ChatItem, ChatStep } from "./useBase44Chat";
import type { MessageProps, QuestionProps } from "./components.types";
import { useChatComponents } from "./context";

export type TextProps = { text: string; role: ChatItem["role"] };
export type StepProps = ChatStep;

// Every part of a message, in one flat set. Replace any of them; the rest stay default.
export type MessageComponents = {
Text: ComponentType<TextProps>;
Step: ComponentType<StepProps>;
} & QuestionComponents;

// Renders one chat item: its text, its steps, and its open question.
export function Message({ item, chat, components }: { item: ChatItem; chat: Base44Chat; components?: Partial<MessageComponents> }) {
const parts = { ...defaultMessageComponents, ...components };
const { Text, Step } = parts;
/**
* Draws one chat item with the parts from the nearest {@link Base44ChatProvider}: its text,
* one `Step` per tool call, and its open question. It adds no element of its own, so wrap it
* to lay messages out. Outside a provider it uses the unstyled fallbacks.
*
* @param props - The item to draw.
* @returns The item's parts.
*
* @example
* ```tsx
* // Align by role in your own wrapper
* <div className={item.role === "user" ? "ml-auto" : ""}>
* <Message item={item} />
* </div>
* ```
*/
export function Message({ item }: MessageProps) {
const { Text, Step } = useChatComponents().message;
return (
<div className={item.role === "user" ? "ml-auto max-w-[80%]" : "flex flex-col gap-1"}>
<>
{item.text && <Text text={item.text} role={item.role} />}
{item.steps.map((step) => (
{item.steps.map(step => (
<Step key={step.id} {...step} />
))}
{item.question && <Question question={item.question} chat={chat} components={parts} />}
</div>
{item.question && <Question question={item.question} />}
</>
);
}

function DefaultText({ text, role }: TextProps) {
return <p className={role === "user" ? "rounded-2xl bg-black/5 px-4 py-2 whitespace-pre-wrap" : "whitespace-pre-wrap"}>{text}</p>;
}

const stepIcons: Record<ChatStep["status"], string> = { running: "…", waiting: "?", done: "✓", error: "✗" };

function DefaultStep({ label, status }: StepProps) {
return (
<p className="text-xs opacity-60">
{stepIcons[status]} {label}
</p>
);
/**
* 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 an item's question; use this to show it somewhere else.
*
* @param props - The question to draw.
* @returns The part for the question's kind.
*
* @example
* ```tsx
* // The open question pinned above the composer
* const open = items.find((item) => item.question)?.question;
* return open ? <Question question={open} /> : null;
* ```
*/
export function Question({ question }: QuestionProps) {
const { Choice, Input, Approval, Unknown } = useChatComponents().question;
switch (question.kind) {
case "choice":
return <Choice {...question} />;
case "input":
return <Input {...question} />;
case "approval":
return <Approval {...question} />;
default:
return <Unknown {...question} />;
}
}

export const defaultMessageComponents: MessageComponents = { Text: DefaultText, Step: DefaultStep, ...defaultQuestionComponents };
Loading