mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
Guard issue chat against assistant-ui crashes
This commit is contained in:
parent
de1cd5858d
commit
a4b05d8831
2 changed files with 219 additions and 18 deletions
|
|
@ -11,6 +11,10 @@ const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||||
markdownEditorFocusMock: vi.fn(),
|
markdownEditorFocusMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { threadMessagesMock } = vi.hoisted(() => ({
|
||||||
|
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@assistant-ui/react", () => ({
|
vi.mock("@assistant-ui/react", () => ({
|
||||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
ThreadPrimitive: {
|
ThreadPrimitive: {
|
||||||
|
|
@ -21,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
|
||||||
<div data-testid="thread-viewport" className={className}>{children}</div>
|
<div data-testid="thread-viewport" className={className}>{children}</div>
|
||||||
),
|
),
|
||||||
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
Messages: () => <div data-testid="thread-messages" />,
|
Messages: () => threadMessagesMock(),
|
||||||
},
|
},
|
||||||
MessagePrimitive: {
|
MessagePrimitive: {
|
||||||
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
|
@ -116,12 +120,14 @@ describe("IssueChatThread", () => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
threadMessagesMock.mockImplementation(() => <div data-testid="thread-messages" />);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
container.remove();
|
container.remove();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
markdownEditorFocusMock.mockReset();
|
markdownEditorFocusMock.mockReset();
|
||||||
|
threadMessagesMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: "agent-1",
|
||||||
|
authorUserId: null,
|
||||||
|
body: "Agent summary",
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
}]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("stores and restores the composer draft per issue key", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
Component,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -18,7 +19,9 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
|
type ErrorInfo,
|
||||||
type Ref,
|
type Ref,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Link, useLocation } from "@/lib/router";
|
import { Link, useLocation } from "@/lib/router";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -76,7 +79,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 {
|
interface IssueChatMessageContext {
|
||||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||||
|
|
@ -218,6 +221,145 @@ interface IssueChatThreadProps {
|
||||||
composerRef?: Ref<IssueChatComposerHandle>;
|
composerRef?: Ref<IssueChatComposerHandle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<IssueChatErrorBoundaryProps, IssueChatErrorBoundaryState> {
|
||||||
|
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 (
|
||||||
|
<IssueChatFallbackThread
|
||||||
|
messages={this.props.messages}
|
||||||
|
emptyMessage={this.props.emptyMessage}
|
||||||
|
variant={this.props.variant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessage) {
|
||||||
|
const custom = message.metadata?.custom as Record<string, unknown> | 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<string, unknown> | 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 (
|
||||||
|
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||||
|
<div className="rounded-xl border border-amber-300/60 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">Chat renderer hit an internal state error.</p>
|
||||||
|
<p className="text-xs opacity-80">
|
||||||
|
Showing a safe fallback transcript instead of crashing the issues page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className={cn(
|
||||||
|
"text-center text-sm text-muted-foreground",
|
||||||
|
variant === "embedded"
|
||||||
|
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||||||
|
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
||||||
|
)}>
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||||
|
{messages.map((message) => {
|
||||||
|
const lines = fallbackTextParts(message);
|
||||||
|
return (
|
||||||
|
<div key={message.id} className="rounded-xl border border-border/60 bg-card/70 px-4 py-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium text-foreground">{fallbackAuthorLabel(message)}</span>
|
||||||
|
{message.createdAt ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{commentDateLabel(message.createdAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lines.length > 0 ? lines.map((line, index) => (
|
||||||
|
<MarkdownBody key={`${message.id}:fallback:${index}`}>{line}</MarkdownBody>
|
||||||
|
)) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No message content.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DRAFT_DEBOUNCE_MS = 800;
|
const DRAFT_DEBOUNCE_MS = 800;
|
||||||
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||||||
|
|
||||||
|
|
@ -1808,6 +1950,10 @@ export function IssueChatThread({
|
||||||
?? (variant === "embedded"
|
?? (variant === "embedded"
|
||||||
? "No run output yet."
|
? "No run output yet."
|
||||||
: "This issue conversation is empty. Start with a message below.");
|
: "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 (
|
return (
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
|
@ -1825,6 +1971,12 @@ export function IssueChatThread({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<IssueChatErrorBoundary
|
||||||
|
resetKey={errorBoundaryResetKey}
|
||||||
|
messages={messages}
|
||||||
|
emptyMessage={resolvedEmptyMessage}
|
||||||
|
variant={variant}
|
||||||
|
>
|
||||||
<ThreadPrimitive.Root className="">
|
<ThreadPrimitive.Root className="">
|
||||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||||
<ThreadPrimitive.Empty>
|
<ThreadPrimitive.Empty>
|
||||||
|
|
@ -1841,6 +1993,7 @@ export function IssueChatThread({
|
||||||
<div ref={bottomAnchorRef} />
|
<div ref={bottomAnchorRef} />
|
||||||
</ThreadPrimitive.Viewport>
|
</ThreadPrimitive.Viewport>
|
||||||
</ThreadPrimitive.Root>
|
</ThreadPrimitive.Root>
|
||||||
|
</IssueChatErrorBoundary>
|
||||||
|
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
<IssueChatComposer
|
<IssueChatComposer
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue