mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 01:18:45 +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 {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
CircleAlert,
|
||||||
Clock3,
|
Clock3,
|
||||||
Copy,
|
Copy,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@ -44,6 +45,8 @@ import type {
|
|||||||
UIImage,
|
UIImage,
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
|
MessageDeliveryErrorKind,
|
||||||
|
MessageDeliveryStatus,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
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. */
|
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
@ -163,6 +251,8 @@ export function MessageBubble({
|
|||||||
const parsedMessage = parseQuotedUserMessage(message.content);
|
const parsedMessage = parseQuotedUserMessage(message.content);
|
||||||
const userContent = parsedMessage.content;
|
const userContent = parsedMessage.content;
|
||||||
const hasText = userContent.trim().length > 0;
|
const hasText = userContent.trim().length > 0;
|
||||||
|
const showDeliveryStatus =
|
||||||
|
message.deliveryStatus === "sending" || message.deliveryStatus === "failed";
|
||||||
const quotedContext = parsedMessage.quotedContext;
|
const quotedContext = parsedMessage.quotedContext;
|
||||||
const slashCommand = matchingSlashCommand(userContent, slashCommands);
|
const slashCommand = matchingSlashCommand(userContent, slashCommands);
|
||||||
const messageText = slashCommand ? (
|
const messageText = slashCommand ? (
|
||||||
@ -208,10 +298,14 @@ export function MessageBubble({
|
|||||||
{messageText}
|
{messageText}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{hasText && showCopyAction ? (
|
{showDeliveryStatus || (hasText && showCopyAction) ? (
|
||||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||||
<div className="flex min-h-8 items-center justify-end text-muted-foreground">
|
<div className="flex min-h-8 items-center justify-end gap-1.5 text-muted-foreground">
|
||||||
<MessageCopyButton content={message.content} />
|
<UserDeliveryStatus
|
||||||
|
status={message.deliveryStatus}
|
||||||
|
errorKind={message.deliveryErrorKind}
|
||||||
|
/>
|
||||||
|
{hasText && showCopyAction ? <MessageCopyButton content={message.content} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -11,9 +11,8 @@ interface StreamErrorNoticeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dismissible banner that surfaces transport-level faults the user needs to
|
* Fallback banner for transport-level faults that cannot be attached to a
|
||||||
* know about. Rendered above the composer so the message the fault referred
|
* visible failed message. ``role="alert"`` + ``aria-live="assertive"``
|
||||||
* to remains in view just above. ``role="alert"`` + ``aria-live="assertive"``
|
|
||||||
* ensures screen readers announce the failure.
|
* ensures screen readers announce the failure.
|
||||||
*/
|
*/
|
||||||
export function StreamErrorNotice({ error, onDismiss }: StreamErrorNoticeProps) {
|
export function StreamErrorNotice({ error, onDismiss }: StreamErrorNoticeProps) {
|
||||||
|
|||||||
@ -107,7 +107,11 @@ export function ThreadMessages({
|
|||||||
unit.type === "message" && unit.message.role === "assistant" && forkFlags[index]
|
unit.type === "message" && unit.message.role === "assistant" && forkFlags[index]
|
||||||
? nextUserIndex
|
? nextUserIndex
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<ThreadDisplayUnit
|
<ThreadDisplayUnit
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import {
|
|||||||
installedMcpPresetsFromPayload,
|
installedMcpPresetsFromPayload,
|
||||||
isMcpPresetsPayload,
|
isMcpPresetsPayload,
|
||||||
} from "@/lib/mcp-preset-events";
|
} 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 { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
@ -222,11 +222,28 @@ function latestActiveTurnId(messages: UIMessage[]): string | null {
|
|||||||
}
|
}
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index];
|
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;
|
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[] {
|
function completedAssistantTurnIds(messages: UIMessage[]): string[] {
|
||||||
return Array.from(new Set(
|
return Array.from(new Set(
|
||||||
messages
|
messages
|
||||||
@ -1283,7 +1300,7 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const composer = (
|
const composer = (
|
||||||
<>
|
<>
|
||||||
{streamError ? (
|
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
|
||||||
<StreamErrorNotice
|
<StreamErrorNotice
|
||||||
error={streamError}
|
error={streamError}
|
||||||
onDismiss={dismissStreamError}
|
onDismiss={dismissStreamError}
|
||||||
|
|||||||
@ -250,7 +250,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const hiddenUserMessageCount =
|
const hiddenUserMessageCount =
|
||||||
userMessageOffset
|
userMessageOffset
|
||||||
+ (hiddenMessageCount > 0
|
+ (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);
|
: 0);
|
||||||
const visibleForkBoundaryMessageCount =
|
const visibleForkBoundaryMessageCount =
|
||||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type {
|
|||||||
OutboundMcpPresetMention,
|
OutboundMcpPresetMention,
|
||||||
OutboundMedia,
|
OutboundMedia,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
|
MessageDeliveryStatus,
|
||||||
ToolProgressEvent,
|
ToolProgressEvent,
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
UIFileEdit,
|
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;
|
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(
|
export function useNanobotStream(
|
||||||
chatId: string | null,
|
chatId: string | null,
|
||||||
initialMessages: UIMessage[] = [],
|
initialMessages: UIMessage[] = [],
|
||||||
@ -697,7 +719,15 @@ export function useNanobotStream(
|
|||||||
) {
|
) {
|
||||||
fileEditSegmentRef.current = null;
|
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);
|
const remainingStartedAt = client.getRunStartedAt(chatId);
|
||||||
@ -975,6 +1005,11 @@ export function useNanobotStream(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const turnId = eventTurnId(ev);
|
||||||
|
if (turnId) {
|
||||||
|
setMessages((prev) => transitionTurnDelivery(prev, turnId, "accepted"));
|
||||||
|
}
|
||||||
|
if (ev.event === "message_accepted") return;
|
||||||
const sideChannelEvent = isSideChannelEvent(ev);
|
const sideChannelEvent = isSideChannelEvent(ev);
|
||||||
if (
|
if (
|
||||||
streamEndTimerRef.current !== null
|
streamEndTimerRef.current !== null
|
||||||
@ -1354,6 +1389,7 @@ export function useNanobotStream(
|
|||||||
turnId,
|
turnId,
|
||||||
turnPhase: "user",
|
turnPhase: "user",
|
||||||
turnSeq: 0,
|
turnSeq: 0,
|
||||||
|
deliveryStatus: "sending",
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
...(previews ? { media: previews } : {}),
|
...(previews ? { media: previews } : {}),
|
||||||
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||||
|
|||||||
@ -1172,6 +1172,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "streaming",
|
"streaming": "streaming",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "Sending…",
|
||||||
|
"failed": "Not sent"
|
||||||
|
},
|
||||||
"assistantTyping": "Assistant is typing",
|
"assistantTyping": "Assistant is typing",
|
||||||
"toolSingle": "Using a tool",
|
"toolSingle": "Using a tool",
|
||||||
"toolMany": "Used {{count}} tools",
|
"toolMany": "Used {{count}} tools",
|
||||||
|
|||||||
@ -1159,6 +1159,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "transmitiendo",
|
"streaming": "transmitiendo",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "Enviando…",
|
||||||
|
"failed": "No enviado"
|
||||||
|
},
|
||||||
"assistantTyping": "El asistente está escribiendo",
|
"assistantTyping": "El asistente está escribiendo",
|
||||||
"toolSingle": "Usando una herramienta",
|
"toolSingle": "Usando una herramienta",
|
||||||
"toolMany": "Se usaron {{count}} herramientas",
|
"toolMany": "Se usaron {{count}} herramientas",
|
||||||
|
|||||||
@ -1158,6 +1158,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "en cours de génération",
|
"streaming": "en cours de génération",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "Envoi…",
|
||||||
|
"failed": "Non envoyé"
|
||||||
|
},
|
||||||
"assistantTyping": "L’assistant est en train d’écrire",
|
"assistantTyping": "L’assistant est en train d’écrire",
|
||||||
"toolSingle": "Utilisation d’un outil",
|
"toolSingle": "Utilisation d’un outil",
|
||||||
"toolMany": "{{count}} outils utilisés",
|
"toolMany": "{{count}} outils utilisés",
|
||||||
|
|||||||
@ -1158,6 +1158,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "sedang mengalir",
|
"streaming": "sedang mengalir",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "Mengirim…",
|
||||||
|
"failed": "Belum terkirim"
|
||||||
|
},
|
||||||
"assistantTyping": "Asisten sedang mengetik",
|
"assistantTyping": "Asisten sedang mengetik",
|
||||||
"toolSingle": "Menggunakan sebuah alat",
|
"toolSingle": "Menggunakan sebuah alat",
|
||||||
"toolMany": "Menggunakan {{count}} alat",
|
"toolMany": "Menggunakan {{count}} alat",
|
||||||
|
|||||||
@ -1158,6 +1158,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "生成中",
|
"streaming": "生成中",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "送信中…",
|
||||||
|
"failed": "未送信"
|
||||||
|
},
|
||||||
"assistantTyping": "アシスタントが入力中",
|
"assistantTyping": "アシスタントが入力中",
|
||||||
"toolSingle": "ツールを使用中",
|
"toolSingle": "ツールを使用中",
|
||||||
"toolMany": "{{count}} 個のツールを使用",
|
"toolMany": "{{count}} 個のツールを使用",
|
||||||
|
|||||||
@ -1158,6 +1158,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "생성 중",
|
"streaming": "생성 중",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "전송 중…",
|
||||||
|
"failed": "전송되지 않음"
|
||||||
|
},
|
||||||
"assistantTyping": "도우미가 입력 중",
|
"assistantTyping": "도우미가 입력 중",
|
||||||
"toolSingle": "도구 사용 중",
|
"toolSingle": "도구 사용 중",
|
||||||
"toolMany": "도구 {{count}}개 사용됨",
|
"toolMany": "도구 {{count}}개 사용됨",
|
||||||
|
|||||||
@ -1172,6 +1172,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "transmitindo",
|
"streaming": "transmitindo",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "Enviando…",
|
||||||
|
"failed": "Não enviada"
|
||||||
|
},
|
||||||
"assistantTyping": "Assistente está digitando",
|
"assistantTyping": "Assistente está digitando",
|
||||||
"toolSingle": "Usando uma ferramenta",
|
"toolSingle": "Usando uma ferramenta",
|
||||||
"toolMany": "Foram usadas {{count}} ferramentas",
|
"toolMany": "Foram usadas {{count}} ferramentas",
|
||||||
|
|||||||
@ -1158,6 +1158,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "đang truyền",
|
"streaming": "đang truyền",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "Đang gửi…",
|
||||||
|
"failed": "Chưa gửi"
|
||||||
|
},
|
||||||
"assistantTyping": "Trợ lý đang nhập",
|
"assistantTyping": "Trợ lý đang nhập",
|
||||||
"toolSingle": "Đang dùng một công cụ",
|
"toolSingle": "Đang dùng một công cụ",
|
||||||
"toolMany": "Đã dùng {{count}} công cụ",
|
"toolMany": "Đã dùng {{count}} công cụ",
|
||||||
|
|||||||
@ -1172,6 +1172,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "流式输出中",
|
"streaming": "流式输出中",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "发送中…",
|
||||||
|
"failed": "未发送"
|
||||||
|
},
|
||||||
"assistantTyping": "助手正在输入",
|
"assistantTyping": "助手正在输入",
|
||||||
"toolSingle": "正在使用工具",
|
"toolSingle": "正在使用工具",
|
||||||
"toolMany": "已使用 {{count}} 个工具",
|
"toolMany": "已使用 {{count}} 个工具",
|
||||||
|
|||||||
@ -1158,6 +1158,10 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "串流輸出中",
|
"streaming": "串流輸出中",
|
||||||
|
"delivery": {
|
||||||
|
"sending": "傳送中…",
|
||||||
|
"failed": "未傳送"
|
||||||
|
},
|
||||||
"assistantTyping": "助理正在輸入",
|
"assistantTyping": "助理正在輸入",
|
||||||
"toolSingle": "正在使用工具",
|
"toolSingle": "正在使用工具",
|
||||||
"toolMany": "已使用 {{count}} 個工具",
|
"toolMany": "已使用 {{count}} 個工具",
|
||||||
|
|||||||
@ -935,6 +935,9 @@ export class NanobotClient {
|
|||||||
: null;
|
: null;
|
||||||
if (parsed.event === "message_accepted") {
|
if (parsed.event === "message_accepted") {
|
||||||
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
|
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
|
||||||
|
if (!isSystemCommandTurnId(turnId)) {
|
||||||
|
this.dispatch(parsed.chat_id, parsed);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSystemCommandTurnId(turnId)) {
|
if (isSystemCommandTurnId(turnId)) {
|
||||||
|
|||||||
@ -5,6 +5,11 @@ export type Role = "user" | "assistant" | "tool" | "system";
|
|||||||
export type MessageKind = "message" | "trace";
|
export type MessageKind = "message" | "trace";
|
||||||
|
|
||||||
export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete";
|
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.
|
/** One image attached to a UIMessage.
|
||||||
*
|
*
|
||||||
@ -76,6 +81,10 @@ export interface UIMessage {
|
|||||||
turnId?: string;
|
turnId?: string;
|
||||||
turnPhase?: UITurnPhase;
|
turnPhase?: UITurnPhase;
|
||||||
turnSeq?: number;
|
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 {
|
export interface UICliAppAttachment {
|
||||||
|
|||||||
@ -113,6 +113,53 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
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", () => {
|
it("styles only generated quoted context in user messages", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "u-quote",
|
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", () => {
|
it("can swap the socket factory when the runtime URL changes", () => {
|
||||||
const browserFactory = vi.fn(
|
const browserFactory = vi.fn(
|
||||||
(url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
|
(url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
|
||||||
|
|||||||
@ -954,6 +954,34 @@ describe("ThreadMessages", () => {
|
|||||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
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", () => {
|
it("uses turn ids as activity grouping boundaries when available", () => {
|
||||||
const units = buildDisplayUnits([
|
const units = buildDisplayUnits([
|
||||||
{ id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 },
|
{ 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 { ThreadCameraController } from "@/components/thread/thread-camera";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events";
|
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 { ClientProvider } from "@/providers/ClientProvider";
|
||||||
import type { CliAppsPayload, ConnectionStatus, SettingsPayload, UIMessage } from "@/lib/types";
|
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\?/;
|
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
||||||
|
|
||||||
function makeClient() {
|
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 statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
||||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||||
const runtimeModelHandlers = new Set<
|
const runtimeModelHandlers = new Set<
|
||||||
@ -107,6 +107,7 @@ function makeClient() {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||||
|
hasUnsettledRun: () => false,
|
||||||
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
|
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
|
||||||
canReconcileCanonicalCompletion,
|
canReconcileCanonicalCompletion,
|
||||||
reconcileCanonicalCompletion,
|
reconcileCanonicalCompletion,
|
||||||
@ -122,7 +123,7 @@ function makeClient() {
|
|||||||
handlers?.delete(handler);
|
handlers?.delete(handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onError: (handler: (err: { kind: string }) => void) => {
|
onError: (handler: (err: StreamError) => void) => {
|
||||||
errorHandlers.add(handler);
|
errorHandlers.add(handler);
|
||||||
return () => {
|
return () => {
|
||||||
errorHandlers.delete(handler);
|
errorHandlers.delete(handler);
|
||||||
@ -134,7 +135,7 @@ function makeClient() {
|
|||||||
sessionUpdateHandlers.delete(handler);
|
sessionUpdateHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
_emitError(err: { kind: string }) {
|
_emitError(err: StreamError) {
|
||||||
for (const h of errorHandlers) h(err);
|
for (const h of errorHandlers) h(err);
|
||||||
},
|
},
|
||||||
_emitStatus(nextStatus: ConnectionStatus) {
|
_emitStatus(nextStatus: ConnectionStatus) {
|
||||||
@ -3208,7 +3209,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
|
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 client = makeClient();
|
||||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
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 () => {
|
it("clears the stream error banner when the user switches to another chat", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
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].content).toBe("fine");
|
||||||
expect(result.current.messages[0].turnId).toEqual(expect.any(String));
|
expect(result.current.messages[0].turnId).toEqual(expect.any(String));
|
||||||
expect(result.current.messages[0].turnPhase).toBe("user");
|
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", () => {
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useNanobotStream("chat-reject-one", EMPTY_MESSAGES),
|
() => useNanobotStream("chat-reject-one", EMPTY_MESSAGES),
|
||||||
@ -1705,10 +1733,18 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.messages).toEqual([
|
expect(result.current.messages).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: first!.userMessageId,
|
||||||
|
turnId: first!.turnId,
|
||||||
|
content: "first",
|
||||||
|
deliveryStatus: "failed",
|
||||||
|
deliveryErrorKind: "turn_rejected",
|
||||||
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: second!.userMessageId,
|
id: second!.userMessageId,
|
||||||
turnId: second!.turnId,
|
turnId: second!.turnId,
|
||||||
content: "second",
|
content: "second",
|
||||||
|
deliveryStatus: "sending",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(result.current.isStreaming).toBe(true);
|
expect(result.current.isStreaming).toBe(true);
|
||||||
@ -1751,6 +1787,12 @@ describe("useNanobotStream", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: first!.userMessageId,
|
id: first!.userMessageId,
|
||||||
turnId: first!.turnId,
|
turnId: first!.turnId,
|
||||||
|
deliveryStatus: "accepted",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: second!.userMessageId,
|
||||||
|
turnId: second!.turnId,
|
||||||
|
deliveryStatus: "failed",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(result.current.runStartedAt).toBe(1234);
|
expect(result.current.runStartedAt).toBe(1234);
|
||||||
@ -1784,7 +1826,13 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
await flushStreamFrame();
|
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.runStartedAt).toBeNull();
|
||||||
expect(result.current.isStreaming).toBe(false);
|
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({
|
expect(result.current.streamError).toMatchObject({
|
||||||
kind: "turn_rejected",
|
kind: "turn_rejected",
|
||||||
chatId: "chat-replayed-reject",
|
chatId: "chat-replayed-reject",
|
||||||
@ -1860,7 +1913,7 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.streamError).toEqual({ kind: "message_too_big" });
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useNanobotStream("chat-side-reject", EMPTY_MESSAGES),
|
() => useNanobotStream("chat-side-reject", EMPTY_MESSAGES),
|
||||||
@ -1893,6 +1946,12 @@ describe("useNanobotStream", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: main!.userMessageId,
|
id: main!.userMessageId,
|
||||||
turnId: main!.turnId,
|
turnId: main!.turnId,
|
||||||
|
deliveryStatus: "accepted",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: side!.userMessageId,
|
||||||
|
turnId: side!.turnId,
|
||||||
|
deliveryStatus: "failed",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(result.current.runStartedAt).toBe(9876);
|
expect(result.current.runStartedAt).toBe(9876);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user