diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx
index 85884184..5ba66f14 100644
--- a/ui/src/components/IssueChatThread.test.tsx
+++ b/ui/src/components/IssueChatThread.test.tsx
@@ -11,6 +11,10 @@ const { markdownEditorFocusMock } = vi.hoisted(() => ({
markdownEditorFocusMock: vi.fn(),
}));
+const { threadMessagesMock } = vi.hoisted(() => ({
+ threadMessagesMock: vi.fn(() =>
),
+}));
+
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => {children}
,
ThreadPrimitive: {
@@ -21,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
{children}
),
Empty: ({ children }: { children: ReactNode }) => {children}
,
- Messages: () => ,
+ Messages: () => threadMessagesMock(),
},
MessagePrimitive: {
Root: ({ children }: { children: ReactNode }) => {children}
,
@@ -116,12 +120,14 @@ describe("IssueChatThread", () => {
container = document.createElement("div");
document.body.appendChild(container);
localStorage.clear();
+ threadMessagesMock.mockImplementation(() => );
});
afterEach(() => {
container.remove();
vi.useRealTimers();
markdownEditorFocusMock.mockReset();
+ threadMessagesMock.mockReset();
});
it("drops the count heading and does not use an internal scrollbox", () => {
@@ -189,6 +195,48 @@ describe("IssueChatThread", () => {
});
});
+ it("falls back to a safe transcript warning when assistant-ui throws during message rendering", () => {
+ const root = createRoot(container);
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ threadMessagesMock.mockImplementation(() => {
+ throw new Error("tapClientLookup: Index 8 out of bounds (length: 8)");
+ });
+
+ act(() => {
+ root.render(
+
+ {}}
+ showComposer={false}
+ enableLiveTranscriptPolling={false}
+ />
+ ,
+ );
+ });
+
+ expect(container.textContent).toContain("Chat renderer hit an internal state error.");
+ expect(container.textContent).toContain("Agent summary");
+ expect(consoleErrorSpy).toHaveBeenCalled();
+
+ consoleErrorSpy.mockRestore();
+ act(() => {
+ root.unmount();
+ });
+ });
+
it("stores and restores the composer draft per issue key", () => {
vi.useFakeTimers();
const root = createRoot(container);
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index c126fe08..884517b4 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -10,6 +10,7 @@ import {
import type { ToolCallMessagePart } from "@assistant-ui/react";
import {
createContext,
+ Component,
forwardRef,
useContext,
useEffect,
@@ -18,7 +19,9 @@ import {
useRef,
useState,
type ChangeEvent,
+ type ErrorInfo,
type Ref,
+ type ReactNode,
} from "react";
import { Link, useLocation } from "@/lib/router";
import type {
@@ -76,7 +79,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
-import { ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
+import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
interface IssueChatMessageContext {
feedbackVoteByTargetId: Map;
@@ -218,6 +221,145 @@ interface IssueChatThreadProps {
composerRef?: Ref;
}
+type IssueChatErrorBoundaryProps = {
+ resetKey: string;
+ messages: readonly import("@assistant-ui/react").ThreadMessage[];
+ emptyMessage: string;
+ variant: "full" | "embedded";
+ children: ReactNode;
+};
+
+type IssueChatErrorBoundaryState = {
+ hasError: boolean;
+};
+
+class IssueChatErrorBoundary extends Component {
+ override state: IssueChatErrorBoundaryState = { hasError: false };
+
+ static getDerivedStateFromError(): IssueChatErrorBoundaryState {
+ return { hasError: true };
+ }
+
+ override componentDidCatch(error: unknown, info: ErrorInfo): void {
+ console.error("Issue chat renderer failed; falling back to safe transcript view", {
+ error,
+ info: info.componentStack,
+ });
+ }
+
+ override componentDidUpdate(prevProps: IssueChatErrorBoundaryProps): void {
+ if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
+ this.setState({ hasError: false });
+ }
+ }
+
+ override render() {
+ if (this.state.hasError) {
+ return (
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessage) {
+ const custom = message.metadata?.custom as Record | undefined;
+ if (typeof custom?.["authorName"] === "string") return custom["authorName"];
+ if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"];
+ if (message.role === "assistant") return "Agent";
+ if (message.role === "user") return "You";
+ return "System";
+}
+
+function fallbackTextParts(message: import("@assistant-ui/react").ThreadMessage) {
+ const contentLines: string[] = [];
+ for (const part of message.content) {
+ if (part.type === "text" || part.type === "reasoning") {
+ if (part.text.trim().length > 0) contentLines.push(part.text);
+ continue;
+ }
+ if (part.type === "tool-call") {
+ const lines = [`Tool: ${part.toolName}`];
+ if (part.argsText?.trim()) lines.push(`Args:\n${part.argsText}`);
+ if (typeof part.result === "string" && part.result.trim()) lines.push(`Result:\n${part.result}`);
+ contentLines.push(lines.join("\n\n"));
+ }
+ }
+
+ const custom = message.metadata?.custom as Record | undefined;
+ if (contentLines.length === 0 && typeof custom?.["waitingText"] === "string" && custom["waitingText"].trim()) {
+ contentLines.push(custom["waitingText"]);
+ }
+ return contentLines;
+}
+
+function IssueChatFallbackThread({
+ messages,
+ emptyMessage,
+ variant,
+}: {
+ messages: readonly import("@assistant-ui/react").ThreadMessage[];
+ emptyMessage: string;
+ variant: "full" | "embedded";
+}) {
+ return (
+
+
+
+
+
+
Chat renderer hit an internal state error.
+
+ Showing a safe fallback transcript instead of crashing the issues page.
+
+
+
+
+
+ {messages.length === 0 ? (
+
+ {emptyMessage}
+
+ ) : (
+
+ {messages.map((message) => {
+ const lines = fallbackTextParts(message);
+ return (
+
+
+ {fallbackAuthorLabel(message)}
+ {message.createdAt ? (
+
+ {commentDateLabel(message.createdAt)}
+
+ ) : null}
+
+
+ {lines.length > 0 ? lines.map((line, index) => (
+
{line}
+ )) : (
+
No message content.
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
const DRAFT_DEBOUNCE_MS = 800;
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
@@ -1808,6 +1950,10 @@ export function IssueChatThread({
?? (variant === "embedded"
? "No run output yet."
: "This issue conversation is empty. Start with a message below.");
+ const errorBoundaryResetKey = useMemo(
+ () => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"),
+ [messages],
+ );
return (
@@ -1825,22 +1971,29 @@ export function IssueChatThread({
) : null}
-
-
-
-
- {resolvedEmptyMessage}
-
-
-
-
-
-
+
+
+
+
+
+ {resolvedEmptyMessage}
+
+
+
+
+
+
+
{showComposer ? (