diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index c75af3c0ac..74c9c97f19 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -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], ], ); diff --git a/src/bundled/agent-activity/ActivityAccessory.module.css b/src/bundled/agent-activity/ActivityAccessory.module.css index 8d0adb78fb..5eee4e34dd 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; } @@ -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. */ @@ -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); @@ -68,6 +57,8 @@ } .action { + align-self: center; + flex-shrink: 0; display: inline-flex; align-items: center; padding: var(--space-1); diff --git a/src/bundled/agent-activity/ActivityAccessory.test.tsx b/src/bundled/agent-activity/ActivityAccessory.test.tsx index a9c5738ec3..15f70bf734 100644 --- a/src/bundled/agent-activity/ActivityAccessory.test.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx @@ -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"; @@ -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"; @@ -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.", @@ -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( @@ -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(); @@ -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/ }), ); @@ -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( - ( - - )} - />, - ); - 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 +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( - - activity ? ( - - ) : null - } - />, - ); + const view = render(); try { act(() => { local @@ -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/ }), @@ -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[]) => ( + + + + + + + + ); + 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(); + } +}); diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx index c3b9266ce2..9ce9c9770a 100644 --- a/src/bundled/agent-activity/ActivityAccessory.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -11,7 +11,10 @@ import { AvatarStack } from "../../shared/design-system/ui/AvatarStack"; import type { ComposerAccessoryProps } from "../../features/conversation/contracts"; import { useChannelIdentityNames } from "../../features/identity-names/react"; import type { activityRecords } from "../../features/agents/activity-records"; -import { profileActivityViewTarget } from "../../features/profiles/target"; +import { + openProfileTranscript, + profileActivityViewTarget, +} from "../../features/profiles/target"; import { publicKeyLabels } from "../../shared/identity/public-key"; import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; @@ -19,11 +22,14 @@ import { OctagonIcon, WarningCircleIcon, XIcon, + ArrowUpRightIcon, } from "../../shared/design-system/icons"; -import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; +import { + NavigationCard, + 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, @@ -32,7 +38,8 @@ import { import { currentActivity } from "./current-activity"; import { messageWork } from "./message-activity"; import type { ActivityTurn } from "../../features/agents/activity"; -import type { Agents } from "../../features/agents/service"; +import { workStop, type WorkStopAgents } from "../../features/agents/work-stop"; +import { useWorkStops } from "../../features/agents/use-work-stops"; import { requestWork, type RequestWork, @@ -41,16 +48,17 @@ 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 messageStyles from "../../features/messages/Messages.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: - * its provider may never report the turn's end. */ -const HOSTED_STOP_MS = 30_000; -/** What Stop needs of Agents: a hosted agent's provider stops its turns. */ -export type ActivityAgents = Pick; +export type ActivityAgents = WorkStopAgents; type ActivityProps = ComposerAccessoryProps & { agents?: ActivityAgents }; /** Activity belongs to the exact triggering message, not the composer. */ export function ActivityAccessory(props: ActivityProps) { @@ -113,21 +121,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 +181,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 +208,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 (
-
+ ({ + id: entry.agent, + name: entry.name, + picture: avatarMedia(entry.picture, session.media), + }))} + picture={avatarMedia(primary.picture, session.media)} + layout={props.messageLayout} + > {shown.map((entry) => ( - + ))} 0} /> -
+
); } @@ -252,99 +272,51 @@ function ActivityBubble({ ...props }: ActivityProps & { entries: Entry[]; interactive: boolean }) { const [expanded, setExpanded] = useState(false); - // Agents asked to stop whose work has not stopped yet. - const [stops, setStops] = useState>(new Set()); - // The latest stop asked of each agent, so a late timeout knows its own. - const attempts = useRef(new Map()); - const notify = useToastNotification(); - // A hosted stop still pending when its activity goes away needs no retry. - useEffect(() => { - const pending = attempts.current; - return () => pending.clear(); - }, []); const message = props.message; - const stoppable = (entry: Entry) => - !!message && - (entry.lifecycle - ? !!entry.stoppable && - (entry.state === "working" || entry.state === "waiting") - : entry.working && !!agents?.interruptible(entry.agent)); - const forget = (agent: string) => { - attempts.current.delete(agent); - setStops((prior) => { - if (!prior.has(agent)) return prior; - const next = new Set(prior); - next.delete(agent); - return next; - }); - }; - const stop = (entry: Entry) => { - if (!message) return; - const attempt = Symbol(entry.agent); - attempts.current.set(entry.agent, attempt); - setStops((prior) => new Set(prior).add(entry.agent)); - const newer = () => { - const latest = attempts.current.get(entry.agent); - return !!latest && latest !== attempt; - }; - // A local request settles as stopped; a hosted turn ends when its - // provider reports it, so it shows as stopping until then. - const asked = entry.lifecycle - ? props.session.agentActivity.interrupt( - entry.agent, - props.channelId, - message.id, - ) - : agents?.interrupt(entry.agent, { - channelId: props.channelId, - threadRootId: message.threadRootId ?? message.id, - }); - void Promise.resolve(asked).then( - (running) => { - // A newer stop of this agent reports its own result. - if (newer()) return; - if (entry.lifecycle) { - forget(entry.agent); - notify(`Stopped ${entry.name}`, "success"); - } else if (running === false) { - forget(entry.agent); - notify(`${entry.name} had nothing running to stop`, "info"); - } else { - notify(`Stopping ${entry.name}`, "success"); - setTimeout(() => { - if (attempts.current.get(entry.agent) !== attempt) return; - forget(entry.agent); - notify(`${entry.name} hasn’t stopped yet. Try again.`, "error"); - }, HOSTED_STOP_MS); - } - }, - (error: unknown) => { - if (newer()) return; - forget(entry.agent); - notify( - `Couldn’t stop ${entry.name}. ${error instanceof Error ? error.message : String(error)}`, - "error", + const controls = new Map( + entries.flatMap((entry) => { + const control = + message && + workStop( + entry, + { + channelId: props.channelId, + messageId: message.id, + threadRootId: message.threadRootId ?? message.id, + }, + props.session.agentActivity, + agents, ); - }, - ); - }; - // A stop is forgotten once its agent's work here has ended. - const live = entries - .filter((entry) => entry.working || entry.state === "waiting") - .map((entry) => entry.agent) - .join(":"); - useEffect(() => { - const agents = new Set(live.split(":")); - for (const agent of attempts.current.keys()) - if (!agents.has(agent)) attempts.current.delete(agent); - setStops((prior) => - [...prior].every((agent) => agents.has(agent)) - ? prior - : new Set([...prior].filter((agent) => agents.has(agent))), - ); - }, [live]); + return control ? [[entry.agent, control] as const] : []; + }), + ); + const { pending: stops, stop } = useWorkStops([...controls.values()]); 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 +371,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)} {(working || waiting) && ( - + )} {unknown && Status unknown} @@ -426,26 +414,16 @@ function ActivityBubble({ className={`${styles.summaryActivity} ${styles.failure}`} data-activity-failure="" > - {avatars(failedEntries)} + {avatars( + failedEntries.filter((entry) => entry.agent !== entries[0]?.agent), + )}