diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index 7e9ae0ca4..f81966dc3 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -9,6 +9,7 @@ import {
import {
Check,
ChevronRight,
+ CircleAlert,
Clock3,
Copy,
ImageIcon,
@@ -44,6 +45,8 @@ import type {
UIImage,
UIMediaAttachment,
UIMessage,
+ MessageDeliveryErrorKind,
+ MessageDeliveryStatus,
} from "@/lib/types";
interface MessageBubbleProps {
@@ -130,6 +133,91 @@ function MessageCopyButton({ content }: { content: string }) {
);
}
+function deliveryErrorCopy(
+ kind: MessageDeliveryErrorKind | undefined,
+ t: (key: string) => string,
+): { title: string; body: string } {
+ switch (kind) {
+ case "message_too_big":
+ return {
+ title: t("errors.messageTooBig.title"),
+ body: t("errors.messageTooBig.body"),
+ };
+ case "workspace_scope_rejected":
+ return {
+ title: t("errors.workspaceScopeRejected.title"),
+ body: t("errors.workspaceScopeRejected.body"),
+ };
+ case "turn_rejected":
+ case undefined:
+ return {
+ title: t("errors.turnRejected.title"),
+ body: t("errors.turnRejected.body"),
+ };
+ default: {
+ const _exhaustive: never = kind;
+ return { title: String(_exhaustive), body: "" };
+ }
+ }
+}
+
+function UserDeliveryStatus({
+ status,
+ errorKind,
+}: {
+ status: MessageDeliveryStatus | undefined;
+ errorKind: MessageDeliveryErrorKind | undefined;
+}) {
+ const { t } = useTranslation();
+ if (status !== "sending" && status !== "failed") return null;
+ if (status === "sending") {
+ return (
+
+
+ {t("message.delivery.sending")}
+
+ );
+ }
+
+ const label = t("message.delivery.failed");
+ const { title, body } = deliveryErrorCopy(errorKind, t);
+ return (
+ <>
+
+
+
+
+
+ {title}
+ {body}
+
+
+
+ {title}. {body}
+
+ >
+ );
+}
+
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
export function MessageBubble({
message,
@@ -163,6 +251,8 @@ export function MessageBubble({
const parsedMessage = parseQuotedUserMessage(message.content);
const userContent = parsedMessage.content;
const hasText = userContent.trim().length > 0;
+ const showDeliveryStatus =
+ message.deliveryStatus === "sending" || message.deliveryStatus === "failed";
const quotedContext = parsedMessage.quotedContext;
const slashCommand = matchingSlashCommand(userContent, slashCommands);
const messageText = slashCommand ? (
@@ -208,10 +298,14 @@ export function MessageBubble({
{messageText}
) : null}
- {hasText && showCopyAction ? (
+ {showDeliveryStatus || (hasText && showCopyAction) ? (
-
-
+
+
+ {hasText && showCopyAction ? : null}
) : null}
diff --git a/webui/src/components/thread/StreamErrorNotice.tsx b/webui/src/components/thread/StreamErrorNotice.tsx
index 4875b7353..541fc45d9 100644
--- a/webui/src/components/thread/StreamErrorNotice.tsx
+++ b/webui/src/components/thread/StreamErrorNotice.tsx
@@ -11,9 +11,8 @@ interface StreamErrorNoticeProps {
}
/**
- * Dismissible banner that surfaces transport-level faults the user needs to
- * know about. Rendered above the composer so the message the fault referred
- * to remains in view just above. ``role="alert"`` + ``aria-live="assertive"``
+ * Fallback banner for transport-level faults that cannot be attached to a
+ * visible failed message. ``role="alert"`` + ``aria-live="assertive"``
* ensures screen readers announce the failure.
*/
export function StreamErrorNotice({ error, onDismiss }: StreamErrorNoticeProps) {
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index ad7cd18ca..a9464c316 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -107,7 +107,11 @@ export function ThreadMessages({
unit.type === "message" && unit.message.role === "assistant" && forkFlags[index]
? nextUserIndex
: undefined;
- if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
+ if (
+ unit.type === "message"
+ && unit.message.role === "user"
+ && unit.message.deliveryStatus !== "failed"
+ ) nextUserIndex += 1;
return (
= 0; index -= 1) {
const message = messages[index];
- if (message.role === "user" && message.turnId) return message.turnId;
+ if (
+ message.role === "user"
+ && message.deliveryStatus !== "failed"
+ && message.turnId
+ ) return message.turnId;
}
return null;
}
+function hasInlineDeliveryError(
+ messages: UIMessage[],
+ error: StreamError | null,
+): boolean {
+ if (!error?.turnId) return false;
+ return messages.some((message) => (
+ message.role === "user"
+ && message.turnId === error.turnId
+ && message.deliveryStatus === "failed"
+ && message.deliveryErrorKind === error.kind
+ ));
+}
+
function completedAssistantTurnIds(messages: UIMessage[]): string[] {
return Array.from(new Set(
messages
@@ -1283,7 +1300,7 @@ export function ThreadShell({
const composer = (
<>
- {streamError ? (
+ {streamError && !hasInlineDeliveryError(messages, streamError) ? (
0
- ? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
+ ? messages.slice(0, hiddenMessageCount).filter(
+ (message) => message.role === "user" && message.deliveryStatus !== "failed",
+ ).length
: 0);
const visibleForkBoundaryMessageCount =
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index e0bcde04a..a81b3a221 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -17,6 +17,7 @@ import type {
OutboundMcpPresetMention,
OutboundMedia,
GoalStateWsPayload,
+ MessageDeliveryStatus,
ToolProgressEvent,
UIMediaAttachment,
UIFileEdit,
@@ -519,6 +520,27 @@ function eventTurnId(ev: InboundEvent): string | undefined {
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
}
+function transitionTurnDelivery(
+ messages: UIMessage[],
+ turnId: string,
+ status: MessageDeliveryStatus,
+): UIMessage[] {
+ let changed = false;
+ const next = messages.map((message) => {
+ if (
+ message.role !== "user"
+ || message.turnId !== turnId
+ || message.deliveryStatus === status
+ || (status === "accepted" && message.deliveryStatus !== "sending")
+ ) {
+ return message;
+ }
+ changed = true;
+ return { ...message, deliveryStatus: status };
+ });
+ return changed ? next : messages;
+}
+
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
@@ -697,7 +719,15 @@ export function useNanobotStream(
) {
fileEditSegmentRef.current = null;
}
- return prev.filter((message) => message.turnId !== rejectedTurnId);
+ return prev.flatMap((message) => {
+ if (message.turnId !== rejectedTurnId) return [message];
+ if (message.role !== "user") return [];
+ return [{
+ ...message,
+ deliveryStatus: "failed",
+ deliveryErrorKind: err.kind,
+ }];
+ });
});
const remainingStartedAt = client.getRunStartedAt(chatId);
@@ -975,6 +1005,11 @@ export function useNanobotStream(
}
return;
}
+ const turnId = eventTurnId(ev);
+ if (turnId) {
+ setMessages((prev) => transitionTurnDelivery(prev, turnId, "accepted"));
+ }
+ if (ev.event === "message_accepted") return;
const sideChannelEvent = isSideChannelEvent(ev);
if (
streamEndTimerRef.current !== null
@@ -1354,6 +1389,7 @@ export function useNanobotStream(
turnId,
turnPhase: "user",
turnSeq: 0,
+ deliveryStatus: "sending",
createdAt: Date.now(),
...(previews ? { media: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 417495294..1e8cc1a7a 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -1172,6 +1172,10 @@
},
"message": {
"streaming": "streaming",
+ "delivery": {
+ "sending": "Sending…",
+ "failed": "Not sent"
+ },
"assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools",
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 2a37f01a9..d8847971f 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -1159,6 +1159,10 @@
},
"message": {
"streaming": "transmitiendo",
+ "delivery": {
+ "sending": "Enviando…",
+ "failed": "No enviado"
+ },
"assistantTyping": "El asistente está escribiendo",
"toolSingle": "Usando una herramienta",
"toolMany": "Se usaron {{count}} herramientas",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 1e61365b2..d241c6fa4 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "en cours de génération",
+ "delivery": {
+ "sending": "Envoi…",
+ "failed": "Non envoyé"
+ },
"assistantTyping": "L’assistant est en train d’écrire",
"toolSingle": "Utilisation d’un outil",
"toolMany": "{{count}} outils utilisés",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 60223028a..49b06fc00 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "sedang mengalir",
+ "delivery": {
+ "sending": "Mengirim…",
+ "failed": "Belum terkirim"
+ },
"assistantTyping": "Asisten sedang mengetik",
"toolSingle": "Menggunakan sebuah alat",
"toolMany": "Menggunakan {{count}} alat",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index 0e256f775..b85e9c225 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "生成中",
+ "delivery": {
+ "sending": "送信中…",
+ "failed": "未送信"
+ },
"assistantTyping": "アシスタントが入力中",
"toolSingle": "ツールを使用中",
"toolMany": "{{count}} 個のツールを使用",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index 716bea6da..fa8159d0b 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "생성 중",
+ "delivery": {
+ "sending": "전송 중…",
+ "failed": "전송되지 않음"
+ },
"assistantTyping": "도우미가 입력 중",
"toolSingle": "도구 사용 중",
"toolMany": "도구 {{count}}개 사용됨",
diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json
index f78f15218..e3bac6f03 100644
--- a/webui/src/i18n/locales/pt-BR/common.json
+++ b/webui/src/i18n/locales/pt-BR/common.json
@@ -1172,6 +1172,10 @@
},
"message": {
"streaming": "transmitindo",
+ "delivery": {
+ "sending": "Enviando…",
+ "failed": "Não enviada"
+ },
"assistantTyping": "Assistente está digitando",
"toolSingle": "Usando uma ferramenta",
"toolMany": "Foram usadas {{count}} ferramentas",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 6d633874b..5ae45f6f7 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "đang truyền",
+ "delivery": {
+ "sending": "Đang gửi…",
+ "failed": "Chưa gửi"
+ },
"assistantTyping": "Trợ lý đang nhập",
"toolSingle": "Đang dùng một công cụ",
"toolMany": "Đã dùng {{count}} công cụ",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 40a6feafd..385f27aae 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -1172,6 +1172,10 @@
},
"message": {
"streaming": "流式输出中",
+ "delivery": {
+ "sending": "发送中…",
+ "failed": "未发送"
+ },
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 2d02c80da..2052e8ce2 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "串流輸出中",
+ "delivery": {
+ "sending": "傳送中…",
+ "failed": "未傳送"
+ },
"assistantTyping": "助理正在輸入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 個工具",
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index b7f2b2b5a..1c8d4630e 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -935,6 +935,9 @@ export class NanobotClient {
: null;
if (parsed.event === "message_accepted") {
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
+ if (!isSystemCommandTurnId(turnId)) {
+ this.dispatch(parsed.chat_id, parsed);
+ }
return;
}
if (isSystemCommandTurnId(turnId)) {
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index a0ef20047..1f4cb1180 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -5,6 +5,11 @@ export type Role = "user" | "assistant" | "tool" | "system";
export type MessageKind = "message" | "trace";
export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete";
+export type MessageDeliveryStatus = "sending" | "accepted" | "failed";
+export type MessageDeliveryErrorKind =
+ | "message_too_big"
+ | "workspace_scope_rejected"
+ | "turn_rejected";
/** One image attached to a UIMessage.
*
@@ -76,6 +81,10 @@ export interface UIMessage {
turnId?: string;
turnPhase?: UITurnPhase;
turnSeq?: number;
+ /** Ephemeral delivery lifecycle for optimistic user messages. */
+ deliveryStatus?: MessageDeliveryStatus;
+ /** Structured rejection reason shown with a failed optimistic message. */
+ deliveryErrorKind?: MessageDeliveryErrorKind;
}
export interface UICliAppAttachment {
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index 9b82b8d88..62f73d164 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -113,6 +113,53 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
+ it("renders failed delivery details on focus without persistent accepted chrome", async () => {
+ const message: UIMessage = {
+ id: "u-delivery",
+ role: "user",
+ content: "hello",
+ createdAt: Date.now(),
+ deliveryStatus: "sending",
+ };
+
+ const { rerender } = render();
+
+ expect(screen.getByRole("status")).toHaveTextContent("Sending…");
+
+ rerender();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ const failedStatus = screen.getByRole("button", {
+ name: "Not sent: Message too large",
+ });
+ expect(failedStatus).toHaveClass(
+ "text-destructive/80",
+ "dark:text-red-400/80",
+ );
+ expect(screen.getByText("hello")).not.toHaveClass("ring-1");
+ expect(screen.getByText("hello")).not.toHaveClass("ring-destructive/30");
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+
+ fireEvent.focus(failedStatus);
+
+ const tooltip = await screen.findByRole("tooltip");
+ expect(tooltip).toHaveTextContent("Message too large");
+ expect(tooltip).toHaveTextContent(
+ "The server rejected your last message because it exceeded the size limit.",
+ );
+ expect(screen.getByRole("alert")).toHaveClass("sr-only");
+ });
+
it("styles only generated quoted context in user messages", () => {
const message: UIMessage = {
id: "u-quote",
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
index bdecddc6d..c8e074ee8 100644
--- a/webui/src/tests/nanobot-client.test.ts
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -90,6 +90,31 @@ describe("NanobotClient", () => {
});
});
+ it("routes message acceptance acknowledgements to the matching chat handler", () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const handler = vi.fn();
+ client.onChat("chat-ack", handler);
+ client.connect();
+ lastSocket().fakeOpen();
+ client.sendMessage("chat-ack", "hello", undefined, { turnId: "turn-ack" });
+
+ lastSocket().fakeMessage({
+ event: "message_accepted",
+ chat_id: "chat-ack",
+ turn_id: "turn-ack",
+ });
+
+ expect(handler).toHaveBeenCalledWith({
+ event: "message_accepted",
+ chat_id: "chat-ack",
+ turn_id: "turn-ack",
+ });
+ });
+
it("can swap the socket factory when the runtime URL changes", () => {
const browserFactory = vi.fn(
(url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx
index 341d187a4..95cbb7aec 100644
--- a/webui/src/tests/thread-messages.test.tsx
+++ b/webui/src/tests/thread-messages.test.tsx
@@ -954,6 +954,34 @@ describe("ThreadMessages", () => {
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
});
+ it("does not count failed optimistic messages in assistant fork indices", () => {
+ const onForkFromMessage = vi.fn();
+ const messages: UIMessage[] = [
+ { id: "u1", role: "user", content: "one", createdAt: 1 },
+ { id: "a1", role: "assistant", content: "answer one", createdAt: 2 },
+ {
+ id: "u-failed",
+ role: "user",
+ content: "not persisted",
+ deliveryStatus: "failed",
+ createdAt: 3,
+ },
+ { id: "u2", role: "user", content: "two", createdAt: 4 },
+ { id: "a2", role: "assistant", content: "answer two", createdAt: 5 },
+ ];
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getAllByRole("button", { name: "Fork" }).at(-1)!);
+ expect(onForkFromMessage).toHaveBeenCalledWith(2);
+ });
+
it("uses turn ids as activity grouping boundaries when available", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 },
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
index 6954bb2b3..2d4b489c6 100644
--- a/webui/src/tests/thread-shell.test.tsx
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -6,7 +6,7 @@ import { preloadMarkdownText } from "@/components/MarkdownText";
import { ThreadCameraController } from "@/components/thread/thread-camera";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
-import type { CanonicalRunSnapshot } from "@/lib/nanobot-client";
+import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { ClientProvider } from "@/providers/ClientProvider";
import type { CliAppsPayload, ConnectionStatus, SettingsPayload, UIMessage } from "@/lib/types";
@@ -14,7 +14,7 @@ const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
function makeClient() {
- const errorHandlers = new Set<(err: { kind: string }) => void>();
+ const errorHandlers = new Set<(err: StreamError) => void>();
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const chatHandlers = new Map void>>();
const runtimeModelHandlers = new Set<
@@ -107,6 +107,7 @@ function makeClient() {
};
},
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
+ hasUnsettledRun: () => false,
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
canReconcileCanonicalCompletion,
reconcileCanonicalCompletion,
@@ -122,7 +123,7 @@ function makeClient() {
handlers?.delete(handler);
};
},
- onError: (handler: (err: { kind: string }) => void) => {
+ onError: (handler: (err: StreamError) => void) => {
errorHandlers.add(handler);
return () => {
errorHandlers.delete(handler);
@@ -134,7 +135,7 @@ function makeClient() {
sessionUpdateHandlers.delete(handler);
};
},
- _emitError(err: { kind: string }) {
+ _emitError(err: StreamError) {
for (const h of errorHandlers) h(err);
},
_emitStatus(nextStatus: ConnectionStatus) {
@@ -3208,7 +3209,7 @@ describe("ThreadShell", () => {
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
});
- it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
+ it("surfaces a dismissible banner for an uncorrelated message_too_big error", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -3243,6 +3244,52 @@ describe("ThreadShell", () => {
});
});
+ it("moves a correlated delivery error from the banner into the failed message tooltip", async () => {
+ const client = makeClient();
+
+ render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={() => {}}
+ />,
+ ),
+ );
+
+ fireEvent.change(screen.getByLabelText("Message input"), {
+ target: { value: "oversized payload" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ await waitFor(() => expect(client.sendMessage).toHaveBeenCalledTimes(1));
+ const turnId = client.sendMessage.mock.calls[0][3]?.turnId;
+ expect(turnId).toEqual(expect.any(String));
+
+ await act(async () => {
+ client._emitError({
+ kind: "message_too_big",
+ chatId: "chat-inline-error",
+ turnId,
+ });
+ });
+
+ expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument();
+ const status = screen.getByRole("button", {
+ name: "Not sent: Message too large",
+ });
+ expect(screen.getByRole("alert")).toHaveClass("sr-only");
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+
+ fireEvent.focus(status);
+
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ "The server rejected your last message because it exceeded the size limit.",
+ );
+ });
+
it("clears the stream error banner when the user switches to another chat", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index 8ced5bccf..b320318d8 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -1652,6 +1652,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].content).toBe("fine");
expect(result.current.messages[0].turnId).toEqual(expect.any(String));
expect(result.current.messages[0].turnPhase).toBe("user");
+ expect(result.current.messages[0].deliveryStatus).toBe("sending");
});
it("returns the submitted turn identity used by the optimistic row and wire frame", () => {
@@ -1681,7 +1682,34 @@ describe("useNanobotStream", () => {
);
});
- it("removes only the optimistic turn named by a correlated rejection", () => {
+ it("marks an optimistic turn accepted when its acknowledgement arrives", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(
+ () => useNanobotStream("chat-accept-one", EMPTY_MESSAGES),
+ { wrapper: wrap(fake.client) },
+ );
+ let submitted: ReturnType = null;
+ act(() => {
+ submitted = result.current.send("hello");
+ });
+
+ act(() => {
+ fake.emit("chat-accept-one", {
+ event: "message_accepted",
+ chat_id: "chat-accept-one",
+ turn_id: submitted!.turnId,
+ });
+ });
+
+ expect(result.current.messages).toEqual([
+ expect.objectContaining({
+ id: submitted!.userMessageId,
+ deliveryStatus: "accepted",
+ }),
+ ]);
+ });
+
+ it("marks only the optimistic turn named by a correlated rejection as failed", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-reject-one", EMPTY_MESSAGES),
@@ -1705,10 +1733,18 @@ describe("useNanobotStream", () => {
});
expect(result.current.messages).toEqual([
+ expect.objectContaining({
+ id: first!.userMessageId,
+ turnId: first!.turnId,
+ content: "first",
+ deliveryStatus: "failed",
+ deliveryErrorKind: "turn_rejected",
+ }),
expect.objectContaining({
id: second!.userMessageId,
turnId: second!.turnId,
content: "second",
+ deliveryStatus: "sending",
}),
]);
expect(result.current.isStreaming).toBe(true);
@@ -1751,6 +1787,12 @@ describe("useNanobotStream", () => {
expect.objectContaining({
id: first!.userMessageId,
turnId: first!.turnId,
+ deliveryStatus: "accepted",
+ }),
+ expect.objectContaining({
+ id: second!.userMessageId,
+ turnId: second!.turnId,
+ deliveryStatus: "failed",
}),
]);
expect(result.current.runStartedAt).toBe(1234);
@@ -1784,7 +1826,13 @@ describe("useNanobotStream", () => {
});
await flushStreamFrame();
- expect(result.current.messages).toEqual([]);
+ expect(result.current.messages).toEqual([
+ expect.objectContaining({
+ id: submitted!.userMessageId,
+ deliveryStatus: "failed",
+ deliveryErrorKind: "turn_rejected",
+ }),
+ ]);
expect(result.current.runStartedAt).toBeNull();
expect(result.current.isStreaming).toBe(false);
});
@@ -1810,7 +1858,12 @@ describe("useNanobotStream", () => {
});
});
- expect(result.current.messages).toEqual([]);
+ expect(result.current.messages).toEqual([
+ expect.objectContaining({
+ id: submitted!.userMessageId,
+ deliveryStatus: "failed",
+ }),
+ ]);
expect(result.current.streamError).toMatchObject({
kind: "turn_rejected",
chatId: "chat-replayed-reject",
@@ -1860,7 +1913,7 @@ describe("useNanobotStream", () => {
expect(result.current.streamError).toEqual({ kind: "message_too_big" });
});
- it("removes rejected side-channel guidance without stopping the main run", () => {
+ it("marks rejected side-channel guidance failed without stopping the main run", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-side-reject", EMPTY_MESSAGES),
@@ -1893,6 +1946,12 @@ describe("useNanobotStream", () => {
expect.objectContaining({
id: main!.userMessageId,
turnId: main!.turnId,
+ deliveryStatus: "accepted",
+ }),
+ expect.objectContaining({
+ id: side!.userMessageId,
+ turnId: side!.turnId,
+ deliveryStatus: "failed",
}),
]);
expect(result.current.runStartedAt).toBe(9876);