Skip to content

Commit 17c2d1a

Browse files
amitbardosclaude
andcommitted
docs(platform/react): Tiny Sunny's chat on the hook, as a self-contained example
examples/react-chat/ replaces the single-file example: the chat (Chat.tsx), its parts (chatParts.tsx) and the four server functions (server.ts), with a README saying what to read first. It imports @base44/platform/react from the workspace, so it needs no copy of the package, and the type tests check it, JSX included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 55d7916 commit 17c2d1a

7 files changed

Lines changed: 271 additions & 36 deletions

File tree

‎packages/platform/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,4 @@ a `phase` (`idle`, `creating`, `loading`, `waiting`, `building`), an `error`, `c
193193
and the actions `send`, `create` and `clearError`. A question is a union on `kind`
194194
(`choice`, `input`, `approval`, `unknown`) and carries only the actions its kind allows;
195195
declining is always one of them. Every shape has JSDoc in `src/react/chat.types.ts`, and
196-
[`examples/react-chat.ts`](examples/react-chat.ts) shows a `fetch`-based `server`.
196+
[`examples/react-chat/`](examples/react-chat) shows Tiny Sunny's chat built on it, with its `server`.

‎packages/platform/examples/react-chat.ts‎

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use client";
2+
import { useState } from "react";
3+
import { useBase44Chat, type Base44App, type Base44ChatServer, type Question } from "@base44/platform/react";
4+
import { Approval, Button, Choice, Secrets, StatusLine, Step, Text } from "./chatParts";
5+
import { createApp, openLiveSession, sendMessage, submitToolCallInput } from "./server";
6+
7+
// The four server functions, handed to the library. Next.js calls them on the server.
8+
const server: Base44ChatServer = { createApp, openLiveSession, sendMessage, submitToolCallInput };
9+
10+
export type ChatProps = {
11+
app: Base44App | null;
12+
onAppCreated: (app: Base44App) => void;
13+
};
14+
15+
// Option A, the hook only: the hook holds all the Base44 logic, and the chat draws every item
16+
// itself with its own parts, choosing the part for each question kind.
17+
export default function Chat({ app, onAppCreated }: ChatProps) {
18+
const chat = useBase44Chat({ appId: app?.id ?? null, server, onAppCreated });
19+
const [prompt, setPrompt] = useState("");
20+
21+
function submit() {
22+
// No app yet: the first prompt creates one. Otherwise it goes to the selected app.
23+
if (app) chat.send(prompt);
24+
else chat.create(prompt);
25+
setPrompt("");
26+
}
27+
28+
return (
29+
<div className="flex h-full flex-col text-sm">
30+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
31+
{chat.items.map((item) => (
32+
<div key={item.id} className="flex flex-col gap-1">
33+
{item.text && <Text text={item.text} role={item.role} />}
34+
{item.steps.map((step) => (
35+
<Step key={step.id} {...step} />
36+
))}
37+
{item.question && <OpenQuestion question={item.question} />}
38+
</div>
39+
))}
40+
<StatusLine phase={chat.phase} error={chat.error} onDismiss={chat.clearError} />
41+
</div>
42+
<form onSubmit={(e) => { e.preventDefault(); submit(); }} className="m-3 flex gap-2 rounded-2xl border p-2">
43+
<input
44+
value={prompt}
45+
onChange={(e) => setPrompt(e.target.value)}
46+
placeholder={app ? "Ask for a change…" : "Describe your app…"}
47+
className="flex-1 bg-transparent px-2 outline-none"
48+
/>
49+
{/* canSend is false while a question is open: Base44 drops a message sent into a stopped turn. */}
50+
<Button disabled={!chat.canSend || !prompt.trim()}>Send</Button>
51+
</form>
52+
</div>
53+
);
54+
}
55+
56+
// Each kind carries only its own actions, already bound to this question's tool call.
57+
function OpenQuestion({ question }: { question: Question }) {
58+
switch (question.kind) {
59+
case "choice":
60+
return <Choice {...question} />;
61+
case "input":
62+
return <Secrets {...question} />;
63+
case "approval":
64+
return <Approval {...question} />;
65+
default:
66+
return <Button variant="ghost" onClick={question.decline}>Decline {question.action}</Button>;
67+
}
68+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Tiny Sunny's chat on `@base44/platform/react`, option A
2+
3+
The headless chat from [Tiny Sunny](https://github.com/base44/base44-platform-starter/tree/amitb/tiny-sunny-lean/examples/white-label-minimal/lean),
4+
rebuilt on the hook alone. It is type-checked with the package's tests,
5+
not run. Read the files in this order:
6+
7+
1. [`Chat.tsx`](Chat.tsx): the chat itself. It calls `useBase44Chat`, draws every item, and picks the part for each question kind.
8+
2. [`chatParts.tsx`](chatParts.tsx): Tiny's look. Each part takes the props it is called with. Tiny's shadcn
9+
`Button` and lucide icons are replaced by a small local button and text glyphs, so the example has
10+
no UI dependency.
11+
3. [`server.ts`](server.ts): the four calls the partner's backend provides, as Next.js server
12+
functions. Each wraps one Base44 REST call with the partner's credentials.
13+
14+
No Base44 detail appears in `Chat.tsx` or `chatParts.tsx`: no socket, no message ordering, no tool
15+
call ids, no answer formats. That all stays in the package.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"use client";
2+
import { useState, type ButtonHTMLAttributes } from "react";
3+
import type { ApprovalQuestion, ChatError, ChatItem, ChatPhase, ChatStep, ChoiceQuestion, InputQuestion } from "@base44/platform/react";
4+
5+
// Tiny's look. The hook has no parts API, so Chat.tsx calls these itself; a question part gets
6+
// the question with its bound actions.
7+
8+
// Stands in for Tiny's shadcn Button, so the example has no UI dependency.
9+
export function Button({ variant = "default", className = "", ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "outline" | "ghost" }) {
10+
const look = { default: "bg-primary text-primary-foreground", outline: "border", ghost: "" }[variant];
11+
return <button {...props} data-variant={variant} className={`rounded-md px-2.5 py-1 text-sm ${look} ${className}`} />;
12+
}
13+
14+
export function Text({ text, role }: { text: string; role: ChatItem["role"] }) {
15+
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>;
16+
}
17+
18+
const stepIcons = { running: "…", waiting: "?", done: "✓", error: "✗" };
19+
20+
export function Step({ label, status }: ChatStep) {
21+
return <p className="text-xs text-muted-foreground">{stepIcons[status]} {label}</p>;
22+
}
23+
24+
export function Choice({ questions, answer, decline }: ChoiceQuestion) {
25+
// One list of picked labels per question, in the same order as `questions`.
26+
const [picked, setPicked] = useState<string[][]>(questions.map(() => []));
27+
function toggle(i: number, option: string, multi: boolean) {
28+
setPicked((all) =>
29+
all.map((labels, j) => {
30+
if (j !== i) return labels;
31+
if (labels.includes(option)) return labels.filter((o) => o !== option);
32+
return multi ? [...labels, option] : [option];
33+
}),
34+
);
35+
}
36+
return (
37+
<div className="flex flex-col gap-3 rounded-xl border p-3">
38+
{questions.map((q, i) => (
39+
<div key={i} className="flex flex-col gap-1.5">
40+
<p className="font-medium">{q.text}</p>
41+
{q.description && <p className="text-xs text-muted-foreground">{q.description}</p>}
42+
<div className="flex flex-wrap gap-1">
43+
{q.options.map((option) => (
44+
<Button key={option} variant={picked[i].includes(option) ? "default" : "outline"} onClick={() => toggle(i, option, q.multi)}>
45+
{option}
46+
</Button>
47+
))}
48+
</div>
49+
</div>
50+
))}
51+
<div className="flex gap-2">
52+
<Button onClick={() => answer(picked)}>Send answer</Button>
53+
<Button variant="ghost" onClick={decline}>Decline</Button>
54+
</div>
55+
</div>
56+
);
57+
}
58+
59+
export function Secrets({ fields, submit, decline }: InputQuestion) {
60+
const [values, setValues] = useState<Record<string, string>>({});
61+
return (
62+
<div className="flex flex-col gap-2 rounded-xl border p-3">
63+
{fields.map((field) => (
64+
<input
65+
key={field.name}
66+
type="password"
67+
placeholder={field.name}
68+
title={field.description}
69+
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
70+
className="rounded-md border px-2 py-1"
71+
/>
72+
))}
73+
<div className="flex gap-2">
74+
<Button onClick={() => submit(values)}>Save</Button>
75+
<Button variant="ghost" onClick={decline}>Decline</Button>
76+
</div>
77+
</div>
78+
);
79+
}
80+
81+
export function Approval({ action, reason, approve, decline }: ApprovalQuestion) {
82+
return (
83+
<div className="flex flex-col gap-2 rounded-xl border p-3">
84+
<p className="font-medium">Allow {action}?</p>
85+
{reason && <p className="text-xs text-muted-foreground">{reason}</p>}
86+
<div className="flex gap-2">
87+
<Button onClick={approve}>Approve</Button>
88+
<Button variant="ghost" onClick={decline}>Decline</Button>
89+
</div>
90+
</div>
91+
);
92+
}
93+
94+
// The wording for each phase is the partner's, not the library's.
95+
const phaseText: Record<ChatPhase, string> = {
96+
idle: "",
97+
creating: "Creating app…",
98+
loading: "Loading the conversation…",
99+
waiting: "Waiting for your answer",
100+
building: "Building…",
101+
};
102+
103+
export function StatusLine({ phase, error, onDismiss }: { phase: ChatPhase; error: ChatError | null; onDismiss: () => void }) {
104+
if (error) {
105+
return (
106+
<p className="text-xs text-destructive">
107+
{error.message} <Button variant="ghost" onClick={onDismiss}>Dismiss</Button>
108+
</p>
109+
);
110+
}
111+
return <p className="text-xs text-muted-foreground">{phaseText[phase]}</p>;
112+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use server";
2+
// The four calls the hook needs, as Next.js server functions. They run only on the server, with
3+
// the partner's credentials, so the browser never sees them. Any backend language works: the hook
4+
// only needs functions with these signatures.
5+
import type { Base44App, LiveSession, ToolCallAnswer } from "@base44/platform/react";
6+
7+
const host = process.env.BASE44_PLATFORM_HOST!;
8+
const workspaceId = process.env.BASE44_WORKSPACE_ID!;
9+
10+
// The Apps API takes the integration account's personal access token.
11+
function headers() {
12+
return {
13+
Authorization: `Bearer ${process.env.BASE44_ACCESS_TOKEN}`,
14+
"X-Active-Workspace-Id": workspaceId,
15+
"Content-Type": "application/json",
16+
};
17+
}
18+
19+
// POST /api/apps: create an app from its first prompt. The build starts inside this call.
20+
export async function createApp(prompt: string): Promise<Base44App> {
21+
const response = await fetch(`${host}/api/apps`, {
22+
method: "POST",
23+
headers: headers(),
24+
body: JSON.stringify({ organization_id: workspaceId, name: prompt.slice(0, 80), initial_message: { content: prompt } }),
25+
});
26+
if (!response.ok) throw new Error(`Create app failed: ${response.status}`);
27+
const app = await response.json();
28+
return { id: app.id, name: app.name };
29+
}
30+
31+
// POST /api/service/socket-sessions: a read-only live-updates session for one app. It takes the
32+
// workspace key; the browser gets only the session token.
33+
export async function openLiveSession(appId: string): Promise<LiveSession> {
34+
const response = await fetch(`${host}/api/service/socket-sessions`, {
35+
method: "POST",
36+
headers: { Authorization: `Bearer ${process.env.BASE44_SVC_KEY}`, "Content-Type": "application/json" },
37+
body: JSON.stringify({ app_ids: [appId] }),
38+
});
39+
if (!response.ok) throw new Error(`Socket session failed: ${response.status}`);
40+
const session = await response.json();
41+
return { serverUrl: new URL(session.socket_url).origin, sessionToken: session.session_token };
42+
}
43+
44+
// POST /api/apps/{id}/chat/message: every prompt after the first.
45+
export async function sendMessage(appId: string, content: string): Promise<void> {
46+
const request = fetch(`${host}/api/apps/${appId}/chat/message`, { method: "POST", headers: headers(), body: JSON.stringify({ content }) });
47+
await acceptedOrRunning(request, "Send message");
48+
}
49+
50+
// POST /api/apps/{id}/chat/submit-tool-call-input: answer a question the builder waits on, or
51+
// decline it. The request id stays the same on a retry, so Base44 resumes the turn only once.
52+
export async function submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise<void> {
53+
const request = fetch(`${host}/api/apps/${appId}/chat/submit-tool-call-input`, {
54+
method: "POST",
55+
headers: { ...headers(), "X-Request-ID": `submit-${answer.toolCallId}` },
56+
body: JSON.stringify({
57+
tool_call_id: answer.toolCallId,
58+
message_id: answer.messageId,
59+
action: answer.approve ? "approved" : "rejected",
60+
extra_user_input: answer.extraUserInput ?? {},
61+
}),
62+
});
63+
await acceptedOrRunning(request, "Submit answer");
64+
}
65+
66+
// Send and submit stay open for the whole turn. Wait only long enough to catch a refusal; the live
67+
// session shows the rest.
68+
async function acceptedOrRunning(request: Promise<Response>, call: string) {
69+
const response = await Promise.race([request, new Promise<null>((resolve) => setTimeout(resolve, 3000, null))]);
70+
if (response && !response.ok) throw new Error(`${call} failed: ${response.status}`);
71+
}

‎packages/platform/tsconfig.type-tests.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
"@base44/platform/react": [
1212
"src/react/index.ts"
1313
]
14-
}
14+
},
15+
"jsx": "react-jsx"
1516
},
1617
"include": [
1718
"src/**/*.ts",
1819
"tests/types/**/*.ts",
19-
"examples/**/*.ts"
20+
"examples/**/*.ts",
21+
"examples/**/*.tsx"
2022
]
2123
}

0 commit comments

Comments
 (0)