901 lines
28 KiB
TypeScript
901 lines
28 KiB
TypeScript
/* eslint-disable max-lines -- shared renderer and transcript-navigation invariants use one mocked harness. */
|
|
import { useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildGroupedRenderItems,
|
|
TASK_DESCRIPTION_SYNTHETIC_ID,
|
|
type RenderItem,
|
|
} from "@/hooks/use-processed-messages";
|
|
import type { Message } from "@/lib/types/http";
|
|
|
|
const rendererSpy = vi.fn();
|
|
const turnGroupSpy = vi.fn();
|
|
|
|
function elementWithRect(top: number, bottom: number): HTMLElement {
|
|
const el = document.createElement("div");
|
|
el.getBoundingClientRect = () => ({ top, bottom }) as DOMRect;
|
|
return el;
|
|
}
|
|
const AGENT_ERROR_MESSAGE = "agent process exited";
|
|
|
|
// NOTE: mockStoreState is vi.hoisted, so it cannot reference the const above —
|
|
// the factory runs before module initialization.
|
|
const mockStoreState = vi.hoisted(() => ({
|
|
taskSessions: {
|
|
items: {
|
|
s1: {
|
|
metadata: {
|
|
last_agent_error: {
|
|
message: "agent process exited",
|
|
occurred_at: "2026-06-14T12:00:00Z",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
dismissedAgentErrors: {} as Record<string, string>,
|
|
dismissAgentError: () => {},
|
|
setTaskSession: () => {},
|
|
}));
|
|
|
|
vi.mock("@/components/task/chat/message-renderer", () => ({
|
|
MessageRenderer: (props: { onOpenFile?: unknown }) => {
|
|
rendererSpy(props);
|
|
return <div data-testid="renderer" />;
|
|
},
|
|
}));
|
|
vi.mock("@/components/task/chat/messages/turn-group-message", () => ({
|
|
TurnGroupMessage: (props: unknown) => {
|
|
turnGroupSpy(props);
|
|
return <div data-testid="turn-group" />;
|
|
},
|
|
}));
|
|
vi.mock("@/components/session/prepare-progress", () => ({
|
|
PrepareProgress: () => <div data-testid="prepare" />,
|
|
}));
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState),
|
|
useAppStoreApi: () => ({
|
|
getState: () => mockStoreState,
|
|
}),
|
|
}));
|
|
vi.mock("@/hooks/use-lazy-load-messages", () => ({
|
|
useLazyLoadMessages: () => ({
|
|
loadMore: async () => 0,
|
|
hasMore: false,
|
|
isLoadingMore: false,
|
|
}),
|
|
}));
|
|
vi.mock("@/components/task/chat/messages/agent-status", () => ({
|
|
AgentStatus: () => <div data-testid="agent-status" />,
|
|
}));
|
|
vi.mock("@kandev/ui/pannel-session", () => ({
|
|
SessionPanelContent: ({ children }: { children: ReactNode }) => (
|
|
<div data-testid="session-panel-content">{children}</div>
|
|
),
|
|
}));
|
|
vi.mock("@/lib/api/domains/session-api", () => ({
|
|
dismissLastAgentError: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
MessageItem,
|
|
MessageListStatus,
|
|
UnreadDivider,
|
|
anchoredBarScrollOffsetPx,
|
|
getConversationLoadingState,
|
|
getEffectiveActiveTurnId,
|
|
getItemKey,
|
|
getStreamingAgentMessageId,
|
|
canReassertDividerScroll,
|
|
getLastUserMessageId,
|
|
getFirstUserMessageId,
|
|
isElementFullyVisible,
|
|
filterLaunchErrorItems,
|
|
filterLaunchErrorMessages,
|
|
resolveLastPromptControls,
|
|
resolveLastPromptEdge,
|
|
shouldAutoScrollToBottom,
|
|
} from "./message-list-shared";
|
|
|
|
describe("anchoredBarScrollOffsetPx", () => {
|
|
it("passes through a measured height unchanged", () => {
|
|
expect(anchoredBarScrollOffsetPx(63)).toBe(63);
|
|
});
|
|
|
|
it("treats an unmeasured (undefined) height as no offset", () => {
|
|
expect(anchoredBarScrollOffsetPx(undefined)).toBe(0);
|
|
});
|
|
|
|
it("clamps a negative height to zero", () => {
|
|
expect(anchoredBarScrollOffsetPx(-12)).toBe(0);
|
|
});
|
|
|
|
it("rounds a fractional height to whole pixels", () => {
|
|
expect(anchoredBarScrollOffsetPx(63.7)).toBe(64);
|
|
});
|
|
});
|
|
|
|
const item: RenderItem = { type: "message", message: { id: "m1" } as Message };
|
|
const noop = () => {};
|
|
const perm = new Map<string, Message>();
|
|
const kids = new Map<string, Message[]>();
|
|
|
|
describe("getEffectiveActiveTurnId", () => {
|
|
it("preserves the active turn while the session is working", () => {
|
|
expect(getEffectiveActiveTurnId("turn-active", true)).toBe("turn-active");
|
|
});
|
|
|
|
it("ignores a stale active turn after the session settles", () => {
|
|
expect(getEffectiveActiveTurnId("turn-stale", false)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("launch error-owned transcript filtering", () => {
|
|
it("removes launch-only messages only when the task card owns the error", () => {
|
|
const emptyTurn = {
|
|
id: "empty-turn-1",
|
|
metadata: { empty_turn: true },
|
|
} as unknown as Message;
|
|
const runtimeFailure = {
|
|
id: "runtime-failure-1",
|
|
metadata: { failure_kind: "provider_quota_limited" },
|
|
} as unknown as Message;
|
|
|
|
expect(filterLaunchErrorMessages([emptyTurn, runtimeFailure], false)).toEqual([
|
|
emptyTurn,
|
|
runtimeFailure,
|
|
]);
|
|
expect(filterLaunchErrorMessages([emptyTurn, runtimeFailure], true)).toEqual([runtimeFailure]);
|
|
});
|
|
|
|
it("removes preparation and previous-agent rows while retaining transcript messages", () => {
|
|
const runtimeMessage = { id: "message-1" } as unknown as Message;
|
|
const items: RenderItem[] = [
|
|
{ type: "prepare_progress", id: "prepare-1", sessionId: "s1" },
|
|
{
|
|
type: "agent_error_notice",
|
|
id: "agent-error-1",
|
|
sessionId: "s1",
|
|
error: { message: "matching error", stamp: "stamp-1" },
|
|
},
|
|
{
|
|
type: "agent_error_notice",
|
|
id: "agent-error-2",
|
|
sessionId: "s1",
|
|
error: { message: "unrelated error", stamp: "old-stamp" },
|
|
},
|
|
{
|
|
type: "message",
|
|
message: {
|
|
id: "empty-1",
|
|
created_at: "2026-06-14T12:00:01Z",
|
|
metadata: { empty_turn: true },
|
|
} as unknown as Message,
|
|
},
|
|
{ type: "message", message: runtimeMessage },
|
|
];
|
|
|
|
expect(filterLaunchErrorItems(items, true, "stamp-1", "2026-06-14T12:00:00Z")).toEqual([
|
|
{
|
|
type: "agent_error_notice",
|
|
id: "agent-error-2",
|
|
sessionId: "s1",
|
|
error: { message: "unrelated error", stamp: "old-stamp" },
|
|
},
|
|
{ type: "message", message: runtimeMessage },
|
|
]);
|
|
});
|
|
|
|
it("keeps stale launch-only history when the current failure has no carried stamp", () => {
|
|
const staleEmptyTurn = {
|
|
id: "empty-stale",
|
|
created_at: "2026-07-21T00:00:00Z",
|
|
metadata: { empty_turn: true },
|
|
} as unknown as Message;
|
|
const currentEmptyTurn = {
|
|
id: "empty-current",
|
|
created_at: "2026-07-22T00:00:00Z",
|
|
metadata: { empty_turn: true },
|
|
} as unknown as Message;
|
|
|
|
expect(filterLaunchErrorMessages([staleEmptyTurn, currentEmptyTurn], true)).toEqual([
|
|
staleEmptyTurn,
|
|
]);
|
|
expect(
|
|
filterLaunchErrorItems(
|
|
[
|
|
{ type: "message", message: staleEmptyTurn },
|
|
{ type: "message", message: currentEmptyTurn },
|
|
],
|
|
true,
|
|
"current-stamp",
|
|
"2026-07-22T00:00:00Z",
|
|
),
|
|
).toEqual([{ type: "message", message: staleEmptyTurn }]);
|
|
});
|
|
|
|
it("filters only launch-only rows carrying the active failure stamp", () => {
|
|
const staleMissingBranch = {
|
|
id: "missing-stale",
|
|
metadata: { failure_kind: "missing_pr_branch", launch_error_stamp: "old-stamp" },
|
|
} as unknown as Message;
|
|
const currentMissingBranch = {
|
|
id: "missing-current",
|
|
metadata: { failure_kind: "missing_pr_branch", launch_error_stamp: "current-stamp" },
|
|
} as unknown as Message;
|
|
|
|
expect(
|
|
filterLaunchErrorMessages([staleMissingBranch, currentMissingBranch], true, "current-stamp"),
|
|
).toEqual([staleMissingBranch]);
|
|
});
|
|
});
|
|
|
|
describe("canReassertDividerScroll", () => {
|
|
it("never applies without a divider target", () => {
|
|
expect(
|
|
canReassertDividerScroll({
|
|
hasDividerTarget: false,
|
|
didScrollToDivider: false,
|
|
isUserScrolling: false,
|
|
isWithinSettlingWindow: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("always applies the first time, regardless of the other gates", () => {
|
|
expect(
|
|
canReassertDividerScroll({
|
|
hasDividerTarget: true,
|
|
didScrollToDivider: false,
|
|
isUserScrolling: true,
|
|
isWithinSettlingWindow: false,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("re-asserts while still settling and the reader hasn't scrolled", () => {
|
|
expect(
|
|
canReassertDividerScroll({
|
|
hasDividerTarget: true,
|
|
didScrollToDivider: true,
|
|
isUserScrolling: false,
|
|
isWithinSettlingWindow: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("stops re-asserting once the reader has scrolled, even mid-settling-window", () => {
|
|
expect(
|
|
canReassertDividerScroll({
|
|
hasDividerTarget: true,
|
|
didScrollToDivider: true,
|
|
isUserScrolling: true,
|
|
isWithinSettlingWindow: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("stops re-asserting once the settling window elapses, even without any interaction", () => {
|
|
// Covers a scrollbar drag or a live message arriving long after the
|
|
// visit settled — neither fires wheel/touchstart/keydown, so the
|
|
// settling window is the only thing bounding this case.
|
|
expect(
|
|
canReassertDividerScroll({
|
|
hasDividerTarget: true,
|
|
didScrollToDivider: true,
|
|
isUserScrolling: false,
|
|
isWithinSettlingWindow: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
function row(onOpenFile: (p: string) => void) {
|
|
return (
|
|
<MessageItem
|
|
item={item}
|
|
sessionId="s1"
|
|
permissionsByToolCallId={perm}
|
|
childrenByParentToolCallId={kids}
|
|
taskId="t1"
|
|
worktreePath="/wt"
|
|
onOpenFile={onOpenFile}
|
|
isLastGroup={false}
|
|
activeTurnId={null}
|
|
onScrollToMessage={noop}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Harness({ onOpenFile }: { onOpenFile: (p: string) => void }) {
|
|
const [, setTick] = useState(0);
|
|
return (
|
|
<div>
|
|
<button onClick={() => setTick((t) => t + 1)}>tick</button>
|
|
{row(onOpenFile)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("MessageItem memo boundary", () => {
|
|
afterEach(() => {
|
|
rendererSpy.mockClear();
|
|
turnGroupSpy.mockClear();
|
|
});
|
|
|
|
it("does not re-render the row when the parent re-renders with stable props", () => {
|
|
const { getByText } = render(<Harness onOpenFile={noop} />);
|
|
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
|
fireEvent.click(getByText("tick"));
|
|
fireEvent.click(getByText("tick"));
|
|
expect(rendererSpy).toHaveBeenCalledTimes(1); // memo bailed on stable props
|
|
});
|
|
|
|
it("re-renders the row when onOpenFile identity changes (stability requirement)", () => {
|
|
const { rerender } = render(row(() => {}));
|
|
expect(rendererSpy).toHaveBeenCalledTimes(1);
|
|
rerender(row(() => {}));
|
|
expect(rendererSpy).toHaveBeenCalledTimes(2); // fresh callback ref breaks memo
|
|
});
|
|
});
|
|
|
|
describe("MessageItem agent error notice", () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
const REMEDIATION_URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
|
|
|
|
function renderNotice(error: {
|
|
message: string;
|
|
occurredAt?: string;
|
|
remediationUrl?: string;
|
|
details?: string;
|
|
}) {
|
|
render(
|
|
<MessageItem
|
|
item={{
|
|
type: "agent_error_notice",
|
|
id: "last-agent-error-s1-2026-06-14T12:00:00Z",
|
|
sessionId: "s1",
|
|
error,
|
|
}}
|
|
sessionId="s1"
|
|
permissionsByToolCallId={perm}
|
|
childrenByParentToolCallId={kids}
|
|
taskId="t1"
|
|
isLastGroup={false}
|
|
activeTurnId={null}
|
|
onScrollToMessage={noop}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
it("shows retained agent errors even when there are no messages", () => {
|
|
renderNotice({ message: AGENT_ERROR_MESSAGE });
|
|
|
|
expect(screen.getByTestId("last-agent-error-notice").getAttribute("role")).toBe("alert");
|
|
expect(screen.getByTestId("last-agent-error-notice").textContent).toContain(
|
|
AGENT_ERROR_MESSAGE,
|
|
);
|
|
});
|
|
|
|
it("uses a touch-sized dismiss target on coarse pointers", () => {
|
|
renderNotice({ message: AGENT_ERROR_MESSAGE });
|
|
|
|
expect(screen.getByRole("button").className).toContain("[@media(pointer:coarse)]:h-11");
|
|
expect(screen.getByRole("button").className).toContain("[@media(pointer:coarse)]:w-11");
|
|
});
|
|
|
|
it("reveals the sanitized failure cause in a collapsed technical-details disclosure", () => {
|
|
const details = 'workflow step "Review Step": provider context reset: provider reset timed out';
|
|
renderNotice({ message: "context reset failed", details });
|
|
|
|
const disclosure = screen.getByTestId("last-agent-error-details") as HTMLDetailsElement;
|
|
expect(disclosure.querySelector("summary")?.textContent).toBe("Technical details");
|
|
expect(disclosure.open).toBe(false);
|
|
|
|
fireEvent.click(screen.getByText("Technical details"));
|
|
expect(disclosure.open).toBe(true);
|
|
expect(disclosure.textContent).toContain(details);
|
|
});
|
|
|
|
it("renders a validated remediation link, and nothing for an invalid URL", () => {
|
|
renderNotice({
|
|
message: "usage limit reached",
|
|
remediationUrl: REMEDIATION_URL,
|
|
});
|
|
|
|
const link = screen.getByTestId("remediation-link") as HTMLAnchorElement;
|
|
expect(link.href).toBe(REMEDIATION_URL);
|
|
expect(link.rel).toBe("noopener noreferrer");
|
|
expect(screen.getByTestId("last-agent-error-notice").textContent).toContain(
|
|
"usage limit reached",
|
|
);
|
|
|
|
cleanup();
|
|
renderNotice({ message: "usage limit reached", remediationUrl: "https://evil.example.com/x" });
|
|
expect(screen.queryByTestId("remediation-link")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("MessageItem containing turn activity", () => {
|
|
afterEach(() => {
|
|
rendererSpy.mockClear();
|
|
turnGroupSpy.mockClear();
|
|
});
|
|
|
|
it("does not reactivate a historical message while another turn is active", () => {
|
|
const historicalItem: RenderItem = {
|
|
type: "message",
|
|
message: { id: "historical", turn_id: "turn-old" } as Message,
|
|
};
|
|
render(
|
|
<MessageItem
|
|
item={historicalItem}
|
|
sessionId="s1"
|
|
permissionsByToolCallId={perm}
|
|
childrenByParentToolCallId={kids}
|
|
isLastGroup={false}
|
|
activeTurnId="turn-new"
|
|
onScrollToMessage={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(rendererSpy).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ isContainingTurnActive: false }),
|
|
);
|
|
});
|
|
|
|
it("marks only the matching turn group active", () => {
|
|
const historicalGroup: RenderItem = {
|
|
type: "turn_group",
|
|
id: "group-old",
|
|
turnId: "turn-old",
|
|
messages: [],
|
|
};
|
|
const { rerender } = render(
|
|
<MessageItem
|
|
item={historicalGroup}
|
|
sessionId="s1"
|
|
permissionsByToolCallId={perm}
|
|
childrenByParentToolCallId={kids}
|
|
isLastGroup
|
|
activeTurnId="turn-new"
|
|
onScrollToMessage={noop}
|
|
/>,
|
|
);
|
|
expect(turnGroupSpy).toHaveBeenLastCalledWith(expect.objectContaining({ isTurnActive: false }));
|
|
|
|
rerender(
|
|
<MessageItem
|
|
item={historicalGroup}
|
|
sessionId="s1"
|
|
permissionsByToolCallId={perm}
|
|
childrenByParentToolCallId={kids}
|
|
isLastGroup
|
|
activeTurnId="turn-old"
|
|
onScrollToMessage={noop}
|
|
/>,
|
|
);
|
|
expect(turnGroupSpy).toHaveBeenLastCalledWith(expect.objectContaining({ isTurnActive: true }));
|
|
});
|
|
});
|
|
|
|
describe("getConversationLoadingState", () => {
|
|
it("shows loading while conversation history is still fetching with initial content rendered", () => {
|
|
expect(
|
|
getConversationLoadingState({
|
|
messagesLoading: true,
|
|
messagesCount: 1,
|
|
isWorking: false,
|
|
sessionState: "COMPLETED",
|
|
}),
|
|
).toEqual({ isInitialLoading: false, showLoadingState: true });
|
|
});
|
|
|
|
it("shows an initial loading state when no messages are rendered yet", () => {
|
|
expect(
|
|
getConversationLoadingState({
|
|
messagesLoading: true,
|
|
messagesCount: 0,
|
|
isWorking: false,
|
|
sessionState: "RUNNING",
|
|
}),
|
|
).toEqual({ isInitialLoading: true, showLoadingState: true });
|
|
});
|
|
|
|
it("does not compete with the active agent status while the session is working", () => {
|
|
expect(
|
|
getConversationLoadingState({
|
|
messagesLoading: true,
|
|
messagesCount: 1,
|
|
isWorking: true,
|
|
sessionState: "RUNNING",
|
|
}),
|
|
).toEqual({ isInitialLoading: false, showLoadingState: false });
|
|
});
|
|
|
|
it("suppresses loading for empty sessions that cannot load conversation history", () => {
|
|
expect(
|
|
getConversationLoadingState({
|
|
messagesLoading: true,
|
|
messagesCount: 0,
|
|
isWorking: false,
|
|
sessionState: "FAILED",
|
|
}),
|
|
).toEqual({ isInitialLoading: true, showLoadingState: false });
|
|
|
|
expect(
|
|
getConversationLoadingState({
|
|
messagesLoading: true,
|
|
messagesCount: 0,
|
|
isWorking: false,
|
|
sessionState: null,
|
|
}),
|
|
).toEqual({ isInitialLoading: true, showLoadingState: false });
|
|
});
|
|
|
|
it("suppresses loading for CREATED sessions even when a synthetic task-description message is present", () => {
|
|
// Prepare-only launches keep the session in CREATED with the "Start agent"
|
|
// button as the primary CTA. useProcessedMessages injects a synthetic
|
|
// task-description message so messagesCount is 1, but there is nothing to
|
|
// load — the spinner would clash with the button.
|
|
expect(
|
|
getConversationLoadingState({
|
|
messagesLoading: true,
|
|
messagesCount: 1,
|
|
isWorking: false,
|
|
sessionState: "CREATED",
|
|
}),
|
|
).toEqual({ isInitialLoading: false, showLoadingState: false });
|
|
});
|
|
});
|
|
|
|
describe("getStreamingAgentMessageId", () => {
|
|
it("only marks an agent reply after the latest user prompt as streaming", () => {
|
|
const message = (id: string, author_type: "user" | "agent", type = "message") =>
|
|
({ id, author_type, type }) as Message;
|
|
|
|
expect(
|
|
getStreamingAgentMessageId([
|
|
message("old-reply", "agent"),
|
|
message("prompt", "user"),
|
|
message("reply", "agent"),
|
|
message("status", "agent", "status"),
|
|
]),
|
|
).toBe("reply");
|
|
expect(getStreamingAgentMessageId([message("auto-started-reply", "agent")])).toBe(
|
|
"auto-started-reply",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getLastUserMessageId", () => {
|
|
it("returns the id of the most recent user-authored message", () => {
|
|
const message = (id: string, author_type: "user" | "agent") => ({ id, author_type }) as Message;
|
|
|
|
expect(
|
|
getLastUserMessageId([
|
|
message("first-prompt", "user"),
|
|
message("reply", "agent"),
|
|
message("second-prompt", "user"),
|
|
message("reply-2", "agent"),
|
|
]),
|
|
).toBe("second-prompt");
|
|
});
|
|
|
|
it("returns null when there are no user messages", () => {
|
|
const message = (id: string, author_type: "user" | "agent") => ({ id, author_type }) as Message;
|
|
|
|
expect(
|
|
getLastUserMessageId([
|
|
message(TASK_DESCRIPTION_SYNTHETIC_ID, "user"),
|
|
message("reply", "agent"),
|
|
]),
|
|
).toBeNull();
|
|
expect(getLastUserMessageId([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getFirstUserMessageId", () => {
|
|
it("returns the id of the earliest user-authored message", () => {
|
|
const message = (id: string, author_type: "user" | "agent") => ({ id, author_type }) as Message;
|
|
|
|
expect(
|
|
getFirstUserMessageId([
|
|
message("agent-status", "agent"),
|
|
message("first-prompt", "user"),
|
|
message("reply", "agent"),
|
|
message("second-prompt", "user"),
|
|
]),
|
|
).toBe("first-prompt");
|
|
});
|
|
|
|
it("returns null when there are no user messages", () => {
|
|
const message = (id: string, author_type: "user" | "agent") => ({ id, author_type }) as Message;
|
|
|
|
expect(
|
|
getFirstUserMessageId([
|
|
message(TASK_DESCRIPTION_SYNTHETIC_ID, "user"),
|
|
message("reply", "agent"),
|
|
]),
|
|
).toBeNull();
|
|
expect(getFirstUserMessageId([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("shouldAutoScrollToBottom", () => {
|
|
const base = {
|
|
isNearBottom: true,
|
|
isProgrammaticScrollLocked: false,
|
|
hasPendingLayoutRestore: false,
|
|
};
|
|
|
|
it("forces the scroll when near bottom with nothing else claiming it", () => {
|
|
expect(shouldAutoScrollToBottom(base)).toBe(true);
|
|
});
|
|
|
|
it("does not force the scroll while not near the bottom", () => {
|
|
expect(shouldAutoScrollToBottom({ ...base, isNearBottom: false })).toBe(false);
|
|
});
|
|
|
|
it("does not force the scroll while a programmatic scroll-to-start/last-prompt is in flight", () => {
|
|
// Regression: a message streaming in mid-flight must not silently
|
|
// cancel a user-initiated scroll-to-start/scroll-to-last-prompt action.
|
|
expect(shouldAutoScrollToBottom({ ...base, isProgrammaticScrollLocked: true })).toBe(false);
|
|
});
|
|
|
|
it("does not force the scroll while a layout-rebuild restore is pending", () => {
|
|
expect(shouldAutoScrollToBottom({ ...base, hasPendingLayoutRestore: true })).toBe(false);
|
|
});
|
|
|
|
it("does not force the scroll when multiple claims are active at once", () => {
|
|
expect(
|
|
shouldAutoScrollToBottom({
|
|
isNearBottom: true,
|
|
isProgrammaticScrollLocked: true,
|
|
hasPendingLayoutRestore: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("prompt row identity survives turn-group merging", () => {
|
|
// CodeRabbit review #3668338263 worried that `getItemKey` returns a
|
|
// `turn_group` id for the row backing `lastPromptMessageId`/`firstMessageId`,
|
|
// which would break the `msg-<id>` DOM lookup in
|
|
// `useTranscriptEdgeTracking`. That can't happen: `groupActivityMessages`
|
|
// only merges consecutive ACTIVITY_MESSAGE_TYPES messages (thinking/tool_*)
|
|
// sharing a turn id, and user prompts are always emitted with
|
|
// `type: "message"`, so they can never join a turn group. Lock in the
|
|
// invariant so a future change to the activity type set can't silently
|
|
// break the scroll affordances.
|
|
function activityMessage(id: string, turnId: string): Message {
|
|
return {
|
|
id,
|
|
session_id: "s1",
|
|
task_id: "t1",
|
|
author_type: "agent",
|
|
content: "",
|
|
type: "tool_call",
|
|
turn_id: turnId,
|
|
created_at: "",
|
|
} as Message;
|
|
}
|
|
|
|
function promptMessage(id: string): Message {
|
|
return {
|
|
id,
|
|
session_id: "s1",
|
|
task_id: "t1",
|
|
author_type: "user",
|
|
content: "hi",
|
|
type: "message",
|
|
created_at: "",
|
|
} as Message;
|
|
}
|
|
|
|
it("renders the first and last prompts as standalone message items, not turn_group members", () => {
|
|
const firstPrompt = promptMessage("earliest-prompt");
|
|
const activityA = activityMessage("think-1", "turn-1");
|
|
const activityB = activityMessage("tool-1", "turn-1");
|
|
const lastPrompt = promptMessage("latest-prompt");
|
|
const allMessages = [firstPrompt, activityA, activityB, lastPrompt];
|
|
|
|
const items = buildGroupedRenderItems(allMessages, "s1", { canAnchorPrepareProgress: false });
|
|
|
|
const firstItem = items[0];
|
|
const middleItem = items[1];
|
|
const lastItem = items[items.length - 1];
|
|
|
|
expect(firstItem.type).toBe("message");
|
|
expect(middleItem.type).toBe("turn_group");
|
|
expect(lastItem.type).toBe("message");
|
|
|
|
expect(getItemKey(firstItem)).toBe(getFirstUserMessageId(allMessages));
|
|
expect(getItemKey(lastItem)).toBe(getLastUserMessageId(allMessages));
|
|
// The grouped activity's key is a synthetic turn-group id, never a raw message id.
|
|
expect(getItemKey(middleItem)).not.toBe(activityA.id);
|
|
expect(getItemKey(middleItem)).not.toBe(activityB.id);
|
|
});
|
|
});
|
|
|
|
describe("resolveLastPromptEdge", () => {
|
|
it("is 'above' once the target's bottom clears the two-pixel settle tolerance past the top", () => {
|
|
const container = elementWithRect(40, 200);
|
|
const target = elementWithRect(20, 35);
|
|
|
|
expect(resolveLastPromptEdge(container, target)).toBe("above");
|
|
});
|
|
|
|
it("is 'visible' while any part of a prompt remains visible above the viewport", () => {
|
|
const container = elementWithRect(40, 200);
|
|
|
|
expect(resolveLastPromptEdge(container, elementWithRect(20, 80))).toBe("visible");
|
|
});
|
|
|
|
it("is 'below' once the target's top clears the tolerance past the bottom", () => {
|
|
const container = elementWithRect(40, 200);
|
|
|
|
expect(resolveLastPromptEdge(container, elementWithRect(220, 260))).toBe("below");
|
|
});
|
|
|
|
it("is 'visible' while the target is within the two-pixel settle tolerance", () => {
|
|
const container = elementWithRect(40, 200);
|
|
|
|
expect(resolveLastPromptEdge(container, elementWithRect(40, 80))).toBe("visible");
|
|
expect(resolveLastPromptEdge(container, elementWithRect(38, 78))).toBe("visible");
|
|
});
|
|
});
|
|
|
|
describe("resolveLastPromptControls", () => {
|
|
it("shows the anchored bar and an upward scroll arrow only once the prompt is above the viewport", () => {
|
|
expect(resolveLastPromptControls("above")).toEqual({
|
|
anchoredBarVisible: true,
|
|
scrollButtonEligible: true,
|
|
scrollDirection: "up",
|
|
});
|
|
});
|
|
|
|
it("hides the anchored bar and points the scroll arrow down while the prompt sits below the viewport", () => {
|
|
expect(resolveLastPromptControls("below")).toEqual({
|
|
anchoredBarVisible: false,
|
|
scrollButtonEligible: true,
|
|
scrollDirection: "down",
|
|
});
|
|
});
|
|
|
|
it("hides both the anchored bar and the scroll button while the prompt is visible", () => {
|
|
expect(resolveLastPromptControls("visible")).toEqual({
|
|
anchoredBarVisible: false,
|
|
scrollButtonEligible: false,
|
|
scrollDirection: "up",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("isElementFullyVisible", () => {
|
|
it("is true when the target sits entirely within the container's viewport", () => {
|
|
const container = elementWithRect(0, 100);
|
|
const target = elementWithRect(10, 50);
|
|
expect(isElementFullyVisible(container, target)).toBe(true);
|
|
});
|
|
|
|
it("is false once the target's top has scrolled even slightly above the container's top", () => {
|
|
const container = elementWithRect(40, 200);
|
|
const target = elementWithRect(30, 80);
|
|
expect(isElementFullyVisible(container, target)).toBe(false);
|
|
});
|
|
|
|
it("is false when the target's bottom overflows below the container's bottom", () => {
|
|
const container = elementWithRect(0, 100);
|
|
const target = elementWithRect(60, 150);
|
|
expect(isElementFullyVisible(container, target)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("MessageListStatus", () => {
|
|
it("does not render the older control during routine pagination", () => {
|
|
render(
|
|
<MessageListStatus
|
|
isLoadingMore={false}
|
|
hasMore
|
|
showLoadingState={false}
|
|
messagesLoading={false}
|
|
isInitialLoading={false}
|
|
messagesCount={1}
|
|
onLoadMore={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByTestId("load-older-messages")).toBeNull();
|
|
});
|
|
|
|
it("renders one explicit retry control during recovery", () => {
|
|
const onLoadMore = vi.fn();
|
|
render(
|
|
<MessageListStatus
|
|
isLoadingMore={false}
|
|
hasMore
|
|
showRecovery
|
|
showLoadingState={false}
|
|
messagesLoading={false}
|
|
isInitialLoading={false}
|
|
messagesCount={1}
|
|
onLoadMore={onLoadMore}
|
|
/>,
|
|
);
|
|
|
|
const button = screen.getByTestId("load-older-messages");
|
|
button.click();
|
|
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders a conversation loading indicator while existing content remains visible", () => {
|
|
render(
|
|
<MessageListStatus
|
|
isLoadingMore={false}
|
|
hasMore={false}
|
|
showLoadingState
|
|
messagesLoading
|
|
isInitialLoading={false}
|
|
messagesCount={1}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByTestId("conversation-loading-state")).not.toBeNull();
|
|
expect(screen.queryByText("Loading conversation...")).not.toBeNull();
|
|
});
|
|
|
|
it("does not show the empty invitation before history is ready", () => {
|
|
render(
|
|
<MessageListStatus
|
|
isLoadingMore={false}
|
|
hasMore={false}
|
|
showLoadingState
|
|
messagesLoading
|
|
isInitialLoading
|
|
messagesCount={0}
|
|
sessionId="sess-1"
|
|
historyStatus="loading"
|
|
onRetryHistory={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("No messages yet. Start the conversation!")).toBeNull();
|
|
expect(screen.getByTestId("session-history-loading")).toBeTruthy();
|
|
});
|
|
|
|
it("shows the empty invitation after a successful empty history snapshot", () => {
|
|
render(
|
|
<MessageListStatus
|
|
isLoadingMore={false}
|
|
hasMore={false}
|
|
showLoadingState={false}
|
|
messagesLoading={false}
|
|
isInitialLoading={false}
|
|
messagesCount={0}
|
|
sessionId="sess-1"
|
|
historyStatus="ready"
|
|
onRetryHistory={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("No messages yet. Start the conversation!")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("UnreadDivider", () => {
|
|
it("renders a labeled separator marking new messages", () => {
|
|
render(<UnreadDivider />);
|
|
|
|
const divider = screen.getByTestId("unread-divider");
|
|
expect(divider.getAttribute("role")).toBe("separator");
|
|
expect(screen.getByText("New")).not.toBeNull();
|
|
});
|
|
});
|