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
5 changes: 5 additions & 0 deletions .changeset/acp-elicitation-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zooid/web": minor
---

Display and answer agent elicitation forms, with confirmed resolution, field feedback, threaded questions, notifications, and awaiting-input status.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TypingIndicator } from "./typing-indicator";
const meta = { title: "Rooms/TypingIndicator", component: TypingIndicator, args: { typingUserIds: [], awaitingUserIds: ["@architect.acme:h.example"] } } satisfies Meta<typeof TypingIndicator>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Awaiting: Story = {};
export const AwaitingAndTyping: Story = { args: { typingUserIds: ["@bob:h.example", "@architect.acme:h.example"] } };
export const TwoAwaiting: Story = { args: { awaitingUserIds: ["@architect.acme:h.example", "@claude:h.example"] } };
15 changes: 14 additions & 1 deletion packages/web/src/components/rooms/typing-indicator.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TypingIndicator } from "./typing-indicator";

Expand Down Expand Up @@ -43,3 +43,16 @@ describe("<TypingIndicator />", () => {
expect(container.textContent).toMatch(/typing/i);
});
});

describe("TypingIndicator — awaiting input", () => {
it("shows an agent awaiting input instead of typing", () => {
render(<TypingIndicator typingUserIds={["@architect.acme:h.example"]} awaitingUserIds={["@architect.acme:h.example"]} />);
expect(screen.getByText(/is awaiting your input/i)).toBeInTheDocument();
expect(screen.queryByText(/is typing/i)).not.toBeInTheDocument();
});
it("shows both typing and awaiting users", () => {
render(<TypingIndicator typingUserIds={["@bob:h.example"]} awaitingUserIds={["@architect.acme:h.example"]} />);
expect(screen.getByText(/is typing/i)).toBeInTheDocument();
expect(screen.getByText(/is awaiting your input/i)).toBeInTheDocument();
});
});
14 changes: 10 additions & 4 deletions packages/web/src/components/rooms/typing-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useUserName } from "../../hooks/use-user-name";

interface Props {
typingUserIds: string[];
awaitingUserIds?: string[];
roomId?: string;
}

Expand All @@ -12,8 +13,9 @@ function TypingName({ userId, roomId }: { userId: string; roomId?: string }) {
);
}

export function TypingIndicator({ typingUserIds, roomId }: Props) {
if (typingUserIds.length === 0) return <div className="h-5" aria-hidden />;
export function TypingIndicator({ typingUserIds: allTyping, awaitingUserIds = [], roomId }: Props) {
const typingUserIds = allTyping.filter((id) => !awaitingUserIds.includes(id));
if (typingUserIds.length === 0 && awaitingUserIds.length === 0) return <div className="h-5" aria-hidden />;

const MAX_NAMES = 2;
const named = typingUserIds.slice(0, MAX_NAMES);
Expand All @@ -36,8 +38,12 @@ export function TypingIndicator({ typingUserIds, roomId }: Props) {

return (
<div className="h-5 px-3 text-xs text-muted-foreground leading-5">
{parts}
{suffix}
{awaitingUserIds.length > 0 && <span className="text-amber-600 dark:text-amber-400">
{awaitingUserIds.map((id, index) => <span key={id}>{index > 0 ? ", " : ""}<TypingName userId={id} roomId={roomId} /></span>)}
{awaitingUserIds.length === 1 ? " is awaiting your input" : " are awaiting your input"}
</span>}
{awaitingUserIds.length > 0 && typingUserIds.length > 0 && " · "}
{typingUserIds.length > 0 && <span>{parts}{suffix}</span>}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { setSoundEnabledLocally, soundEnabledLocally } from "@/lib/notification-
import type { GlobalNotifMode } from "@/lib/matrix/notification-prefs";

const AGENT_RULE_LABELS: Record<string, string> = {
"dev.zooid.elicitation_request": "Questions from agents",
"dev.zooid.approval_request": "Approval requests",
"dev.zooid.turn.end": "Agent finished a turn",
"dev.zooid.error": "Agent errors",
Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/components/structures/room-view.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAwaitingInput } from "../../hooks/use-timeline";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { Composer } from "../rooms/composer";
Expand All @@ -17,6 +18,7 @@ export function RoomView() {
const threadRootEventId = searchParams.get("thread");
const highlightEventId = searchParams.get("event") ?? undefined;
const known = useRoomKnown(roomId ?? "");
const awaitingUserIds = useAwaitingInput(roomId ?? "");
const typingUserIds = useTyping(roomId ?? "");
useMarkRead(roomId ?? "");

Expand Down Expand Up @@ -66,7 +68,7 @@ export function RoomView() {
/>
)}
</div>
<TypingIndicator typingUserIds={typingUserIds} roomId={roomId} />
<TypingIndicator awaitingUserIds={awaitingUserIds} typingUserIds={typingUserIds} roomId={roomId} />
{showPlan && (
<div className="px-3 pt-1">
<PlanBoard
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { makeFakeClient, makeRoom, mkMatrixEvent, pushTimelineEvent } from "../../../test/factories";
import { MatrixClientPeg } from "../../client/peg";
import { ThreadView } from "./thread-view";
import { askSchema } from "../timeline/question-card-view.stories";

const me = "@me:h.example";
const agent = "@architect.acme:h.example";
const roomId = "!question:h.example";
const root = "$question-root";
const thread = { "m.relates_to": { rel_type: "m.thread", event_id: root } };
function seed(answered: boolean) {
const client = makeFakeClient({ userId: me });
const room = makeRoom(roomId, { client, myUserId: me });
(client as unknown as { getRoom: (id: string) => unknown }).getRoom = (id) => id === roomId ? room : null;
// Stories must stay offline even when TimelinePanel asks for pagination.
(client as unknown as { paginateEventTimeline: unknown }).paginateEventTimeline = async () => false;
const add = (id: string, type: string, sender: string, content: Record<string, unknown>) => pushTimelineEvent(room, mkMatrixEvent({ roomId, eventId: id, sender, type, content }));
add(root, "m.room.message", me, { msgtype: "m.text", body: "Prepare a deployment. Ask me which environment first." });
add("$question-prose", "m.room.message", agent, { msgtype: "m.notice", body: "I have the deployment ready. One choice before I continue.", ...thread });
add("$question-tool", "dev.zooid.tool_call", agent, { session_id: "s1", tool_call_id: "tc1", title: "AskUserQuestion", kind: "other", status: "in_progress", ...thread });
add("$question-request", "dev.zooid.elicitation_request", agent, { version: 1, request_id: "question-1", session_id: "s1", message: "Which environment should I deploy to?", requested_schema: askSchema, ...thread });
if (answered) {
add("$question-answer", "dev.zooid.elicitation_response", me, { request_id: "question-1", request_event_id: "$question-request", action: "accept", content: { question_0: "staging" }, ...thread });
add("$question-resolved", "dev.zooid.elicitation_resolved", agent, { version: 1, request_id: "question-1", request_event_id: "$question-request", response_event_id: "$question-answer", status: "accepted", responded_by: me, content: { question_0: "staging" }, ...thread });
add("$question-continued", "m.room.message", agent, { msgtype: "m.notice", body: "Thanks. I’m preparing the staging deployment now.", ...thread });
}
MatrixClientPeg.injectClientForTest(client);
}
const meta = { title: "Structures/ThreadViewQuestion", component: ThreadView, parameters: { layout: "fullscreen" }, args: { roomId, rootEventId: root, onBack: () => {} } } satisfies Meta<typeof ThreadView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OpenQuestion: Story = { render: (args) => { seed(false); return <ThreadView {...args} />; } };
export const AnsweredThenContinued: Story = { render: (args) => { seed(true); return <ThreadView {...args} />; } };
4 changes: 4 additions & 0 deletions packages/web/src/components/timeline/event-tile.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ElicitationEventType } from "../../events/elicitation";
import { QuestionCard } from "./question-card";
import type { MatrixEvent } from "matrix-js-sdk";
import { ApprovalEventType } from "../../events/approval";
import { decodeZooidEvent, isZooidLifecycle } from "../../events/zooid-events";
Expand Down Expand Up @@ -39,6 +41,8 @@ export function EventTile({
/>
);
}
if (event.getType() === ElicitationEventType.Request) return <QuestionCard event={event} />;
if ([ElicitationEventType.Response, ElicitationEventType.Resolved, ElicitationEventType.Rejected].some((type) => type === event.getType())) return null;
if (event.getType() === ApprovalEventType.Request) return <ApprovalCard event={event} />;
// Approval *responses* are not rendered as their own tile — they only matter
// as input to <ApprovalCard /> resolution. Skip silently.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildFormModel } from "../../lib/elicitation-form";
import { QuestionCardView } from "./question-card-view";

export const askSchema = {
type: "object",
properties: {
question_0: { type: "string", title: "Environment", description: "Where should I deploy?", oneOf: [
{ const: "staging", title: "Staging (Recommended)", description: "Try the change before production." },
{ const: "production", title: "Production", description: "Make the change available to everyone." },
{ const: "local", title: "Local", description: "Run on your computer." },
] },
question_0_custom: { type: "string", title: "Other", description: "Add a custom answer or context.", _meta: { _askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true } } },
},
};
export const typedSchema = {
type: "object",
properties: {
name: { type: "string", title: "Project name", minLength: 2 },
email: { type: "string", title: "Contact email", format: "email" },
count: { type: "integer", title: "Instances", minimum: 1, maximum: 5 },
ratio: { type: "number", title: "Traffic share", minimum: 0, maximum: 1 },
enabled: { type: "boolean", title: "Enable logging", default: true },
tags: { type: "array", title: "Checks", items: { type: "string", enum: ["lint", "test", "build"] }, maxItems: 2 },
},
required: ["name", "count"],
};
const meta = {
title: "Timeline/QuestionCardView",
component: QuestionCardView,
args: { agentName: "architect", message: "Which environment should I deploy to?", model: buildFormModel(askSchema), state: "open", onSubmit: (content) => { window.dispatchEvent(new CustomEvent("question-submit", { detail: content })); } },
} satisfies Meta<typeof QuestionCardView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SingleQuestion: Story = {};
export const MultipleQuestions: Story = { args: { model: buildFormModel({ ...askSchema, properties: {
...askSchema.properties,
question_1: { type: "array", title: "Checks", description: "Which checks should run?", items: { anyOf: [{ const: "lint", title: "Lint" }, { const: "test", title: "Test" }] } },
question_1_custom: { type: "string", title: "Other checks", _meta: { _askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true } } },
} }) } };
export const TypedFields: Story = { args: { message: "Configure the deployment", model: buildFormModel(typedSchema) } };
export const ServerErrors: Story = { args: { model: buildFormModel(typedSchema), fieldErrors: { name: "must be at least 2 characters", count: "must be ≤ 5" } } };
export const Sending: Story = { args: { state: "sending" } };
export const AwaitingConfirmation: Story = { args: { state: "awaiting_confirmation" } };
export const Stale: Story = { args: { stale: true } };
export const SendError: Story = { args: { error: "M_FORBIDDEN: Your response could not be sent. Try again." } };
export const Unsupported: Story = { args: { model: buildFormModel({ type: "object", properties: { note: { type: "string", title: "Note" }, nested: { type: "object" } } }) } };
export const Answered: Story = { args: { state: "resolved", model: buildFormModel({ ...askSchema, properties: { ...askSchema.properties, question_0: { ...askSchema.properties.question_0, default: "staging" } } }), resolution: { status: "accepted", respondedBy: "Ori" } } };
export const Skipped: Story = { args: { state: "resolved", resolution: { status: "declined", respondedBy: "Ori" } } };
export const Cancelled: Story = { args: { state: "resolved", resolution: { status: "cancelled" } } };
export const Interrupted: Story = { args: { state: "resolved", resolution: { status: "interrupted" } } };
export const NoPermission: Story = { args: { canAnswer: false } };
export const LongContent: Story = { args: { message: "Please consider the deployment environment and the implications of this change. ".repeat(6), model: buildFormModel({ type: "object", properties: { env: { type: "string", title: "Environment", enum: ["production-with-a-very-long-environment-name", "/workspace/" + "very-long-unbreakable-path".repeat(12)] } } }) } };
export const UntrustedMarkup: Story = { args: { message: '<img src=x onerror="alert(1)"> **Which environment?**', model: buildFormModel({ type: "object", properties: { env: { type: "string", oneOf: [{ const: "prod", title: "<script>alert(1)</script> **Production**" }] } } }) } };
89 changes: 89 additions & 0 deletions packages/web/src/components/timeline/question-card-view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { QuestionCardView } from "./question-card-view";
import { buildFormModel } from "../../lib/elicitation-form";

const model = buildFormModel({
type: "object",
properties: {
question_0: { type: "string", title: "Env", oneOf: [{ const: "staging", title: "Staging" }, { const: "prod", title: "Production" }] },
question_0_custom: { type: "string", title: "Other", _meta: { _askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true } } },
},
});

const base = { agentName: "architect", message: "Which environment?", model, state: "open" as const };

describe("<QuestionCardView />", () => {
it("renders the agent, question, options, custom field and three actions", () => {
render(<QuestionCardView {...base} onSubmit={vi.fn()} onSkip={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText("architect")).toBeInTheDocument();
expect(screen.getByText("Which environment?")).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Production" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /other/i })).toBeInTheDocument();
for (const name of ["Submit", "Skip", "Cancel"]) expect(screen.getByRole("button", { name })).toBeEnabled();
});

it("submits typed content", async () => {
const onSubmit = vi.fn();
render(<QuestionCardView {...base} onSubmit={onSubmit} onSkip={vi.fn()} onCancel={vi.fn()} />);
await userEvent.click(screen.getByRole("radio", { name: "Production" }));
await userEvent.type(screen.getByRole("textbox", { name: /other/i }), "after 5pm");
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmit).toHaveBeenCalledWith({ question_0: "prod", question_0_custom: "after 5pm" });
});

it("flags an empty required field and does not submit", async () => {
const required = buildFormModel({ type: "object", properties: { env: { type: "string" } }, required: ["env"] });
const onSubmit = vi.fn();
render(<QuestionCardView {...base} model={required} onSubmit={onSubmit} onSkip={vi.fn()} onCancel={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText("required")).toBeInTheDocument();
});

it("shows server field errors passed in", () => {
render(<QuestionCardView {...base} fieldErrors={{ question_0: "must be one of: staging, prod" }} onSubmit={vi.fn()} onSkip={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText("must be one of: staging, prod")).toBeInTheDocument();
});

it("disables actions while sending", () => {
render(<QuestionCardView {...base} state="sending" onSubmit={vi.fn()} onSkip={vi.fn()} onCancel={vi.fn()} />);
for (const name of ["Submit", "Skip", "Cancel"]) expect(screen.getByRole("button", { name })).toBeDisabled();
});

it("renders read-only with the responder once resolved", () => {
render(<QuestionCardView {...base} state="resolved" resolution={{ status: "accepted", respondedBy: "Beno" }} />);
expect(screen.getByText(/answered by/i)).toHaveTextContent("Answered by Beno");
expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Production" })).toBeDisabled();
});

it.each([
["declined", /skipped by/i],
["cancelled", /cancelled/i],
["interrupted", /no longer answerable/i],
] as const)("renders %s as read-only", (status, text) => {
render(<QuestionCardView {...base} state="resolved" resolution={{ status, respondedBy: "Beno" }} />);
expect(screen.getByText(text)).toBeInTheDocument();
});

it("shows stale feedback", () => {
render(<QuestionCardView {...base} stale onSubmit={vi.fn()} onSkip={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent(/no longer waiting/i);
});

it("renders unsupported fields visibly and disables Submit", () => {
const bad = buildFormModel({ type: "object", properties: { o: { type: "object" } } });
render(<QuestionCardView {...base} model={bad} onSubmit={vi.fn()} onSkip={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent(/can't be shown/i);
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Skip" })).toBeEnabled();
});

it("renders untrusted text as text, never HTML", () => {
render(<QuestionCardView {...base} message={'<img src=x onerror="alert(1)">'} onSubmit={vi.fn()} onSkip={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText('<img src=x onerror="alert(1)">')).toBeInTheDocument();
expect(document.querySelector("img")).toBeNull();
});
});
Loading
Loading