Skip to content

Commit ea976c4

Browse files
feat(message): add the Message family (#121)
* feat(message): add the Message family Message renders an agent, person or own message with an avatar, header, content, footer and send status; own messages sit in a bubble, and markdown comes only through MessageContent's render prop. MessageFold folds a long answer and keeps the reader's place when folding from inside it. MessageActions puts extras in an overflow menu without an optional peer, and MessageReactions, MessageReplies and useCopy cover reactions, reply summaries and copy feedback. A failed message offers a retry. * fix(message): merge a host ref into the action menu and keep the fold A ref passed to MessageActionMenu replaced the root ref, so a click outside no longer closed the menu and it no longer flipped above a trigger near the bottom. The host's ref is now merged into the root. Folding a long answer kept the reader's place only when the host committed the close in the same frame as the click. The fold now reads the toggle's position with getSnapshotBeforeUpdate, after React decides to fold and before the DOM changes, so the place is kept however and whenever the close lands. * fix(message): keep copy and menu focus on the latest request useCopy let whichever clipboard write settled last set `copied`, so a slow earlier write could show "Copied" after a newer copy failed, or clear the state after a newer one succeeded. Each copy now takes a number, and only the latest one updates `copied` and the timer. The action menu kept the "focus the last item" request from an ArrowUp that a controlled host ignored, so a later open, by a click or by the host, landed on the last item. The request is now state and is dropped in the render that finds the menu still closed, as the prompt's add menu does, so any later open starts on the first item. Also note in the menu's docs that a click outside closes it without taking focus back, as the other popovers do. * fix(message): mark a pressed action apart from hover without colour A pressed MessageAction in the row took `bg-muted` and `text-foreground`, the same two classes hover applies, and both are colour. Once the pointer left, the only difference between a set vote and an unset one was a faint tint and a darker icon, so a reader could not tell which vote was set. A pressed action now also draws an inset outline in the control-boundary colour, which clears 3:1 against the page and which hover does not draw. The MessageAction docs say what a pressed action looks like, and the e2e action row gains a pressed and an unpressed vote to check it. * fix(message): forward the ref on MessageActionMenu under React 18 MessageActionMenu read `ref` from its props. React 18 strips `ref` from the props of a plain function component, so a host's ref never reached the root there, though the peer range accepts React 18. The menu is now wrapped in forwardRef, which passes the ref on React 18 and 19 alike, and still merges it with the root ref that outside-press dismissal and the side flip measure. * fix(message): forward the ref on MessageAction under React 18 MessageAction read `ref` from its props. React 18 strips `ref` from the props of a plain function component, so the trigger ref MessageActionMenu passes never reached the button, and closing the overflow menu did not return focus to "More actions". The peer range still accepts React 18. MessageAction is now wrapped in forwardRef, which passes the ref on React 18 and 19 alike, and attaches it to the button it renders in the row and in the menu. * test(message): keep the MessageAction ref check to a few lines Focus already returns to the trigger when an overflow item is chosen. Assert that MessageAction is a forwardRef there, instead of a separate test. * ci: retrigger checks against the updated stack The previous run merged a stack commit whose mid-glide test failed typecheck. That test is fixed on conversation/05-conversation. --------- Co-authored-by: Sawyer Cutler <sawyer@dirtroad.dev>
1 parent 415de5b commit ea976c4

13 files changed

Lines changed: 3249 additions & 0 deletions

‎e2e/fixtures.tsx‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ import {
5555
type ToolApprovalRecord,
5656
ToolGroup,
5757
type PartToolBatch,
58+
Message,
59+
MessageAction,
60+
MessageActionMenu,
61+
MessageActions,
62+
MessageContent,
63+
MessageFold,
64+
MessageFooter,
5865
} from "@corbits/react-ui";
5966
import {
6067
CommandPalette,
@@ -1069,6 +1076,123 @@ function ToolGroupApprovalScenario() {
10691076
);
10701077
}
10711078

1079+
/** A long answer: 40 paragraphs of fixed-height lines, past the fold. */
1080+
const FOLD_PARAGRAPHS = Array.from(
1081+
{ length: 40 },
1082+
(_, index) => `Paragraph ${index + 1} of a long answer.`,
1083+
);
1084+
const FOLD_LENGTH = 10_000;
1085+
1086+
function FoldedAnswer() {
1087+
return (
1088+
<Message from="agent" data-testid="long-answer">
1089+
<MessageFold length={FOLD_LENGTH}>
1090+
<MessageContent>
1091+
{FOLD_PARAGRAPHS.map((text) => (
1092+
<p key={text} style={{ height: 48, margin: 0 }}>
1093+
{text}
1094+
</p>
1095+
))}
1096+
</MessageContent>
1097+
</MessageFold>
1098+
</Message>
1099+
);
1100+
}
1101+
1102+
/** A long answer between other rows, in a plain scroll container. */
1103+
function MessageFoldScenario() {
1104+
return (
1105+
<div
1106+
data-testid="scroller"
1107+
style={{ height: 400, width: 600, overflowY: "auto" }}
1108+
>
1109+
<p style={{ height: 600, margin: 0 }}>Before</p>
1110+
<FoldedAnswer />
1111+
<p style={{ height: 600, margin: 0 }}>After</p>
1112+
</div>
1113+
);
1114+
}
1115+
1116+
/** The same answer inside a conversation, which anchors its own scrolling. */
1117+
function MessageFoldConversationScenario({
1118+
policy,
1119+
}: {
1120+
policy: ConversationScrollPolicy;
1121+
}) {
1122+
return (
1123+
<div
1124+
style={{
1125+
display: "flex",
1126+
flexDirection: "column",
1127+
height: 400,
1128+
width: 600,
1129+
}}
1130+
>
1131+
<Conversation scrollPolicy={policy}>
1132+
<ConversationContent data-testid="scroller">
1133+
{historyRows("h", 10).map((row) => (
1134+
<ConversationItem key={row.id} itemId={row.id}>
1135+
<p style={{ height: row.lines * 24, margin: 0 }}>Row {row.id}</p>
1136+
</ConversationItem>
1137+
))}
1138+
<ConversationItem itemId="long">
1139+
<FoldedAnswer />
1140+
</ConversationItem>
1141+
{historyRows("t", 10).map((row) => (
1142+
<ConversationItem key={row.id} itemId={row.id}>
1143+
<p style={{ height: row.lines * 24, margin: 0 }}>Row {row.id}</p>
1144+
</ConversationItem>
1145+
))}
1146+
</ConversationContent>
1147+
</Conversation>
1148+
</div>
1149+
);
1150+
}
1151+
1152+
function ActionRow({ record }: { record: (event: string) => void }) {
1153+
return (
1154+
<MessageActions>
1155+
<MessageAction label="Copy" onClick={() => record("copy")} />
1156+
<MessageAction label="Good response" aria-pressed />
1157+
<MessageAction label="Bad response" aria-pressed={false} />
1158+
<MessageActionMenu>
1159+
<MessageAction label="Pin" onClick={() => record("pin")} />
1160+
<MessageAction label="Copy link" onClick={() => record("link")} />
1161+
<MessageAction label="Report" onClick={() => record("report")} />
1162+
</MessageActionMenu>
1163+
</MessageActions>
1164+
);
1165+
}
1166+
1167+
/** An action row at the top of the page, and one at the foot of a short
1168+
* scroll container where a menu opening downwards would be clipped. */
1169+
function MessageActionsScenario() {
1170+
const { log, record } = useEventLog();
1171+
return (
1172+
<div style={{ width: 480 }}>
1173+
<Message from="agent" data-testid="top">
1174+
<MessageContent text="An answer with actions." />
1175+
<MessageFooter>
1176+
<ActionRow record={record} />
1177+
</MessageFooter>
1178+
</Message>
1179+
<button type="button">After</button>
1180+
<div
1181+
data-testid="clipped"
1182+
style={{ height: 240, overflowY: "auto", marginTop: 24 }}
1183+
>
1184+
<div style={{ height: 200 }} />
1185+
<Message from="agent" data-testid="bottom">
1186+
<MessageFooter>
1187+
<ActionRow record={record} />
1188+
</MessageFooter>
1189+
</Message>
1190+
</div>
1191+
{log}
1192+
</div>
1193+
);
1194+
}
1195+
10721196
export const scenarios: Record<string, () => ReactNode> = {
10731197
smoke: () => <Button>Ready</Button>,
10741198
"approval-card": () => <Approval state="idle" />,
@@ -1113,4 +1237,12 @@ export const scenarios: Record<string, () => ReactNode> = {
11131237
"panel-tabs": () => <PanelTabsScenario />,
11141238
"tool-approval": () => <ToolApprovalScenario />,
11151239
"tool-group-approval": () => <ToolGroupApprovalScenario />,
1240+
"message-fold": () => <MessageFoldScenario />,
1241+
"message-fold-settle": () => (
1242+
<MessageFoldConversationScenario policy="settle" />
1243+
),
1244+
"message-fold-stick": () => (
1245+
<MessageFoldConversationScenario policy="stick" />
1246+
),
1247+
"message-actions": () => <MessageActionsScenario />,
11161248
};

‎e2e/message.spec.ts‎

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { expect, test, type Locator, type Page } from "@playwright/test";
2+
3+
const scroller = (page: Page) => page.getByTestId("scroller");
4+
5+
const toggle = (page: Page) =>
6+
page.locator("[data-slot='message-fold-toggle']");
7+
8+
/** How far the fold control sits below the top of the scroll container. */
9+
const toggleOffset = async (page: Page) => {
10+
const box = await scroller(page).evaluate(
11+
(element) => element.getBoundingClientRect().top,
12+
);
13+
const top = await toggle(page).evaluate(
14+
(element) => element.getBoundingClientRect().top,
15+
);
16+
return top - box;
17+
};
18+
19+
/** Waits for the container to stop moving. */
20+
const settled = async (page: Page) => {
21+
let previous = Number.NaN;
22+
await expect
23+
.poll(
24+
async () => {
25+
const top = await scroller(page).evaluate(
26+
(element) => element.scrollTop,
27+
);
28+
const still = top === previous;
29+
previous = top;
30+
return still;
31+
},
32+
{ intervals: [100] },
33+
)
34+
.toBe(true);
35+
};
36+
37+
/** Scrolls as the reader would, with the wheel, to put `contentTop` (a
38+
* position inside the answer) at the top of the view. */
39+
const readInto = async (page: Page, contentTop: number) => {
40+
const answerTop = await page
41+
.getByTestId("long-answer")
42+
.evaluate((element, scrollerTop) => {
43+
const container = element.closest("[data-testid='scroller']");
44+
if (container === null) throw new Error("expected the scroller");
45+
return (
46+
element.getBoundingClientRect().top -
47+
container.getBoundingClientRect().top +
48+
container.scrollTop -
49+
scrollerTop
50+
);
51+
}, 0);
52+
const current = await scroller(page).evaluate((element) => element.scrollTop);
53+
const box = await scroller(page).boundingBox();
54+
if (box === null) throw new Error("expected the scroller on screen");
55+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
56+
await page.mouse.wheel(0, answerTop + contentTop - current);
57+
await settled(page);
58+
};
59+
60+
/** Opens the answer, reads into the middle of it, and folds it from there:
61+
* the control must stay where it was on screen. */
62+
const foldFromInside = async (page: Page) => {
63+
await toggle(page).click();
64+
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
65+
await readInto(page, 900);
66+
const before = await toggleOffset(page);
67+
const height = await scroller(page).evaluate(
68+
(element) => element.clientHeight,
69+
);
70+
// Open, the control rides the bottom of the view while the answer is read.
71+
expect(before).toBeGreaterThan(height / 2);
72+
expect(before).toBeLessThan(height);
73+
74+
await toggle(page).click();
75+
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
76+
await settled(page);
77+
const after = await toggleOffset(page);
78+
expect(Math.abs(after - before)).toBeLessThanOrEqual(1);
79+
};
80+
81+
test("folding an answer from inside it keeps the reader's place", async ({
82+
page,
83+
}) => {
84+
await page.goto("/message-fold");
85+
await expect(toggle(page)).toHaveText(/Show full answer/);
86+
await foldFromInside(page);
87+
});
88+
89+
for (const policy of ["settle", "stick"]) {
90+
test(`folding inside a conversation (${policy}) keeps the reader's place`, async ({
91+
page,
92+
}) => {
93+
await page.goto(`/message-fold-${policy}`);
94+
await expect(toggle(page)).toBeAttached();
95+
await foldFromInside(page);
96+
});
97+
}
98+
99+
const row = (page: Page, id: string) => page.getByTestId(id);
100+
const trigger = (scope: Locator) =>
101+
scope.getByRole("button", { name: "More actions" });
102+
const menu = (scope: Locator) =>
103+
scope.getByRole("menu", { name: "More actions" });
104+
const item = (scope: Locator, name: string) =>
105+
scope.getByRole("menuitem", { name });
106+
107+
test("a pressed action differs from a hovered one by more than colour", async ({
108+
page,
109+
}) => {
110+
await page.goto("/message-actions");
111+
const top = row(page, "top");
112+
const pressed = top.getByRole("button", { name: "Good response" });
113+
const hovered = top.getByRole("button", { name: "Bad response" });
114+
await hovered.hover();
115+
const shadow = (button: Locator) =>
116+
button.evaluate((element) => getComputedStyle(element).boxShadow);
117+
// The pressed action carries an inset outline that hover does not draw.
118+
expect(await shadow(pressed)).toContain("inset");
119+
expect(await shadow(hovered)).not.toContain("inset");
120+
});
121+
122+
test("the overflow menu works from the keyboard and returns focus", async ({
123+
page,
124+
}) => {
125+
await page.goto("/message-actions");
126+
const top = row(page, "top");
127+
await trigger(top).focus();
128+
await page.keyboard.press("Enter");
129+
await expect(menu(top)).toBeVisible();
130+
await expect(trigger(top)).toHaveAttribute("aria-expanded", "true");
131+
await expect(item(top, "Pin")).toBeFocused();
132+
133+
await page.keyboard.press("ArrowDown");
134+
await expect(item(top, "Copy link")).toBeFocused();
135+
await page.keyboard.press("ArrowDown");
136+
await page.keyboard.press("ArrowDown");
137+
await expect(item(top, "Pin")).toBeFocused();
138+
await page.keyboard.press("ArrowUp");
139+
await expect(item(top, "Report")).toBeFocused();
140+
await page.keyboard.press("Home");
141+
await expect(item(top, "Pin")).toBeFocused();
142+
await page.keyboard.press("End");
143+
await expect(item(top, "Report")).toBeFocused();
144+
145+
await page.keyboard.press("Escape");
146+
await expect(menu(top)).toHaveCount(0);
147+
await expect(trigger(top)).toBeFocused();
148+
await expect(trigger(top)).toHaveAttribute("aria-expanded", "false");
149+
});
150+
151+
test("ArrowUp on the trigger opens the menu on its last item", async ({
152+
page,
153+
}) => {
154+
await page.goto("/message-actions");
155+
const top = row(page, "top");
156+
await trigger(top).focus();
157+
await page.keyboard.press("ArrowUp");
158+
await expect(item(top, "Report")).toBeFocused();
159+
});
160+
161+
test("choosing an item runs it, closes the menu and refocuses the trigger", async ({
162+
page,
163+
}) => {
164+
await page.goto("/message-actions");
165+
const top = row(page, "top");
166+
await trigger(top).focus();
167+
await page.keyboard.press("ArrowDown");
168+
await page.keyboard.press("ArrowDown");
169+
await page.keyboard.press("Enter");
170+
await expect(page.getByRole("status", { name: "Events" })).toHaveText("link");
171+
await expect(menu(top)).toHaveCount(0);
172+
await expect(trigger(top)).toBeFocused();
173+
});
174+
175+
test("focus leaving the menu closes it", async ({ page }) => {
176+
await page.goto("/message-actions");
177+
const top = row(page, "top");
178+
await trigger(top).click();
179+
await expect(item(top, "Pin")).toBeFocused();
180+
await page.keyboard.press("Tab");
181+
await expect(menu(top)).toHaveCount(0);
182+
});
183+
184+
test("a menu that would be clipped below opens above", async ({ page }) => {
185+
await page.goto("/message-actions");
186+
const bottom = row(page, "bottom");
187+
await trigger(bottom).click();
188+
const content = menu(bottom);
189+
await expect(content).toBeVisible();
190+
await expect(content).toHaveAttribute("data-side", "top");
191+
const clip = await page.getByTestId("clipped").boundingBox();
192+
const box = await content.boundingBox();
193+
if (clip === null || box === null) throw new Error("expected both boxes");
194+
expect(box.y).toBeGreaterThanOrEqual(clip.y);
195+
expect(box.y + box.height).toBeLessThanOrEqual(clip.y + clip.height);
196+
});

0 commit comments

Comments
 (0)