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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
"Tool", "ToolApproval", "ToolGroup", "Reasoning", "Activity",
"Message", "MessageFold", "MessageActions", "MessageReactions",
"MessageReplies", "Attachments", "AttachmentDropZone",
"GeneratedImages", "Sources", "MediaViewer",
"GeneratedImages", "Sources", "MediaViewer", "MessageParts",
];
const missing = names.filter((n) => m[n] === undefined);
if (missing.length) throw new Error("missing exports: " + missing.join(", "));
Expand Down
69 changes: 35 additions & 34 deletions README.md

Large diffs are not rendered by default.

160 changes: 120 additions & 40 deletions e2e/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ import {
PanelTabs,
panelTabsHeaderClassName,
useTabs,
Activity,
ToolApproval,
groupActivity,
MessageParts,
type ConversationPart,
type PartTool,
type ToolApprovalRecord,
ToolGroup,
Expand Down Expand Up @@ -927,17 +926,45 @@ function PanelTabsScenario() {
);
}

/** A call's execution status once a decision is recorded on it. */
function decidedStatus(approval: ToolApprovalRecord): PartTool["status"] {
if (approval.status === "pending") return "approval-requested";
return approval.status === "approved" ? "output-available" : "output-denied";
}

function pendingApproval(id: string): ToolApprovalRecord {
return { id, status: "pending", scope: null, timeoutAt: null };
}

/** A call that stopped waiting with no decision, as the server reports it. */
type Lapse = {
readonly toolCallId: string;
readonly status: "timeout" | "expired";
};

/**
* Calls `onLapse` for each `approval-lapse` event on the window: the specs'
* stand-in for the server resolving an approval nobody decided, without
* moving focus the way a control on the page would.
*/
function useApprovalLapse(onLapse: (lapse: Lapse) => void) {
useEffect(() => {
const listener = (event: Event) =>
onLapse((event as CustomEvent<Lapse>).detail);
window.addEventListener("approval-lapse", listener);
return () => window.removeEventListener("approval-lapse", listener);
});
}

// A host's view of one agent turn: a finished draft, then a send waiting on a
// person. Deciding records the outcome on the call, which then folds into the
// run before it, the way a message renders `groupActivity`.
// person. Deciding records the outcome on the call, which `MessageParts` then
// folds into the run before it.
function ToolApprovalScenario() {
const { log, record } = useEventLog();
const [approval, setApproval] = useState<ToolApprovalRecord>({
id: "approval-1",
status: "pending",
scope: null,
timeoutAt: null,
});
const [approval, setApproval] = useState(pendingApproval("approval-1"));
useApprovalLapse(({ status }) =>
setApproval((current) => ({ ...current, status })),
);
const parts: PartTool[] = [
{
kind: "tool-trace",
Expand All @@ -950,36 +977,26 @@ function ToolApprovalScenario() {
kind: "tool-trace",
toolCallId: "send",
name: "mail__send",
status:
approval.status === "pending"
? "approval-requested"
: approval.status === "approved"
? "output-available"
: "output-denied",
status: decidedStatus(approval),
input: { to: "team" },
approval,
},
];
return (
<>
{groupActivity(parts).map((group) =>
group.kind === "activity" ? (
<Activity key={group.key} steps={group.steps} />
) : group.part.kind === "tool-trace" ? (
<ToolApproval
key={group.key}
part={group.part}
onDecide={(decision) => {
record(`${decision.outcome}:${decision.scope}`);
setApproval((current) => ({
...current,
status: decision.outcome,
scope: decision.scope,
}));
}}
/>
) : null,
)}
<Message from="agent" aria-label="Agent">
<MessageParts
parts={parts}
onDecide={(_, decision) => {
record(`${decision.outcome}:${decision.scope}`);
setApproval((current) => ({
...current,
status: decision.outcome,
scope: decision.scope,
}));
}}
/>
</Message>
{log}
</>
);
Expand Down Expand Up @@ -1049,6 +1066,72 @@ function ToolGroupApprovalScenario() {
);
}

const BATCH_CALLS = ["notice", "totals", "calendar"] as const;

// Three calls that ran at once after a finished read, two of them waiting on
// a person. Each decision is recorded on its call; the batch folds into the
// run once none is left waiting.
function ToolBatchApprovalScenario() {
const [approvals, setApprovals] = useState<
Record<string, ToolApprovalRecord>
>({
notice: pendingApproval("approval-notice"),
calendar: pendingApproval("approval-calendar"),
});
const update = (toolCallId: string, change: Partial<ToolApprovalRecord>) =>
setApprovals((current) => {
const approval = current[toolCallId];
if (approval === undefined) return current;
return { ...current, [toolCallId]: { ...approval, ...change } };
});
useApprovalLapse(({ toolCallId, status }) => update(toolCallId, { status }));
const parts: ConversationPart[] = [
{
kind: "tool-trace",
toolCallId: "read",
name: "files__read",
status: "output-available",
output: "Read 3 files",
},
{
kind: "tool-batch",
id: "batch",
tools: BATCH_CALLS.map((name): PartTool => {
const approval = approvals[name];
return approval === undefined
? {
kind: "tool-trace",
toolCallId: name,
name: `send__${name}`,
status: "output-available",
output: "Sent",
}
: {
kind: "tool-trace",
toolCallId: name,
name: `send__${name}`,
status: decidedStatus(approval),
input: { to: "team" },
approval,
};
}),
},
];
return (
<Message from="agent" aria-label="Agent">
<MessageParts
parts={parts}
onDecide={(tool, decision) =>
update(tool.toolCallId, {
status: decision.outcome,
scope: decision.scope,
})
}
/>
</Message>
);
}

/** A long answer: 40 paragraphs of fixed-height lines, past the fold. */
const FOLD_PARAGRAPHS = Array.from(
{ length: 40 },
Expand Down Expand Up @@ -1534,11 +1617,7 @@ function ThreadMessage({ message }: { message: ConversationMessage }) {
from={own ? "own" : "agent"}
aria-label={`${message.author.name} ${message.id}`}
>
<MessageContent
text={message.parts
.map((part) => (part.kind === "text" ? part.text : ""))
.join("")}
/>
<MessageParts parts={message.parts} />
</Message>
);
}
Expand Down Expand Up @@ -1675,6 +1754,7 @@ export const scenarios: Record<string, () => ReactNode> = {
"panel-tabs": () => <PanelTabsScenario />,
"tool-approval": () => <ToolApprovalScenario />,
"tool-group-approval": () => <ToolGroupApprovalScenario />,
"tool-batch-approval": () => <ToolBatchApprovalScenario />,
"message-fold": () => <MessageFoldScenario />,
"message-fold-settle": () => (
<MessageFoldConversationScenario policy="settle" />
Expand Down
37 changes: 37 additions & 0 deletions e2e/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Page } from "@playwright/test";

type Watched = Window & {
__bodyFocus?: { frames: number; stop: () => void };
};

/**
* Starts checking, every frame, whether focus has fallen to the page (no
* element, or `<body>`). The returned function stops and resolves to how many
* frames it had: zero means a reader never saw focus lost.
*/
export async function watchFocusOnBody(
page: Page,
): Promise<() => Promise<number>> {
await page.evaluate(() => {
const watched = window as Watched;
let frame = 0;
const state = {
frames: 0,
stop: () => cancelAnimationFrame(frame),
};
const tick = () => {
const active = document.activeElement;
if (active === null || active === document.body) state.frames += 1;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
watched.__bodyFocus = state;
});
return () =>
page.evaluate(() => {
const state = (window as Watched).__bodyFocus;
if (state === undefined) throw new Error("focus was not being watched");
state.stop();
return state.frames;
});
}
8 changes: 8 additions & 0 deletions e2e/full-chat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test, type Page } from "@playwright/test";

import { watchFocusOnBody } from "./focus.js";

// The whole-chat composition lives in the story, so the spec drives the
// static Storybook build the accessibility sweep also serves.
const STORIES = "http://localhost:61000";
Expand Down Expand Up @@ -41,10 +43,16 @@ for (const [story, decision, outcome] of [
);
await expect(approval).toBeVisible();
await approval.getByRole("button", { name: decision, exact: true }).focus();
const stop = await watchFocusOnBody(page);
await page.keyboard.press("Enter");

// The call folds into the run before it, and its buttons go with it.
await expect(approval).toHaveCount(0);
await expect
.poll(async () => (await focused(page)).slot)
.toBe("activity-trigger");
// Not for a single frame did focus fall to the page.
expect(await stop()).toBe(0);
const active = await focused(page);
expect(active.body).toBe(false);
expect(active.slot).toBe("activity-trigger");
Expand Down
Loading
Loading