Skip to content

Commit dba5b9e

Browse files
committed
feat(conversation): add ConversationThread
ConversationThread renders a reply conversation headed by its parent message, with a divider counting the replies, built from the same parts as the main conversation through a render prop. Each thread has its own Conversation, so a reply settles at the top of its own log, and in a keep-mounted panel tab a thread keeps its place and the host's draft while the reader switches tabs. The thread's PromptInput sits below the log, and aria-label or aria-labelledby names the log.
1 parent 9660d62 commit dba5b9e

6 files changed

Lines changed: 1013 additions & 0 deletions

File tree

‎e2e/conversation-thread.spec.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
const log = (page: Page) =>
4+
page.locator("[data-slot='conversation-thread'] [role='log']");
5+
6+
const reply = (page: Page) => page.getByRole("textbox", { name: "Reply" });
7+
8+
const scrollTop = (page: Page) =>
9+
log(page).evaluate((element) => element.scrollTop);
10+
11+
/** How far an item's top sits below the top of the thread's view. */
12+
const offsetOf = (page: Page, id: string) =>
13+
log(page).evaluate((element, itemId) => {
14+
const item = element.querySelector(`[data-item-id='${itemId}']`);
15+
if (item === null) throw new Error(`no item ${itemId}`);
16+
return (
17+
item.getBoundingClientRect().top - element.getBoundingClientRect().top
18+
);
19+
}, id);
20+
21+
/** Waits for the view to stop moving. */
22+
const settled = async (page: Page) => {
23+
let previous = Number.NaN;
24+
await expect
25+
.poll(
26+
async () => {
27+
const current = await scrollTop(page);
28+
const still = current === previous;
29+
previous = current;
30+
return still;
31+
},
32+
{ intervals: [100] },
33+
)
34+
.toBe(true);
35+
};
36+
37+
const openThread = async (page: Page) => {
38+
await page.goto("/conversation-thread");
39+
await page.getByRole("button", { name: /12 replies/ }).click();
40+
await expect(page.getByRole("tab", { name: "Thread" })).toHaveAttribute(
41+
"aria-selected",
42+
"true",
43+
);
44+
await expect(log(page)).toHaveAttribute("aria-label", "Thread");
45+
await settled(page);
46+
};
47+
48+
test("a draft and the scroll position in a thread tab survive switching tabs", async ({
49+
page,
50+
}) => {
51+
await openThread(page);
52+
// The thread opens at its latest reply.
53+
await expect(log(page).locator("[data-item-id='reply-12']")).toBeInViewport();
54+
// The thread's log scrolls, not the pane around it.
55+
const pane = await log(page).evaluate((element) => {
56+
const host = element.closest("[data-slot='panel-pane']");
57+
if (host === null) throw new Error("the thread is not in a pane");
58+
return { scrollHeight: host.scrollHeight, clientHeight: host.clientHeight };
59+
});
60+
expect(pane.scrollHeight).toBe(pane.clientHeight);
61+
await reply(page).fill("A draft for later");
62+
const before = await scrollTop(page);
63+
expect(before).toBeGreaterThan(0);
64+
65+
await page.getByRole("tab", { name: "Notes" }).click();
66+
await expect(page.getByText("Notes pane")).toBeVisible();
67+
await expect(reply(page)).toBeHidden();
68+
69+
await page.getByRole("tab", { name: "Thread" }).click();
70+
await expect(reply(page)).toBeVisible();
71+
await expect(reply(page)).toHaveValue("A draft for later");
72+
expect(await scrollTop(page)).toBe(before);
73+
});
74+
75+
test("sending in the thread settles the sent reply at the top", async ({
76+
page,
77+
}) => {
78+
await openThread(page);
79+
await reply(page).fill("Add owners to the follow-ups");
80+
await reply(page).press("Enter");
81+
82+
await expect(reply(page)).toHaveValue("");
83+
await expect
84+
.poll(async () => Math.abs(await offsetOf(page, "sent-1")))
85+
.toBeLessThanOrEqual(1);
86+
await settled(page);
87+
expect(Math.abs(await offsetOf(page, "sent-1"))).toBeLessThanOrEqual(1);
88+
await expect(log(page).locator("[data-item-id='answer-1']")).toBeVisible();
89+
});

‎e2e/fixtures.tsx‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ import {
7878
useMediaViewer,
7979
type ConversationImage,
8080
type PartImage,
81+
ConversationThread,
82+
MessageReplies,
83+
type ConversationMessage,
84+
type ConversationParticipant,
8185
} from "@corbits/react-ui";
8286
import {
8387
CommandPalette,
@@ -1277,6 +1281,156 @@ function MediaViewerChatScenario() {
12771281
);
12781282
}
12791283

1284+
const THREAD_AGENT: ConversationParticipant = {
1285+
id: "agent",
1286+
kind: "agent",
1287+
name: "Agent",
1288+
};
1289+
1290+
const THREAD_READER: ConversationParticipant = {
1291+
id: "reader",
1292+
kind: "person",
1293+
name: "Reader",
1294+
};
1295+
1296+
function threadMessage(
1297+
id: string,
1298+
author: ConversationParticipant,
1299+
text: string,
1300+
): ConversationMessage {
1301+
return {
1302+
id,
1303+
author,
1304+
at: "2026-03-04T11:00:00.000Z",
1305+
status: "sent",
1306+
parts: [{ kind: "text", text }],
1307+
};
1308+
}
1309+
1310+
const THREAD_PARENT: ConversationMessage = {
1311+
...threadMessage("parent", THREAD_AGENT, "The weekly report is ready."),
1312+
replies: { count: 12, lastAt: "2026-03-04T11:30:00.000Z" },
1313+
};
1314+
1315+
const THREAD_REPLIES = Array.from({ length: 12 }, (_, index) =>
1316+
threadMessage(
1317+
`reply-${index + 1}`,
1318+
index % 2 === 0 ? THREAD_READER : THREAD_AGENT,
1319+
`Reply ${index + 1}: a note on the report, long enough to wrap onto a second line in the panel.`,
1320+
),
1321+
);
1322+
1323+
function ThreadMessage({ message }: { message: ConversationMessage }) {
1324+
const own = message.author.id === THREAD_READER.id;
1325+
return (
1326+
<Message
1327+
from={own ? "own" : "agent"}
1328+
aria-label={`${message.author.name} ${message.id}`}
1329+
>
1330+
<MessageContent
1331+
text={message.parts
1332+
.map((part) => (part.kind === "text" ? part.text : ""))
1333+
.join("")}
1334+
/>
1335+
</Message>
1336+
);
1337+
}
1338+
1339+
const renderThreadMessage = (message: ConversationMessage) => (
1340+
<ThreadMessage message={message} />
1341+
);
1342+
1343+
// A thread's tab keeps its draft and replies in its own state, so they last
1344+
// exactly as long as the pane stays mounted.
1345+
function ThreadPaneScenario() {
1346+
const [replies, setReplies] = useState(THREAD_REPLIES);
1347+
const [draft, setDraft] = useState("");
1348+
const [sentId, setSentId] = useState<string>();
1349+
const [sends, setSends] = useState(0);
1350+
return (
1351+
<ConversationThread
1352+
parent={THREAD_PARENT}
1353+
replies={replies}
1354+
renderMessage={renderThreadMessage}
1355+
sentId={sentId}
1356+
>
1357+
<PromptInput
1358+
value={draft}
1359+
onValueChange={setDraft}
1360+
onSubmit={() => {
1361+
const count = sends + 1;
1362+
setSends(count);
1363+
setReplies((current) => [
1364+
...current,
1365+
threadMessage(`sent-${count}`, THREAD_READER, draft),
1366+
threadMessage(`answer-${count}`, THREAD_AGENT, "Noted."),
1367+
]);
1368+
setSentId(`sent-${count}`);
1369+
setDraft("");
1370+
}}
1371+
>
1372+
<PromptInputTextarea aria-label="Reply" />
1373+
<PromptInputSubmit />
1374+
</PromptInput>
1375+
</ConversationThread>
1376+
);
1377+
}
1378+
1379+
type ThreadScenarioTab = { value: string; title: string };
1380+
1381+
function ConversationThreadScenario() {
1382+
const { tabs, value, open, tabsProps, paneProps } =
1383+
useTabs<ThreadScenarioTab>({
1384+
defaultTabs: [{ value: "notes", title: "Notes" }],
1385+
});
1386+
return (
1387+
<div
1388+
style={{
1389+
position: "relative",
1390+
display: "flex",
1391+
height: 600,
1392+
width: 1200,
1393+
containerType: "inline-size",
1394+
}}
1395+
>
1396+
<main style={{ flex: 1, minWidth: 0 }}>
1397+
<ThreadMessage message={THREAD_PARENT} />
1398+
{THREAD_PARENT.replies === undefined ? null : (
1399+
<MessageReplies
1400+
replies={THREAD_PARENT.replies}
1401+
now={Date.parse("2026-03-04T12:00:00.000Z")}
1402+
onClick={() => open({ value: "thread", title: "Thread" })}
1403+
/>
1404+
)}
1405+
</main>
1406+
{value === null ? null : (
1407+
<Panel aria-label="Side panel">
1408+
<PanelHeader className="items-end bg-muted pt-1.5 pb-0">
1409+
<PanelTabs label="Open items" {...tabsProps}>
1410+
{tabs.map((tab) => (
1411+
<PanelTab key={tab.value} value={tab.value}>
1412+
{tab.title}
1413+
</PanelTab>
1414+
))}
1415+
</PanelTabs>
1416+
</PanelHeader>
1417+
<PanelContent value={value} keepMounted>
1418+
{tabs.map((tab) => (
1419+
<PanelPane
1420+
key={tab.value}
1421+
value={tab.value}
1422+
{...paneProps(tab.value)}
1423+
>
1424+
{tab.value === "thread" ? <ThreadPaneScenario /> : "Notes pane"}
1425+
</PanelPane>
1426+
))}
1427+
</PanelContent>
1428+
</Panel>
1429+
)}
1430+
</div>
1431+
);
1432+
}
1433+
12801434
export const scenarios: Record<string, () => ReactNode> = {
12811435
smoke: () => <Button>Ready</Button>,
12821436
"approval-card": () => <Approval state="idle" />,
@@ -1343,4 +1497,5 @@ export const scenarios: Record<string, () => ReactNode> = {
13431497
attachments: () => <AttachmentsScenario />,
13441498
"media-viewer": () => <MediaViewerScenario />,
13451499
"media-viewer-chat": () => <MediaViewerChatScenario />,
1500+
"conversation-thread": () => <ConversationThreadScenario />,
13461501
};

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,4 @@ export * from "./ui/attachment-drop-zone.js";
159159
export * from "./ui/generated-images.js";
160160
export * from "./ui/sources.js";
161161
export * from "./ui/media-viewer.js";
162+
export * from "./ui/conversation-thread.js";

0 commit comments

Comments
 (0)