From f88c09dce12cb764135f5a0367145a89faf0b47c Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:19:05 -0400
Subject: [PATCH 01/10] fix(ai-chat): hide message data parts with no renderer
Old transcripts can carry data parts from retired features. Counting them
as displayable made such messages render as a blank bubble instead of
falling through to the empty-response affordance.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../ai-chat/ai-chat-display-state.ts | 19 ++++---------------
1 file changed, 4 insertions(+), 15 deletions(-)
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(
From 215bac6578e9f75d6d1ad34e46b15de4068fc286 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:20:33 -0400
Subject: [PATCH 02/10] feat(ai-chat): show a stopped notice for runs ended
before first token
Stopping a response while it was still pending left the user message as
the transcript tail with no indicator and no way to retry short of
retyping. Surface an inline notice with the existing Try again action;
mid-stream stops keep their partial reply and stay quiet.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../components/ai-chat/AiChatMessageList.tsx | 7 +++++
.../components/ai-chat/AiChatThreadView.tsx | 1 +
.../ai-chat/ai-chat-error-state.test.ts | 30 +++++++++++++++++++
.../components/ai-chat/ai-chat-error-state.ts | 12 +++++++-
4 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx
index 5f926cb7..ee46d3b0 100644
--- a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx
@@ -67,6 +67,9 @@ export type AiChatAssistantErrorState =
kind: "assistant";
stage?: ChatErrorContext["stage"] | null;
}
+ | {
+ kind: "aborted";
+ }
| {
kind: "connection";
};
@@ -437,6 +440,10 @@ function getChatErrorMessage({
return "The chat connection closed before the response could finish. Refresh the page to reconnect.";
}
+ if (errorState.kind === "aborted") {
+ return "The response was stopped before it started.";
+ }
+
if (errorState.classification === "context_overflow") {
return "This chat got too large to finish. Try again or start a new chat.";
}
diff --git a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
index 43f9218a..d1c96b2f 100644
--- a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
@@ -69,6 +69,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) => {
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..2ef6d597 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
@@ -30,6 +30,36 @@ describe("AI chat error state", () => {
});
});
+ 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,
+ 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,
+ 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..707265f5 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,6 +1,6 @@
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,
@@ -10,6 +10,7 @@ type AIThreadErrorSummary = Pick<
export function deriveAiChatAssistantErrorState(input: {
chatStatus: AiChatStatus;
hasConnectionError: boolean;
+ lastMessageRole?: AiChatMessage["role"];
threadSummary?: AIThreadErrorSummary;
}): AiChatAssistantErrorState | null {
if (input.hasConnectionError) {
@@ -37,5 +38,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;
}
From 05e2f2fa05208ceec8f7f3d1610fd4c022239674 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:20:51 -0400
Subject: [PATCH 03/10] fix(ai-chat): toast when creating a new chat fails
Creation failures only hit the console, so the New chat button appeared
to do nothing. Match the delete-thread failure toast.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../workspaces/components/ai-chat/useAiChatPanelController.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
index 60134cf4..2f59c6f6 100644
--- a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
+++ b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
@@ -54,7 +54,7 @@ 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."));
}
};
From f79b688d745fdd8edb37aa864006329c776bf36b Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:22:43 -0400
Subject: [PATCH 04/10] feat(ai-chat): start-new-chat action on context
overflow errors
The overflow banner told users to start a new chat but offered no way to
do it. Add the action next to Try again; it creates the thread and
carries the failed prompt over as a queued direct prompt (text only).
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../workspaces/components/AiChatPanel.tsx | 1 +
.../components/ai-chat/AiChatMessageList.tsx | 55 +++++++++++++++----
.../components/ai-chat/AiChatThreadView.tsx | 18 ++++++
.../ai-chat/useAiChatPanelController.ts | 8 ++-
4 files changed, 68 insertions(+), 14 deletions(-)
diff --git a/src/features/workspaces/components/AiChatPanel.tsx b/src/features/workspaces/components/AiChatPanel.tsx
index 28e57ae8..682e84ad 100644
--- a/src/features/workspaces/components/AiChatPanel.tsx
+++ b/src/features/workspaces/components/AiChatPanel.tsx
@@ -70,6 +70,7 @@ function AiChatPanelLayout({ context }: AiChatPanelProps) {
modelId={modelId}
onModelChange={onModelChange}
onRecoveringChange={setActiveThreadIsRecovering}
+ onStartNewChat={onNewChat}
threadSummary={threads.find((thread) => 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 ee46d3b0..32eb932c 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, RotateCcw } from "lucide-react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { HTMLMotionProps } from "motion/react";
import type { ReactNode } from "react";
@@ -96,6 +96,7 @@ interface AiChatMessageListProps {
assistantError?: AiChatAssistantErrorState | null;
messages: AiChatMessage[];
onRegenerateLastResponse?: () => void;
+ onStartNewChat?: () => void;
presentation: AiChatPresentation;
sentMessageAnimationId?: string | null;
workspaceId: string;
@@ -105,6 +106,7 @@ export default function AiChatMessageList({
assistantError,
messages,
onRegenerateLastResponse,
+ onStartNewChat,
presentation,
sentMessageAnimationId,
workspaceId,
@@ -167,6 +169,7 @@ export default function AiChatMessageList({
row={row}
status={status}
onRegenerateLastResponse={onRegenerateLastResponse}
+ onStartNewChat={onStartNewChat}
/>
))}
@@ -236,6 +239,7 @@ function AiChatListRowView({
hasAssistantContent,
lastAssistantMessageId,
onRegenerateLastResponse,
+ onStartNewChat,
row,
status,
}: {
@@ -243,6 +247,7 @@ function AiChatListRowView({
hasAssistantContent: boolean;
lastAssistantMessageId: string | undefined;
onRegenerateLastResponse?: () => void;
+ onStartNewChat?: () => void;
row: AiChatListRow;
status: AiChatPresentation["status"];
}) {
@@ -262,6 +267,7 @@ function AiChatListRowView({
errorState={row.errorState}
hasAssistantContent={hasAssistantContent}
onRetry={onRegenerateLastResponse}
+ onStartNewChat={onStartNewChat}
/>
);
@@ -287,12 +293,21 @@ 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);
+
return (
@@ -310,17 +325,33 @@ function AiChatAssistantError({
})}
- {canRetry ? (
-
+ {canRetry || canStartNewChat ? (
+
+ {canRetry ? (
+
+ ) : null}
+ {canStartNewChat ? (
+
+ ) : null}
+
) : null}
diff --git a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
index d1c96b2f..f35392a2 100644
--- a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
@@ -8,6 +8,7 @@ import AiChatPromptInput from "#/features/workspaces/components/ai-chat/AiChatPr
import { deriveAiChatAssistantErrorState } from "#/features/workspaces/components/ai-chat/ai-chat-error-state";
import { aiChatComposerRailClassName } from "#/features/workspaces/components/ai-chat/ai-chat-layout";
import type {
+ AiChatMessage,
AiChatModelId,
AiChatSendMessage,
} from "#/features/workspaces/components/ai-chat/types";
@@ -25,6 +26,7 @@ export default function AiChatThreadView({
modelId,
onModelChange,
onRecoveringChange,
+ onStartNewChat,
threadSummary,
threadId,
}: {
@@ -32,6 +34,7 @@ export default function AiChatThreadView({
modelId: AiChatModelId;
onModelChange: (modelId: AiChatModelId) => void;
onRecoveringChange?: (isRecovering: boolean) => void;
+ onStartNewChat?: (carryPrompt?: string) => void;
threadSummary?: AIThreadSummary;
threadId: string;
}) {
@@ -125,6 +128,9 @@ export default function AiChatThreadView({
sentMessageAnimationId={sentMessageAnimationId}
workspaceId={context.workspaceId}
onRegenerateLastResponse={regenerate}
+ onStartNewChat={
+ onStartNewChat ? () => onStartNewChat(getLastUserMessageText(messages)) : undefined
+ }
/>
@@ -147,6 +153,18 @@ export default function AiChatThreadView({
);
}
+// Carries the failed prompt into a fresh thread as text only — attachments
+// stay behind with the old thread.
+function getLastUserMessageText(messages: AiChatMessage[]) {
+ const lastUserMessage = [...messages].reverse().find((message) => message.role === "user");
+ const text = lastUserMessage?.parts
+ .filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("\n\n");
+
+ return text || undefined;
+}
+
function getChatMessageFromPrompt(
message: PromptInputMessage,
id: string,
diff --git a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
index 2f59c6f6..f58a72ee 100644
--- a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
+++ b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
@@ -9,6 +9,7 @@ import {
useWorkspaceAiChatSurfaceMode,
useWorkspaceUiStore,
} from "#/features/workspaces/state/workspace-ui-store";
+import { useWorkspaceAiComposerDraftStore } from "#/features/workspaces/state/workspace-ai-composer-draft-store";
import { getErrorMessage } from "#/lib/error-message";
type UseAiChatPanelControllerInput = {
@@ -45,13 +46,16 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
setActiveAiChatThread(workspaceId, threadId);
};
- const handleNewChat = async () => {
+ const handleNewChat = async (carryPrompt?: string) => {
if (isCreatingThread) {
return;
}
try {
const thread = await createThread();
+ if (carryPrompt) {
+ useWorkspaceAiComposerDraftStore.getState().queueDirectPrompt(thread.id, carryPrompt);
+ }
selectThread(thread.id);
} catch (error) {
toast.error(getErrorMessage(error, "Unable to start a new chat right now."));
@@ -118,7 +122,7 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
},
onMaximize: () => setChatSurfaceMode(workspaceId, "fullscreen"),
onModelChange: (nextModelId: AiChatModelId) => setAiChatModel(nextModelId),
- onNewChat: () => void handleNewChat(),
+ onNewChat: (carryPrompt?: string) => void handleNewChat(carryPrompt),
onRestore: () => setChatSurfaceMode(workspaceId, "docked"),
onSelectThread: (threadId: string) => selectThread(threadId),
threads: threads.map((thread) =>
From 765d253cad7054eb747993005b2f0f63af9a3e9e Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:23:46 -0400
Subject: [PATCH 05/10] feat(ai-chat): show stored error detail and a refresh
action
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Unclassified failures rendered only generic copy while the real reason
(usage-limit reset date, recovery outcome) sat in a thread-list tooltip;
surface it as a detail line under the banner headline. The terminal
connection banner also told users to refresh with no way to do it — give
it a button.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../components/ai-chat/AiChatMessageList.tsx | 40 +++++++++++++++----
.../ai-chat/ai-chat-error-state.test.ts | 4 ++
.../components/ai-chat/ai-chat-error-state.ts | 3 +-
3 files changed, 38 insertions(+), 9 deletions(-)
diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx
index 32eb932c..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, Plus, 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,6 +65,7 @@ export type AiChatAssistantErrorState =
| {
classification?: ChatErrorClassification | null;
kind: "assistant";
+ message?: string | null;
stage?: ChatErrorContext["stage"] | null;
}
| {
@@ -307,6 +308,10 @@ function AiChatAssistantError({
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 (
@@ -318,13 +323,32 @@ 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}
+
+ {errorState.kind === "connection" ? (
+
+ ) : null}
{canRetry || canStartNewChat ? (
{canRetry ? (
@@ -468,7 +492,7 @@ 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") {
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 2ef6d597..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,6 +27,7 @@ describe("AI chat error state", () => {
).toEqual({
classification: "context_overflow",
kind: "assistant",
+ message: "Context window exceeded",
stage: "recovery",
});
});
@@ -38,6 +40,7 @@ describe("AI chat error state", () => {
lastMessageRole: "user",
threadSummary: {
lastErrorClassification: null,
+ lastErrorMessage: null,
lastErrorStage: null,
lastRunResult: "aborted",
},
@@ -53,6 +56,7 @@ describe("AI chat error state", () => {
lastMessageRole: "assistant",
threadSummary: {
lastErrorClassification: null,
+ lastErrorMessage: null,
lastErrorStage: null,
lastRunResult: "aborted",
},
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 707265f5..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
@@ -4,7 +4,7 @@ import type { AiChatMessage, AiChatStatus } from "#/features/workspaces/componen
type AIThreadErrorSummary = Pick<
AIThreadSummary,
- "lastErrorClassification" | "lastErrorStage" | "lastRunResult"
+ "lastErrorClassification" | "lastErrorMessage" | "lastErrorStage" | "lastRunResult"
>;
export function deriveAiChatAssistantErrorState(input: {
@@ -31,6 +31,7 @@ export function deriveAiChatAssistantErrorState(input: {
...(threadError
? {
classification: threadError.lastErrorClassification,
+ message: threadError.lastErrorMessage,
stage: threadError.lastErrorStage,
}
: {}),
From 2562d16ff9181a463b2f13438dadf190d44ef127 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:31:14 -0400
Subject: [PATCH 06/10] refactor(ai-chat): drop prompt carry-over from
start-new-chat
The overflow action now just creates and selects a fresh thread; the
queued-direct-prompt plumbing wasn't worth its wiring.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../components/ai-chat/AiChatThreadView.tsx | 19 ++-----------------
.../ai-chat/useAiChatPanelController.ts | 8 ++------
2 files changed, 4 insertions(+), 23 deletions(-)
diff --git a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
index f35392a2..4b59d3f4 100644
--- a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx
@@ -8,7 +8,6 @@ import AiChatPromptInput from "#/features/workspaces/components/ai-chat/AiChatPr
import { deriveAiChatAssistantErrorState } from "#/features/workspaces/components/ai-chat/ai-chat-error-state";
import { aiChatComposerRailClassName } from "#/features/workspaces/components/ai-chat/ai-chat-layout";
import type {
- AiChatMessage,
AiChatModelId,
AiChatSendMessage,
} from "#/features/workspaces/components/ai-chat/types";
@@ -34,7 +33,7 @@ export default function AiChatThreadView({
modelId: AiChatModelId;
onModelChange: (modelId: AiChatModelId) => void;
onRecoveringChange?: (isRecovering: boolean) => void;
- onStartNewChat?: (carryPrompt?: string) => void;
+ onStartNewChat?: () => void;
threadSummary?: AIThreadSummary;
threadId: string;
}) {
@@ -128,9 +127,7 @@ export default function AiChatThreadView({
sentMessageAnimationId={sentMessageAnimationId}
workspaceId={context.workspaceId}
onRegenerateLastResponse={regenerate}
- onStartNewChat={
- onStartNewChat ? () => onStartNewChat(getLastUserMessageText(messages)) : undefined
- }
+ onStartNewChat={onStartNewChat}
/>
@@ -153,18 +150,6 @@ export default function AiChatThreadView({
);
}
-// Carries the failed prompt into a fresh thread as text only — attachments
-// stay behind with the old thread.
-function getLastUserMessageText(messages: AiChatMessage[]) {
- const lastUserMessage = [...messages].reverse().find((message) => message.role === "user");
- const text = lastUserMessage?.parts
- .filter((part) => part.type === "text")
- .map((part) => part.text)
- .join("\n\n");
-
- return text || undefined;
-}
-
function getChatMessageFromPrompt(
message: PromptInputMessage,
id: string,
diff --git a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
index f58a72ee..2f59c6f6 100644
--- a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
+++ b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
@@ -9,7 +9,6 @@ import {
useWorkspaceAiChatSurfaceMode,
useWorkspaceUiStore,
} from "#/features/workspaces/state/workspace-ui-store";
-import { useWorkspaceAiComposerDraftStore } from "#/features/workspaces/state/workspace-ai-composer-draft-store";
import { getErrorMessage } from "#/lib/error-message";
type UseAiChatPanelControllerInput = {
@@ -46,16 +45,13 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
setActiveAiChatThread(workspaceId, threadId);
};
- const handleNewChat = async (carryPrompt?: string) => {
+ const handleNewChat = async () => {
if (isCreatingThread) {
return;
}
try {
const thread = await createThread();
- if (carryPrompt) {
- useWorkspaceAiComposerDraftStore.getState().queueDirectPrompt(thread.id, carryPrompt);
- }
selectThread(thread.id);
} catch (error) {
toast.error(getErrorMessage(error, "Unable to start a new chat right now."));
@@ -122,7 +118,7 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
},
onMaximize: () => setChatSurfaceMode(workspaceId, "fullscreen"),
onModelChange: (nextModelId: AiChatModelId) => setAiChatModel(nextModelId),
- onNewChat: (carryPrompt?: string) => void handleNewChat(carryPrompt),
+ onNewChat: () => void handleNewChat(),
onRestore: () => setChatSurfaceMode(workspaceId, "docked"),
onSelectThread: (threadId: string) => selectThread(threadId),
threads: threads.map((thread) =>
From 631fc96780d1ac296588cc19bf2545a390709a34 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:57:40 -0400
Subject: [PATCH 07/10] perf(ai-chat): render cached transcripts instantly on
thread switch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Switching threads suspended on the /get-messages fetch every time, even
for a thread visited seconds ago. Serve the last settled transcript from
an in-session cache via a pre-fulfilled thenable (React 19 unwraps it
synchronously, so no fallback frame); the server's connect-time broadcast
replaces it with authoritative state one round trip later. Streaming
threads are never cached — the resume handshake is their fast path, and a
seeded partial risks a duplicate bubble on message-id drift.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../components/ai-chat/useWorkspaceAiChat.ts | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
index 81bd275f..ef449d86 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,29 @@ 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.
+const transcriptCache = new Map
();
+
+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 +59,10 @@ 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,
throttle: AI_CHAT_RENDER_THROTTLE_MS,
});
const {
@@ -62,6 +84,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;
+ }
+ transcriptCache.set(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
From 5e0a753bd70f1b4939259536a5fafdb12f9e698d Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:57:48 -0400
Subject: [PATCH 08/10] fix(ai-chat): keep thread deletes clean while the
thread is open
Deleting a thread never closes the viewer's socket (it lives on the
directory DO), so a mounted view kept a zombie transcript whose next
frame silently resurrected the deleted thread (cloudflare/agents#2003).
Switch to the most recent surviving thread before deleting; when none
survives, remount the view after the delete so it reconnects to a fresh
default thread. Also evict the deleted thread's transcript cache.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../workspaces/components/AiChatPanel.tsx | 3 ++-
.../ai-chat/useAiChatPanelController.ts | 27 +++++++++++++++----
2 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/src/features/workspaces/components/AiChatPanel.tsx b/src/features/workspaces/components/AiChatPanel.tsx
index 682e84ad..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,7 +65,7 @@ function AiChatPanelLayout({ context }: AiChatPanelProps) {
threads={threads}
/>
- }>
+ }>
state.setActiveAiChatThread);
const setAiChatModel = useWorkspaceUiStore((state) => state.setAiChatModel);
const [markingViewedThreadIds] = useState(() => new Set());
+ const [threadViewEpoch, setThreadViewEpoch] = useState(0);
const {
createThread,
deleteThread,
@@ -59,19 +61,33 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
};
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) {
+ if (wasActive) {
+ selectThread(threadId);
+ }
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 +124,7 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
return {
activeThreadId: resolvedActiveThreadId,
+ threadViewKey: `${resolvedActiveThreadId}:${threadViewEpoch}`,
isCreatingThread,
isLoading: !areThreadsReady,
isMaximized,
From 3ce936b22847b69864bfb29d0dae03b7bb710037 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:58:07 -0400
Subject: [PATCH 09/10] fix(ai): regenerate from the last user message, not as
a continue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Think assembles the model prompt from the stored path, so a regenerated
turn still carried the old assistant answer plus a provider-prefill
"continue your previous response" instruction — regenerate behaved like
continue, worst on retries of partial failed responses. Flag regenerate
turns in the request body and trim the prompt back to the last user
message in beforeTurn (non-continuation turns only, so tool round-trips
keep their in-flight assistant). Branch storage was already correct.
Workaround for cloudflare/agents#2028; remove once #2038 ships.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
src/features/workspaces/ai/ai-thread.ts | 16 +++++++++++++++-
.../components/ai-chat/useWorkspaceAiChat.ts | 4 ++++
2 files changed, 19 insertions(+), 1 deletion(-)
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/ai-chat/useWorkspaceAiChat.ts b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
index ef449d86..ca881f58 100644
--- a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
+++ b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
@@ -63,6 +63,10 @@ export function useWorkspaceAiChat({ modelId, threadId }: UseWorkspaceAiChatOpti
// 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 {
From 82b0f37488701e27b6a2c484e75a420fb9b92c8a Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Sat, 15 Aug 2026 21:18:07 -0400
Subject: [PATCH 10/10] refactor(ai-chat): bound transcript cache, drop
failed-delete reselect
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cap the in-session transcript cache at 8 threads (LRU via Map order) so
long sessions don't retain every visited transcript. On a failed delete,
keep whatever thread the user is on instead of reselecting the errored
one — it's still in the list and the toast explains.
Claude-Session: https://claude.ai/code/session_01S9YxTH6xRfC6bq3vbgmTQD
---
.../ai-chat/useAiChatPanelController.ts | 5 ++---
.../components/ai-chat/useWorkspaceAiChat.ts | 16 +++++++++++++++-
2 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
index 91c23082..ecda56fd 100644
--- a/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
+++ b/src/features/workspaces/components/ai-chat/useAiChatPanelController.ts
@@ -76,9 +76,8 @@ export function useAiChatPanelController({ workspaceId }: UseAiChatPanelControll
try {
await deleteThread(threadId);
} catch (error) {
- if (wasActive) {
- selectThread(threadId);
- }
+ // 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;
}
diff --git a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
index ca881f58..6adc914e 100644
--- a/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
+++ b/src/features/workspaces/components/ai-chat/useWorkspaceAiChat.ts
@@ -30,8 +30,22 @@ 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);
}
@@ -99,7 +113,7 @@ export function useWorkspaceAiChat({ modelId, threadId }: UseWorkspaceAiChatOpti
transcriptCache.delete(threadId);
return;
}
- transcriptCache.set(threadId, messages);
+ cacheTranscript(threadId, messages);
}, [messages, presentation.isBusy, status, threadId]);
const canStop = status === "submitted" || presentation.isBusy;
const isConnected = agent.identified && agent.readyState === agent.OPEN;