Skip to content
Draft
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
67 changes: 28 additions & 39 deletions examples/white-label-minimal/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import { useBase44Chat, type Base44Chat, type Base44ChatBackend, type ChatItem, type ChatStep } from "@base44/platform/react";
import { useState } from "react";
import { Message, useChatActions, useChatState, type ChatStep, type ChatState, type StepProps, type TextProps } from "@base44/platform/react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { StickToBottom } from "use-stick-to-bottom";
import { createApp, openLiveSession, sendMessage, submitToolCallInput, type App } from "../server/base44";
import ToolQuestion from "./ToolQuestion";

// The four Base44 calls, run on the server. The hook calls them, so the browser never holds a key.
const backend: Base44ChatBackend = { createApp, openLiveSession, sendMessage, submitToolCallInput };

export default function Chat({ app, onAppCreated, onBuildingChange }: {
app: App | null;
onAppCreated: (app: App) => void;
onBuildingChange: (building: boolean) => void;
}) {
const chat = useBase44Chat({ appId: app?.id ?? null, backend, onAppCreated });
// The chat reads the chat that Workspace's <Base44ChatProvider> runs. <Message> draws each item with
// Tiny's parts: the text and step parts below, and the question parts from QuestionParts.
export default function Chat({ hasApp }: { hasApp: boolean }) {
const { items, status } = useChatState();
const { send, create } = useChatActions();
const [prompt, setPrompt] = useState("");
const building = chat.status === "building";
// Send only when the builder can take a prompt now. After a failed build, a prompt can ask for the fix.
const canSend = chat.status === "ready" || chat.status === "error";

useEffect(() => {
onBuildingChange(building);
}, [building, onBuildingChange]);
const canSend = status === "ready" || status === "error";

// The first prompt creates the app; every later one goes to it.
function submit() {
if (!canSend || !prompt.trim()) return;
if (app) chat.send(prompt);
else chat.create(prompt);
if (hasApp) send(prompt);
else create(prompt);
setPrompt("");
}

return (
<div className="chat">
<StickToBottom className="chat-scroll" initial="instant">
<StickToBottom.Content className="chat-messages">
{chat.items.map((item) => (
{items.map((item) => (
<div key={item.id} className="chat-message">
{item.text && <p className={textClass(item)}>{item.text}</p>}
{item.steps.map((step) =>
step.question ? (
<ToolQuestion key={step.id} question={step.question} />
) : (
<p key={step.id} className="chat-tool-line">{stepIcon(step)} {step.label}</p>
),
)}
<Message item={item} />
</div>
))}
<StatusLine chat={chat} hasApp={!!app} />
<StatusLine hasApp={hasApp} />
</StickToBottom.Content>
</StickToBottom>
<div className="chat-composer">
Expand All @@ -61,7 +43,7 @@ export default function Chat({ app, onAppCreated, onBuildingChange }: {
e.preventDefault();
submit();
}}
placeholder={app ? "Ask for a change…" : "Describe your app…"}
placeholder={hasApp ? "Ask for a change…" : "Describe your app…"}
className="chat-input"
rows={1}
/>
Expand All @@ -72,10 +54,15 @@ export default function Chat({ app, onAppCreated, onBuildingChange }: {
}

// A prompt Base44 hasn't confirmed yet shows faded.
function textClass(item: ChatItem) {
if (item.role === "assistant") return "chat-assistant-text";
if (item.pending) return "chat-user-text chat-pending";
return "chat-user-text";
export function Text({ text, role, pending }: TextProps) {
let className = "chat-user-text";
if (role === "assistant") className = "chat-assistant-text";
else if (pending) className = "chat-user-text chat-pending";
return <p className={className}>{text}</p>;
}

export function Step(step: StepProps) {
return <p className="chat-tool-line">{stepIcon(step)} {step.label}</p>;
}

function stepIcon(step: ChatStep) {
Expand All @@ -86,8 +73,10 @@ function stepIcon(step: ChatStep) {
return "✓";
}

// Tiny's wording for what the hook reports.
function StatusLine({ chat, hasApp }: { chat: Base44Chat; hasApp: boolean }) {
// Tiny's wording for what the chat reports.
function StatusLine({ hasApp }: { hasApp: boolean }) {
const chat: ChatState = useChatState();
const { clearError } = useChatActions();
let text = "";
if (chat.error) text = chat.error.message;
else if (chat.status === "submitted") text = hasApp ? "Sending…" : "Creating app…";
Expand All @@ -98,7 +87,7 @@ function StatusLine({ chat, hasApp }: { chat: Base44Chat; hasApp: boolean }) {
return (
<p className="chat-status">
{text}
{chat.error && <Button size="sm" variant="ghost" onClick={chat.clearError}>Dismiss</Button>}
{chat.error && <Button size="sm" variant="ghost" onClick={clearError}>Dismiss</Button>}
</p>
);
}
85 changes: 85 additions & 0 deletions examples/white-label-minimal/components/QuestionParts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import type { ApprovalQuestion, ChoiceQuestion, QuestionComponents, SecretsQuestion } from "@base44/platform/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

// Tiny's part for each question kind the builder can wait on. The provider picks the part, and
// each one gets its question with the actions already bound to its tool call.

// A failed answer opens the question again, so the buttons come back.
function useSend() {
const [sent, setSent] = useState(false);
async function send(action: () => Promise<void>) {
setSent(true);
await action();
setSent(false);
}
return { sent, send };
}

function Actions({ label, onSubmit, onDecline, sent }: { label: string; onSubmit: () => void; onDecline: () => void; sent: boolean }) {
return (
<div className="question-actions">
<Button size="sm" onClick={onSubmit} disabled={sent}>{label}</Button>
<Button size="sm" variant="ghost" onClick={onDecline} disabled={sent}>Decline</Button>
</div>
);
}

function Choice({ questions, answer, decline }: ChoiceQuestion) {
const { sent, send } = useSend();
const [picked, setPicked] = useState<string[][]>(() => questions.map(() => []));

function pick(index: number, label: string, multi: boolean) {
const current = picked[index];
let next = [label];
if (multi && current.includes(label)) next = current.filter((l) => l !== label);
else if (multi) next = [...current, label];
setPicked(picked.map((labels, i) => (i === index ? next : labels)));
}

return (
<div className="question">
{questions.map((q, i) => (
<div key={i} className="question-item">
<p className="question-title">{q.text}</p>
<div className="question-options">
{q.options.map((label) => (
<Button key={label} size="sm" variant={picked[i].includes(label) ? "default" : "outline"} onClick={() => pick(i, label, q.multi)}>
{label}
</Button>
))}
</div>
</div>
))}
<Actions label="Submit" onSubmit={() => send(() => answer(picked))} onDecline={() => send(decline)} sent={sent} />
</div>
);
}

// Tiny passes these values to Base44 and keeps none.
function Secrets({ fields, submit, decline }: SecretsQuestion) {
const { sent, send } = useSend();
const [values, setValues] = useState<Record<string, string>>({});
return (
<div className="question">
{fields.map(({ name }) => (
<Input key={name} type="password" placeholder={name} onChange={(e) => setValues({ ...values, [name]: e.target.value })} />
))}
<Actions label="Submit" onSubmit={() => send(() => submit(values))} onDecline={() => send(decline)} sent={sent} />
</div>
);
}

function Approval({ action, reason, approve, decline }: ApprovalQuestion) {
const { sent, send } = useSend();
return (
<div className="question">
<p>Allow {action}? {reason}</p>
<Actions label="Allow" onSubmit={() => send(approve)} onDecline={() => send(decline)} sent={sent} />
</div>
);
}

export const questionParts: QuestionComponents = { Choice, Secrets, Approval };
62 changes: 0 additions & 62 deletions examples/white-label-minimal/components/ToolQuestion.tsx

This file was deleted.

34 changes: 27 additions & 7 deletions examples/white-label-minimal/components/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { listApps, type App } from "../server/base44";
import { Base44ChatProvider, useChatState, type Base44ChatBackend } from "@base44/platform/react";
import { createApp, listApps, openLiveSession, sendMessage, submitToolCallInput, type App } from "../server/base44";
import AppPreview from "./AppPreview";
import Chat from "./Chat";
import Chat, { Step, Text } from "./Chat";
import Header from "./Header";
import { questionParts } from "./QuestionParts";

// The four Base44 calls, run on the server. The chat calls them, so the browser never holds a key.
const backend: Base44ChatBackend = { createApp, openLiveSession, sendMessage, submitToolCallInput };
// Tiny's parts. Every question kind needs one; the text and step parts are optional.
const components = { message: { Text, Step }, question: questionParts };

export default function Workspace() {
const [apps, setApps] = useState<App[]>([]);
Expand All @@ -28,11 +35,24 @@ export default function Workspace() {
[appId, liveAppId],
);

// One provider above everything that reads the chat.
return (
<div className="workspace">
<Header apps={apps} selectedAppId={appId} onSelectApp={setAppId} />
<AppPreview key={`preview-${appId}`} app={app} live={liveAppId === appId} />
<Chat app={app} onAppCreated={onAppCreated} onBuildingChange={onBuildingChange} />
</div>
<Base44ChatProvider appId={appId} backend={backend} onAppCreated={onAppCreated} components={components}>
<div className="workspace">
<Header apps={apps} selectedAppId={appId} onSelectApp={setAppId} />
<AppPreview key={`preview-${appId}`} app={app} live={liveAppId === appId} />
<Chat hasApp={!!app} />
<BuildWatcher onBuildingChange={onBuildingChange} />
</div>
</Base44ChatProvider>
);
}

// Tells the workspace when a build starts and ends, so the preview goes live and the app list refreshes.
function BuildWatcher({ onBuildingChange }: { onBuildingChange: (building: boolean) => void }) {
const building = useChatState().status === "building";
useEffect(() => {
onBuildingChange(building);
}, [building, onBuildingChange]);
return null;
}