Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dd3d121
Refine grouped agent activity previews
mahanti Oct 27, 2026
1070435
Merge main into am-1026-agent-activity
mahanti Oct 27, 2026
1401a60
Clarify activity specimen coverage
mahanti Oct 27, 2026
a7ec8ea
Use shared spacing token for activity avatar alignment
mahanti Oct 27, 2026
13c9817
Preserve collapsed reply branch grouping boundaries
mahanti Oct 27, 2026
f2231fa
Unify agent activity with incoming message previews
mahanti Oct 27, 2026
db6c31a
Merge main into am-1026-agent-activity
mahanti Oct 27, 2026
430bf04
Allow incoming activity accessories and update conversation fixtures
mahanti Oct 27, 2026
dc64aca
Wait for reminder menu focus before keyboard navigation
mahanti Oct 27, 2026
0375916
Center live typing indicators within the message column
mahanti Oct 27, 2026
06630d1
Merge main into am-1026-agent-activity
mahanti Oct 28, 2026
779a662
Refine agent activity handoff and typing presentation
mahanti Oct 28, 2026
cb0b744
Preserve team avatar labels and update interaction checks
mahanti Oct 28, 2026
205c3be
Scope activity history avatar assertions to their control
mahanti Oct 28, 2026
e4aa224
Fix agent activity lifecycle and simplify shared presentation
mahanti Oct 28, 2026
0abae18
Refine agent activity bubbles and preserve message navigation
mahanti Oct 28, 2026
0608386
Merge latest main into agent activity changes
mahanti Oct 28, 2026
f4209f8
Type navigation regression fixture inputs explicitly
mahanti Oct 28, 2026
a1ac7c0
Preserve timeline restoration across unchanged activity projections
mahanti Oct 28, 2026
12300aa
Merge latest main into agent activity changes
mahanti Oct 29, 2026
e872c11
Simplify activity controls and retire completed work
mahanti Oct 29, 2026
f4030a9
Unify activity stop controls and bubble interactions
mahanti Oct 29, 2026
a2fb07a
Fix activity navigation and scope Stop controls to each request
mahanti Oct 29, 2026
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 src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
.snapshot()
.map(({ id, placement }) => [id, placement]),
[
["activity", "message"],
["activity", "response"],
["typing-presentation", undefined],
],
);
Expand Down
23 changes: 7 additions & 16 deletions src/bundled/agent-activity/ActivityAccessory.module.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.root {
margin-block-start: var(--space-1);
margin-block-start: var(--space-half);
min-width: 0;
flex-shrink: 0;
}
Expand All @@ -8,19 +8,6 @@
flex-direction: column;
gap: var(--space-1);
}
/* Dismiss and Stop are sibling actions, never buttons nested in NavigationItem. */
.row {
display: flex;
align-items: center;
gap: var(--space-1);
}
.row > :first-child {
min-width: 0;
flex: 1;
}
.row > :not(:first-child) {
flex-shrink: 0;
}

/* One glanceable line each, ending in an ellipsis; the profile's Activity tab
* has the full text. NavigationItem's label truncation applies per line. */
Expand All @@ -44,16 +31,18 @@
color: var(--text-subtle);
display: inline-flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: var(--space-1);
min-width: 0;
}

.summaryGroups {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: center;
gap: var(--space-1);
flex-wrap: wrap;
flex-wrap: nowrap;
}
.failure {
color: var(--text-danger);
Expand All @@ -68,6 +57,8 @@
}

.action {
align-self: center;
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: var(--space-1);
Expand Down
191 changes: 135 additions & 56 deletions src/bundled/agent-activity/ActivityAccessory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { activityPresentation } from "../../features/messages/activity-presentation";
import { createLocalAgentActivity } from "../../features/agents/local-activity";
import "@testing-library/jest-dom/vitest";
import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing";
Expand All @@ -17,7 +18,10 @@ import {
ActivityAccessory,
ActivityTypingReplacement,
} from "./ActivityAccessory";
import { TypingPresentation } from "../../features/conversation/typing-presentation";
import {
ActivityIndicatorPresentation,
TypingPresentation,
} from "../../features/conversation/typing-presentation";
import { TypingIndicator } from "../../features/messages/TypingIndicator";
import { createAgentActivity } from "../../features/agents/activity";
import { createAgentLibrary } from "../../features/agents/library";
Expand Down Expand Up @@ -304,7 +308,7 @@ it("makes the compact avatar and tool preview one action, retaining the popup wh
const popup = within(
screen.getByRole("dialog", { name: "Agent activity" }),
);
expect(popup.getAllByRole("button")).toHaveLength(1);
expect(popup.getAllByRole("button")).toHaveLength(2);
for (const text of [
"Agent activity",
"Latest reported activity for this message.",
Expand All @@ -318,9 +322,9 @@ it("makes the compact avatar and tool preview one action, retaining the popup wh
});
expect(row).toHaveTextContent("Read file · Running");
expect(row.querySelector(".buzz-avatar")).not.toBeNull();
fireEvent.click(popup.getByText("file.ts"));
fireEvent.click(popup.getByRole("button", { name: "View full activity" }));
expect(f.props.open).toHaveBeenCalledExactlyOnceWith(
`buzz:profile?agent=${A}&tab=activity`,
`buzz:profile?agent=${A}&tab=activity&open=transcript`,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
view.rerender(
Expand Down Expand Up @@ -410,11 +414,15 @@ it("shows local receipt and failure without requiring an ACP transcript, and ope
"View agent activity: Blossom, Starting",
);
expect(bubble.querySelector('[data-state="waiting"]')).not.toBeNull();
act(() => work.step("send_message"));
expect(bubble).not.toHaveTextContent("Sending a message");
expect(bubble.querySelector('[data-state="working"]')).not.toBeNull();
act(() => work.step("Read", "notes.txt"));
expect(bubble).toHaveAccessibleName(
"View agent activity: Blossom, Reading notes.txt",
);
expect(bubble.querySelector('[data-state="working"]')).not.toBeNull();
expect(bubble).toHaveTextContent("Reading notes.txt");
expect(bubble.querySelector('[data-state="working"]')).toBeNull();
act(() => work.finish({ ok: false, error: "Provider unavailable" }));
expect(bubble).toHaveTextContent("1 failed");
expect(bubble.querySelector("[data-state]")).toBeNull();
Expand Down Expand Up @@ -460,11 +468,11 @@ it("dismisses only that agent's error on this message, without changing other wo
expect(mixed).toHaveTextContent("1 failed");
const failedBadge = mixed.querySelector("[data-activity-failure]");
const workingBadge = mixed.querySelector("[data-thread-working]");
expect(failedBadge?.querySelector('[title="Blossom"]')).not.toBeNull();
expect(workingBadge?.querySelector('[title="Bubbles"]')).not.toBeNull();
expect(workingBadge?.querySelector('[title="Blossom"]')).toBeNull();
expect(
screen.getByRole("img", { name: "Blossom, Bubbles" }),
).toBeInTheDocument();
expect(failedBadge?.parentElement).toBe(workingBadge?.parentElement);
expect(mixed.querySelector('[data-state="working"]')).not.toBeNull();
expect(workingBadge).toHaveTextContent("Reading notes.txt");
fireEvent.click(
screen.getByRole("button", { name: /View agent activity/ }),
);
Expand Down Expand Up @@ -549,38 +557,71 @@ it.each([false, true])(
},
);

it("keeps an idle reply summary mounted without advertising an activity popup", () => {
const f = fixture();
const navigate = vi.fn();
const view = render(
<ActivityAccessory
{...f.props}
renderMessageSummary={(activity) => (
<button type="button" ref={activity?.ref} onClick={navigate}>
View thread{activity ? `: ${activity.label}` : ""}
</button>
)}
/>,
);
try {
const summary = screen.getByRole("button", { name: "View thread" });
expect(summary).not.toHaveAttribute("aria-haspopup");
expect(summary).not.toHaveAttribute("aria-expanded");
f.send(A, "here", 1, "turn_started", { triggeringEventIds: [root] });
expect(screen.getByRole("button", { name: /View thread/ })).toBe(summary);
expect(summary).toHaveAttribute("aria-haspopup", "dialog");
f.send(A, "here", 2, "turn_completed");
expect(screen.getByRole("button", { name: "View thread" })).toBe(summary);
expect(summary).not.toHaveAttribute("aria-haspopup");
expect(summary).not.toHaveAttribute("aria-expanded");
fireEvent.click(summary);
expect(navigate).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
} finally {
view.unmount();
f.dispose();
}
});
it.each([false, true])(
"hands live activity to published content without hiding other work (thread=%s)",
(thread) => {
const f = fixture();
const onFocusedRemoval = vi.fn();
const view = render(
<ActivityAccessory {...f.props} onFocusedRemoval={onFocusedRemoval} />,
);
try {
expect(
screen.queryByLabelText("Agent activity on this message"),
).toBeNull();
f.send(A, "here", 1, "turn_started", { triggeringEventIds: [root] });
f.send(B, "other", 1, "turn_started", { triggeringEventIds: [root] });
const response = {
...f.props.message,
id: "response",
authorId: A,
agentEnvelope: true as const,
content: "Here is the answer",
replyParentId: root,
};
const rows = [
f.props.message,
{ ...f.props.message, id: next },
response,
];
const represented = activityPresentation(
thread ? rows.slice(1) : rows,
undefined,
new Set([A, B]),
thread ? root : undefined,
).representedAgents;
view.rerender(
<ActivityAccessory
{...f.props}
onFocusedRemoval={onFocusedRemoval}
representedAgents={represented.get(root)}
/>,
);
expect(screen.queryByTitle("Blossom")).toBeNull();
expect(screen.getByTitle("Bubbles")).toBeInTheDocument();
expect(represented.get(next)).toBeUndefined();
act(() =>
screen.getByRole("button", { name: /View agent activity/ }).focus(),
);
f.send(B, "other", 2, "turn_completed");
expect(onFocusedRemoval).toHaveBeenCalledOnce();
expect(
screen.queryByLabelText("Agent activity on this message"),
).toBeNull();
view.rerender(
<ActivityAccessory
{...f.props}
onFocusedRemoval={onFocusedRemoval}
representedAgents={represented.get(root)}
/>,
);
expect(screen.queryByTitle("Blossom")).toBeNull();
} finally {
view.unmount();
f.dispose();
}
},
);

const settle = () => act(async () => {});
const openBubble = () =>
Expand Down Expand Up @@ -665,21 +706,10 @@ it.each(["waiting", "working"])(
}
},
);
it("notes a stopped request beside a thread summary host", async () => {
it("notes a stopped request in the response lane", async () => {
const local = createLocalAgentActivity();
const f = fixture(local);
const view = render(
<ActivityAccessory
{...f.props}
renderMessageSummary={(activity) =>
activity ? (
<button type="button" ref={activity.ref}>
View thread: {activity.label}
</button>
) : null
}
/>,
);
const view = render(<ActivityAccessory {...f.props} />);
try {
act(() => {
local
Expand All @@ -693,9 +723,9 @@ it("notes a stopped request beside a thread summary host", async () => {
.interruptible(async () => {});
});
expect(
screen.getByRole("button", { name: /View thread: Blossom/ }),
screen.getByRole("button", { name: /View agent activity: Blossom/ }),
).toBeInTheDocument();
// The browser journey stops it through the summary's popup.
// Stop retains its exact request and response-side notice.
await act(() => f.props.session.agentActivity.interrupt(A, "c", root));
expect(
screen.queryByRole("button", { name: /View thread/ }),
Expand Down Expand Up @@ -1143,3 +1173,52 @@ it("shows nothing later when a stopped hosted turn ends before its 30 seconds",
f.dispose();
}
});

it("hands the visible request bubble to the live preview after a reply, even with the composer replacement mounted", () => {
const local = createLocalAgentActivity();
const f = fixture(local);
const typing = [{ pubkey: A, channelId: "c", threadRootId: root }];
const session = {
...f.props.session,
typing: { snapshot: () => typing, subscribe: () => () => {} },
} as unknown as RelaySession;
const props = { ...f.props, session };
const ui = (representedAgents: string[]) => (
<ActivityIndicatorPresentation>
<ActivityAccessory {...props} representedAgents={representedAgents} />
<TypingPresentation active>
<ActivityTypingReplacement {...props} />
</TypingPresentation>
<TypingIndicator session={session} channelId="c" threadRootId={root} />
</ActivityIndicatorPresentation>
);
const work = local.begin({
scope: "test",
agent: A,
channelId: "c",
messageId: root,
threadRootId: root,
});
work.step("Read", "notes.txt");
const view = render(ui([]));
try {
expect(
screen.getByRole("button", {
name: "View agent activity: Blossom, Reading notes.txt",
}),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Activity: Blossom working" }),
).toBeNull();
view.rerender(ui([A]));
expect(
screen.queryByLabelText("Agent activity on this message"),
).toBeNull();
expect(
screen.getByRole("button", { name: "Activity: Blossom working" }),
).toHaveTextContent("Reading notes.txt");
} finally {
view.unmount();
f.dispose();
}
});
Loading
Loading