@@ -300,25 +323,60 @@ function AiChatAssistantError({
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
-
- {getChatErrorMessage({
- errorState,
- hasAssistantContent,
- })}
-
+
+
+ {getChatErrorMessage({
+ errorState,
+ hasAssistantContent,
+ })}
+
+ {errorDetail ? (
+
{errorDetail}
+ ) : null}
+
- {canRetry ? (
+ {errorState.kind === "connection" ? (
) : null}
+ {canRetry || canStartNewChat ? (
+
+ {canRetry ? (
+
+ ) : null}
+ {canStartNewChat ? (
+
+ ) : null}
+
+ ) : null}
@@ -434,7 +492,11 @@ function getChatErrorMessage({
hasAssistantContent: boolean;
}) {
if (errorState.kind === "connection") {
- return "The chat connection closed before the response could finish. Refresh the page to reconnect.";
+ return "The chat connection closed before the response could finish.";
+ }
+
+ if (errorState.kind === "aborted") {
+ return "The response was stopped before it started.";
}
if (errorState.classification === "context_overflow") {
diff --git a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
index 43f9218a..4b59d3f4 100644
--- a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
@@ -25,6 +25,7 @@ export default function AiChatThreadView({
modelId,
onModelChange,
onRecoveringChange,
+ onStartNewChat,
threadSummary,
threadId,
}: {
@@ -32,6 +33,7 @@ export default function AiChatThreadView({
modelId: AiChatModelId;
onModelChange: (modelId: AiChatModelId) => void;
onRecoveringChange?: (isRecovering: boolean) => void;
+ onStartNewChat?: () => void;
threadSummary?: AIThreadSummary;
threadId: string;
}) {
@@ -69,6 +71,7 @@ export default function AiChatThreadView({
const assistantError = deriveAiChatAssistantErrorState({
chatStatus: presentation.status,
hasConnectionError: Boolean(connectionError),
+ lastMessageRole: messages.at(-1)?.role,
threadSummary,
});
const sendMessage = (message: PromptInputMessage, clearDraft = true) => {
@@ -124,6 +127,7 @@ export default function AiChatThreadView({
sentMessageAnimationId={sentMessageAnimationId}
workspaceId={context.workspaceId}
onRegenerateLastResponse={regenerate}
+ onStartNewChat={onStartNewChat}
/>
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts
index c9788766..a0731355 100644
--- a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts
+++ b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts
@@ -1,6 +1,5 @@
import { isToolUIPart } from "ai";
-import { WORKSPACE_REFERENCES_DATA_PART_TYPE } from "#/features/workspaces/ai/workspace-references";
import type {
AiChatMessage,
AiChatMessagePart,
@@ -203,10 +202,6 @@ export function getDisplayableParts(message: AiChatMessage): AiChatRenderablePar
}
export function isDisplayableMessagePart(part: AiChatMessagePart): boolean {
- if (part.type === WORKSPACE_REFERENCES_DATA_PART_TYPE) {
- return false;
- }
-
if (part.type === "text") {
return part.text.length > 0 || part.state === "streaming";
}
@@ -219,16 +214,10 @@ export function isDisplayableMessagePart(part: AiChatMessagePart): boolean {
return isVisibleToolPart(part);
}
- if (
- part.type === "file" ||
- part.type === "source-url" ||
- part.type === "source-document" ||
- part.type.startsWith("data-")
- ) {
- return true;
- }
-
- return false;
+ // Deliberately excludes `data-*` parts: old transcripts can carry data parts
+ // from retired features, and counting one as displayable makes a message
+ // render as a blank bubble instead of falling through to "no response".
+ return part.type === "file" || part.type === "source-url" || part.type === "source-document";
}
export function getToolActivityForPart(
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-error-state.test.ts b/src/features/workspaces/components/ai-chat/ai-chat-error-state.test.ts
index 6aaf31c7..b3636c27 100644
--- a/src/features/workspaces/components/ai-chat/ai-chat-error-state.test.ts
+++ b/src/features/workspaces/components/ai-chat/ai-chat-error-state.test.ts
@@ -19,6 +19,7 @@ describe("AI chat error state", () => {
hasConnectionError: false,
threadSummary: {
lastErrorClassification: "context_overflow",
+ lastErrorMessage: "Context window exceeded",
lastErrorStage: "recovery",
lastRunResult: "error",
},
@@ -26,10 +27,43 @@ describe("AI chat error state", () => {
).toEqual({
classification: "context_overflow",
kind: "assistant",
+ message: "Context window exceeded",
stage: "recovery",
});
});
+ it("flags a run stopped before the first token so the send visibly ended", () => {
+ expect(
+ deriveAiChatAssistantErrorState({
+ chatStatus: "ready",
+ hasConnectionError: false,
+ lastMessageRole: "user",
+ threadSummary: {
+ lastErrorClassification: null,
+ lastErrorMessage: null,
+ lastErrorStage: null,
+ lastRunResult: "aborted",
+ },
+ }),
+ ).toEqual({ kind: "aborted" });
+ });
+
+ it("stays quiet for a mid-stream stop that kept its partial reply", () => {
+ expect(
+ deriveAiChatAssistantErrorState({
+ chatStatus: "ready",
+ hasConnectionError: false,
+ lastMessageRole: "assistant",
+ threadSummary: {
+ lastErrorClassification: null,
+ lastErrorMessage: null,
+ lastErrorStage: null,
+ lastRunResult: "aborted",
+ },
+ }),
+ ).toBeNull();
+ });
+
it("keeps a terminal connection error distinct", () => {
expect(
deriveAiChatAssistantErrorState({
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-error-state.ts b/src/features/workspaces/components/ai-chat/ai-chat-error-state.ts
index e60e3cf8..21a633d5 100644
--- a/src/features/workspaces/components/ai-chat/ai-chat-error-state.ts
+++ b/src/features/workspaces/components/ai-chat/ai-chat-error-state.ts
@@ -1,15 +1,16 @@
import type { AIThreadSummary } from "#/features/workspaces/ai/user-ai-agents";
import type { AiChatAssistantErrorState } from "#/features/workspaces/components/ai-chat/AiChatMessageList";
-import type { AiChatStatus } from "#/features/workspaces/components/ai-chat/types";
+import type { AiChatMessage, AiChatStatus } from "#/features/workspaces/components/ai-chat/types";
type AIThreadErrorSummary = Pick<
AIThreadSummary,
- "lastErrorClassification" | "lastErrorStage" | "lastRunResult"
+ "lastErrorClassification" | "lastErrorMessage" | "lastErrorStage" | "lastRunResult"
>;
export function deriveAiChatAssistantErrorState(input: {
chatStatus: AiChatStatus;
hasConnectionError: boolean;
+ lastMessageRole?: AiChatMessage["role"];
threadSummary?: AIThreadErrorSummary;
}): AiChatAssistantErrorState | null {
if (input.hasConnectionError) {
@@ -30,6 +31,7 @@ export function deriveAiChatAssistantErrorState(input: {
...(threadError
? {
classification: threadError.lastErrorClassification,
+ message: threadError.lastErrorMessage,
stage: threadError.lastErrorStage,
}
: {}),
@@ -37,5 +39,14 @@ export function deriveAiChatAssistantErrorState(input: {
};
}
+ // A run stopped before the first token leaves the user message as the tail
+ // with nothing after it — without this row there is no sign the send ended.
+ // A mid-stream stop keeps its partial assistant tail, so no row is needed.
+ if (input.threadSummary?.lastRunResult === "aborted" && input.lastMessageRole === "user") {
+ return {
+ kind: "aborted",
+ };
+ }
+
return null;
}
diff --git a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
index 60134cf4..ecda56fd 100644
--- a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
+++ b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
@@ -3,6 +3,7 @@ import { toast } from "sonner";
import { getDefaultWorkspaceThreadId } from "#/features/workspaces/ai/ai-thread-identity";
import type { AiChatModelId } from "#/features/workspaces/components/ai-chat/types";
import { useWorkspaceAiChatThreads } from "#/features/workspaces/components/ai-chat/useWorkspaceAiChatThreads";
+import { evictWorkspaceAiTranscript } from "#/features/workspaces/components/ai-chat/useWorkspaceAiChat";
import {
useWorkspaceActiveAiChatThreadId,
useWorkspaceAiChatModelId,
@@ -28,6 +29,7 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
const setActiveAiChatThread = useWorkspaceUiStore((state) => state.setActiveAiChatThread);
const setAiChatModel = useWorkspaceUiStore((state) => state.setAiChatModel);
const [markingViewedThreadIds] = useState(() => new Set());
+ const [threadViewEpoch, setThreadViewEpoch] = useState(0);
const {
createThread,
deleteThread,
@@ -54,24 +56,37 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
const thread = await createThread();
selectThread(thread.id);
} catch (error) {
- console.warn("[AiChatPanel] Failed to create chat thread", error);
+ toast.error(getErrorMessage(error, "Unable to start a new chat right now."));
}
};
const handleDeleteThread = async (threadId: string) => {
+ // The thread's socket lives on the directory DO, so deleting the thread
+ // never closes it — a still-mounted view keeps a zombie transcript whose
+ // next frame resurrects the deleted thread (cloudflare/agents#2003).
+ // Switch away first; when nothing survives, remount the view after the
+ // delete so it reconnects to a fresh default thread.
+ const wasActive = resolvedActiveThreadId === threadId;
+ const survivorId = wasActive ? threads.find((thread) => thread.id !== threadId)?.id : undefined;
+
+ if (wasActive) {
+ selectThread(survivorId);
+ }
+
try {
await deleteThread(threadId);
- toast.success("Chat deleted.");
} catch (error) {
+ // No selection restore: the thread is still in the list, and the user
+ // may have moved on during the in-flight delete.
toast.error(getErrorMessage(error, "Unable to delete chat right now."));
return;
}
- if (resolvedActiveThreadId !== threadId) {
- return;
+ evictWorkspaceAiTranscript(threadId);
+ toast.success("Chat deleted.");
+ if (wasActive && !survivorId) {
+ setThreadViewEpoch((epoch) => epoch + 1);
}
-
- selectThread(undefined);
};
useEffect(() => {
@@ -108,6 +123,7 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
return {
activeThreadId: resolvedActiveThreadId,
+ threadViewKey: `${resolvedActiveThreadId}:${threadViewEpoch}`,
isCreatingThread,
isLoading: !areThreadsReady,
isMaximized,
diff --git a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
index 81bd275f..6adc914e 100644
--- a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
+++ b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
@@ -1,5 +1,6 @@
import { useAgentChat } from "@cloudflare/think/react";
import { useAgent } from "agents/react";
+import { useEffect } from "react";
import {
aiThreadAgentName,
@@ -26,12 +27,43 @@ interface UseWorkspaceAiChatOptions {
const AI_CHAT_RENDER_THROTTLE_MS = 100;
+// Last settled transcript per thread, so switching back renders instantly
+// instead of suspending on the /get-messages fetch. The server's connect-time
+// broadcast replaces the seed with authoritative state one round trip later.
+// Bounded: transcripts are heavy, and only recently visited threads are
+// likely to be revisited. Map iteration order gives us LRU for free.
+const TRANSCRIPT_CACHE_MAX_THREADS = 8;
+const transcriptCache = new Map();
+
+function cacheTranscript(threadId: string, messages: AiChatMessage[]) {
+ transcriptCache.delete(threadId);
+ transcriptCache.set(threadId, messages);
+ for (const staleId of transcriptCache.keys()) {
+ if (transcriptCache.size <= TRANSCRIPT_CACHE_MAX_THREADS) {
+ break;
+ }
+ transcriptCache.delete(staleId);
+ }
+}
+
+export function evictWorkspaceAiTranscript(threadId: string) {
+ transcriptCache.delete(threadId);
+}
+
+// React 19's use() unwraps a thenable synchronously when it carries
+// status/value (the tracked-thenable convention), so a cached transcript
+// renders without a Suspense fallback frame.
+function fulfilledThenable(value: T): Promise {
+ return Object.assign(Promise.resolve(value), { status: "fulfilled", value });
+}
+
export function useWorkspaceAiChat({ modelId, threadId }: UseWorkspaceAiChatOptions) {
const agent = useAgent({
agent: userAIAgentName,
basePath: userAIBasePath,
sub: [{ agent: aiThreadAgentName, name: threadId }],
});
+ const cachedTranscript = transcriptCache.get(threadId);
const chat = useAgentChat({
agent,
body: () => ({
@@ -41,6 +73,14 @@ export function useWorkspaceAiChat({ modelId, threadId }: UseWorkspaceAiChatOpti
analyticsConsent: hasAnalyticsConsent(),
sessionReplayConsent: hasExplicitSessionReplayConsent(),
}),
+ // No cache → the default /get-messages fetch, which also covers
+ // mid-stream reconnects (the socket deliberately sends no transcript
+ // while a stream is active).
+ getInitialMessages: cachedTranscript ? () => fulfilledThenable(cachedTranscript) : undefined,
+ // Think assembles the prompt from the stored path, so the server needs
+ // to know a regeneration when it sees one. See beforeTurn in ai-thread.
+ prepareSendMessagesRequest: ({ trigger }) =>
+ trigger === "regenerate-message" ? { body: { regenerate: true } } : {},
throttle: AI_CHAT_RENDER_THROTTLE_MS,
});
const {
@@ -62,6 +102,19 @@ export function useWorkspaceAiChat({ modelId, threadId }: UseWorkspaceAiChatOpti
isStreaming,
isToolContinuation,
});
+
+ // Cache only settled transcripts: seeding a mid-stream partial risks a
+ // duplicate assistant bubble if the server's message id drifts while away.
+ useEffect(() => {
+ if (presentation.isBusy || status !== "ready") {
+ return;
+ }
+ if (messages.length === 0) {
+ transcriptCache.delete(threadId);
+ return;
+ }
+ cacheTranscript(threadId, messages);
+ }, [messages, presentation.isBusy, status, threadId]);
const canStop = status === "submitted" || presentation.isBusy;
const isConnected = agent.identified && agent.readyState === agent.OPEN;
const inputStatus: AiChatStatus = connectionError