From dd3d1219b6073657531d68daba75381db7f8d7b6 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Tue, 27 Oct 2026 11:13:23 -0400 Subject: [PATCH 01/19] Refine grouped agent activity previews Co-authored-by: OpenAI Codex Signed-off-by: Arjun Mahanti --- .../ActivityAccessory.module.css | 8 +- .../messages/ActivityGroupPreview.test.tsx | 142 +++++++++++ .../messages/ActivityGroupPreview.tsx | 114 +++++++++ src/features/messages/ChannelTimeline.tsx | 119 ++++++---- .../ChannelTimeline.viewport.test.tsx | 190 +++++++++++---- src/features/messages/MessageRow.test.tsx | 97 +++++--- src/features/messages/MessageRow.tsx | 90 ++++--- src/features/messages/Messages.module.css | 222 ++++++++++-------- src/features/messages/ThreadPanel.tsx | 159 ++++++++++--- .../messages/activity-avatar-motion.ts | 121 ++++++++++ src/features/messages/activity-groups.test.ts | 4 +- src/features/messages/activity-groups.ts | 24 +- .../messages/activity-presentation.ts | 137 +++++++++++ .../messages/message-grouping.test.ts | 9 + src/features/messages/message-grouping.ts | 19 +- src/shared/design-system/ui/AvatarStack.tsx | 3 + tests/fixtures/message-gallery/examples.ts | 12 +- 17 files changed, 1160 insertions(+), 310 deletions(-) create mode 100644 src/features/messages/ActivityGroupPreview.test.tsx create mode 100644 src/features/messages/ActivityGroupPreview.tsx create mode 100644 src/features/messages/activity-avatar-motion.ts create mode 100644 src/features/messages/activity-presentation.ts diff --git a/src/bundled/agent-activity/ActivityAccessory.module.css b/src/bundled/agent-activity/ActivityAccessory.module.css index 8d0adb78fb..9d3ba835b9 100644 --- a/src/bundled/agent-activity/ActivityAccessory.module.css +++ b/src/bundled/agent-activity/ActivityAccessory.module.css @@ -1,5 +1,5 @@ .root { - margin-block-start: var(--space-1); + margin-block-start: var(--space-half); min-width: 0; flex-shrink: 0; } @@ -44,16 +44,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); diff --git a/src/features/messages/ActivityGroupPreview.test.tsx b/src/features/messages/ActivityGroupPreview.test.tsx new file mode 100644 index 0000000000..d53cfab1fe --- /dev/null +++ b/src/features/messages/ActivityGroupPreview.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { stubAvatarBrowserApis } from "../agents/avatar-testing"; +import { createAgentActivity } from "../agents/activity"; +import { createLocalAgentActivity } from "../agents/local-activity"; +import { createRelaySession } from "../relay/session"; +import { keypair } from "../relay/testing"; +import type { ChannelMessage } from "../relay/contracts"; +import { ActivityGroupPreview } from "./ActivityGroupPreview"; +import { useActivityPresentation } from "./activity-presentation"; +import { MessageRow } from "./MessageRow"; + +stubAvatarBrowserApis(); +afterEach(cleanup); +const step: ChannelMessage = { + id: "step", + channelId: "channel", + threadRootId: "request", + authorId: "agent", + audience: "agents", + createdAt: 100, + content: "Checking the layout", + mentions: [], + attachments: [], + reactions: [], + participants: [], + replyCount: 0, +}; +function fixture() { + const local = createLocalAgentActivity(); + const activity = createAgentActivity( + true, + () => {}, + () => true, + undefined, + undefined, + { source: local.reader, scope: "test" }, + ); + const release = activity.queries.activate(); + return { + local, + activity: activity.queries, + dispose: () => { + release(); + activity.dispose(); + }, + }; +} +it("keeps a closed sequence completed when another request starts in the same thread", () => { + const f = fixture(); + try { + const { audience: _audience, ...publicStep } = step; + function Preview() { + const presentation = useActivityPresentation( + [ + { ...step, id: "first" }, + step, + { + ...publicStep, + id: "reply", + content: "Result", + createdAt: 101, + }, + ], + f.activity, + new Set(["agent"]), + "request", + ); + const group = presentation.groups[0]; + if (!group) throw new Error("Missing activity group"); + return ( + undefined} + expanded={false} + onToggle={() => {}} + /> + ); + } + render(); + expect(screen.getByText("2 completed steps")).toBeVisible(); + act(() => { + f.local + .begin({ + scope: "test", + agent: "agent", + channelId: "channel", + messageId: "next-request", + threadRootId: "request", + }) + .started(); + }); + expect(screen.getByText("2 completed steps")).toBeVisible(); + expect(screen.getByText("2 completed steps")).not.toHaveAttribute( + "data-active", + ); + } finally { + f.dispose(); + } +}); +it("shimmers a standalone working step and stops when that request finishes", () => { + const f = fixture(); + const owner = createRelaySession({ + viewer: keypair().pubkey, + relayAuthor: keypair().pubkey, + media: () => undefined, + query: async () => [], + }); + try { + const work = f.local.begin({ + scope: "test", + agent: "agent", + channelId: "channel", + messageId: "request", + threadRootId: "request", + }); + work.started(); + render( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + />, + ); + expect(screen.getByText(step.content)).toHaveAttribute( + "data-active", + "true", + ); + act(() => work.finish({ ok: true })); + expect(screen.getByText(step.content)).not.toHaveAttribute("data-active"); + } finally { + owner.dispose(); + f.dispose(); + } +}); diff --git a/src/features/messages/ActivityGroupPreview.tsx b/src/features/messages/ActivityGroupPreview.tsx new file mode 100644 index 0000000000..0230c8d92e --- /dev/null +++ b/src/features/messages/ActivityGroupPreview.tsx @@ -0,0 +1,114 @@ +import { ShimmerText } from "../../shared/design-system/ui/ShimmerText"; +import type { ActivityPresentation } from "./activity-presentation"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { CaretUpIcon } from "../../shared/design-system/icons"; +import { AvatarStack } from "../../shared/design-system/ui/AvatarStack"; +import { toggleActivityHistory } from "./activity-avatar-motion"; +import type { Profile } from "../relay/contracts"; +import { AgentAvatar } from "../agents/AgentAvatar"; +import { avatarMedia } from "../../shared/avatar-source"; +import { messagePreviewText } from "../conversation/message-preview-text"; +import styles from "./Messages.module.css"; + +/** One stable disclosure for a run of activity, showing its latest speaker. */ +export function ActivityGroupPreview({ + group, + profiles, + media, + expanded, + onToggle, + layout = "timeline", +}: { + group: ActivityPresentation; + profiles: ReadonlyMap; + media: (url: string, size?: "small") => string | undefined; + expanded: boolean; + onToggle: () => void; + layout?: "timeline" | "thread"; +}) { + const { latest: row, history, participants, done, working } = group; + const profile = profiles.get(row.authorId); + const motionKey = (author: string) => `${history[0]?.id ?? row.id}:${author}`; + const name = profile?.name ?? row.authorId.slice(0, 10); + const text = Array.from(messagePreviewText(row.content).replace(/\s+/g, " ")); + const preview = done + ? `${history.length} completed ${history.length === 1 ? "step" : "steps"}` + : text.length > 280 + ? `${text.slice(0, 279).join("")}…` + : text.join(""); + return ( +
+
+ +
+
+ ); +} diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 181d36218e..8a7af78d08 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -1,16 +1,14 @@ // biome-ignore-all lint/a11y/noNoninteractiveTabindex: The history region must support keyboard scrolling. +import { ActivityGroupPreview } from "./ActivityGroupPreview"; import { ActivityExpansion } from "./ActivityExpansion"; import { useChannelIdentityNames } from "../identity-names/react"; import { useMessageSelectionCopy } from "./selection-copy"; import { Button } from "../../shared/design-system/ui/Button"; import { EmptyState } from "../../shared/design-system/ui/EmptyState"; -import { - CaretDownIcon, - ChatCircleIcon, -} from "../../shared/design-system/icons"; +import { ChatCircleIcon } from "../../shared/design-system/icons"; import { MembershipRow } from "./MembershipRow"; import { membershipRows, withoutStartingAgents } from "./membership-rows"; -import { activityGroups, RECENT_ACTIVITY_COUNT } from "./activity-groups"; +import { useActivityPresentation } from "./activity-presentation"; import { DayDivider } from "./MessageTimestamp"; import type { ConversationExtensions } from "../conversation/contracts"; import type { RelaySession } from "../relay/session"; @@ -25,7 +23,10 @@ import { } from "react"; import { Virtualizer, type VirtualizerHandle } from "virtua"; import { MessageRow, type MessageLinkOrigin } from "./MessageRow"; -import { continuesMessageGroup } from "./message-grouping"; +import { + continuesMessageGroup, + publicMessageNeighbor, +} from "./message-grouping"; import type { Attachment, ChannelWindow } from "../relay/contracts"; import { useRowProfiles } from "../relay/react"; import { geometryFor, geometrySignature } from "./geometry"; @@ -237,10 +238,12 @@ function Timeline({ const [expandedActivity, setExpandedActivity] = useState>( () => new Set(), ); - const activity = useMemo( - () => (chatPresentation ? activityGroups(rows) : []), - [rows, chatPresentation], + const presentation = useActivityPresentation( + rows, + queries.agentActivity, + agentPubkeys, ); + const activity = presentation.groups; // Resolve a saved anchor once, before measuring initial geometry. A bottom // restore ignores it; absent/visible anchors must not expand on later loads. const [activityRestore, setActivityRestore] = useState( @@ -248,9 +251,9 @@ function Timeline({ ); if (activityRestore && rows.length > 0) { setActivityRestore(undefined); - const older = activity - .find((group) => group.ids.includes(activityRestore)) - ?.ids.slice(0, -RECENT_ACTIVITY_COUNT); + const older = activity.find((group) => + group.ids.includes(activityRestore), + )?.ids; if (older?.includes(activityRestore)) setExpandedActivity((expanded) => new Set([...expanded, ...older])); } @@ -259,15 +262,13 @@ function Timeline({ group.ids[0], { ...group, - expanded: group.ids - .slice(0, -RECENT_ACTIVITY_COUNT) - .some((id) => expandedActivity.has(id)), + expanded: group.ids.some((id) => expandedActivity.has(id)), }, ]), ); const hiddenActivity = new Set( [...disclosures.values()].flatMap((group) => - group.expanded ? [] : group.ids.slice(0, -RECENT_ACTIVITY_COUNT), + group.expanded ? [] : group.ids, ), ); const resolveName = useChannelIdentityNames(queries, channelId); @@ -296,6 +297,22 @@ function Timeline({ (id) => id !== focusedMessageId && !pinnedIds.has(id), ), ); + useLayoutEffect(() => { + const element = scroller.current; + if ( + !element || + !focusedActivityId || + !rows.some((row) => row.id === focusedActivityId) || + element.ownerDocument.activeElement !== element.ownerDocument.body + ) + return; + const group = [ + ...element.querySelectorAll("[data-activity-group-id]"), + ].find((node) => node.dataset.activityGroupId === focusedActivityId); + group + ?.querySelector("button") + ?.focus({ preventScroll: true }); + }, [rows, focusedActivityId]); const contentSignature = useMemo( () => geometrySignature(visibleRows, profiles, resolveName), [visibleRows, profiles, resolveName], @@ -304,6 +321,8 @@ function Timeline({ const keptIndices = rows.flatMap((row, index) => row.id === focusedMessageId || row.id === focusedActivityId || + (disclosures.get(row.id)?.ids[0] === focusedActivityId && + !!focusedActivityId) || pinnedIds.has(row.id) ? [index] : [], @@ -499,9 +518,7 @@ function Timeline({ if (!id || !request || request === activityReveal.current) return; if (!rows.some((row) => row.id === id)) return; activityReveal.current = request; - const older = activity - .find((group) => group.ids.includes(id)) - ?.ids.slice(0, -RECENT_ACTIVITY_COUNT); + const older = activity.find((group) => group.ids.includes(id))?.ids; if (older?.includes(id) && foldedActivity.has(id)) setExpandedActivity((expanded) => new Set([...expanded, ...older])); }, [activity, rows, targetId, targetSignal, revealMessageId, foldedActivity]); @@ -998,6 +1015,7 @@ function Timeline({ data-chat-presentation={chatPresentation || undefined} className={styles.feed} data-channel-timeline={channelId} + data-activity-history-scope="" data-buzz-launch-pending={launchPending ? "settling" : undefined} data-jump-visible={showJumpToLatest || undefined} onWheel={(event) => gesture(event.deltaY < 0 && !event.ctrlKey)} @@ -1140,8 +1158,14 @@ function Timeline({ {rows.map((row, index) => { const disclosure = disclosures.get(row.id); const hidden = foldedActivity.has(row.id); - const previousRow = rows[index - 1]; - const nextRow = rows[index + 1]; + const previousRow = + row.audience === "agents" + ? rows[index - 1] + : publicMessageNeighbor(rows, index, -1); + const nextRow = + row.audience === "agents" + ? rows[index + 1] + : publicMessageNeighbor(rows, index, 1); const day = index === 0 ? true @@ -1154,40 +1178,42 @@ function Timeline({ {disclosure && (
- {day && } - + />
)} ) : ( { +it("shows one latest-agent preview, retains expanded history through appends, and folds back", () => { const { owner, updates, content } = activityFixture(); try { const view = render(content(updates(9))); expect(screen.queryByText("Update 3")).toBeNull(); - expect(screen.getByText("Update 4")).toBeVisible(); - const toggle = screen.getByRole("button", { - name: "Show 4 previous steps", + expect(screen.getByText("Update 8")).toBeVisible(); + let toggle = screen.getByRole("button", { + name: /Show activity history/, }); fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "true"); expect(screen.getByText("Update 0")).toBeVisible(); view.rerender(content(updates(11))); - expect(screen.getByRole("button", { name: "Show fewer" })).toBe(toggle); + toggle = screen.getByRole("button", { name: /Collapse activity history/ }); + expect(toggle).toHaveFocus(); expect(screen.getByText("Update 0")).toBeVisible(); - expect(screen.getByText("Update 10")).toBeVisible(); + expect(screen.getAllByText("Update 10")).toHaveLength(1); + expect( + screen.getByText("Update 0").compareDocumentPosition(toggle) & + Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); fireEvent.click(toggle); expect(screen.queryByText("Update 5")).toBeNull(); - expect(screen.getByText("Update 6")).toBeVisible(); - expect(toggle).toHaveTextContent("Show 6 previous steps"); + expect(screen.getByText("Update 10")).toBeVisible(); + expect(toggle).toHaveTextContent("Update 10"); expect(toggle).toHaveFocus(); + view.rerender( + content( + updates(12).map((row, index) => + index === 11 ? { ...row, authorId: "reviewer" } : row, + ), + ), + ); + toggle = screen.getByRole("button", { name: /Show activity history/ }); + expect(toggle).toHaveFocus(); + expect(toggle).toHaveTextContent("Update 11"); + expect(screen.getByTitle("reviewer")).toBeVisible(); + expect(screen.queryByText("Update 10")).toBeNull(); + } finally { + owner.dispose(); + } +}); + +it("stacks distinct agents with overflow and removes the stack when history opens", () => { + const { owner, updates, content } = activityFixture(); + try { + const authors = ["one", "two", "three", "four"]; + render( + content( + updates(9).map((row, index) => ({ + ...row, + authorId: authors[index % authors.length] ?? "one", + })), + ), + ); + expect(screen.getByText("+1")).toBeVisible(); + expect(screen.getAllByTitle("one")).toHaveLength(1); + expect(screen.getByTitle("two")).toBeVisible(); + expect(screen.getByTitle("three")).toBeVisible(); + expect(screen.queryByTitle("four")).toBeNull(); + expect( + screen + .getByTitle("one") + .compareDocumentPosition(screen.getByTitle("two")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + screen + .getByTitle("two") + .compareDocumentPosition(screen.getByTitle("three")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: /Show activity history/ }), + ); + expect(screen.queryByText("+1")).toBeNull(); + expect(screen.getByText("Update 8")).toBeVisible(); + const collapse = screen.getByRole("button", { + name: /Collapse activity history/, + }); + expect(collapse).toHaveTextContent("Collapse"); + expect( + screen.getByText("Update 8").compareDocumentPosition(collapse) & + Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + expect(screen.getByText("Update 0")).toBeVisible(); } finally { owner.dispose(); } @@ -182,20 +249,22 @@ it("reveals an older folded message for an exact visit, then permits folding it }), ); expect(screen.getByText("Update 0")).toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "Show fewer" })); + fireEvent.click( + screen.getByRole("button", { name: /Collapse activity history/ }), + ); expect(screen.queryByText("Update 0")).toBeNull(); } finally { owner.dispose(); } }); -it("does not fold short runs, normal replies, or non-Solo timelines", () => { +it("uses one preview in channels and separates runs at public replies", () => { const { owner, updates, content } = activityFixture(); try { const rows = updates(9); const view = render(content(rows, undefined, false)); - expect(screen.getByText("Update 0")).toBeVisible(); - expect(screen.queryByRole("button", { name: /previous steps/ })).toBeNull(); + expect(screen.queryByText("Update 0")).toBeNull(); + expect(screen.getByText("Update 8")).toBeVisible(); const middle = rows[4]; if (!middle) throw new Error("Missing fixture reply"); const { audience: _audience, ...publicReply } = middle; @@ -206,20 +275,23 @@ it("does not fold short runs, normal replies, or non-Solo timelines", () => { ...rows.slice(5), ]), ); - expect(screen.getByText("Update 0")).toBeVisible(); + expect(screen.queryByText("Update 0")).toBeNull(); + expect(screen.getByText("4 completed steps")).toBeVisible(); expect(screen.getByText("Main reply")).toBeVisible(); - expect(screen.getByText("Update 5")).toBeVisible(); - expect(screen.queryByRole("button", { name: /previous steps/ })).toBeNull(); + expect(screen.getByText("Update 8")).toBeVisible(); + expect( + screen.getAllByRole("button", { name: /Show activity history/ }), + ).toHaveLength(2); } finally { owner.dispose(); } }); it.each([ - { anchor: "activity-8", expanded: false }, + { anchor: "activity-8", expanded: true }, { anchor: "activity-0", expanded: true }, ])( - "restores $anchor without unnecessarily expanding recent activity", + "restores exact activity $anchor by revealing its history", ({ anchor, expanded }) => { const { owner, updates, content } = activityFixture(); writeView("activity-folding", "scroll:alpha", { @@ -230,7 +302,7 @@ it.each([ try { render(content(updates(9))); const toggle = screen.getByRole("button", { - name: expanded ? "Show fewer" : "Show 4 previous steps", + name: expanded ? /Collapse activity history/ : /Show activity history/, }); expect(toggle).toHaveAttribute("aria-expanded", String(expanded)); if (expanded) expect(screen.getByText("Update 0")).toBeVisible(); @@ -245,19 +317,14 @@ it("starts visible and retained steps with author artwork after a hidden predece const { owner, updates, content } = activityFixture(); try { render(content(updates(9))); - expect(screen.getByText("Update 4")).toHaveAttribute( - "data-stacked", - "false", - ); - expect(screen.getByText("Update 5")).toHaveAttribute( - "data-stacked", - "true", - ); + expect(screen.queryByText("Update 4")).toBeNull(); fireEvent.click( - screen.getByRole("button", { name: "Show 4 previous steps" }), + screen.getByRole("button", { name: /Show activity history/ }), ); fireEvent.click(screen.getByRole("button", { name: "Retain activity-2" })); - fireEvent.click(screen.getByRole("button", { name: "Show fewer" })); + fireEvent.click( + screen.getByRole("button", { name: /Collapse activity history/ }), + ); expect(screen.getByText("Update 2")).toHaveAttribute( "data-stacked", "false", @@ -271,7 +338,7 @@ it("starts visible and retained steps with author artwork after a hidden predece } }); -it("consumes a visit to a visible step without expanding it later on append", () => { +it("retains an explicitly revealed step through later appends", () => { const { owner, updates, content } = activityFixture(); try { const target = { @@ -280,12 +347,12 @@ it("consumes a visit to a visible step without expanding it later on append", () }; const view = render(content(updates(9), target)); expect( - screen.getByRole("button", { name: /previous steps/ }), - ).toHaveAttribute("aria-expanded", "false"); + screen.getByRole("button", { name: /activity history/ }), + ).toHaveAttribute("aria-expanded", "true"); view.rerender(content(updates(10), target)); expect( - screen.getByRole("button", { name: /previous steps/ }), - ).toHaveAttribute("aria-expanded", "false"); + screen.getByRole("button", { name: /activity history/ }), + ).toHaveAttribute("aria-expanded", "true"); } finally { owner.dispose(); } @@ -297,10 +364,12 @@ it("does not expand history when visiting a retained visible step", () => { const rows = updates(9); const view = render(content(rows)); fireEvent.click( - screen.getByRole("button", { name: "Show 4 previous steps" }), + screen.getByRole("button", { name: /Show activity history/ }), ); fireEvent.click(screen.getByRole("button", { name: "Retain activity-2" })); - fireEvent.click(screen.getByRole("button", { name: "Show fewer" })); + fireEvent.click( + screen.getByRole("button", { name: /Collapse activity history/ }), + ); expect(screen.getByText("Update 2")).toBeVisible(); view.rerender( content(rows, { @@ -309,7 +378,7 @@ it("does not expand history when visiting a retained visible step", () => { }), ); expect( - screen.getByRole("button", { name: /previous steps/ }), + screen.getByRole("button", { name: /activity history/ }), ).toHaveAttribute("aria-expanded", "false"); } finally { owner.dispose(); @@ -317,7 +386,7 @@ it("does not expand history when visiting a retained visible step", () => { }); it.each([false, true])( - "does not expand a previously visible restore anchor after append (bottom: %s)", + "preserves the initial restore expansion decision after append (bottom: %s)", (bottom) => { const { owner, updates, content } = activityFixture(); writeView("activity-folding", "scroll:alpha", { @@ -329,8 +398,8 @@ it.each([false, true])( const view = render(content(updates(9))); view.rerender(content(updates(10))); expect( - screen.getByRole("button", { name: /previous steps/ }), - ).toHaveAttribute("aria-expanded", "false"); + screen.getByRole("button", { name: /activity history/ }), + ).toHaveAttribute("aria-expanded", String(!bottom)); } finally { owner.dispose(); } @@ -347,10 +416,9 @@ it("waits for the first non-empty window to resolve a hidden saved activity anch try { const view = render(content([])); view.rerender(content(updates(9))); - expect(screen.getByRole("button", { name: "Show fewer" })).toHaveAttribute( - "aria-expanded", - "true", - ); + expect( + screen.getByRole("button", { name: /Collapse activity history/ }), + ).toHaveAttribute("aria-expanded", "true"); expect(screen.getByText("Update 0")).toBeVisible(); } finally { owner.dispose(); @@ -371,9 +439,11 @@ it.each(["collapse", "wheel"])( const view = render(content(rows.slice(5))); if (intent === "collapse") { fireEvent.click( - screen.getByRole("button", { name: "Show 4 previous steps" }), + screen.getByRole("button", { name: /Show activity history/ }), + ); + fireEvent.click( + screen.getByRole("button", { name: /Collapse activity history/ }), ); - fireEvent.click(screen.getByRole("button", { name: "Show fewer" })); } else { const timeline = view.container.querySelector( "[data-channel-timeline]", @@ -383,7 +453,7 @@ it.each(["collapse", "wheel"])( } view.rerender(content(rows)); expect( - screen.getByRole("button", { name: "Show 9 previous steps" }), + screen.getByRole("button", { name: /Show activity history/ }), ).toHaveAttribute("aria-expanded", "false"); expect(screen.queryByText("Update 0")).toBeNull(); } finally { @@ -402,7 +472,7 @@ it("ignores a hidden reading anchor when restoring the bottom", () => { try { render(content(updates(9))); expect( - screen.getByRole("button", { name: /previous steps/ }), + screen.getByRole("button", { name: /activity history/ }), ).toHaveAttribute("aria-expanded", "false"); } finally { owner.dispose(); @@ -740,3 +810,29 @@ it.each([ owner.dispose(); } }); + +it("summarizes historical steps after the agent returns to a public reply without live completion state", () => { + const { owner, updates, content } = activityFixture(); + try { + const rows = updates(3); + const last = rows[2]; + if (!last) throw new Error("Missing fixture activity"); + const { audience: _audience, ...base } = last; + const response = { + ...base, + id: "public-response", + createdAt: 110, + content: "Here is the result.", + }; + const view = render(content([...rows, { ...response, authorId: "human" }])); + expect(screen.queryByText("3 completed steps")).toBeNull(); + view.rerender(content([...rows, response])); + expect(screen.getByText("3 completed steps")).toBeVisible(); + fireEvent.click( + screen.getByRole("button", { name: /Show activity history/ }), + ); + expect(screen.getByText("Update 2")).toBeVisible(); + } finally { + owner.dispose(); + } +}); diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index ac28b4d433..b7a101db56 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -2650,6 +2650,56 @@ it("keeps the timestamp seek action beside a detached session card", () => { } }); +it("identifies the author on each activity summary in a message group", () => { + const props = { + row: { ...row, audience: "agents" as const }, + profile: { name: "Activity author" }, + media: () => undefined, + onOpenLink: () => false, + day: false, + retry: undefined, + }; + try { + const { rerender } = renderDom(); + expect(screen.getByTitle("Activity author")).toBeVisible(); + rerender(); + expect(screen.getByTitle("Activity author")).toBeVisible(); + } finally { + cleanup(); + } +}); + +it.each([280, 281])( + "caps activity previews at 280 characters (%s input)", + (length) => { + const content = "🙂".repeat(length); + try { + renderDom( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + />, + ); + const preview = length === 280 ? content : `${"🙂".repeat(279)}…`; + const trigger = screen.getByRole("button", { + name: `author: ${preview}`, + }); + expect(trigger).toHaveTextContent(preview); + fireEvent.click(trigger); + expect(screen.getByText(content)).toBeVisible(); + expect(trigger).not.toHaveTextContent(preview); + fireEvent.click(trigger); + expect(trigger).toHaveTextContent(preview); + } finally { + cleanup(); + } + }, +); + it("shows a message for agents as one line from its author to whom it addresses", () => { const agent = keypair(); const rivet = "b".repeat(64); @@ -2693,43 +2743,18 @@ it("shows a message for agents as one line from its author to whom it addresses" name: "Morgarita to Rivet: **run** the suite…", }); expect(line).toHaveAttribute("aria-expanded", "false"); - const timestamp = line.parentElement?.querySelector("time"); - const timestampSlot = timestamp?.parentElement; - expect(timestamp).toBeVisible(); - expect( - screen.getByText(/(?:1969|1970) at /, { - selector: 'time > span[aria-hidden="true"]', - }), - ).toBeVisible(); + expect(line.parentElement?.querySelector("time")).toBeNull(); // Reveal focuses the scroll anchor with tabIndex -1; on the button, that // would take the only toggle out of the Tab order. expect(line).not.toHaveAttribute("data-message-id"); expect(line.parentElement).toHaveAttribute("data-message-id"); expect(screen.queryByText(/Then report back/)).toBeNull(); - expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBe( - timestamp, - ); + expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBeNull(); fireEvent.pointerEnter(line); - expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBe( - timestamp, - ); + expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBeNull(); fireEvent.click(line); expect(line).toHaveAttribute("aria-expanded", "true"); - // Expanding preserves the timestamp node and its trailing slot, rather - // than remounting it above the body and moving it to the opposite edge. - expect( - line.parentElement?.querySelectorAll("time:not(.sr-only)"), - ).toHaveLength(1); - expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBe( - timestamp, - ); - expect(timestamp?.parentElement).toBe(timestampSlot); - expect(line.parentElement?.lastElementChild).toBe(timestampSlot); - expect( - screen.getByText(/(?:1969|1970) at /, { - selector: 'time > span[aria-hidden="true"]', - }), - ).toBeVisible(); + expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBeNull(); expect( screen.queryByRole("button", { name: "View Morgarita profile" }), ).toBeNull(); @@ -2738,13 +2763,9 @@ it("shows a message for agents as one line from its author to whom it addresses" expect(expanded?.querySelector('[class*="messageBubble"]')).toBeNull(); expect(expanded?.querySelector("[data-own]")).toBeNull(); - expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBe( - timestamp, - ); + expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBeNull(); fireEvent.pointerEnter(expanded ?? line); - expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBe( - timestamp, - ); + expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBeNull(); // Base UI binds the trigger to its panel. One stable message anchor stays // outside the animated content, including while that content is retiring. const panelId = line.getAttribute("aria-controls"); @@ -2763,9 +2784,7 @@ it("shows a message for agents as one line from its author to whom it addresses" // Anywhere in the open box folds it back. fireEvent.click(screen.getByText(/Then report back/)); expect(line).toHaveAttribute("aria-expanded", "false"); - expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBe( - timestamp, - ); + expect(line.parentElement?.querySelector("time:not(.sr-only)")).toBeNull(); } finally { cleanup(); } @@ -3187,7 +3206,7 @@ it.each([0, 23])( }); expect(summary).toHaveTextContent("1 failed"); expect(summary).not.toHaveTextContent("View thread"); - if (replyCount) expect(summary).toHaveTextContent("23 replies"); + if (replyCount) expect(summary).not.toHaveTextContent("23 replies"); expect( summary.querySelector('[data-thread-working] [data-state="working"]'), ).not.toBeNull(); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 03ae301116..fcd00f99ad 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -1,3 +1,5 @@ +import { activityPresentation } from "./activity-presentation"; +import { ShimmerText } from "../../shared/design-system/ui/ShimmerText"; import { Collapsible } from "@base-ui/react/collapsible"; import { formatItemTimestamp } from "../../shared/datetime"; import { ProfilePeek } from "../profiles/ProfilePeek"; @@ -8,11 +10,7 @@ import { } from "../conversation/typing-presentation"; import { breathing, localActivity } from "../agents/local-activity"; import { Avatar } from "../../shared/design-system/ui/Avatar"; -import { - ArrowRightIcon, - LightningIcon, - RobotIcon, -} from "../../shared/design-system/icons"; +import { LightningIcon, RobotIcon } from "../../shared/design-system/icons"; import { useProfileOwner } from "../profiles/useProfileOwner"; import { messagePreviewText } from "../conversation/message-preview-text"; import { profileMentionParts } from "./profile-mentions"; @@ -99,6 +97,9 @@ import { avatarMedia } from "../../shared/avatar-source"; export type MessageLinkOrigin = Readonly<{ rootId: string }>; export type MessageRowProps = { row: ChannelMessage; + activityMotionKey?: string | undefined; + activityFollows?: boolean | undefined; + activityActive?: boolean | undefined; staticMentions?: boolean | undefined; getThreadRoot?: (() => ChannelMessage | undefined) | undefined; session?: RelaySession | undefined; @@ -193,6 +194,7 @@ function MessageRowContent({ viewer = session?.viewer, stackPrevious = layout === "continuation", stackNext = false, + activityFollows = false, alignViewer = true, mediaMode = "inline", @@ -711,7 +713,6 @@ function MessageRowContent({ const remainingWorkingLabel = remainingThreadAgents.length ? `${remainingThreadAgents.map(({ pubkey, name }) => resolveName(pubkey, name)).join(", ")} ${threadWaiting ? "waiting" : "working"}` : undefined; - const activityOnly = !!activity && row.replyCount === 0; return ( ((row.replyCount > 0 || (startsThreads && !row.threadRootId && threadAgents.length > 0) || @@ -719,11 +720,13 @@ function MessageRowContent({ onOpenThread && ( - )} - />, - ); - 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.queryByTitle("Blossom")).toBeNull(); + } finally { + view.unmount(); + f.dispose(); + } + }, +); const settle = () => act(async () => {}); const openBubble = () => @@ -665,21 +700,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( - - activity ? ( - - ) : null - } - />, - ); + const view = render(); try { act(() => { local @@ -693,9 +717,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/ }), diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx index c3b9266ce2..1ef4962752 100644 --- a/src/bundled/agent-activity/ActivityAccessory.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -23,7 +23,7 @@ import { import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { ShimmerText } from "../../shared/design-system/ui/ShimmerText"; import { useToastNotification } from "../../shared/design-system/ui/Toast"; -import { TypingDots } from "../../shared/design-system/ui/TypingDots"; +import { ActivityStepPreview } from "../../features/messages/ActivityStepPreview"; import { PopoverRoot, PopoverTrigger, @@ -41,9 +41,13 @@ import { localWorkLabel, type WorkStep, } from "../../features/agents/local-activity"; -import { useTypingReplacement } from "../../features/conversation/typing-presentation"; +import { + useTypingReplacement, + useActivityTypingReplacement, +} from "../../features/conversation/typing-presentation"; import styles from "./ActivityAccessory.module.css"; import { avatarMedia } from "../../shared/avatar-source"; +import { AgentResponsePreview } from "../../features/messages/AgentResponsePreview"; const noSubscribe = () => () => {}; /** How long a hosted stop shows as stopping before Stop can be tried again: @@ -113,21 +117,28 @@ export function ActivityAccessory(props: ActivityProps) { }; const visible = (entry: { agent: string }) => canOpen(profileActivityViewTarget(entry.agent) ?? ""); - const shown: Entry[] = entries.filter(visible).map((entry) => { - return { - agent: entry.agent, - name: named(entry.agent), - picture: profiles.get(entry.agent)?.picture, - working: entry.working, - state: entry.state, - reason: entry.reason, - ...(entry.step ? { step: entry.step } : {}), - ...(entry.stoppable ? { stoppable: true } : {}), - lifecycle: entry.lifecycle, - records: entry.selected.records, - turns: entry.turns, - }; - }); + const shown: Entry[] = entries + .filter( + (entry) => + visible(entry) && + (entry.state === "error" || + !props.representedAgents?.includes(entry.agent)), + ) + .map((entry) => { + return { + agent: entry.agent, + name: named(entry.agent), + picture: profiles.get(entry.agent)?.picture, + working: entry.working, + state: entry.state, + reason: entry.reason, + ...(entry.step ? { step: entry.step } : {}), + ...(entry.stoppable ? { stoppable: true } : {}), + lifecycle: entry.lifecycle, + records: entry.selected.records, + turns: entry.turns, + }; + }); // When work ends (not when access is revoked), keep the last bubble long // enough for the shared leave recipe, then remove it. const section = useRef(null); @@ -166,9 +177,13 @@ export function ActivityAccessory(props: ActivityProps) { }, [leaving]); // The render where work ends keeps the same element (from the last shown // entries) so the list never loses and regains its height mid-handoff. - const bubble = shown.length - ? shown - : (leaving ?? (ended ? retained.current : [])); + const bubble = ( + shown.length ? shown : (leaving ?? (ended ? retained.current : [])) + ).filter( + (entry) => + entry.state === "error" || + !props.representedAgents?.includes(entry.agent), + ); // Once its live bubble has left, a stopped request stays noted here. const noted = bubble.length ? [] : stopped.filter(visible); const note = noted.length ? ( @@ -189,22 +204,9 @@ export function ActivityAccessory(props: ActivityProps) { {noted.map(({ agent }) => named(agent)).join(", ")} · Stopped ) : null; - if (props.renderMessageSummary) { - return ( - <> - {shown.map((entry) => ( - - ))} - 0} - /> - {note} - - ); - } if (!bubble.length) return note; + const primary = bubble[0]; + if (!primary) return note; return (
-
+ {shown.map((entry) => ( ))} @@ -226,7 +232,7 @@ export function ActivityAccessory(props: ActivityProps) { entries={bubble} interactive={shown.length > 0} /> -
+
); } @@ -345,6 +351,30 @@ function ActivityBubble({ }, [live]); const trigger = useRef(null); const popup = useRef(null); + const retirement = useRef({ + interactive, + onFocusedRemoval: props.onFocusedRemoval, + }); + useLayoutEffect(() => { + retirement.current = { + interactive, + onFocusedRemoval: props.onFocusedRemoval, + }; + }); + useLayoutEffect( + () => () => { + // Published content removes this preview immediately, without a leave phase. + // Ordinary completion uses the existing interactive=false handoff below. + if (!retirement.current.interactive) return; + const focused = trigger.current?.ownerDocument.activeElement; + if ( + focused && + (trigger.current?.contains(focused) || popup.current?.contains(focused)) + ) + retirement.current.onFocusedRemoval?.(); + }, + [], + ); useLayoutEffect(() => { if (interactive) return; const source = popup.current; @@ -399,6 +429,20 @@ function ActivityBubble({ const otherEntries = entries.filter( (entry) => !failedEntries.includes(entry), ); + const current = + otherEntries.find((entry) => entry.working && entry.step) ?? + otherEntries.find((entry) => entry.working); + const currentPreview = + current && !current.lifecycle + ? currentActivity(current.records, current.turns) + : undefined; + const stepText = current?.step + ? label(current) + : currentPreview + ? [currentPreview.title, currentPreview.detail] + .filter(Boolean) + .join(" · ") + : undefined; const avatars = (group: Entry[]) => ( {otherEntries.length > 0 && ( - {avatars(otherEntries)} + {avatars( + otherEntries.filter((entry) => entry.agent !== entries[0]?.agent), + )} {(working || waiting) && ( - + )} {unknown && Status unknown} @@ -426,26 +475,16 @@ function ActivityBubble({ className={`${styles.summaryActivity} ${styles.failure}`} data-activity-failure="" > - {avatars(failedEntries)} + {avatars( + failedEntries.filter((entry) => entry.agent !== entries[0]?.agent), + )}