2026-04-06 09:28:03 -05:00
|
|
|
// @vitest-environment jsdom
|
|
|
|
|
|
|
|
|
|
import { act } from "react";
|
|
|
|
|
import type { ReactNode } from "react";
|
|
|
|
|
import { createRoot } from "react-dom/client";
|
|
|
|
|
import { MemoryRouter } from "react-router-dom";
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
2026-04-07 17:02:48 -05:00
|
|
|
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
2026-04-06 09:28:03 -05:00
|
|
|
|
|
|
|
|
vi.mock("@assistant-ui/react", () => ({
|
|
|
|
|
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
|
|
|
ThreadPrimitive: {
|
|
|
|
|
Root: ({ children, className }: { children: ReactNode; className?: string }) => (
|
|
|
|
|
<div data-testid="thread-root" className={className}>{children}</div>
|
|
|
|
|
),
|
|
|
|
|
Viewport: ({ children, className }: { children: ReactNode; className?: string }) => (
|
|
|
|
|
<div data-testid="thread-viewport" className={className}>{children}</div>
|
|
|
|
|
),
|
|
|
|
|
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
|
|
|
Messages: () => <div data-testid="thread-messages" />,
|
|
|
|
|
},
|
|
|
|
|
MessagePrimitive: {
|
|
|
|
|
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
|
|
|
Content: () => null,
|
2026-04-06 11:00:12 -05:00
|
|
|
Parts: () => null,
|
2026-04-06 09:28:03 -05:00
|
|
|
},
|
|
|
|
|
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
|
|
|
|
|
useAuiState: () => false,
|
|
|
|
|
useMessage: () => ({
|
|
|
|
|
id: "message",
|
|
|
|
|
role: "assistant",
|
|
|
|
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
|
|
|
|
content: [],
|
|
|
|
|
metadata: { custom: {} },
|
|
|
|
|
status: { type: "complete" },
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
|
|
|
|
useLiveRunTranscripts: () => ({
|
|
|
|
|
transcriptByRun: new Map(),
|
|
|
|
|
hasOutputForRun: () => false,
|
|
|
|
|
}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./MarkdownBody", () => ({
|
|
|
|
|
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./MarkdownEditor", () => ({
|
2026-04-06 11:00:12 -05:00
|
|
|
MarkdownEditor: ({
|
|
|
|
|
value = "",
|
|
|
|
|
onChange,
|
2026-04-06 11:43:45 -05:00
|
|
|
placeholder,
|
2026-04-08 09:11:21 -05:00
|
|
|
className,
|
|
|
|
|
contentClassName,
|
2026-04-06 11:00:12 -05:00
|
|
|
}: {
|
|
|
|
|
value?: string;
|
|
|
|
|
onChange?: (value: string) => void;
|
2026-04-06 11:43:45 -05:00
|
|
|
placeholder?: string;
|
2026-04-08 09:11:21 -05:00
|
|
|
className?: string;
|
|
|
|
|
contentClassName?: string;
|
2026-04-06 11:00:12 -05:00
|
|
|
}) => (
|
|
|
|
|
<textarea
|
|
|
|
|
aria-label="Issue chat editor"
|
2026-04-08 09:11:21 -05:00
|
|
|
data-class-name={className}
|
|
|
|
|
data-content-class-name={contentClassName}
|
2026-04-06 11:43:45 -05:00
|
|
|
placeholder={placeholder}
|
2026-04-06 11:00:12 -05:00
|
|
|
value={value}
|
|
|
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
),
|
2026-04-06 09:28:03 -05:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./InlineEntitySelector", () => ({
|
|
|
|
|
InlineEntitySelector: () => null,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./Identity", () => ({
|
|
|
|
|
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./OutputFeedbackButtons", () => ({
|
|
|
|
|
OutputFeedbackButtons: () => null,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./AgentIconPicker", () => ({
|
|
|
|
|
AgentIcon: () => null,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("./StatusBadge", () => ({
|
|
|
|
|
StatusBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
|
|
|
|
|
usePaperclipIssueRuntime: () => ({}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
|
|
|
|
|
|
describe("IssueChatThread", () => {
|
|
|
|
|
let container: HTMLDivElement;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
container = document.createElement("div");
|
|
|
|
|
document.body.appendChild(container);
|
2026-04-06 11:00:12 -05:00
|
|
|
localStorage.clear();
|
2026-04-06 09:28:03 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
container.remove();
|
2026-04-06 11:00:12 -05:00
|
|
|
vi.useRealTimers();
|
2026-04-06 09:28:03 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drops the count heading and does not use an internal scrollbox", () => {
|
|
|
|
|
const root = createRoot(container);
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.render(
|
|
|
|
|
<MemoryRouter>
|
|
|
|
|
<IssueChatThread
|
|
|
|
|
comments={[]}
|
|
|
|
|
linkedRuns={[]}
|
|
|
|
|
timelineEvents={[]}
|
|
|
|
|
liveRuns={[]}
|
|
|
|
|
onAdd={async () => {}}
|
|
|
|
|
showComposer={false}
|
|
|
|
|
enableLiveTranscriptPolling={false}
|
|
|
|
|
/>
|
|
|
|
|
</MemoryRouter>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(container.textContent).toContain("Jump to latest");
|
|
|
|
|
expect(container.textContent).not.toContain("Chat (");
|
|
|
|
|
|
|
|
|
|
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
|
|
|
|
|
expect(viewport).not.toBeNull();
|
|
|
|
|
expect(viewport?.className).not.toContain("overflow-y-auto");
|
|
|
|
|
expect(viewport?.className).not.toContain("max-h-[70vh]");
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.unmount();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-06 11:00:12 -05:00
|
|
|
|
2026-04-07 18:17:29 -05:00
|
|
|
it("supports the embedded read-only variant without the jump control", () => {
|
|
|
|
|
const root = createRoot(container);
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.render(
|
|
|
|
|
<MemoryRouter>
|
|
|
|
|
<IssueChatThread
|
|
|
|
|
comments={[]}
|
|
|
|
|
linkedRuns={[]}
|
|
|
|
|
timelineEvents={[]}
|
|
|
|
|
liveRuns={[]}
|
|
|
|
|
onAdd={async () => {}}
|
|
|
|
|
showComposer={false}
|
|
|
|
|
showJumpToLatest={false}
|
|
|
|
|
variant="embedded"
|
|
|
|
|
emptyMessage="No run output captured."
|
|
|
|
|
enableLiveTranscriptPolling={false}
|
|
|
|
|
/>
|
|
|
|
|
</MemoryRouter>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(container.textContent).toContain("No run output captured.");
|
|
|
|
|
expect(container.textContent).not.toContain("Jump to latest");
|
|
|
|
|
|
|
|
|
|
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
|
|
|
|
|
expect(viewport?.className).toContain("space-y-3");
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.unmount();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-06 11:00:12 -05:00
|
|
|
it("stores and restores the composer draft per issue key", () => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
const root = createRoot(container);
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.render(
|
|
|
|
|
<MemoryRouter>
|
|
|
|
|
<IssueChatThread
|
|
|
|
|
comments={[]}
|
|
|
|
|
linkedRuns={[]}
|
|
|
|
|
timelineEvents={[]}
|
|
|
|
|
liveRuns={[]}
|
|
|
|
|
onAdd={async () => {}}
|
|
|
|
|
draftKey="issue-chat-draft:test-1"
|
|
|
|
|
enableLiveTranscriptPolling={false}
|
|
|
|
|
/>
|
|
|
|
|
</MemoryRouter>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
|
|
|
|
expect(editor).not.toBeNull();
|
2026-04-06 11:43:45 -05:00
|
|
|
expect(editor?.placeholder).toBe("Reply");
|
2026-04-06 11:00:12 -05:00
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
|
|
|
window.HTMLTextAreaElement.prototype,
|
|
|
|
|
"value",
|
|
|
|
|
)?.set;
|
|
|
|
|
valueSetter?.call(editor, "Draft survives refresh");
|
|
|
|
|
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
vi.advanceTimersByTime(900);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(localStorage.getItem("issue-chat-draft:test-1")).toBe("Draft survives refresh");
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const remount = createRoot(container);
|
|
|
|
|
act(() => {
|
|
|
|
|
remount.render(
|
|
|
|
|
<MemoryRouter>
|
|
|
|
|
<IssueChatThread
|
|
|
|
|
comments={[]}
|
|
|
|
|
linkedRuns={[]}
|
|
|
|
|
timelineEvents={[]}
|
|
|
|
|
liveRuns={[]}
|
|
|
|
|
onAdd={async () => {}}
|
|
|
|
|
draftKey="issue-chat-draft:test-1"
|
|
|
|
|
enableLiveTranscriptPolling={false}
|
|
|
|
|
/>
|
|
|
|
|
</MemoryRouter>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const restoredEditor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
|
|
|
|
expect(restoredEditor?.value).toBe("Draft survives refresh");
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
remount.unmount();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-07 17:02:48 -05:00
|
|
|
|
2026-04-08 09:11:21 -05:00
|
|
|
it("keeps the composer pinned with a capped editor height", () => {
|
|
|
|
|
const root = createRoot(container);
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.render(
|
|
|
|
|
<MemoryRouter>
|
|
|
|
|
<IssueChatThread
|
|
|
|
|
comments={[]}
|
|
|
|
|
linkedRuns={[]}
|
|
|
|
|
timelineEvents={[]}
|
|
|
|
|
liveRuns={[]}
|
|
|
|
|
onAdd={async () => {}}
|
|
|
|
|
enableLiveTranscriptPolling={false}
|
|
|
|
|
/>
|
|
|
|
|
</MemoryRouter>,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
|
|
|
|
expect(composer).not.toBeNull();
|
|
|
|
|
expect(composer?.className).toContain("sticky");
|
|
|
|
|
expect(composer?.className).toContain("bottom-0");
|
|
|
|
|
expect(composer?.className).toContain("pb-[calc(env(safe-area-inset-bottom)+0.75rem)]");
|
|
|
|
|
|
|
|
|
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
|
|
|
|
expect(editor?.dataset.contentClassName).toContain("max-h-[40dvh]");
|
|
|
|
|
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
root.unmount();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-07 17:02:48 -05:00
|
|
|
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
|
|
|
|
expect(resolveAssistantMessageFoldedState({
|
|
|
|
|
messageId: "message-1",
|
|
|
|
|
currentFolded: false,
|
|
|
|
|
isFoldable: true,
|
|
|
|
|
previousMessageId: "message-1",
|
|
|
|
|
previousIsFoldable: false,
|
|
|
|
|
})).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("preserves a manually opened completed message across rerenders", () => {
|
|
|
|
|
expect(resolveAssistantMessageFoldedState({
|
|
|
|
|
messageId: "message-1",
|
|
|
|
|
currentFolded: false,
|
|
|
|
|
isFoldable: true,
|
|
|
|
|
previousMessageId: "message-1",
|
|
|
|
|
previousIsFoldable: true,
|
|
|
|
|
})).toBe(false);
|
|
|
|
|
});
|
2026-04-06 09:28:03 -05:00
|
|
|
});
|