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 ? (