mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +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(),
|
||||
}));
|
||||
|
||||
const { threadMessagesMock } = vi.hoisted(() => ({
|
||||
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
||||
}));
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ThreadPrimitive: {
|
||||
|
|
@ -21,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
|
|||
<div data-testid="thread-viewport" className={className}>{children}</div>
|
||||
),
|
||||
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Messages: () => <div data-testid="thread-messages" />,
|
||||
Messages: () => threadMessagesMock(),
|
||||
},
|
||||
MessagePrimitive: {
|
||||
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
|
|
@ -116,12 +120,14 @@ describe("IssueChatThread", () => {
|
|||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
localStorage.clear();
|
||||
threadMessagesMock.mockImplementation(() => <div data-testid="thread-messages" />);
|
||||
});
|
||||
|
||||
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(
|
||||
<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", () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(container);
|
||||
|
|
|
|||
|
|
@ -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<string, FeedbackVoteValue>;
|
||||
|
|
@ -218,6 +221,145 @@ interface IssueChatThreadProps {
|
|||
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 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 (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
|
@ -1825,22 +1971,29 @@ export function IssueChatThread({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<ThreadPrimitive.Root className="">
|
||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||
<ThreadPrimitive.Empty>
|
||||
<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",
|
||||
)}>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={components} />
|
||||
<div ref={bottomAnchorRef} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
<IssueChatErrorBoundary
|
||||
resetKey={errorBoundaryResetKey}
|
||||
messages={messages}
|
||||
emptyMessage={resolvedEmptyMessage}
|
||||
variant={variant}
|
||||
>
|
||||
<ThreadPrimitive.Root className="">
|
||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||
<ThreadPrimitive.Empty>
|
||||
<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",
|
||||
)}>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={components} />
|
||||
<div ref={bottomAnchorRef} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
</IssueChatErrorBoundary>
|
||||
|
||||
{showComposer ? (
|
||||
<IssueChatComposer
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue