Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer",
| `ConversationThread` | A reply thread headed by its parent message |
| `Message` | One message, built from `MessageAvatar`, `MessageHeader`, `MessageContent`, `MessageSendStatus` and `MessageFooter` |
| `MessageFold` | Folds a very long answer to its opening |
| `MessageParts` | A message's parts as their components, any kind replaceable; keeps focus when a decided call folds |
| `MessageActions` | Row of actions under a message: `MessageAction`, `MessageActionMenu` |
| `MessageReactions` | Emoji reactions with counts |
| `MessageReplies` | Reply count and time that opens the thread |
Expand Down
138 changes: 98 additions & 40 deletions e2e/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ import {
PanelTabAdd,
PanelTabs,
useTabs,
Activity,
ToolApproval,
groupActivity,
MessageParts,
type ConversationPart,
type PartTool,
type ToolApprovalRecord,
Message,
Expand Down Expand Up @@ -738,17 +737,22 @@ 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 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"));
const parts: PartTool[] = [
{
kind: "tool-trace",
Expand All @@ -761,41 +765,98 @@ 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}
</>
);
}

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 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) =>
setApprovals((current) => {
const approval = current[tool.toolCallId];
if (approval === undefined) return current;
return {
...current,
[tool.toolCallId]: {
...approval,
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 @@ -1275,11 +1336,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 @@ -1410,6 +1467,7 @@ export const scenarios: Record<string, () => ReactNode> = {
"prompt-input-toolbar": () => <PromptToolbarScenario />,
"panel-tabs": () => <PanelTabsScenario />,
"tool-approval": () => <ToolApprovalScenario />,
"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 @@ -34,10 +36,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
51 changes: 51 additions & 0 deletions e2e/tool-approval.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from "@playwright/test";

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

const DECISIONS = [
["Approve", "approved:once", "Approved once"],
["Approve always", "approved:always", "Approved always"],
Expand Down Expand Up @@ -44,3 +46,52 @@ test("the decision works from the keyboard", async ({ page }) => {
"approved:always",
);
});

for (const [button, key] of [
["Approve", "Enter"],
["Approve always", "Space"],
["Deny", "Enter"],
] as const) {
test(`${button} by keyboard never leaves focus on the page`, async ({
page,
}) => {
await page.goto("/tool-approval");
await page.getByRole("button", { name: button, exact: true }).focus();
const stop = await watchFocusOnBody(page);
await page.keyboard.press(key);

// The call folds into the run before it; focus goes to that run's line.
const run = page.getByRole("button", { name: "Worked through 2 steps" });
await expect(run).toBeFocused();
expect(await stop()).toBe(0);
});
}

test("in a batch, a keyboard decision moves to the next waiting call, then to the run", async ({
page,
}) => {
await page.goto("/tool-batch-approval");
const waiting = page.locator(
"[data-slot='tool-approval'][data-status='pending']",
);
await expect(waiting).toHaveCount(2);
await waiting
.first()
.getByRole("button", { name: "Deny", exact: true })
.focus();
const stop = await watchFocusOnBody(page);

await page.keyboard.press("Enter");
await expect(waiting).toHaveCount(1);
await expect(waiting).toContainText("send__calendar");
await expect(
waiting.getByRole("button", { name: "Approve", exact: true }),
).toBeFocused();

await page.keyboard.press("Enter");
await expect(waiting).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Worked through 4 steps" }),
).toBeFocused();
expect(await stop()).toBe(0);
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export * from "./ui/message-fold.js";
export * from "./ui/message-actions.js";
export * from "./ui/message-reactions.js";
export * from "./ui/message-replies.js";
export * from "./ui/message-parts.js";
export * from "./ui/conversation-earlier.js";
export * from "./ui/conversation-map.js";
export * from "./ui/attachments.js";
Expand Down
Loading
Loading