From eab1b53dc501d0449cfd47ee2178e9305abef6ee Mon Sep 17 00:00:00 2001 From: amitbardos Date: Wed, 21 Oct 2026 13:28:18 +0300 Subject: [PATCH 1/2] Sunny: run the builder chat on @base44/platform/react's useChat AppBuilderSidebar follows the app live over a socket session instead of polling getConversation. The proxy gains openLiveSession, app-scoped and ownership-gated like every other app action, and drops getConversation, which nothing calls now. getApp is fetched again on each change of the chat's status, for the commit and deploy fields the chat doesn't carry. The widgets take the hook's bound question (answer, submit, approve, decline) instead of a raw tool call, through one QuestionWidget switch; useSubmitToolInput gives way to useAnswer. A secrets Skip is now a decline. Co-Authored-By: Claude Opus 5.5 --- scripts/base44-smoke.ts | 2 +- src/app/api/base44/platform/route.ts | 28 +- src/components/AppBuilderSidebar.jsx | 401 ++++++------------ src/components/builder/QuestionWidget.jsx | 14 + src/components/builder/ToolRow.jsx | 19 +- src/components/builder/toolWidgets.js | 23 - src/components/builder/useAnswer.js | 21 + src/components/builder/useSubmitToolInput.js | 38 -- .../builder/widgets/ApprovalWidget.jsx | 103 ++--- .../widgets/ClarifyingQuestionsWidget.jsx | 130 ++---- .../builder/widgets/SecretsWidget.jsx | 92 ++-- src/lib/base44Platform.ts | 32 +- 12 files changed, 294 insertions(+), 609 deletions(-) create mode 100644 src/components/builder/QuestionWidget.jsx delete mode 100644 src/components/builder/toolWidgets.js create mode 100644 src/components/builder/useAnswer.js delete mode 100644 src/components/builder/useSubmitToolInput.js diff --git a/scripts/base44-smoke.ts b/scripts/base44-smoke.ts index 929e972..7212d49 100644 --- a/scripts/base44-smoke.ts +++ b/scripts/base44-smoke.ts @@ -218,7 +218,7 @@ async function main() { const foreign: Record[] = [ { action: "getApp", appId: APP }, { action: "renameApp", appId: APP, name: "mine now" }, - { action: "getConversation", appId: APP }, + { action: "openLiveSession", appId: APP }, { action: "sendMessage", appId: APP, content: "add a delete-all button" }, { action: "getPreviewUrl", appId: APP }, { action: "deployApp", appId: APP }, diff --git a/src/app/api/base44/platform/route.ts b/src/app/api/base44/platform/route.ts index 12acf7a..00db8fb 100644 --- a/src/app/api/base44/platform/route.ts +++ b/src/app/api/base44/platform/route.ts @@ -73,15 +73,6 @@ const DEFAULT_TIMEOUT_MS = 30_000; */ const BUILDER_TIMEOUT_MS = 120_000; -/** - * The full builder transcript, and it is polled every few seconds while a build - * runs. Every tool call's `arguments_string` and `results` come back with it — - * file contents included — so the body grows all build long and a late-build read - * does not reliably fit in 30s. A slow read is not an upstream fault; timing it - * out at the default just turns a working build into an error banner. - */ -const CONVERSATION_TIMEOUT_MS = 60_000; - const str = (v: unknown) => (v === undefined || v === null ? "" : String(v)); const num = (v: unknown, fallback: number) => String(Number(v) > 0 ? Number(v) : fallback); @@ -139,14 +130,15 @@ const OPS: Record = { path: (p) => `/api/apps/${str(p.appId)}`, body: (p) => ({ name: str(p.name).trim() }), }, - getConversation: { - method: "GET", - timeoutMs: CONVERSATION_TIMEOUT_MS, - path: (p) => - `/api/apps/${str(p.appId)}/chat/full-conversation?${new URLSearchParams({ - limit: num(p.limit, 100), - skip: String(Number(p.skip) || 0), - })}`, + /** + * A read-only socket session for one app, which the browser's chat + * (`useChat`) follows live. The body carries a session token scoped to this + * app, never the access token, so it goes to the browser unchanged. + */ + openLiveSession: { + method: "POST", + path: () => "/api/v1/socket-sessions", + body: (p) => ({ app_ids: [str(p.appId)] }), }, sendMessage: { method: "POST", @@ -200,7 +192,7 @@ const OPS: Record = { const APP_SCOPED = [ "getApp", "renameApp", - "getConversation", + "openLiveSession", "sendMessage", "getPreviewUrl", "deployApp", diff --git a/src/components/AppBuilderSidebar.jsx b/src/components/AppBuilderSidebar.jsx index 71ecbfc..99ed0b0 100644 --- a/src/components/AppBuilderSidebar.jsx +++ b/src/components/AppBuilderSidebar.jsx @@ -10,24 +10,25 @@ * 1. `useAuth().builderAvailable` gates the UI: is the Base44 bridge configured * on this deployment? One integration account builds every app, server-side, * so there is nothing per-user to connect — unavailable means "not here". - * 2. First submit → `platform.createApp()` with the prompt, a locally-derived - * name, and `buildCustomInstructions()` — the text Base44's builder applies on - * every turn. Later submits → `platform.sendMessage()` on the same app. - * 3. **Base44 builds asynchronously**, so there is no completion callback: this - * polls `getApp` + `getConversation` (fast while `status.state === "processing"`, - * slowly when idle) and re-renders from whatever the platform reports. - * 4. A builder turn can *pause* on a tool call needing input — approval, - * clarifying questions, secrets. Those arrive as `tool_calls` with status - * `waiting_for_user_input`, get rendered as widgets - * (`src/components/builder/`), and resume the turn via - * `submitToolCallInput()`. - * 5. Preview boots a sandbox (`getPreviewUrl`, up to ~40s) into an iframe; "Save + * 2. The chat runs on `useChat` from `@base44/platform/react`, over `chatBackend` + * below: four calls, each through the proxy. It follows the app live over a + * socket session the proxy opens (`openLiveSession`), so the conversation, + * the turn's status and its steps arrive as they happen, with no polling. + * 3. First submit → `chat.create()`, which calls `platform.createApp()` with the + * prompt, a locally-derived name, and `buildCustomInstructions()` — the text + * Base44's builder applies on every turn. Later submits → `chat.send()`. + * 4. A builder turn can *pause* on a question — approval, clarifying questions, + * secrets. The hook binds each to its tool call, the widgets in + * `src/components/builder/` render it, and its own actions resume the turn. + * 5. The app's own fields (commit hashes, deploy state) come from `getApp`, + * fetched again whenever the chat's status changes. + * 6. Preview boots a sandbox (`getPreviewUrl`, up to ~40s) into an iframe; "Save * to My Tools" deploys and records local ownership. * - * Because every reply is awaited against a moving target, `shownAppIdRef` records - * which app the chat is *meant* to be showing and every commit checks it first — - * otherwise a poll that lands after the user navigates away re-hydrates the app it - * was fetched for. + * Because every `getApp` reply is awaited against a moving target, `shownAppIdRef` + * records which app the chat is *meant* to be showing and every commit checks it + * first — otherwise a reply that lands after the user navigates away re-hydrates + * the app it was fetched for. */ import React, { useState, useEffect, useRef, useCallback } from "react"; import * as platform from "@/lib/base44Platform"; @@ -43,12 +44,12 @@ import { Widget as WidgetEntity } from "@/lib/entityClient"; import AppPreviewModal from "@/components/AppPreviewModal"; import { Textarea } from "@/components/ui/textarea"; import ReactMarkdown from "react-markdown"; +import { useChat } from "@base44/platform/react"; import { Send, Plus, Loader2, AlertTriangle, - ChevronDown, Bot, User, X, @@ -57,122 +58,73 @@ import { Hammer, } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; -import { widgetFor } from "@/components/builder/toolWidgets"; +import QuestionWidget from "@/components/builder/QuestionWidget"; +import ToolRow from "@/components/builder/ToolRow"; import AppReadyWidget from "@/components/builder/widgets/AppReadyWidget"; import PublishDialog from "@/components/market/PublishDialog"; import AppNameField from "@/components/AppNameField"; -const BUILDING_POLL_MS = 2500; -const IDLE_POLL_MS = 8000; -const TOOL_PENDING = ["running", "waiting_for_user_input"]; -const TOOL_FAILED = ["error", "stopped"]; - -function ToolCallDisplay({ toolCall }) { - const [expanded, setExpanded] = useState(false); - const isPending = TOOL_PENDING.includes(toolCall.status); - const isFailed = TOOL_FAILED.includes(toolCall.status); - let parsedArgs = null; - try { - parsedArgs = toolCall.arguments_string ? JSON.parse(toolCall.arguments_string) : null; - } catch {} - - return ( -
- - {expanded && ( -
- {toolCall.arguments_string && ( -
-

Arguments:

-
-                {parsedArgs ? JSON.stringify(parsedArgs, null, 2) : toolCall.arguments_string}
-              
-
- )} - {toolCall.results && ( -
-

Result:

-
-                {toolCall.results}
-              
-
- )} -
- )} -
- ); -} - -function renderToolCall(tc, i, { message, appId, isLastAssistant, onSubmitted }) { - const Widget = widgetFor(tc); - if (!Widget) return ; - - let parsedArgs = null; - try { - parsedArgs = tc.arguments_string ? JSON.parse(tc.arguments_string) : null; - } catch {} - - return ( - - ); +/** + * The four calls `useChat` makes, each through the allow-listed proxy. The first + * prompt names the app and carries the builder instructions; a tool-call answer + * travels under a request id stable per tool call (see `submitRequestId`). + */ +const chatBackend = { + createApp: (prompt) => + platform.createApp({ + prompt, + name: suggestAppName(prompt), + customInstructions: buildCustomInstructions(), + }), + openLiveSession: (appId) => platform.openLiveSession(appId), + sendMessage: async (appId, content) => { + await platform.sendMessage(appId, content); + }, + submitToolCallInput: async (appId, { toolCallId, messageId, approve, extraUserInput }) => { + await platform.submitToolCallInput(appId, toolCallId, approve, extraUserInput, { messageId }); + }, +}; + +/** A step with an open question renders its widget; any other step is a line of activity. */ +function StepRow({ step }) { + if (step.question) return ; + // What the user answered stays under the step after the question closes. + const answered = step.answered?.map((a, i) => ( +

+ {a.question}: {[...a.labels, a.text].filter(Boolean).join(", ")} +

+ )); + return ; } -function MessageBubble({ message, appId, isLastAssistant, onSubmitted }) { - const isUser = message.role === "user"; - const text = - typeof message.content === "string" - ? message.content - : platform.messageText?.(message.content) || ""; - const toolCalls = message.tool_calls || []; - if (!text && toolCalls.length === 0) return null; +function MessageBubble({ item }) { + const isUser = item.role === "user"; + if (!item.text && item.steps.length === 0) return null; return ( -
+
{isUser ? : }
- {text && ( + {item.text && (
{isUser ? ( -

{text}

+

{item.text}

) : ( - {text} + {item.text} )}
)} - {toolCalls.map((tc, i) => - renderToolCall(tc, i, { message, appId, isLastAssistant, onSubmitted }), - )} + {item.steps.map((step) => ( + + ))}
); @@ -199,9 +151,7 @@ export default function AppBuilderSidebar({ const [apps, setApps] = useState([]); const [isLoadingApps, setIsLoadingApps] = useState(false); const [activeApp, setActiveApp] = useState(null); - const [buildMessages, setBuildMessages] = useState([]); const [buildInput, setBuildInput] = useState(""); - const [isSending, setIsSending] = useState(false); const [buildView, setBuildView] = useState("list"); // "list" | "chat" const { builderAvailable, recheck } = useAuth(); @@ -210,9 +160,6 @@ export default function AppBuilderSidebar({ const [builderReady, setBuilderReady] = useState(null); const [error, setError] = useState(null); - // A send in flight. The composer holds the text until the conversation carries - // it; `userCount` is the fallback for when the server normalises what it stores. - const [pendingSend, setPendingSend] = useState(null); // Which turn the ready card was dismissed for. Openness itself is derived below. const [dismissedReadyFor, setDismissedReadyFor] = useState(null); // Server truth, so re-entering cannot offer to pin an already-pinned app. @@ -259,23 +206,34 @@ export default function AppBuilderSidebar({ const shownAppIdRef = useRef(null); // Same idea for the preview poll loop, which runs for up to 40s. const previewReqRef = useRef(0); - // One refresh at a time. A conversation fetch can outlast the poll interval — - // the transcript grows all build long — and overlapping refreshes each re-fetch - // a bigger body, so they queue up behind each other until the proxy's timeout - // fires. The symptom is intermittent `getConversation` timeouts on a build that - // is otherwise progressing fine. - const refreshInFlightRef = useRef(false); + // Which composer a create came from. Leaving the composer bumps it, so an app + // created after the user moved on is listed but not opened. const composerReqRef = useRef(0); + const createReqRef = useRef(null); const resetComposer = useCallback(() => { composerReqRef.current++; setBuildInput(""); - setPendingSend(null); - setIsSending(false); }, []); const activeAppId = activeApp?.id || null; - const isBuilding = activeApp?.status?.state === "processing"; + + const handleAppCreated = (app) => { + setApps((prev) => [app, ...prev]); + if (createReqRef.current !== composerReqRef.current) return; + shownAppIdRef.current = app.id; + setActiveApp(app); + onAppCreated?.(app); + }; + const chat = useChat({ appId: activeAppId, backend: chatBackend, onAppCreated: handleAppCreated }); + const isBuilding = chat.status === "building"; + // Turn paused on a question: lock the composer so a typed prompt can't race + // into the paused turn. + const awaitingInput = chat.status === "waiting"; + const isSending = chat.status === "submitted"; + // A prompt goes only when the builder can take one now. After a failed build, + // a prompt can ask for the fix. + const canSend = chat.status === "ready" || chat.status === "error"; useEffect(() => { onActiveAppChange?.(activeApp); @@ -304,7 +262,7 @@ export default function AppBuilderSidebar({ useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [buildMessages, pendingSend]); + }, [chat.items]); useEffect(() => { if (embedded && open && requestId > 0 && initialAppId == null && buildView === "chat" && builderReady === true) { composerInputRef.current?.focus(); @@ -317,7 +275,6 @@ export default function AppBuilderSidebar({ shownAppIdRef.current = initialAppId; setBuildView("chat"); setActiveApp(null); - setBuildMessages([]); resetComposer(); setError(null); setDismissedReadyFor(null); @@ -325,14 +282,7 @@ export default function AppBuilderSidebar({ platform .getApp(initialAppId) .then((app) => { - if (shownAppIdRef.current !== initialAppId) return null; - setActiveApp(app); - return platform.getConversation(initialAppId, { limit: 100 }); - }) - .then((conv) => { - if (!conv || shownAppIdRef.current !== initialAppId) return; - const visible = (conv?.messages || []).filter((m) => !m.hidden); - setBuildMessages(visible); + if (shownAppIdRef.current === initialAppId) setActiveApp(app); }) .catch((err) => setError(err.message)); } @@ -388,35 +338,28 @@ export default function AppBuilderSidebar({ .finally(() => setIsLoadingApps(false)); }, [builderReady]); - // Poll for build updates + // The app's own fields, which the chat doesn't carry. const refresh = useCallback(async (appId) => { - const [app, conversation] = await Promise.all([ - platform.getApp(appId), - platform.getConversation(appId, { limit: 100 }), - ]); + const app = await platform.getApp(appId); // The list row is always safe to freshen — it is keyed by id either way. setApps((prev) => prev.map((a) => (a.id === app.id ? { ...a, ...app } : a))); // The chat is not: if the user has since gone back or started a new app, - // committing here would put the old conversation back on screen. + // committing here would put the old app back on screen. if (shownAppIdRef.current !== appId) return; - const visible = (conversation?.messages || []).filter((m) => !m.hidden); setActiveApp(app); - setBuildMessages(visible); }, []); + // A turn that starts, pauses or ends changes the app (status, commit hash), so + // fetch it again on every change of the chat's status. useEffect(() => { if (!activeAppId) return; - const id = setInterval(() => { - if (refreshInFlightRef.current) return; - refreshInFlightRef.current = true; - refresh(activeAppId) - .catch(() => {}) - .finally(() => { - refreshInFlightRef.current = false; - }); - }, isBuilding ? BUILDING_POLL_MS : IDLE_POLL_MS); - return () => clearInterval(id); - }, [activeAppId, isBuilding, refresh]); + refresh(activeAppId).catch(() => {}); + }, [activeAppId, chat.status, refresh]); + + // A 501 mid-session flips the builder off here too. + useEffect(() => { + if (platform.isBuilderUnavailable(chat.error?.cause)) setBuilderReady(false); + }, [chat.error]); // The one way to get to an empty composer. Clearing `shownAppIdRef` is the part // that matters: it invalidates any refresh already in flight for the app we are @@ -424,7 +367,6 @@ export default function AppBuilderSidebar({ const startNewApp = useCallback(() => { shownAppIdRef.current = null; setActiveApp(null); - setBuildMessages([]); resetComposer(); setError(null); setDismissedReadyFor(null); @@ -504,53 +446,29 @@ export default function AppBuilderSidebar({ }, []); // App build submit - const submitBuild = async () => { + const submitBuild = () => { const content = buildInput.trim(); - // Answer the pending user-input widget before sending a free-form message, - // or it races into the paused turn. - if (!content || isSending || pendingSend || awaitingInput || isBuilding) return; - const req = composerReqRef.current; + // Answer the open question before sending a free-form prompt, or it races + // into the paused turn. + if (!content || !canSend) return; // Carrying on, so the card steps aside. if (appReady) setDismissedReadyFor(lastAssistantId); setSavedAs(null); setEditedAppId(activeApp?.id ?? null); - // Deliberately not clearing the composer; the effect above retires it. - setPendingSend({ text: content, userCount: userMessageCount }); - setIsSending(true); setError(null); - try { - if (!activeApp) { - const name = suggestAppName(content); - const app = await platform.createApp({ - prompt: content, - name, - customInstructions: buildCustomInstructions(), - }); - setApps((prev) => [app, ...prev]); - if (composerReqRef.current !== req) return; - shownAppIdRef.current = app.id; - setActiveApp(app); - onAppCreated?.(app); - await refresh(app.id); - } else { - await platform.sendMessage(activeApp.id, content); - if (composerReqRef.current !== req) return; - await refresh(activeApp.id); - } - } catch (err) { - if (composerReqRef.current !== req) return; - if (platform.isBuilderUnavailable(err)) setBuilderReady(false); - setError(err.message); - setPendingSend(null); - } finally { - if (composerReqRef.current === req) setIsSending(false); + // The prompt shows at once as a pending item, so the composer clears now. + setBuildInput(""); + if (activeApp) { + chat.send(content); + return; } + createReqRef.current = composerReqRef.current; + chat.create(content); }; const selectApp = async (app) => { shownAppIdRef.current = app.id; setActiveApp(app); - setBuildMessages([]); resetComposer(); setError(null); setDismissedReadyFor(null); @@ -569,7 +487,6 @@ export default function AppBuilderSidebar({ // the user pressed "create new". shownAppIdRef.current = null; setActiveApp(null); - setBuildMessages([]); resetComposer(); setBuildView("list"); setError(null); @@ -625,71 +542,31 @@ export default function AppBuilderSidebar({ openPreviewRef.current = openPreview; - // Newest assistant message drives the running-shimmer; widget interactivity is - // gated on tool-call status, not position. - const lastBuildAssistantIndex = buildMessages.reduce( - (acc, m, i) => (m.role === "assistant" ? i : acc), - -1, - ); - // Turn paused on a user-input widget — lock the build composer so a typed - // message can't race into the paused turn. - const awaitingInput = buildMessages.some((m) => - (m.tool_calls || []).some((tc) => tc.status === "waiting_for_user_input"), - ); - - const userMessageCount = buildMessages.filter((m) => m.role === "user").length; - const lastAssistantId = - lastBuildAssistantIndex >= 0 ? (buildMessages[lastBuildAssistantIndex]?.id ?? null) : null; + const items = chat.items; + const lastAssistantId = items.findLast((item) => item.role === "assistant")?.id ?? null; /** * Derived, not remembered: it used to open on a `processing -> settled` edge, so * only the client that watched the build finish ever saw it. */ - // `status.state` reads "ready" in the gap between sending and the builder - // picking it up, which popped the card on the previous turn's result. The - // conversation's shape is reliable: assistant last, no tool call in flight. - const lastVisible = buildMessages.length ? buildMessages[buildMessages.length - 1] : null; - // Only this turn: a tool left `running` by an abandoned turn would otherwise hide - // the card for good, and it is the only way to publish. - const lastUserIndex = buildMessages.reduce((acc, m, i) => (m.role === "user" ? i : acc), -1); - const hasToolInFlight = buildMessages + // The conversation's shape: assistant last, nothing pending, no step in flight. + const lastItem = items.length ? items[items.length - 1] : null; + // Only this turn: a step left `running` by an abandoned turn would otherwise + // hide the card for good, and it is the only way to publish. + const lastUserIndex = items.findLastIndex((item) => item.role === "user"); + const hasStepInFlight = items .slice(lastUserIndex + 1) - .some((m) => (m.tool_calls || []).some((tc) => TOOL_PENDING.includes(tc.status))); + .some((item) => item.steps.some((step) => step.status === "running" || step.status === "waiting")); const appReady = Boolean(activeApp) && - !isBuilding && - !awaitingInput && - !hasToolInFlight && - !pendingSend && - lastVisible?.role === "assistant" && + chat.status === "ready" && + !hasStepInFlight && + lastItem?.role === "assistant" && + !lastItem.pending && lastAssistantId !== null && dismissedReadyFor !== lastAssistantId; - // Retire the held text once the conversation carries it. Two tests, because an - // exact match alone strands text the server normalised. - useEffect(() => { - if (!pendingSend) return; - const landed = - userMessageCount > pendingSend.userCount || - buildMessages.some( - (m) => - m.role === "user" && - (typeof m.content === "string" ? m.content : platform.messageText?.(m.content) || "") === - pendingSend.text, - ); - if (!landed) return; - setBuildInput((current) => (current === pendingSend.text ? "" : current)); - setPendingSend(null); - }, [pendingSend, buildMessages, userMessageCount]); - - // Never stay locked on an echo that never arrives. - useEffect(() => { - if (!pendingSend) return; - const t = setTimeout(() => setPendingSend(null), 30000); - return () => clearTimeout(t); - }, [pendingSend]); - // So the card cannot offer a pin that already exists. useEffect(() => { if (!activeAppId) { @@ -707,7 +584,7 @@ export default function AppBuilderSidebar({ }; }, [activeAppId, savedAs]); - // The builder reports no completion, so "finished" is an edge in the poll. + // "Finished" is an edge in the chat's status. const buildingAppIdRef = useRef(null); useEffect(() => { if (!activeAppId) { @@ -798,10 +675,15 @@ export default function AppBuilderSidebar({
- {error && ( + {(error || chat.error) && (
-

{error}

+

{error || chat.error.message}

+ {!error && ( + + )}
)} @@ -897,7 +779,7 @@ export default function AppBuilderSidebar({ {builderReady === true && buildView === "chat" && ( <>
- {!activeApp && buildMessages.length === 0 && ( + {!activeApp && items.length === 0 && (
@@ -919,21 +801,14 @@ export default function AppBuilderSidebar({
)} - {buildMessages.map((msg, i) => ( - refresh(activeAppId).catch(() => {})} - /> - ))} - {pendingSend && !buildMessages.some((message) => - message.role === "user" && - (typeof message.content === "string" ? message.content : platform.messageText?.(message.content) || "") === pendingSend.text, - ) && ( - + {activeApp && items.length === 0 && chat.connection === "connecting" && ( +
+ +
)} + {items.map((item) => ( + + ))} {isBuilding && (
building… @@ -974,7 +849,7 @@ export default function AppBuilderSidebar({