Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- **Breaking:** `ChatItem.createdAt` always ends in `Z`. If you added one yourself, remove it.
- `send()` and the answer actions resolve once Base44 has the prompt or answer. A backend failure
after that, such as a server timeout mid-turn, no longer sets `error`, so your `ChatBackend`
can simply forward the request.

## 0.5.0

The first release to build on. Versions 0.1.0 to 0.4.0 were previews and are deprecated on npm.
Expand Down
13 changes: 12 additions & 1 deletion packages/platform/src/react/chat-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function toItems(sortedMessages: Message[], answeredToolCallIds: string[]
id: message.id!,
role: message.role === "user" ? "user" : "assistant",
text: message.content ?? "",
createdAt: message.metadata?.created_date ?? "",
createdAt: toUtcTimestamp(message.metadata?.created_date),
pending: false,
steps: (message.tool_calls ?? []).map(toolCall => {
const isAnswerable = message === lastReply && !answeredToolCallIds.includes(toolCall.id!);
Expand All @@ -66,6 +66,17 @@ export function toItems(sortedMessages: Message[], answeredToolCallIds: string[]
}));
}

/** @internal Base44 writes UTC with no zone, which `new Date()` would read as local time. The
* timestamp comes out with its `Z`, in the shape a pending prompt has. Anything but an ISO date and
* time is left as it is: `new Date()` guesses at other shapes. */
export function toUtcTimestamp(date: string | null | undefined): string {
if (!date) return "";
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(date)) return date;
const hasZone = /(Z|[+-]\d{2}:?\d{2})$/i.test(date);
const parsed = new Date(hasZone ? date : `${date}Z`);
return Number.isNaN(parsed.getTime()) ? date : parsed.toISOString();
}

// What a tool's display projection may carry; each tool declares only some of it.
type ToolDisplay = { file_paths?: string[]; summary?: string; entity_name?: string; record_count?: number };

Expand Down
20 changes: 12 additions & 8 deletions packages/platform/src/react/chat.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ export interface ToolCallAnswer {
* `X-Request-ID` built from the tool call ID, so a retry resumes the turn only once.
*
* Base44 keeps the Send chat message and Submit tool-call input requests open until the AI
* finishes the turn. Resolve your call once Base44 accepts the request, not when it returns. The
* chat follows the rest live. A rejected promise from any call sets [`error`](#error).
* finishes the turn, which can take minutes. Forward the request and return its result: the hook
* learns from the live updates as soon as Base44 has the prompt or the answer, and a failure after
* that, such as a timeout on your server, doesn't set an error. A rejected promise from any call
* before then sets [`error`](#error). Next.js server actions run one at a time, so one that waits
* out the turn holds up every later call: resolve it once Base44 hasn't refused the request
* within a few seconds. Base44 refuses at once.
*
* @example
* ```typescript
Expand Down Expand Up @@ -114,15 +118,16 @@ export interface ChatBackend {
*
* @param appId - ID of the app.
* @param content - The prompt.
* @returns Promise resolving once Base44 accepts the prompt.
* @returns Promise resolving when Base44 answers the request, which can be when the turn ends.
*/
sendMessage(appId: string, content: string): Promise<void>;
/**
* Answers or declines a question the AI waits on.
* Answers or declines a question the AI waits on. If Base44 refuses the answer, reject the
* promise. The hook then sets [`error`](#error) and shows the question again.
*
* @param appId - ID of the app.
* @param answer - The tool call and the answer to it.
* @returns Promise resolving once Base44 accepts the answer.
* @returns Promise resolving when Base44 answers the request, which can be when the turn ends.
*/
submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise<void>;
}
Expand All @@ -136,9 +141,8 @@ export interface ChatItem {
/** The message text, as raw markdown. Show it before the message's steps. It's empty when the message only holds tool calls. */
text: string;
/**
* When the message was created, as an ISO 8601 timestamp in UTC. A timestamp from Base44 has no `Z`
* or offset, so add a `Z` before parsing it with `new Date()`. A pending prompt has the browser's
* time, which already ends in `Z`.
* When the message was created, as an ISO 8601 timestamp in UTC that ends in `Z`, such as
* `2026-10-25T11:00:00.000Z`. `new Date(createdAt)` reads it correctly in any time zone.
*/
createdAt: string;
/** One step per tool call, in order. */
Expand Down
74 changes: 57 additions & 17 deletions packages/platform/src/react/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,16 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR
// Tool calls declined for the user, and those whose decline failed and may try again.
const declinedToolCalls = useRef(new Set<string>());
const failedDeclines = useRef(new Set<string>());
// Tool calls answered from here whose submission ends when Base44 stops reporting them waiting.
const answersAwaitingBase44 = useRef(new Set<string>());
// Base44 keeps a send or an answer open until the turn ends, so the live updates say when Base44
// has it: that ends the action, and a failure after it, such as a timeout on the way, is no
// refusal. Each entry holds what resolves the action.
// Tool calls answered from here, whose submission ends when Base44 stops reporting them waiting.
const answersAwaitingBase44 = useRef(new Map<string, () => void>());
// Prompts sent from here, by pending id, until Base44's copy settles them. A prompt Base44 queues
// needs none: Base44 answers that send at once.
const promptsAwaitingBase44 = useRef(new Map<string, { resolve: () => void; shown: boolean }>());
// The tool calls Base44 reports waiting, as of the last render.
const waitingToolCalls = useRef(new Set<string>());

// The app `create` made, shown while `appId` is null. Any change to `appId` forgets it.
const [createdAppId, setCreatedAppId] = useState<string | null>(null);
Expand Down Expand Up @@ -224,23 +232,26 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR
};
}, [currentAppId]);

// Answers one tool call. Its id leaves the answered list again if the submit fails, so the
// question comes back.
// Answers one tool call. Its id leaves the answered list again if Base44 refuses the answer, so
// the question comes back.
const submitAnswer = useCallback(
(toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record<string, unknown>) => {
if (!currentAppId) return Promise.resolve();
const targetAppId = currentAppId;
setActionError(null);
setSubmittedTo(targetAppId);
setAnsweredToolCallIds(ids => [...ids, toolCallId]);
answersAwaitingBase44.current.add(toolCallId);
const answer = { toolCallId, messageId, approve, extraUserInput };
return backendRef.current.submitToolCallInput(targetAppId, answer).catch((error: unknown) => {
answersAwaitingBase44.current.delete(toolCallId);
if (!stillFollowing(targetAppId)) return;
setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId));
setSubmittedTo(null);
reportActionError(error);
return new Promise<void>(resolve => {
answersAwaitingBase44.current.set(toolCallId, resolve);
backendRef.current.submitToolCallInput(targetAppId, answer).then(resolve, (error: unknown) => {
resolve();
if (!answersAwaitingBase44.current.delete(toolCallId)) return;
if (!stillFollowing(targetAppId)) return;
setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId));
setSubmittedTo(null);
reportActionError(error);
});
});
},
[currentAppId, reportActionError],
Expand Down Expand Up @@ -268,6 +279,8 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR
declinedToolCalls.current.add(declineKey);
setAnsweredToolCallIds(ids => [...ids, toolCallId]);
backendRef.current.submitToolCallInput(followedAppId, { toolCallId, messageId, approve: false }).catch((error: unknown) => {
// A call that no longer waits has the decline: the failure came after it.
if (!waitingToolCalls.current.has(toolCallId)) return;
failedDeclines.current.add(declineKey);
setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId));
reportActionError(error);
Expand All @@ -280,16 +293,32 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR
// An answer's submission ends there too: Base44 has taken it once the call no longer waits.
useEffect(() => {
const stillWaiting = waitingToolCallIds(sortedMessages);
waitingToolCalls.current = stillWaiting;
setAnsweredToolCallIds(ids => (ids.every(answeredId => stillWaiting.has(answeredId)) ? ids : ids.filter(answeredId => stillWaiting.has(answeredId))));
let answerTaken = false;
for (const toolCallId of answersAwaitingBase44.current) {
for (const [toolCallId, resolveAnswer] of answersAwaitingBase44.current) {
if (stillWaiting.has(toolCallId)) continue;
answersAwaitingBase44.current.delete(toolCallId);
resolveAnswer();
answerTaken = true;
}
if (answerTaken) setSubmittedTo(target => (target === followedAppIdRef.current ? null : target));
}, [sortedMessages]);

// A prompt's send ends once Base44's copy settled the pending prompt. A prompt not shown yet is
// still on its way to the list, not settled.
useEffect(() => {
for (const [promptId, sentPrompt] of promptsAwaitingBase44.current) {
if (pendingPrompts.some(prompt => prompt.id === promptId)) {
sentPrompt.shown = true;
continue;
}
if (!sentPrompt.shown) continue;
promptsAwaitingBase44.current.delete(promptId);
sentPrompt.resolve();
}
}, [pendingPrompts]);

const submissionInFlight = (submittedTo === NEW_APP && !currentAppId) || (submittedTo !== null && submittedTo === currentAppId);
let status: ChatStatus = "ready";
if (buildState === "error") status = "error";
Expand All @@ -313,11 +342,22 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR
setActionError(null);
setSubmittedTo(targetAppId);
const pendingPrompt = addPendingPrompt(prompt, targetAppId);
return backendRef.current.sendMessage(targetAppId, prompt).catch((error: unknown) => {
setPendingPrompts(prompts => prompts.filter(other => other !== pendingPrompt));
if (!stillFollowing(targetAppId)) return;
setSubmittedTo(null);
reportActionError(error);
return new Promise<void>(resolve => {
promptsAwaitingBase44.current.set(pendingPrompt.id, { resolve, shown: false });
backendRef.current.sendMessage(targetAppId, prompt).then(
() => {
promptsAwaitingBase44.current.delete(pendingPrompt.id);
resolve();
},
(error: unknown) => {
resolve();
if (!promptsAwaitingBase44.current.delete(pendingPrompt.id)) return;
setPendingPrompts(prompts => prompts.filter(other => other.id !== pendingPrompt.id));
if (!stillFollowing(targetAppId)) return;
setSubmittedTo(null);
reportActionError(error);
},
);
});
},
[currentAppId, reportActionError, addPendingPrompt],
Expand Down
22 changes: 19 additions & 3 deletions packages/platform/tests/unit/react/chat-items.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import type { Message } from "../../../src/client/modules/chat.events.generated.js";
import type { ToolCall } from "../../../src/client/modules/chat.events.types.js";
import { toQuestion, isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, unknownKindToolCalls, waitingToolCallIds } from "../../../src/react/chat-items.js";
import { toQuestion, isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, toUtcTimestamp, unknownKindToolCalls, waitingToolCallIds } from "../../../src/react/chat-items.js";

const message = (id: string, created: string, extra: Partial<Message> = {}): Message => ({ id, role: "assistant", metadata: { created_date: created }, ...extra });
const waiting = (id: string, extra: Partial<ToolCall> = {}): ToolCall => ({ id, name: "tool", status: "waiting_for_user_input", ...extra });
Expand Down Expand Up @@ -47,8 +47,8 @@ describe("items", () => {
{ id: "t3", name: "run", status: "error" },
{ id: "t4", name: "run", status: "stopped" },
];
const [item] = toItems([message("m", "2026-10-14T10:00:00Z", { role: "user", content: "hi", tool_calls: tools })], [], vi.fn());
expect(item).toMatchObject({ id: "m", role: "user", text: "hi", createdAt: "2026-10-14T10:00:00Z" });
const [item] = toItems([message("m", "2026-10-14T10:00:00.123456", { role: "user", content: "hi", tool_calls: tools })], [], vi.fn());
expect(item).toMatchObject({ id: "m", role: "user", text: "hi", createdAt: "2026-10-14T10:00:00.123Z" });
expect(item.steps[0]).toEqual({ id: "t1", tool: "write_file", files: ["src/App.jsx", "src/index.css"], label: "write_file src/App.jsx src/index.css", status: "done" });
expect(item.steps.map(s => [s.label, s.status])).toEqual([["write_file src/App.jsx src/index.css", "done"], ["run", "running"], ["run", "error"], ["run", "stopped"]]);
expect(item.steps.every(s => !("question" in s))).toBe(true);
Expand Down Expand Up @@ -162,6 +162,22 @@ describe("steps", () => {
});
});

describe("timestamps", () => {
test("Base44's zoneless UTC gets its Z, in the shape a pending prompt has", () => {
expect(toUtcTimestamp("2026-10-25T11:00:00")).toBe("2026-10-25T11:00:00.000Z");
expect(toUtcTimestamp("2026-10-25T11:00:00.123456")).toBe("2026-10-25T11:00:00.123Z");
expect(toUtcTimestamp(new Date(Date.UTC(2026, 9, 25, 11)).toISOString())).toBe("2026-10-25T11:00:00.000Z");
});

test("a zone already there is kept, and what doesn't parse is left as it is", () => {
expect(toUtcTimestamp("2026-10-25T11:00:00Z")).toBe("2026-10-25T11:00:00.000Z");
expect(toUtcTimestamp("2026-10-25T14:00:00+03:00")).toBe("2026-10-25T11:00:00.000Z");
expect(toUtcTimestamp("1")).toBe("1");
expect(toUtcTimestamp(undefined)).toBe("");
expect(toUtcTimestamp(null)).toBe("");
});
});

describe("pending prompts", () => {
const pending = (id: string, content: string, appId = "a") => ({ id, content, createdAt: "", appId });

Expand Down
56 changes: 56 additions & 0 deletions packages/platform/tests/unit/react/useChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,28 @@ describe("pending prompts", () => {
expect(latest.error).toMatchObject({ message: "Send message failed: 409" });
});

test("send resolves once Base44's copy arrives, and a failure after that is no refusal", async () => {
const backend = makeBackend();
const call = pending();
backend.sendMessage.mockReturnValueOnce(call.promise);
await mount({ appId: app, backend });
await snapshot([]);
let resolved = false;
let done!: Promise<void>;
await act(async () => { done = latest.send("make it blue").then(() => { resolved = true; }); });
expect(resolved).toBe(false);
await updated(message("u1", "1", { role: "user", content: "make it blue" }));
await settle();
expect(resolved).toBe(true);
// The turn outlives the backend's own timeout.
await act(async () => { call.reject(new Error("Send message failed: 502")); await done; });
await settle();
expect(latest.error).toBeNull();
expect(shown()).toEqual([["make it blue", false]]);
expect(latest.status).toBe("submitted");
});


test("the first prompt shows while the app is created, and the new app's snapshot settles it", async () => {
const backend = makeBackend();
const call = pending<{ id: string; name: string }>();
Expand Down Expand Up @@ -585,6 +607,40 @@ describe("questions", () => {
expect(latest.status).toBe("waiting");
});

test("an answer resolves once its call stops waiting, and a failure after that is no refusal", async () => {
const backend = makeBackend();
const call = pending();
backend.submitToolCallInput.mockReturnValueOnce(call.promise);
await mount({ appId: app, backend });
await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]);
const question = latest.items[0].steps[0].question!;
if (question.kind !== "approval") throw new Error("expected an approval");
let resolved = false;
await act(async () => { void question.approve().then(() => { resolved = true; }); });
expect(resolved).toBe(false);
await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1"), status: "running" }] }));
await settle();
expect(resolved).toBe(true);
await act(async () => { call.reject(new Error("Submit answer failed: 502")); });
await settle();
expect(latest.error).toBeNull();
expect(latest.items[0].steps[0]).toMatchObject({ status: "running" });
expect(latest.items[0].steps[0].question).toBeUndefined();
});

test("a decline that fails after Base44 stopped the call is no refusal", async () => {
const backend = makeBackend();
const call = pending();
backend.submitToolCallInput.mockReturnValueOnce(call.promise);
await mount({ appId: app, backend });
await snapshot([message("m1", "1", { tool_calls: [waiting("t1", "plan_review")] })]);
await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1", "plan_review"), status: "stopped" }] }));
await act(async () => { call.reject(new Error("Submit answer failed: 502")); });
await settle();
expect(latest.error).toBeNull();
expect(latest.items[0].steps[0].status).toBe("stopped");
});

test("a kind this release doesn't know is declined once, then shows stopped when Base44 says so", async () => {
const backend = makeBackend();
await mount({ appId: app, backend });
Expand Down
Loading