Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions e2e/conversation-thread.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect, test, type Page } from "@playwright/test";

const log = (page: Page) =>
page.locator("[data-slot='conversation-thread'] [role='log']");

const reply = (page: Page) => page.getByRole("textbox", { name: "Reply" });

const scrollTop = (page: Page) =>
log(page).evaluate((element) => element.scrollTop);

/** How far an item's top sits below the top of the thread's view. */
const offsetOf = (page: Page, id: string) =>
log(page).evaluate((element, itemId) => {
const item = element.querySelector(`[data-item-id='${itemId}']`);
if (item === null) throw new Error(`no item ${itemId}`);
return (
item.getBoundingClientRect().top - element.getBoundingClientRect().top
);
}, id);

/** Waits for the view to stop moving. */
const settled = async (page: Page) => {
let previous = Number.NaN;
await expect
.poll(
async () => {
const current = await scrollTop(page);
const still = current === previous;
previous = current;
return still;
},
{ intervals: [100] },
)
.toBe(true);
};

const openThread = async (page: Page) => {
await page.goto("/conversation-thread");
await page.getByRole("button", { name: /12 replies/ }).click();
await expect(page.getByRole("tab", { name: "Thread" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(log(page)).toHaveAttribute("aria-label", "Thread");
await settled(page);
};

test("a draft and the scroll position in a thread tab survive switching tabs", async ({
page,
}) => {
await openThread(page);
// The thread opens at its latest reply.
await expect(log(page).locator("[data-item-id='reply-12']")).toBeInViewport();
// The thread's log scrolls, not the pane around it.
const pane = await log(page).evaluate((element) => {
const host = element.closest("[data-slot='panel-pane']");
if (host === null) throw new Error("the thread is not in a pane");
return { scrollHeight: host.scrollHeight, clientHeight: host.clientHeight };
});
expect(pane.scrollHeight).toBe(pane.clientHeight);
await reply(page).fill("A draft for later");
const before = await scrollTop(page);
expect(before).toBeGreaterThan(0);

await page.getByRole("tab", { name: "Notes" }).click();
await expect(page.getByText("Notes pane")).toBeVisible();
await expect(reply(page)).toBeHidden();

await page.getByRole("tab", { name: "Thread" }).click();
await expect(reply(page)).toBeVisible();
await expect(reply(page)).toHaveValue("A draft for later");
expect(await scrollTop(page)).toBe(before);
});

test("sending in the thread settles the sent reply at the top", async ({
page,
}) => {
await openThread(page);
await reply(page).fill("Add owners to the follow-ups");
await reply(page).press("Enter");

await expect(reply(page)).toHaveValue("");
await expect
.poll(async () => Math.abs(await offsetOf(page, "sent-1")))
.toBeLessThanOrEqual(1);
await settled(page);
expect(Math.abs(await offsetOf(page, "sent-1"))).toBeLessThanOrEqual(1);
await expect(log(page).locator("[data-item-id='answer-1']")).toBeVisible();
});
155 changes: 155 additions & 0 deletions e2e/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ import {
useMediaViewer,
type ConversationImage,
type PartImage,
ConversationThread,
MessageReplies,
type ConversationMessage,
type ConversationParticipant,
} from "@corbits/react-ui";
import {
CommandPalette,
Expand Down Expand Up @@ -1536,6 +1540,156 @@ function MediaViewerChatScenario() {
);
}

const THREAD_AGENT: ConversationParticipant = {
id: "agent",
kind: "agent",
name: "Agent",
};

const THREAD_READER: ConversationParticipant = {
id: "reader",
kind: "person",
name: "Reader",
};

function threadMessage(
id: string,
author: ConversationParticipant,
text: string,
): ConversationMessage {
return {
id,
author,
at: "2026-03-04T11:00:00.000Z",
status: "sent",
parts: [{ kind: "text", text }],
};
}

const THREAD_PARENT: ConversationMessage = {
...threadMessage("parent", THREAD_AGENT, "The weekly report is ready."),
replies: { count: 12, lastAt: "2026-03-04T11:30:00.000Z" },
};

const THREAD_REPLIES = Array.from({ length: 12 }, (_, index) =>
threadMessage(
`reply-${index + 1}`,
index % 2 === 0 ? THREAD_READER : THREAD_AGENT,
`Reply ${index + 1}: a note on the report, long enough to wrap onto a second line in the panel.`,
),
);

function ThreadMessage({ message }: { message: ConversationMessage }) {
const own = message.author.id === THREAD_READER.id;
return (
<Message
from={own ? "own" : "agent"}
aria-label={`${message.author.name} ${message.id}`}
>
<MessageContent
text={message.parts
.map((part) => (part.kind === "text" ? part.text : ""))
.join("")}
/>
</Message>
);
}

const renderThreadMessage = (message: ConversationMessage) => (
<ThreadMessage message={message} />
);

// A thread's tab keeps its draft and replies in its own state, so they last
// exactly as long as the pane stays mounted.
function ThreadPaneScenario() {
const [replies, setReplies] = useState(THREAD_REPLIES);
const [draft, setDraft] = useState("");
const [sentId, setSentId] = useState<string>();
const [sends, setSends] = useState(0);
return (
<ConversationThread
parent={THREAD_PARENT}
replies={replies}
renderMessage={renderThreadMessage}
sentId={sentId}
>
<PromptInput
value={draft}
onValueChange={setDraft}
onSubmit={() => {
const count = sends + 1;
setSends(count);
setReplies((current) => [
...current,
threadMessage(`sent-${count}`, THREAD_READER, draft),
threadMessage(`answer-${count}`, THREAD_AGENT, "Noted."),
]);
setSentId(`sent-${count}`);
setDraft("");
}}
>
<PromptInputTextarea aria-label="Reply" />
<PromptInputSubmit />
</PromptInput>
</ConversationThread>
);
}

type ThreadScenarioTab = { value: string; title: string };

function ConversationThreadScenario() {
const { tabs, value, open, tabsProps, paneProps } =
useTabs<ThreadScenarioTab>({
defaultTabs: [{ value: "notes", title: "Notes" }],
});
return (
<div
style={{
position: "relative",
display: "flex",
height: 600,
width: 1200,
containerType: "inline-size",
}}
>
<main style={{ flex: 1, minWidth: 0 }}>
<ThreadMessage message={THREAD_PARENT} />
{THREAD_PARENT.replies === undefined ? null : (
<MessageReplies
replies={THREAD_PARENT.replies}
now={Date.parse("2026-03-04T12:00:00.000Z")}
onClick={() => open({ value: "thread", title: "Thread" })}
/>
)}
</main>
{value === null ? null : (
<Panel aria-label="Side panel">
<PanelHeader className="items-end bg-muted pt-1.5 pb-0">
<PanelTabs label="Open items" {...tabsProps}>
{tabs.map((tab) => (
<PanelTab key={tab.value} value={tab.value}>
{tab.title}
</PanelTab>
))}
</PanelTabs>
</PanelHeader>
<PanelContent value={value} keepMounted>
{tabs.map((tab) => (
<PanelPane
key={tab.value}
value={tab.value}
{...paneProps(tab.value)}
>
{tab.value === "thread" ? <ThreadPaneScenario /> : "Notes pane"}
</PanelPane>
))}
</PanelContent>
</Panel>
)}
</div>
);
}

export const scenarios: Record<string, () => ReactNode> = {
smoke: () => <Button>Ready</Button>,
"approval-card": () => <Approval state="idle" />,
Expand Down Expand Up @@ -1608,4 +1762,5 @@ export const scenarios: Record<string, () => ReactNode> = {
attachments: () => <AttachmentsScenario />,
"media-viewer": () => <MediaViewerScenario />,
"media-viewer-chat": () => <MediaViewerChatScenario />,
"conversation-thread": () => <ConversationThreadScenario />,
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,4 @@ export * from "./ui/attachment-drop-zone.js";
export * from "./ui/generated-images.js";
export * from "./ui/sources.js";
export * from "./ui/media-viewer.js";
export * from "./ui/conversation-thread.js";
Loading
Loading