feat(ai-chat): mark interrupted turns with a "stopped" notice
A turn that ends without a clean finish now shows a neutral marker, so an interrupted answer is visible instead of trailing off silently. Errors keep their existing red banner; this covers the aborted case. - chat-stopped-notice.tsx: new neutral (gray) notice component - chat-thread.tsx: live marker driven by useChat onFinish flags — distinguishes a manual Stop (isAbort) from a dropped connection (isDisconnect); cleared when the next turn streams; flushNext still runs only on a clean finish - message-item.tsx: per-message marker in reopened history for finishReason 'aborted' with no error (combined wording, since the server can't tell a manual Stop from a dropped connection) - ai-chat.types.ts: add metadata.finishReason; rowToUiMessage now carries it - en-US: three new strings Frontend only — the server already persists partial work and finishReason and replays it to the model on the next turn (continue, not restart).
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
|
||||
import { IconPlayerStopFilled } from "@tabler/icons-react";
|
||||
|
||||
/**
|
||||
* A neutral "turn was interrupted" notice (NOT an error). Rendered for an
|
||||
* aborted turn — a manual Stop or a dropped connection — both live (ChatThread)
|
||||
* and in reopened history (MessageItem). Deliberately gray/subtle so it reads as
|
||||
* an informational marker, distinct from the red ChatErrorAlert. Layout-only
|
||||
* props (mt/mb/...) are forwarded to the Alert root.
|
||||
*/
|
||||
interface ChatStoppedNoticeProps extends Omit<AlertProps, "title" | "children"> {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function ChatStoppedNotice({
|
||||
text,
|
||||
style,
|
||||
...alertProps
|
||||
}: ChatStoppedNoticeProps) {
|
||||
return (
|
||||
<Alert
|
||||
{...alertProps}
|
||||
variant="light"
|
||||
color="gray"
|
||||
p="xs"
|
||||
// flexShrink: 0 mirrors ChatErrorAlert so the notice is not compressed as a
|
||||
// flex child of the chat panel.
|
||||
style={[{ flexShrink: 0 }, style]}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<IconPlayerStopFilled
|
||||
size={16}
|
||||
style={{ flex: "none", color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" lh={1.3} c="dimmed">
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
||||
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||
import {
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
@@ -79,13 +80,18 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
||||
? row.metadata.parts
|
||||
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
|
||||
const error = row.metadata?.error;
|
||||
const finishReason = row.metadata?.finishReason;
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (error) metadata.error = error;
|
||||
if (finishReason) metadata.finishReason = finishReason;
|
||||
return {
|
||||
id: row.id,
|
||||
role,
|
||||
parts,
|
||||
// Carry a persisted turn error so MessageItem can render it after a remount
|
||||
// (e.g. when a new chat adopts its id) and in reopened chat history.
|
||||
...(error ? { metadata: { error } } : {}),
|
||||
// Carry persisted turn outcome (error text and/or finishReason) so MessageItem
|
||||
// can render the error banner / "stopped" marker after a remount and in
|
||||
// reopened history.
|
||||
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
@@ -242,6 +248,12 @@ export default function ChatThread({
|
||||
// the user to decide.
|
||||
onFinish: ({ isAbort, isDisconnect, isError }) => {
|
||||
onTurnFinished();
|
||||
// Show a neutral "stopped" marker for an aborted turn; the red error banner
|
||||
// (via `error`) already covers isError, and a clean finish clears any marker.
|
||||
if (isError) setStopNotice(null);
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice("disconnect");
|
||||
else setStopNotice(null);
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
},
|
||||
@@ -261,8 +273,22 @@ export default function ChatThread({
|
||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection
|
||||
// (`isDisconnect`) — a distinction only available live (the server persists
|
||||
// both as finishReason 'aborted'). Cleared when the next turn starts.
|
||||
const [stopNotice, setStopNotice] = useState<null | "manual" | "disconnect">(
|
||||
null,
|
||||
);
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming.
|
||||
useEffect(() => {
|
||||
if (isStreaming) setStopNotice(null);
|
||||
}, [isStreaming]);
|
||||
|
||||
// Mirror the live useChat snapshot into the parent-owned ref so the export
|
||||
// (handled in AiChatWindow) can include the in-progress streaming turn. The
|
||||
// cleanup clears the ref on unmount so a thread torn down by `key` on chat
|
||||
@@ -304,13 +330,22 @@ export default function ChatThread({
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
|
||||
{errorView && (
|
||||
{errorView ? (
|
||||
<ChatErrorAlert
|
||||
title={errorView.title}
|
||||
detail={errorView.detail}
|
||||
mb="xs"
|
||||
/>
|
||||
)}
|
||||
) : stopNotice ? (
|
||||
<ChatStoppedNotice
|
||||
text={
|
||||
stopNotice === "manual"
|
||||
? t("Response stopped.")
|
||||
: t("Connection lost — the answer was interrupted.")
|
||||
}
|
||||
mb="xs"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Stack gap={0} className={classes.inputWrapper}>
|
||||
{queued.length > 0 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
@@ -125,6 +126,22 @@ export default function MessageItem({
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* A persisted turn that was aborted (manual Stop or a dropped connection)
|
||||
with no error banner. The server cannot tell a manual Stop from a
|
||||
connection drop (both persist as finishReason 'aborted'), so reopened
|
||||
history uses a combined wording. */}
|
||||
{(() => {
|
||||
const meta = message.metadata as
|
||||
| { error?: string; finishReason?: string }
|
||||
| undefined;
|
||||
if (meta?.error || meta?.finishReason !== "aborted") return null;
|
||||
return (
|
||||
<ChatStoppedNotice
|
||||
text={t("Response stopped (manually or the connection dropped).")}
|
||||
mt={4}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,11 @@ export interface IAiChatMessageRow {
|
||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||
error?: string;
|
||||
// Terminal outcome of the assistant turn: 'error' (provider/stream error,
|
||||
// paired with `error`), 'aborted' (client disconnect — a manual Stop or a
|
||||
// dropped connection), or the SDK's finish reason on a clean turn. The UI
|
||||
// renders a "stopped" marker on interrupted turns.
|
||||
finishReason?: string;
|
||||
} | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user