From ccf32d5b36f0b6af54b0f66dc04fbc1404ea1871 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 11:11:40 +0530 Subject: [PATCH 1/2] 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. --- e2e/conversation-thread.spec.ts | 89 +++++ e2e/fixtures.tsx | 155 ++++++++ src/index.ts | 1 + src/ui/conversation-thread.test.tsx | 247 ++++++++++++ src/ui/conversation-thread.tsx | 123 ++++++ stories/chat/conversation-thread.stories.tsx | 398 +++++++++++++++++++ 6 files changed, 1013 insertions(+) create mode 100644 e2e/conversation-thread.spec.ts create mode 100644 src/ui/conversation-thread.test.tsx create mode 100644 src/ui/conversation-thread.tsx create mode 100644 stories/chat/conversation-thread.stories.tsx diff --git a/e2e/conversation-thread.spec.ts b/e2e/conversation-thread.spec.ts new file mode 100644 index 00000000..09a1846f --- /dev/null +++ b/e2e/conversation-thread.spec.ts @@ -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(); +}); diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 86d7af7a..7e41c2bd 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -83,6 +83,10 @@ import { useMediaViewer, type ConversationImage, type PartImage, + ConversationThread, + MessageReplies, + type ConversationMessage, + type ConversationParticipant, } from "@corbits/react-ui"; import { CommandPalette, @@ -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 ( + + (part.kind === "text" ? part.text : "")) + .join("")} + /> + + ); +} + +const renderThreadMessage = (message: ConversationMessage) => ( + +); + +// 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(); + const [sends, setSends] = useState(0); + return ( + + { + 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(""); + }} + > + + + + + ); +} + +type ThreadScenarioTab = { value: string; title: string }; + +function ConversationThreadScenario() { + const { tabs, value, open, tabsProps, paneProps } = + useTabs({ + defaultTabs: [{ value: "notes", title: "Notes" }], + }); + return ( +
+
+ + {THREAD_PARENT.replies === undefined ? null : ( + open({ value: "thread", title: "Thread" })} + /> + )} +
+ {value === null ? null : ( + + + + {tabs.map((tab) => ( + + {tab.title} + + ))} + + + + {tabs.map((tab) => ( + + {tab.value === "thread" ? : "Notes pane"} + + ))} + + + )} +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -1608,4 +1762,5 @@ export const scenarios: Record ReactNode> = { attachments: () => , "media-viewer": () => , "media-viewer-chat": () => , + "conversation-thread": () => , }; diff --git a/src/index.ts b/src/index.ts index 6d7434b1..e371e27d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/ui/conversation-thread.test.tsx b/src/ui/conversation-thread.test.tsx new file mode 100644 index 00000000..9bc44cd6 --- /dev/null +++ b/src/ui/conversation-thread.test.tsx @@ -0,0 +1,247 @@ +import { act, createRef } from "react"; +import type { ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, test } from "bun:test"; + +import type { + ConversationMessage, + ConversationParticipant, +} from "../lib/conversation.js"; +import { ConversationThread } from "./conversation-thread.js"; + +type Mounted = { + container: HTMLElement; + rerender: (node: ReactElement) => void; + unmount: () => void; +}; + +function render(node: ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + rerender: (next) => act(() => root.render(next)), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +const agent: ConversationParticipant = { + id: "agent", + kind: "agent", + name: "Agent", +}; + +function message(id: string, text: string): ConversationMessage { + return { + id, + author: agent, + at: "2026-03-04T12:00:00.000Z", + status: "sent", + parts: [{ kind: "text", text }], + }; +} + +const parent = message("parent", "The parent"); +const replies = [message("r1", "First reply"), message("r2", "Second reply")]; + +const renderMessage = (item: ConversationMessage) => ( +

+ {item.parts.map((part) => (part.kind === "text" ? part.text : null))} +

+); + +function items(container: HTMLElement): string[] { + return [ + ...container.querySelectorAll( + "[data-slot='conversation-item']", + ), + ].map((element) => element.dataset.itemId ?? ""); +} + +describe("ConversationThread", () => { + test("heads the thread with its parent, then a count, then the replies in order", () => { + const mounted = render( + , + ); + const log = mounted.container.querySelector("[role='log']"); + expect(log?.getAttribute("aria-label")).toBe("Thread"); + expect(items(mounted.container)).toEqual(["parent", "r1", "r2"]); + const column = mounted.container.querySelector( + "[data-slot='conversation-column']", + ); + const order = [...(column?.children ?? [])].map( + (child) => child.getAttribute("data-slot") ?? "", + ); + expect(order).toEqual([ + "conversation-item", + "conversation-divider", + "conversation-item", + "conversation-item", + ]); + expect( + mounted.container.querySelector("[data-slot='conversation-divider']") + ?.textContent, + ).toBe("2 replies"); + expect( + mounted.container.querySelector("[data-testid='parent']")?.textContent, + ).toBe("The parent"); + mounted.unmount(); + }); + + test("counts one reply, and says when there are none yet", () => { + const divider = (mounted: Mounted) => + mounted.container.querySelector("[data-slot='conversation-divider']") + ?.textContent; + const mounted = render( + , + ); + expect(divider(mounted)).toBe("No replies yet"); + expect(items(mounted.container)).toEqual(["parent"]); + mounted.rerender( + , + ); + expect(divider(mounted)).toBe("1 reply"); + expect(items(mounted.container)).toEqual(["parent", "r1"]); + mounted.unmount(); + }); + + test("puts the input under the conversation, outside the log", () => { + const mounted = render( + +