Skip to content

Commit ddd6235

Browse files
committed
feat(chat): add MessageParts to render a message's parts
Every host mapped ConversationPart to components by hand and re-implemented the focus handoff when a decided call folds into the run before it. MessageParts groups parts with groupActivity and renders each kind as its component (Activity, ToolApproval, ToolGroup, MessageContent, Attachments tiles, GeneratedImages, Sources, ConversationNotice), with a render prop per kind to replace any of them. After a decision folds a call away, focus moves to the run's trigger, or to the next waiting call in a batch, instead of falling to the page. The full chat and activity stories and the e2e fixtures use it in place of their own mappings; the e2e specs check that focus never sits on <body> for a frame after a keyboard decision.
1 parent 5c6401e commit ddd6235

10 files changed

Lines changed: 1088 additions & 242 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer",
189189
| `ConversationThread` | A reply thread headed by its parent message |
190190
| `Message` | One message, built from `MessageAvatar`, `MessageHeader`, `MessageContent`, `MessageSendStatus` and `MessageFooter` |
191191
| `MessageFold` | Folds a very long answer to its opening |
192+
| `MessageParts` | A message's parts as their components, any kind replaceable; keeps focus when a decided call folds |
192193
| `MessageActions` | Row of actions under a message: `MessageAction`, `MessageActionMenu` |
193194
| `MessageReactions` | Emoji reactions with counts |
194195
| `MessageReplies` | Reply count and time that opens the thread |

‎e2e/fixtures.tsx‎

Lines changed: 98 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ import {
4242
PanelTabAdd,
4343
PanelTabs,
4444
useTabs,
45-
Activity,
46-
ToolApproval,
47-
groupActivity,
45+
MessageParts,
46+
type ConversationPart,
4847
type PartTool,
4948
type ToolApprovalRecord,
5049
Message,
@@ -738,17 +737,22 @@ function PanelTabsScenario() {
738737
);
739738
}
740739

740+
/** A call's execution status once a decision is recorded on it. */
741+
function decidedStatus(approval: ToolApprovalRecord): PartTool["status"] {
742+
if (approval.status === "pending") return "approval-requested";
743+
return approval.status === "approved" ? "output-available" : "output-denied";
744+
}
745+
746+
function pendingApproval(id: string): ToolApprovalRecord {
747+
return { id, status: "pending", scope: null, timeoutAt: null };
748+
}
749+
741750
// A host's view of one agent turn: a finished draft, then a send waiting on a
742-
// person. Deciding records the outcome on the call, which then folds into the
743-
// run before it, the way a message renders `groupActivity`.
751+
// person. Deciding records the outcome on the call, which `MessageParts` then
752+
// folds into the run before it.
744753
function ToolApprovalScenario() {
745754
const { log, record } = useEventLog();
746-
const [approval, setApproval] = useState<ToolApprovalRecord>({
747-
id: "approval-1",
748-
status: "pending",
749-
scope: null,
750-
timeoutAt: null,
751-
});
755+
const [approval, setApproval] = useState(pendingApproval("approval-1"));
752756
const parts: PartTool[] = [
753757
{
754758
kind: "tool-trace",
@@ -761,41 +765,98 @@ function ToolApprovalScenario() {
761765
kind: "tool-trace",
762766
toolCallId: "send",
763767
name: "mail__send",
764-
status:
765-
approval.status === "pending"
766-
? "approval-requested"
767-
: approval.status === "approved"
768-
? "output-available"
769-
: "output-denied",
768+
status: decidedStatus(approval),
770769
input: { to: "team" },
771770
approval,
772771
},
773772
];
774773
return (
775774
<>
776-
{groupActivity(parts).map((group) =>
777-
group.kind === "activity" ? (
778-
<Activity key={group.key} steps={group.steps} />
779-
) : group.part.kind === "tool-trace" ? (
780-
<ToolApproval
781-
key={group.key}
782-
part={group.part}
783-
onDecide={(decision) => {
784-
record(`${decision.outcome}:${decision.scope}`);
785-
setApproval((current) => ({
786-
...current,
787-
status: decision.outcome,
788-
scope: decision.scope,
789-
}));
790-
}}
791-
/>
792-
) : null,
793-
)}
775+
<Message from="agent" aria-label="Agent">
776+
<MessageParts
777+
parts={parts}
778+
onDecide={(_, decision) => {
779+
record(`${decision.outcome}:${decision.scope}`);
780+
setApproval((current) => ({
781+
...current,
782+
status: decision.outcome,
783+
scope: decision.scope,
784+
}));
785+
}}
786+
/>
787+
</Message>
794788
{log}
795789
</>
796790
);
797791
}
798792

793+
const BATCH_CALLS = ["notice", "totals", "calendar"] as const;
794+
795+
// Three calls that ran at once after a finished read, two of them waiting on
796+
// a person. Each decision is recorded on its call; the batch folds into the
797+
// run once none is left waiting.
798+
function ToolBatchApprovalScenario() {
799+
const [approvals, setApprovals] = useState<
800+
Record<string, ToolApprovalRecord>
801+
>({
802+
notice: pendingApproval("approval-notice"),
803+
calendar: pendingApproval("approval-calendar"),
804+
});
805+
const parts: ConversationPart[] = [
806+
{
807+
kind: "tool-trace",
808+
toolCallId: "read",
809+
name: "files__read",
810+
status: "output-available",
811+
output: "Read 3 files",
812+
},
813+
{
814+
kind: "tool-batch",
815+
id: "batch",
816+
tools: BATCH_CALLS.map((name): PartTool => {
817+
const approval = approvals[name];
818+
return approval === undefined
819+
? {
820+
kind: "tool-trace",
821+
toolCallId: name,
822+
name: `send__${name}`,
823+
status: "output-available",
824+
output: "Sent",
825+
}
826+
: {
827+
kind: "tool-trace",
828+
toolCallId: name,
829+
name: `send__${name}`,
830+
status: decidedStatus(approval),
831+
input: { to: "team" },
832+
approval,
833+
};
834+
}),
835+
},
836+
];
837+
return (
838+
<Message from="agent" aria-label="Agent">
839+
<MessageParts
840+
parts={parts}
841+
onDecide={(tool, decision) =>
842+
setApprovals((current) => {
843+
const approval = current[tool.toolCallId];
844+
if (approval === undefined) return current;
845+
return {
846+
...current,
847+
[tool.toolCallId]: {
848+
...approval,
849+
status: decision.outcome,
850+
scope: decision.scope,
851+
},
852+
};
853+
})
854+
}
855+
/>
856+
</Message>
857+
);
858+
}
859+
799860
/** A long answer: 40 paragraphs of fixed-height lines, past the fold. */
800861
const FOLD_PARAGRAPHS = Array.from(
801862
{ length: 40 },
@@ -1275,11 +1336,7 @@ function ThreadMessage({ message }: { message: ConversationMessage }) {
12751336
from={own ? "own" : "agent"}
12761337
aria-label={`${message.author.name} ${message.id}`}
12771338
>
1278-
<MessageContent
1279-
text={message.parts
1280-
.map((part) => (part.kind === "text" ? part.text : ""))
1281-
.join("")}
1282-
/>
1339+
<MessageParts parts={message.parts} />
12831340
</Message>
12841341
);
12851342
}
@@ -1410,6 +1467,7 @@ export const scenarios: Record<string, () => ReactNode> = {
14101467
"prompt-input-toolbar": () => <PromptToolbarScenario />,
14111468
"panel-tabs": () => <PanelTabsScenario />,
14121469
"tool-approval": () => <ToolApprovalScenario />,
1470+
"tool-batch-approval": () => <ToolBatchApprovalScenario />,
14131471
"message-fold": () => <MessageFoldScenario />,
14141472
"message-fold-settle": () => (
14151473
<MessageFoldConversationScenario policy="settle" />

‎e2e/focus.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Page } from "@playwright/test";
2+
3+
type Watched = Window & {
4+
__bodyFocus?: { frames: number; stop: () => void };
5+
};
6+
7+
/**
8+
* Starts checking, every frame, whether focus has fallen to the page (no
9+
* element, or `<body>`). The returned function stops and resolves to how many
10+
* frames it had: zero means a reader never saw focus lost.
11+
*/
12+
export async function watchFocusOnBody(
13+
page: Page,
14+
): Promise<() => Promise<number>> {
15+
await page.evaluate(() => {
16+
const watched = window as Watched;
17+
let frame = 0;
18+
const state = {
19+
frames: 0,
20+
stop: () => cancelAnimationFrame(frame),
21+
};
22+
const tick = () => {
23+
const active = document.activeElement;
24+
if (active === null || active === document.body) state.frames += 1;
25+
frame = requestAnimationFrame(tick);
26+
};
27+
frame = requestAnimationFrame(tick);
28+
watched.__bodyFocus = state;
29+
});
30+
return () =>
31+
page.evaluate(() => {
32+
const state = (window as Watched).__bodyFocus;
33+
if (state === undefined) throw new Error("focus was not being watched");
34+
state.stop();
35+
return state.frames;
36+
});
37+
}

‎e2e/full-chat.spec.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { expect, test, type Page } from "@playwright/test";
22

3+
import { watchFocusOnBody } from "./focus.js";
4+
35
// The whole-chat composition lives in the story, so the spec drives the
46
// static Storybook build the accessibility sweep also serves.
57
const STORIES = "http://localhost:61000";
@@ -34,10 +36,16 @@ for (const [story, decision, outcome] of [
3436
);
3537
await expect(approval).toBeVisible();
3638
await approval.getByRole("button", { name: decision, exact: true }).focus();
39+
const stop = await watchFocusOnBody(page);
3740
await page.keyboard.press("Enter");
3841

3942
// The call folds into the run before it, and its buttons go with it.
4043
await expect(approval).toHaveCount(0);
44+
await expect
45+
.poll(async () => (await focused(page)).slot)
46+
.toBe("activity-trigger");
47+
// Not for a single frame did focus fall to the page.
48+
expect(await stop()).toBe(0);
4149
const active = await focused(page);
4250
expect(active.body).toBe(false);
4351
expect(active.slot).toBe("activity-trigger");

‎e2e/tool-approval.spec.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { expect, test } from "@playwright/test";
22

3+
import { watchFocusOnBody } from "./focus.js";
4+
35
const DECISIONS = [
46
["Approve", "approved:once", "Approved once"],
57
["Approve always", "approved:always", "Approved always"],
@@ -44,3 +46,52 @@ test("the decision works from the keyboard", async ({ page }) => {
4446
"approved:always",
4547
);
4648
});
49+
50+
for (const [button, key] of [
51+
["Approve", "Enter"],
52+
["Approve always", "Space"],
53+
["Deny", "Enter"],
54+
] as const) {
55+
test(`${button} by keyboard never leaves focus on the page`, async ({
56+
page,
57+
}) => {
58+
await page.goto("/tool-approval");
59+
await page.getByRole("button", { name: button, exact: true }).focus();
60+
const stop = await watchFocusOnBody(page);
61+
await page.keyboard.press(key);
62+
63+
// The call folds into the run before it; focus goes to that run's line.
64+
const run = page.getByRole("button", { name: "Worked through 2 steps" });
65+
await expect(run).toBeFocused();
66+
expect(await stop()).toBe(0);
67+
});
68+
}
69+
70+
test("in a batch, a keyboard decision moves to the next waiting call, then to the run", async ({
71+
page,
72+
}) => {
73+
await page.goto("/tool-batch-approval");
74+
const waiting = page.locator(
75+
"[data-slot='tool-approval'][data-status='pending']",
76+
);
77+
await expect(waiting).toHaveCount(2);
78+
await waiting
79+
.first()
80+
.getByRole("button", { name: "Deny", exact: true })
81+
.focus();
82+
const stop = await watchFocusOnBody(page);
83+
84+
await page.keyboard.press("Enter");
85+
await expect(waiting).toHaveCount(1);
86+
await expect(waiting).toContainText("send__calendar");
87+
await expect(
88+
waiting.getByRole("button", { name: "Approve", exact: true }),
89+
).toBeFocused();
90+
91+
await page.keyboard.press("Enter");
92+
await expect(waiting).toHaveCount(0);
93+
await expect(
94+
page.getByRole("button", { name: "Worked through 4 steps" }),
95+
).toBeFocused();
96+
expect(await stop()).toBe(0);
97+
});

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export * from "./ui/message-fold.js";
138138
export * from "./ui/message-actions.js";
139139
export * from "./ui/message-reactions.js";
140140
export * from "./ui/message-replies.js";
141+
export * from "./ui/message-parts.js";
141142
export * from "./ui/conversation-earlier.js";
142143
export * from "./ui/conversation-map.js";
143144
export * from "./ui/attachments.js";

0 commit comments

Comments
 (0)