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
401 changes: 401 additions & 0 deletions e2e/conversation.spec.ts

Large diffs are not rendered by default.

293 changes: 292 additions & 1 deletion e2e/fixtures.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { useState } from "react";
import { useEffect, useLayoutEffect, useState } from "react";
import type { ReactNode } from "react";

import {
ApprovalCard,
AuthLayout,
Button,
ChatThread,
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationItem,
ConversationScrollButton,
DictationStatusLine,
FilterChip,
Select,
Expand All @@ -15,6 +20,8 @@ import {
ThinkingMark,
type ApprovalRequest,
type ChatMessage,
type ConversationScrollPolicy,
useConversation,
} from "@corbits/react-ui";
import {
CommandPalette,
Expand Down Expand Up @@ -172,6 +179,286 @@ function Swatches() {
);
}

type ScenarioRow = {
readonly id: string;
readonly from: "reader" | "agent";
/** Height in 24px lines, so positions are exact on every platform. */
readonly lines: number;
};

function historyRows(prefix: string, count: number): ScenarioRow[] {
return Array.from({ length: count }, (_, index) => ({
id: `${prefix}${index}`,
from: index % 2 === 0 ? "reader" : "agent",
lines: 1 + (index % 3),
}));
}

function ConversationControls({
onSend,
onWrite,
onEarlier,
onTrim,
onUnsend,
onRemount,
}: {
onSend: () => void;
onWrite: () => void;
onEarlier: () => void;
onTrim: () => void;
onUnsend: () => void;
onRemount: () => void;
}) {
const { captureAnchor, restoreAnchor } = useConversation();
const [loads, setLoads] = useState(0);
useLayoutEffect(() => {
if (loads > 0) restoreAnchor();
}, [loads, restoreAnchor]);
return (
<div style={{ display: "flex", gap: 8 }}>
<Button onClick={onSend}>Send</Button>
<Button onClick={onWrite}>Write</Button>
<Button
onClick={() => {
captureAnchor();
onEarlier();
setLoads((count) => count + 1);
}}
>
Load earlier
</Button>
<Button onClick={onTrim}>Trim</Button>
<Button onClick={onUnsend}>Delete sent</Button>
<Button onClick={onRemount}>Remount</Button>
</div>
);
}

/** History arrives after mount, as it does from a server. */
function ConversationScenario({
policy,
}: {
policy: ConversationScrollPolicy;
}) {
const [rows, setRows] = useState<readonly ScenarioRow[]>([]);
const [sentId, setSentId] = useState<string>();
const [sends, setSends] = useState(0);
const [pages, setPages] = useState(0);
const [mounts, setMounts] = useState(0);
useEffect(() => {
const timer = setTimeout(() => setRows(historyRows("h", 30)), 50);
return () => clearTimeout(timer);
}, []);
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: 400,
width: 600,
}}
>
<Conversation scrollPolicy={policy} sentId={sentId}>
<ConversationContent key={mounts}>
{rows.map((row) => (
<ConversationItem key={row.id} itemId={row.id} data-testid={row.id}>
<p style={{ height: row.lines * 24, margin: 0 }}>
{row.from === "reader" ? "Reader" : "Agent"} {row.id}
</p>
</ConversationItem>
))}
</ConversationContent>
<ConversationScrollButton />
<ConversationControls
onSend={() => {
const id = `s${sends}`;
setSends(sends + 1);
setRows((previous) => [
...previous,
{ id, from: "reader", lines: 1 },
{ id: `a${sends}`, from: "agent", lines: 1 },
]);
setSentId(id);
}}
onWrite={() =>
setRows((previous) =>
previous.map((row, index) =>
index === previous.length - 1
? { ...row, lines: row.lines + 3 }
: row,
),
)
}
onEarlier={() => {
setPages(pages + 1);
setRows((previous) => [
...historyRows(`p${pages}-`, 10),
...previous,
]);
}}
onTrim={() => setRows((previous) => previous.slice(5))}
onUnsend={() =>
setRows((previous) => previous.filter((row) => row.id !== sentId))
}
onRemount={() => setMounts(mounts + 1)}
/>
</Conversation>
</div>
);
}

/** One frame of a recorded glide: where the tracked item's top sits below
* the top of the view, after the glide has written that frame. */
type GlideFrame = { readonly t: number; readonly offset: number };

type GlideLog = {
readonly frames: GlideFrame[];
/** When the content above changed, on the frames' clock. */
changedAt: number | null;
done: boolean;
};

/** Records `item`'s offset every frame until the view has been still for ten
* frames. Started after the glide, so each frame runs after the glide's. */
function recordGlide(scroller: HTMLElement, item: HTMLElement): GlideLog {
const log: GlideLog = { frames: [], changedAt: null, done: false };
let still = 0;
let last = Number.NaN;
const tick = (t: number) => {
log.frames.push({
t,
offset:
item.getBoundingClientRect().top - scroller.getBoundingClientRect().top,
});
still = scroller.scrollTop === last ? still + 1 : 0;
last = scroller.scrollTop;
if (still < 10 && log.frames.length < 300) requestAnimationFrame(tick);
else log.done = true;
};
requestAnimationFrame(tick);
Object.assign(window, { glideLog: log });
return log;
}

function GlideControls({
onLoad,
onTrim,
}: {
onLoad: () => void;
onTrim: () => void;
}) {
const {
items,
scrollContainer,
scrollToItem,
scrollToEnd,
captureAnchor,
restoreAnchor,
} = useConversation();
const [loads, setLoads] = useState(0);
useLayoutEffect(() => {
if (loads > 0) restoreAnchor();
}, [loads, restoreAnchor]);
/** Glides, then changes the content above while the glide is in flight. */
const glideThen = (glide: () => void, id: string, change: () => void) => {
const scroller = scrollContainer();
const item = items.get(id);
if (scroller === null || item === undefined) return;
glide();
const log = recordGlide(scroller, item);
setTimeout(() => {
log.changedAt = performance.now();
change();
}, 120);
};
const topmostInView = () => {
const top = scrollContainer()?.getBoundingClientRect().top ?? 0;
return (
items
.ids()
.find(
(id) =>
(items.get(id)?.getBoundingClientRect().bottom ?? 0) > top + 1,
) ?? ""
);
};
return (
<div style={{ display: "flex", gap: 8 }}>
<Button
onClick={() =>
glideThen(
() => scrollToItem("h3"),
"h3",
() => {
captureAnchor();
onLoad();
setLoads((count) => count + 1);
},
)
}
>
Glide up and load
</Button>
<Button onClick={() => glideThen(scrollToEnd, topmostInView(), onTrim)}>
Glide down and trim
</Button>
</div>
);
}

/** Content changing above the view while a glide is in flight. */
function ConversationGlideScenario() {
const [rows, setRows] = useState(() => historyRows("h", 30));
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: 400,
width: 600,
}}
>
<Conversation>
<ConversationContent>
{rows.map((row) => (
<ConversationItem key={row.id} itemId={row.id} data-testid={row.id}>
<p style={{ height: row.lines * 24, margin: 0 }}>
{row.from === "reader" ? "Reader" : "Agent"} {row.id}
</p>
</ConversationItem>
))}
</ConversationContent>
<GlideControls
onLoad={() =>
setRows((previous) => [...historyRows("p", 10), ...previous])
}
onTrim={() => setRows((previous) => previous.slice(12))}
/>
</Conversation>
</div>
);
}

/** A conversation with nothing in it yet. */
function ConversationEmptyScenario() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: 400,
width: 600,
}}
>
<Conversation>
<ConversationContent>
<ConversationEmptyState description="Send a message to start." />
</ConversationContent>
</Conversation>
</div>
);
}

export const scenarios: Record<string, () => ReactNode> = {
smoke: () => <Button>Ready</Button>,
"approval-card": () => <Approval state="idle" />,
Expand Down Expand Up @@ -201,4 +488,8 @@ export const scenarios: Record<string, () => ReactNode> = {
<Swatches />
</ThemeProvider>
),
"conversation-settle": () => <ConversationScenario policy="settle" />,
"conversation-stick": () => <ConversationScenario policy="stick" />,
"conversation-glide": () => <ConversationGlideScenario />,
"conversation-empty": () => <ConversationEmptyScenario />,
};
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,5 @@ export * from "./ui/trace-waterfall.js";
export * from "./ui/typing-indicator.js";
export * from "./ui/view-toggle.js";
export * from "./ui/voice-waveform.js";
export * from "./ui/conversation.js";
export * from "./ui/conversation-divider.js";
Loading
Loading