mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
feat(webui): track optimistic message delivery status (#5162)
This commit is contained in:
parent
5a28a6165c
commit
129b74b4cf
@ -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 (
|
||||
<span
|
||||
role="status"
|
||||
className="inline-flex items-center gap-1 text-[12px] leading-none text-muted-foreground"
|
||||
>
|
||||
<Clock3 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("message.delivery.sending")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const label = t("message.delivery.failed");
|
||||
const { title, body } = deliveryErrorCopy(errorKind, t);
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${label}: ${title}`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm text-[12px] leading-none",
|
||||
"text-destructive/80 transition-colors hover:text-destructive",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"dark:text-red-400/80 dark:hover:text-red-400",
|
||||
)}
|
||||
>
|
||||
<CircleAlert className="h-3.5 w-3.5" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="max-w-72 px-3 py-2.5 text-left"
|
||||
>
|
||||
<p className="font-medium text-popover-foreground">{title}</p>
|
||||
<p className="mt-1 leading-relaxed text-muted-foreground">{body}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span role="alert" aria-live="assertive" className="sr-only">
|
||||
{title}. {body}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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}
|
||||
</p>
|
||||
) : null}
|
||||
{hasText && showCopyAction ? (
|
||||
{showDeliveryStatus || (hasText && showCopyAction) ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<div className="flex min-h-8 items-center justify-end text-muted-foreground">
|
||||
<MessageCopyButton content={message.content} />
|
||||
<div className="flex min-h-8 items-center justify-end gap-1.5 text-muted-foreground">
|
||||
<UserDeliveryStatus
|
||||
status={message.deliveryStatus}
|
||||
errorKind={message.deliveryErrorKind}
|
||||
/>
|
||||
{hasText && showCopyAction ? <MessageCopyButton content={message.content} /> : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
<ThreadDisplayUnit
|
||||
|
||||
@ -31,7 +31,7 @@ import {
|
||||
installedMcpPresetsFromPayload,
|
||||
isMcpPresetsPayload,
|
||||
} from "@/lib/mcp-preset-events";
|
||||
import type { CanonicalRunSnapshot } from "@/lib/nanobot-client";
|
||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChatSummary,
|
||||
@ -222,11 +222,28 @@ function latestActiveTurnId(messages: UIMessage[]): string | null {
|
||||
}
|
||||
for (let index = messages.length - 1; index >= 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) ? (
|
||||
<StreamErrorNotice
|
||||
error={streamError}
|
||||
onDismiss={dismissStreamError}
|
||||
|
||||
@ -250,7 +250,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const hiddenUserMessageCount =
|
||||
userMessageOffset
|
||||
+ (hiddenMessageCount > 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
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1158,6 +1158,10 @@
|
||||
},
|
||||
"message": {
|
||||
"streaming": "生成中",
|
||||
"delivery": {
|
||||
"sending": "送信中…",
|
||||
"failed": "未送信"
|
||||
},
|
||||
"assistantTyping": "アシスタントが入力中",
|
||||
"toolSingle": "ツールを使用中",
|
||||
"toolMany": "{{count}} 個のツールを使用",
|
||||
|
||||
@ -1158,6 +1158,10 @@
|
||||
},
|
||||
"message": {
|
||||
"streaming": "생성 중",
|
||||
"delivery": {
|
||||
"sending": "전송 중…",
|
||||
"failed": "전송되지 않음"
|
||||
},
|
||||
"assistantTyping": "도우미가 입력 중",
|
||||
"toolSingle": "도구 사용 중",
|
||||
"toolMany": "도구 {{count}}개 사용됨",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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ụ",
|
||||
|
||||
@ -1172,6 +1172,10 @@
|
||||
},
|
||||
"message": {
|
||||
"streaming": "流式输出中",
|
||||
"delivery": {
|
||||
"sending": "发送中…",
|
||||
"failed": "未发送"
|
||||
},
|
||||
"assistantTyping": "助手正在输入",
|
||||
"toolSingle": "正在使用工具",
|
||||
"toolMany": "已使用 {{count}} 个工具",
|
||||
|
||||
@ -1158,6 +1158,10 @@
|
||||
},
|
||||
"message": {
|
||||
"streaming": "串流輸出中",
|
||||
"delivery": {
|
||||
"sending": "傳送中…",
|
||||
"failed": "未傳送"
|
||||
},
|
||||
"assistantTyping": "助理正在輸入",
|
||||
"toolSingle": "正在使用工具",
|
||||
"toolMany": "已使用 {{count}} 個工具",
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(<MessageBubble message={message} />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Sending…");
|
||||
|
||||
rerender(<MessageBubble message={{ ...message, deliveryStatus: "accepted" }} />);
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MessageBubble
|
||||
message={{
|
||||
...message,
|
||||
deliveryStatus: "failed",
|
||||
deliveryErrorKind: "message_too_big",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
<ThreadMessages
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
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 },
|
||||
|
||||
@ -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<string, Set<(ev: import("@/lib/types").InboundEvent) => 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,
|
||||
<ThreadShell
|
||||
session={session("chat-inline-error")}
|
||||
title="Chat inline error"
|
||||
onToggleSidebar={() => {}}
|
||||
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");
|
||||
|
||||
@ -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<typeof result.current.send> = 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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user