From ebc3b01dc2178a8287c3c1f8592bad067867f492 Mon Sep 17 00:00:00 2001 From: claude_code Date: Mon, 22 Jun 2026 20:56:30 +0300 Subject: [PATCH] feat(ai-chat): mark interrupted turns with a "stopped" notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../public/locales/en-US/translation.json | 3 ++ .../components/chat-stopped-notice.tsx | 41 +++++++++++++++++ .../ai-chat/components/chat-thread.tsx | 45 ++++++++++++++++--- .../ai-chat/components/message-item.tsx | 17 +++++++ .../features/ai-chat/types/ai-chat.types.ts | 5 +++ 5 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 apps/client/src/features/ai-chat/components/chat-stopped-notice.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 4275b5af..2c687936 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1154,6 +1154,9 @@ "Queue message": "Queue message", "Remove queued message": "Remove queued message", "Stop": "Stop", + "Response stopped.": "Response stopped.", + "Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.", + "Response stopped (manually or the connection dropped).": "Response stopped (manually or the connection dropped).", "Chat menu": "Chat menu", "No chats yet.": "No chats yet.", "Delete this chat?": "Delete this chat?", diff --git a/apps/client/src/features/ai-chat/components/chat-stopped-notice.tsx b/apps/client/src/features/ai-chat/components/chat-stopped-notice.tsx new file mode 100644 index 00000000..59298ef0 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/chat-stopped-notice.tsx @@ -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 { + text: string; +} + +export default function ChatStoppedNotice({ + text, + style, + ...alertProps +}: ChatStoppedNoticeProps) { + return ( + + + + + {text} + + + + ); +} diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 5b7e48eb..9ee92377 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -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 = {}; + 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, + ); + 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 ? ( - )} + ) : stopNotice ? ( + + ) : null} {queued.length > 0 && ( diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 7071c2bb..3fc83ded 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -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 ( + + ); + })()} ); } diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts index 1fbb11ab..f4b0ccb6 100644 --- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts +++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts @@ -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; }