diff --git a/src/features/workspaces/ai/ai-thread.ts b/src/features/workspaces/ai/ai-thread.ts index 41b66c23..6d8a9c4a 100644 --- a/src/features/workspaces/ai/ai-thread.ts +++ b/src/features/workspaces/ai/ai-thread.ts @@ -248,9 +248,23 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { thread, tools: activeTools, }); + // A regeneration must not see the answer it replaces: Think assembles + // the prompt from the stored path, whose stale assistant tail would + // turn the turn into a "continue" (cloudflare/agents#2028 — remove + // once #2038 ships). Continuations keep their in-flight assistant. + let turnMessages = ctx.messages; + if (!ctx.continuation && ctx.body?.regenerate === true) { + let end = turnMessages.length; + while (end > 0 && turnMessages[end - 1]?.role !== "user") { + end -= 1; + } + if (end > 0) { + turnMessages = turnMessages.slice(0, end); + } + } const messages = await resolveChatAttachmentModelMessages({ bucket: this.env.WORKSPACE_FILES, - messages: ctx.messages, + messages: turnMessages, threadId: thread.id, userId: thread.userId, workspaceId: thread.workspaceId, diff --git a/src/features/workspaces/components/AiChatPanel.tsx b/src/features/workspaces/components/AiChatPanel.tsx index 28e57ae8..d7423ecc 100644 --- a/src/features/workspaces/components/AiChatPanel.tsx +++ b/src/features/workspaces/components/AiChatPanel.tsx @@ -29,6 +29,7 @@ function AiChatPanelLayout({ context }: AiChatPanelProps) { const [activeThreadIsRecovering, setActiveThreadIsRecovering] = useState(false); const { activeThreadId, + threadViewKey, isCreatingThread, isLoading, isMaximized, @@ -64,12 +65,13 @@ function AiChatPanelLayout({ context }: AiChatPanelProps) { threads={threads} /> - }> + }> thread.id === activeThreadId)} threadId={activeThreadId} /> diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx index 5f926cb7..29b32a72 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx @@ -1,5 +1,5 @@ import type { ChatErrorClassification, ChatErrorContext } from "@cloudflare/think"; -import { AlertCircle, RotateCcw } from "lucide-react"; +import { AlertCircle, Plus, RefreshCw, RotateCcw } from "lucide-react"; import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react"; import type { HTMLMotionProps } from "motion/react"; import type { ReactNode } from "react"; @@ -65,8 +65,12 @@ export type AiChatAssistantErrorState = | { classification?: ChatErrorClassification | null; kind: "assistant"; + message?: string | null; stage?: ChatErrorContext["stage"] | null; } + | { + kind: "aborted"; + } | { kind: "connection"; }; @@ -93,6 +97,7 @@ interface AiChatMessageListProps { assistantError?: AiChatAssistantErrorState | null; messages: AiChatMessage[]; onRegenerateLastResponse?: () => void; + onStartNewChat?: () => void; presentation: AiChatPresentation; sentMessageAnimationId?: string | null; workspaceId: string; @@ -102,6 +107,7 @@ export default function AiChatMessageList({ assistantError, messages, onRegenerateLastResponse, + onStartNewChat, presentation, sentMessageAnimationId, workspaceId, @@ -164,6 +170,7 @@ export default function AiChatMessageList({ row={row} status={status} onRegenerateLastResponse={onRegenerateLastResponse} + onStartNewChat={onStartNewChat} /> ))} @@ -233,6 +240,7 @@ function AiChatListRowView({ hasAssistantContent, lastAssistantMessageId, onRegenerateLastResponse, + onStartNewChat, row, status, }: { @@ -240,6 +248,7 @@ function AiChatListRowView({ hasAssistantContent: boolean; lastAssistantMessageId: string | undefined; onRegenerateLastResponse?: () => void; + onStartNewChat?: () => void; row: AiChatListRow; status: AiChatPresentation["status"]; }) { @@ -259,6 +268,7 @@ function AiChatListRowView({ errorState={row.errorState} hasAssistantContent={hasAssistantContent} onRetry={onRegenerateLastResponse} + onStartNewChat={onStartNewChat} /> ); @@ -284,12 +294,25 @@ function AiChatAssistantError({ errorState, hasAssistantContent, onRetry, + onStartNewChat, }: { canRetry: boolean; errorState: AiChatAssistantErrorState; hasAssistantContent: boolean; onRetry?: () => void; + onStartNewChat?: () => void; }) { + // An overflowed chat can rarely be retried into success, so the escape + // hatch the copy suggests gets its own action. + const canStartNewChat = + errorState.kind === "assistant" && + errorState.classification === "context_overflow" && + Boolean(onStartNewChat); + // Classified errors get curated copy; for the rest the stored server + // message (usage limits, recovery reasons) beats an unexplained failure. + const errorDetail = + errorState.kind === "assistant" && !errorState.classification ? errorState.message : null; + return ( @@ -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