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(
- (
-
- View thread{activity ? `: ${activity.label}` : ""}
-
- )}
- />,
- );
- 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 ? (
-
- View thread: {activity.label}
-
- ) : 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),
+ )}
{failedCount} failed
)}
);
- const summary = props.renderMessageSummary?.(
- entries.length
- ? {
- participants: entries.map((entry) => entry.agent),
- content,
- label: entries
- .map((entry) => `${entry.name}, ${label(entry)}`)
- .join("; "),
- ref: trigger,
- }
- : undefined,
- );
- if (!entries.length && !summary) return null;
+ if (!entries.length) return null;
return (
`${entry.name}, ${label(entry)}`).join("; ")}`}
- >
- {content}
-
- )
+ `${entry.name}, ${label(entry)}`).join("; ")}`}
+ >
+ {content}
+
}
/>
{expanded && (
+
}
+ disabled={
+ !entries[0] ||
+ !props.canOpen(
+ profileActivityViewTarget(entries[0].agent, {
+ transcript: true,
+ }) ?? "",
+ )
+ }
+ onClick={() => {
+ const target =
+ entries[0] &&
+ profileActivityViewTarget(entries[0].agent, {
+ transcript: true,
+ });
+ if (
+ target &&
+ props.canOpen(target) &&
+ openProfileTranscript(target, props.open)
+ )
+ setExpanded(false);
+ }}
+ />
{entries.map((entry) => {
// Opens the agent's profile on its Activity tab.
const target = profileActivityViewTarget(entry.agent);
const preview = entry.working
? currentActivity(entry.records, entry.turns)
: undefined;
- const stopping = stops.has(entry.agent);
+ const control = controls.get(entry.agent);
+ const stopping = !!control && stops.has(control.key);
return (
-
+
- ) : stoppable(entry) ? (
+ ) : control ? (
{
event.stopPropagation();
- stop(entry);
+ stop(control, entry.name);
}}
>
) : undefined}
-
+
);
})}
@@ -644,7 +641,12 @@ function TypingReplacement({
channelId,
threadRootId,
agent,
-}: ComposerAccessoryProps & { agent: string }) {
+ shared = false,
+}: ComposerAccessoryProps & { agent: string; shared?: boolean }) {
+ useActivityTypingReplacement(
+ { session, channelId, threadRootId, pubkey: agent },
+ shared,
+ );
useTypingReplacement(
{ session, channelId, threadRootId, pubkey: agent },
true,
diff --git a/src/bundled/agent-activity/README.md b/src/bundled/agent-activity/README.md
index a3199b66a1..3f00e3d2dd 100644
--- a/src/bundled/agent-activity/README.md
+++ b/src/bundled/agent-activity/README.md
@@ -1,9 +1,9 @@
# Agent Activity
-Shows what your agents are working on, right under the message they're answering. You can follow along as they run commands, edit files, and plan their next step.
+Shows an incoming agent response on the left while your request is being handled. The same avatar and bubble begin with typing dots, then show the current tool step. Published agent content replaces this live preview; recorded coordination history keeps its completed-step disclosure.
## Getting started
1. Mention an agent in a conversation.
-2. Its picture appears under your message while it works.
-3. Hover over it to see the current step, and open the agent's full activity from there.
+2. Its picture and typing bubble appear on the agent's side of the conversation.
+3. Tool details appear in that bubble as they arrive. Hover for activity controls, including Stop, or click to open the thread when available.
diff --git a/src/bundled/agent-activity/index.tsx b/src/bundled/agent-activity/index.tsx
index 3599b39085..4e8a343719 100644
--- a/src/bundled/agent-activity/index.tsx
+++ b/src/bundled/agent-activity/index.tsx
@@ -30,8 +30,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
id: "activity",
title: "Agent Activity",
component: (props) => ,
- placement: "message",
- decoratesMessageSummary: true,
+ placement: "response",
});
ctx.conversation.registerAccessory({
id: "typing-presentation",
diff --git a/src/bundled/channels/ChannelBody.tsx b/src/bundled/channels/ChannelBody.tsx
index 3ff043ec88..e585b1e90b 100644
--- a/src/bundled/channels/ChannelBody.tsx
+++ b/src/bundled/channels/ChannelBody.tsx
@@ -20,11 +20,8 @@ export const ChannelBody = memo(function ChannelBody({
onOpenThread,
navigation,
onContentReady,
- activityLane,
}: {
onContentReady?(channelId: string): void;
- /** The channel composer follows this body and shows conversation activity. */
- activityLane?: boolean | undefined;
cached: boolean;
viewer?: string | undefined;
navigation?: PageNavigation | undefined;
@@ -130,7 +127,6 @@ export const ChannelBody = memo(function ChannelBody({
inlineTarget={inlineTarget}
marksRead={marksRead}
navigation={navigation}
- activityLane={activityLane}
chatPresentation={solo}
historyControl={solo ? "scroll" : "button"}
/>
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 1b7a984f37..e7f97a91d8 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -1671,7 +1671,6 @@ function ChannelWorkspace({
key={current.id}
cached={cached}
// The composer below mounts TypingIndicator for members of a live channel.
- activityLane={!current.readOnly && !current.cached}
navigation={
!requestedMessage || exact?.inTimeline ? navigation : undefined
}
diff --git a/src/bundled/channels/ConversationTab.test.tsx b/src/bundled/channels/ConversationTab.test.tsx
index a49bc470ee..bc16dc96db 100644
--- a/src/bundled/channels/ConversationTab.test.tsx
+++ b/src/bundled/channels/ConversationTab.test.tsx
@@ -49,13 +49,11 @@ const threadOpeners = vi.hoisted(
);
vi.mock("./ChannelBody", () => ({
ChannelBody: ({
- activityLane,
holding,
inlineTarget,
marksRead,
onOpenThread,
}: {
- activityLane?: boolean;
holding?: boolean;
inlineTarget?: { messageId: string };
marksRead?: boolean;
@@ -64,7 +62,6 @@ vi.mock("./ChannelBody", () => ({
threadOpeners.push(onOpenThread);
return (
{
- const channel = {
- id: "beta",
- name: "Beta",
- ...(cached ? { cached: true as const } : {}),
- };
- const snapshot = { status: "ready", channels: [channel] };
- const session = {
- channels: { list: () => snapshot, subscribeList: () => () => {} },
- } as unknown as RelaySession;
- render(
-
false}
- canOpenLink={() => false}
- openThread={() => {}}
- close={() => {}}
- />,
- );
- // The composer below the body mounts TypingIndicator only when live.
- expect(screen.getByText("Conversation content")).toHaveAttribute(
- "data-activity-lane",
- String(!cached),
- );
- },
-);
-
function openingAt(rows: { id: string; threadRootId?: string }[]) {
const channel = { id: "beta", name: "Beta" };
const snapshot = { status: "ready", channels: [channel] };
diff --git a/src/bundled/channels/ConversationTab.tsx b/src/bundled/channels/ConversationTab.tsx
index 5490d528d5..b3502a1968 100644
--- a/src/bundled/channels/ConversationTab.tsx
+++ b/src/bundled/channels/ConversationTab.tsx
@@ -187,7 +187,6 @@ export function ConversationTab({
viewer={session.viewer}
cached={!!channel.cached}
// The composer below mounts TypingIndicator for a live channel.
- activityLane={!channel.cached}
onOpenThread={onOpenThread}
revealMessageId={sent}
// Mount the list once, at the message, not while it is sought.
diff --git a/src/bundled/channels/useConversationTabs.test.tsx b/src/bundled/channels/useConversationTabs.test.tsx
index 79eebbf053..85e6b9b1bc 100644
--- a/src/bundled/channels/useConversationTabs.test.tsx
+++ b/src/bundled/channels/useConversationTabs.test.tsx
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
+const emptyTyping: never[] = [];
import "@testing-library/jest-dom/vitest";
import {
act,
@@ -1785,6 +1786,7 @@ it("a hidden retained thread tab's rows re-render for new panels, not channel-li
snapshot: () => profiles,
subscribe: () => () => {},
},
+ typing: { subscribe: () => () => {}, snapshot: () => emptyTyping },
agentChoices: createAgentLibrary(undefined).queries,
messages: { retry: () => {} },
media: () => undefined,
diff --git a/src/bundled/reminders/RemindDialog.test.tsx b/src/bundled/reminders/RemindDialog.test.tsx
index e758c71e74..9d331cf505 100644
--- a/src/bundled/reminders/RemindDialog.test.tsx
+++ b/src/bundled/reminders/RemindDialog.test.tsx
@@ -193,7 +193,13 @@ it("reveals labelled custom fields and saves a future time with the note", async
await vi.waitFor(() =>
expect(screen.getByRole("option", { selected: true })).toHaveFocus(),
);
- await user.keyboard("{End}{Enter}");
+ await user.keyboard("{End}");
+ await vi.waitFor(() =>
+ expect(
+ screen.getByRole("option", { name: "Custom date & time" }),
+ ).toHaveFocus(),
+ );
+ await user.keyboard("{Enter}");
fireEvent.change(screen.getByLabelText("Reminder date"), {
target: { value: "" },
});
diff --git a/src/features/agents/ActivityRows.module.css b/src/features/agents/ActivityRows.module.css
index 16144bc778..04994c3c41 100644
--- a/src/features/agents/ActivityRows.module.css
+++ b/src/features/agents/ActivityRows.module.css
@@ -121,15 +121,11 @@
.activityStatic > .activityItemBody {
flex: 1;
}
-.activityScope {
- display: block;
- color: var(--text-subtle);
- font-size: var(--text-caption);
+.activityItemMeta {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
-.activityItemWithAction .activityItemMeta,
-.activityItemWithAction .activityScope {
+.activityItemWithAction .activityItemMeta {
padding-inline-end: var(--size-control-sm);
}
diff --git a/src/features/agents/AgentActivityRow.tsx b/src/features/agents/AgentActivityRow.tsx
index d059d1f90c..0b0832fbe2 100644
--- a/src/features/agents/AgentActivityRow.tsx
+++ b/src/features/agents/AgentActivityRow.tsx
@@ -41,12 +41,15 @@ export function AgentActivityRow({
{meta}
)}
- {status}
- {scope && (
-
- {scope}
-
- )}
+
+ {status}
+ {scope && (
+ <>
+ {" "}
+ · {scope}
+ >
+ )}
+
);
return (
diff --git a/src/features/agents/current-activity.test.ts b/src/features/agents/current-activity.test.ts
index 485eb67fc4..1fce36f812 100644
--- a/src/features/agents/current-activity.test.ts
+++ b/src/features/agents/current-activity.test.ts
@@ -78,6 +78,7 @@ it("projects safe brief tool status from exact current owner-visible work only",
const live = snapshot();
expect(project(live).get(agent)).toEqual({
label: "Editing index.html",
+ detailAvailability: "available",
roots: [root],
});
expect(project(live, root).get(agent)?.label).toBe("Editing index.html");
@@ -95,7 +96,11 @@ it("drops owner-only work when not listening without hiding valid public typing"
currentActivity(live, entries, new Set([agent]), "channel", root).get(
agent,
),
- ).toEqual({ label: "Working", roots: [root] });
+ ).toEqual({
+ label: "Working",
+ detailAvailability: "typing-only",
+ roots: [root],
+ });
expect(currentActivity(live, entries, new Set(), "channel").size).toBe(0);
});
@@ -106,7 +111,7 @@ it.each([
],
[{ title: "unknown", kind: "other" }, "Running a tool"],
[{ status: "pending" }, "Preparing a tool"],
- [{ status: "completed" }, "Working"],
+ [{ status: "completed" }, "Working", "no-current-step"],
[
{ rawInput: { path: "C:\\private\\report\u202e.txt" } },
"Editing report.txt",
@@ -119,9 +124,15 @@ it.each([
},
"Reading report.txt",
],
-])("keeps compact labels safe: %j", (tool, expected) => {
- expect(project(snapshot(tool)).get(agent)?.label).toBe(expected);
-});
+])(
+ "keeps compact labels safe: %j",
+ (tool, expected, unavailable = "available") => {
+ expect(project(snapshot(tool)).get(agent)).toMatchObject({
+ label: expected,
+ detailAvailability: unavailable,
+ });
+ },
+);
it.each(["__proto__", "constructor", "toString", "hasOwnProperty"])(
"treats inherited dictionary key %s as telemetry, not a label mapping",
@@ -151,7 +162,11 @@ it("does not merge labels or ownership across roots or expired turns", () => {
currentActivity(live, entries, new Set([agent]), "channel", otherRoot).get(
agent,
),
- ).toEqual({ label: "Working", roots: [otherRoot] });
+ ).toEqual({
+ label: "Working",
+ detailAvailability: "typing-only",
+ roots: [otherRoot],
+ });
expect(
project({
...live,
diff --git a/src/features/agents/current-activity.ts b/src/features/agents/current-activity.ts
index 453d5f17e6..2e1f3dd1ba 100644
--- a/src/features/agents/current-activity.ts
+++ b/src/features/agents/current-activity.ts
@@ -61,6 +61,7 @@ function toolLabel(tool: ToolItem): string {
export type CurrentActivity = {
label: string;
+ detailAvailability: "available" | "no-current-step" | "typing-only";
/** Null is the channel conversation; undefined is genuinely unknown. */
roots: (string | null | undefined)[];
};
@@ -69,7 +70,7 @@ export type CurrentActivity = {
export function currentActivity(
snapshot: Snapshot | undefined,
entries: readonly TypingEntry[],
- ownedAgents: ReadonlySet,
+ knownAgents: ReadonlySet,
channelId: string,
threadRootId?: string,
): ReadonlyMap {
@@ -77,6 +78,7 @@ export function currentActivity(
const add = (agent: string, root: string | null | undefined) => {
const activity: CurrentActivity = agents.get(agent) ?? {
label: "Working",
+ detailAvailability: "typing-only",
roots: [],
};
if (!activity.roots.includes(root)) activity.roots.push(root);
@@ -101,7 +103,7 @@ export function currentActivity(
for (const entry of entries) {
if (
matches(entry.channelId, entry.threadRootId) &&
- (ownedAgents.has(entry.pubkey) ||
+ (knownAgents.has(entry.pubkey) ||
ownerTyping.some((owner) => owner.agent === entry.pubkey) ||
working.some((turn) => turn.agent === entry.pubkey))
)
@@ -118,7 +120,8 @@ export function currentActivity(
(threadRootId && turn?.threadRootId !== threadRootId)
)
continue;
- add(agent, turn?.threadRootId);
+ const detail = add(agent, turn?.threadRootId);
+ detail.detailAvailability = "no-current-step";
tools.push(
...(turn?.items.filter(
(item): item is ToolItem =>
@@ -130,11 +133,13 @@ export function currentActivity(
}
const activity = agents.get(agent);
const firstTool = tools[0];
- if (activity && firstTool)
+ if (activity && firstTool) {
+ activity.detailAvailability = "available";
activity.label =
tools.length > 1
? `Running ${tools.length} tools`
: toolLabel(firstTool);
+ }
}
const local = localActivity(snapshot, channelId, threadRootId);
for (const [agent, work] of local) {
@@ -153,7 +158,11 @@ export function currentActivity(
.map((row) => row.threadRootId),
),
];
- agents.set(agent, { label: localWorkLabel(work), roots });
+ agents.set(agent, {
+ label: localWorkLabel(work),
+ detailAvailability: "available",
+ roots,
+ });
}
return agents;
}
diff --git a/src/features/agents/use-work-stops.test.tsx b/src/features/agents/use-work-stops.test.tsx
new file mode 100644
index 0000000000..ac999914f1
--- /dev/null
+++ b/src/features/agents/use-work-stops.test.tsx
@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { useWorkStops } from "./use-work-stops";
+import type { WorkStop } from "./work-stop";
+
+const notify = vi.hoisted(() => vi.fn());
+vi.mock("../../shared/design-system/ui/Toast", () => ({
+ useToastNotification: () => notify,
+}));
+afterEach(() => {
+ cleanup();
+ notify.mockClear();
+});
+
+it("reports a rejected Stop even after its indicator retires", async () => {
+ let reject!: (error: Error) => void;
+ const control: WorkStop = {
+ key: "request",
+ lifecycle: true,
+ interrupt: () =>
+ new Promise((_resolve, fail) => {
+ reject = fail;
+ }),
+ };
+ function Indicator() {
+ const { stop } = useWorkStops([control]);
+ return (
+ void stop(control, "Agent")}>
+ Stop
+
+ );
+ }
+ const view = render( );
+ fireEvent.click(screen.getByRole("button", { name: "Stop" }));
+ view.unmount();
+ await act(async () => reject(new Error("Connection lost")));
+ expect(notify).toHaveBeenCalledExactlyOnceWith(
+ "Couldn’t stop Agent. Connection lost",
+ "error",
+ );
+});
diff --git a/src/features/agents/use-work-stops.ts b/src/features/agents/use-work-stops.ts
new file mode 100644
index 0000000000..0c9e085ca9
--- /dev/null
+++ b/src/features/agents/use-work-stops.ts
@@ -0,0 +1,81 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useToastNotification } from "../../shared/design-system/ui/Toast";
+import type { WorkStop } from "./work-stop";
+
+const HOSTED_STOP_MS = 30_000;
+
+/** Hosted stops remain pending until evidence ends or the retry window expires. */
+export function useWorkStops(controls: readonly WorkStop[]) {
+ const [pending, setPending] = useState>(new Set());
+ const attempts = useRef(new Map());
+ const timers = useRef(new Map>());
+ const notify = useToastNotification();
+ const forget = useCallback((key: string) => {
+ attempts.current.delete(key);
+ clearTimeout(timers.current.get(key));
+ timers.current.delete(key);
+ setPending((prior) => {
+ if (!prior.has(key)) return prior;
+ const next = new Set(prior);
+ next.delete(key);
+ return next;
+ });
+ }, []);
+ const live = JSON.stringify(controls.map((control) => control.key).sort());
+ useEffect(() => {
+ const keys = new Set(JSON.parse(live));
+ for (const key of attempts.current.keys()) if (!keys.has(key)) forget(key);
+ }, [live, forget]);
+ useEffect(() => {
+ const active = attempts.current;
+ const timeouts = timers.current;
+ return () => {
+ active.clear();
+ for (const timer of timeouts.values()) clearTimeout(timer);
+ timeouts.clear();
+ };
+ }, []);
+ const stop = async (control: WorkStop, name: string) => {
+ const { key } = control;
+ if (attempts.current.has(key)) return;
+ const attempt = Symbol(key);
+ attempts.current.set(key, attempt);
+ setPending((prior) => new Set(prior).add(key));
+ try {
+ const running = await control.interrupt();
+ const current = attempts.current.get(key);
+ if (current && current !== attempt) return;
+ // Local completion can retire the preview before its promise resolves.
+ // Still confirm the successful stop; hosted work needs live evidence.
+ if (!current && !control.lifecycle) return;
+ if (control.lifecycle || running === false) {
+ if (current) forget(key);
+ notify(
+ control.lifecycle
+ ? `Stopped ${name}`
+ : `${name} had nothing running to stop`,
+ control.lifecycle ? "success" : "info",
+ );
+ } else {
+ notify(`Stopping ${name}`, "success");
+ timers.current.set(
+ key,
+ setTimeout(() => {
+ if (attempts.current.get(key) !== attempt) return;
+ forget(key);
+ notify(`${name} hasn’t stopped yet. Try again.`, "error");
+ }, HOSTED_STOP_MS),
+ );
+ }
+ } catch (error) {
+ const current = attempts.current.get(key);
+ if (current && current !== attempt) return;
+ if (current) forget(key);
+ notify(
+ `Couldn’t stop ${name}. ${error instanceof Error ? error.message : String(error)}`,
+ "error",
+ );
+ }
+ };
+ return { pending, stop };
+}
diff --git a/src/features/agents/work-stop.ts b/src/features/agents/work-stop.ts
new file mode 100644
index 0000000000..d52e5dae71
--- /dev/null
+++ b/src/features/agents/work-stop.ts
@@ -0,0 +1,117 @@
+import type { RelaySession } from "../relay/session";
+import type { Agents } from "./service";
+import type { RequestWork } from "./request-work";
+import type { LocalWork } from "./local-activity";
+import { activityTranscript } from "./activity-transcript";
+
+export type WorkStopAgents = Pick;
+export type WorkStop = {
+ key: string;
+ lifecycle: boolean;
+ interrupt: () => Promise | Promise;
+};
+type Scope = {
+ channelId: string;
+ messageId: string;
+ threadRootId?: string | undefined;
+};
+type Activity = RelaySession["agentActivity"];
+
+/** Capability and exact scope are shared by request and conversation popovers. */
+export function workStop(
+ work: Pick<
+ RequestWork,
+ "agent" | "lifecycle" | "stoppable" | "state" | "working"
+ >,
+ scope: Scope,
+ activity: Activity | undefined,
+ agents: WorkStopAgents | undefined,
+): WorkStop | undefined {
+ const key = JSON.stringify([work.agent, scope.channelId, scope.messageId]);
+ if (work.lifecycle) {
+ if (
+ !activity ||
+ !work.stoppable ||
+ (work.state !== "working" && work.state !== "waiting")
+ )
+ return;
+ return {
+ key,
+ lifecycle: true,
+ interrupt: () =>
+ activity.interrupt(work.agent, scope.channelId, scope.messageId),
+ };
+ }
+ const root = scope.threadRootId ?? scope.messageId;
+ if (!root || !work.working || !agents?.interruptible(work.agent)) return;
+ return {
+ key,
+ lifecycle: false,
+ interrupt: () =>
+ agents.interrupt(work.agent, {
+ channelId: scope.channelId,
+ threadRootId: root,
+ }),
+ };
+}
+
+export function conversationWorkStops(
+ snapshot: ReturnType | undefined,
+ agent: string,
+ channelId: string,
+ threadRootId: string | undefined,
+ activity: Activity | undefined,
+ agents: WorkStopAgents | undefined,
+): {
+ control: WorkStop;
+ work?: LocalWork;
+ threadRootId?: string | undefined;
+}[] {
+ if (!snapshot) return [];
+ const local = (snapshot.local ?? []).filter(
+ (work) =>
+ work.agent === agent &&
+ work.channelId === channelId &&
+ (!threadRootId || work.threadRootId === threadRootId),
+ );
+ const result = local.flatMap((work) => {
+ const control = workStop(
+ { ...work, lifecycle: true, working: work.state === "working" },
+ work,
+ activity,
+ agents,
+ );
+ return control ? [{ control, work, threadRootId: work.threadRootId }] : [];
+ });
+ // Local lifecycle evidence owns this agent's state, including settled work.
+ if (local.length || snapshot.status !== "listening") return result;
+ const turns = activityTranscript(snapshot.records, {
+ agent,
+ channelId,
+ }).turns;
+ const roots = new Set(
+ turns.flatMap((turn) =>
+ !turn.endedAt &&
+ typeof turn.threadRootId === "string" &&
+ (!threadRootId || turn.threadRootId === threadRootId) &&
+ snapshot.turns.some(
+ (live) =>
+ live.agent === agent &&
+ live.channelId === channelId &&
+ live.turnId === turn.turnId &&
+ live.state === "working",
+ )
+ ? [turn.threadRootId]
+ : [],
+ ),
+ );
+ return [...roots].flatMap((root) => {
+ const control = workStop(
+ { agent, lifecycle: false, working: true, state: "working" },
+ { channelId, messageId: root, threadRootId: root },
+ activity,
+ agents,
+ );
+ return control ? [{ control, threadRootId: root }] : [];
+ });
+}
diff --git a/src/features/conversation/ComposerAccessories.test.tsx b/src/features/conversation/ComposerAccessories.test.tsx
index f4b8bdf976..270ce9f5a4 100644
--- a/src/features/conversation/ComposerAccessories.test.tsx
+++ b/src/features/conversation/ComposerAccessories.test.tsx
@@ -78,74 +78,95 @@ it("revokes navigation after contribution removal, replacement and composer unmo
expect(canOpen).toHaveBeenCalledTimes(1);
});
-it("keeps message accessories out of the composer and revokes commands on message changes", () => {
- const commands: ComposerAccessoryProps[] = [];
- const entry: Contribution = {
- id: "activity",
- key: "test:message",
- pluginId: "test",
- revision: "one",
- title: "Activity",
- placement: "message",
- component: function MessageAccessory(props) {
- useLayoutEffect(() => {
- commands.push(props);
- });
- return Message activity ;
- },
- };
- const entries = [entry];
- const registry = { snapshot: () => entries, subscribe: () => () => {} };
- const props = {
- registry,
- session: {} as RelaySession,
- scope: "scope",
- channelId: "channel",
- canOpen: () => true,
- open: vi.fn(() => true),
- openThread: vi.fn(() => true),
- onFocusedRemoval: vi.fn(),
- };
- const message = {
- id: "one",
- channelId: "channel",
- authorId: "human",
- content: "request",
- createdAt: 1,
- mentions: [],
- attachments: [],
- reactions: [],
- participants: [],
- replyCount: 0,
- };
- const view = render( );
- expect(screen.queryByText("Message activity")).toBeNull();
- view.rerender( );
- expect(screen.getByText("Message activity")).toBeTruthy();
- const original = commands.at(-1);
- if (!original) throw new Error("Missing accessory commands");
- expect(original.message?.id).toBe("one");
- expect(original.openThread?.()).toBe(true);
- original.onFocusedRemoval?.();
- expect(props.onFocusedRemoval).toHaveBeenCalledOnce();
- expect(original.open("target")).toBe(true);
- view.rerender(
- ,
- );
- expect(original.open("target")).toBe(false);
- expect(original.openThread?.()).toBe(false);
- original.onFocusedRemoval?.();
- expect(props.onFocusedRemoval).toHaveBeenCalledOnce();
- expect(commands.at(-1)?.openThread?.()).toBe(true);
- expect(commands.at(-1)?.message?.id).toBe("two");
- expect(commands.at(-1)?.open("target")).toBe(true);
- view.unmount();
- expect(commands.at(-1)?.open("target")).toBe(false);
- expect(commands.at(-1)?.openThread?.()).toBe(false);
- commands.at(-1)?.onFocusedRemoval?.();
- expect(props.onFocusedRemoval).toHaveBeenCalledOnce();
- expect(props.openThread).toHaveBeenCalledTimes(2);
-});
+it.each(["message", "response"] as const)(
+ "keeps %s accessories in their own slot and revokes commands on message changes",
+ (placement) => {
+ const commands: ComposerAccessoryProps[] = [];
+ const entry: Contribution = {
+ id: "activity",
+ key: "test:message",
+ pluginId: "test",
+ revision: "one",
+ title: "Activity",
+ placement,
+ component: function MessageAccessory(props) {
+ useLayoutEffect(() => {
+ commands.push(props);
+ });
+ return Message activity ;
+ },
+ };
+ const entries = [entry];
+ const registry = { snapshot: () => entries, subscribe: () => () => {} };
+ const props = {
+ registry,
+ session: {} as RelaySession,
+ scope: "scope",
+ channelId: "channel",
+ canOpen: () => true,
+ open: vi.fn(() => true),
+ openThread: vi.fn(() => true),
+ onFocusedRemoval: vi.fn(),
+ };
+ const message = {
+ id: "one",
+ channelId: "channel",
+ authorId: "human",
+ content: "request",
+ createdAt: 1,
+ mentions: [],
+ attachments: [],
+ reactions: [],
+ participants: [],
+ replyCount: 0,
+ };
+ const view = render( );
+ expect(screen.queryByText("Message activity")).toBeNull();
+ view.rerender(
+ ,
+ );
+ expect(screen.queryByText("Message activity")).toBeNull();
+ view.rerender(
+ ,
+ );
+ expect(screen.getByText("Message activity")).toBeTruthy();
+ const original = commands.at(-1);
+ if (!original) throw new Error("Missing accessory commands");
+ expect(original.message?.id).toBe("one");
+ expect(original.openThread?.()).toBe(true);
+ original.onFocusedRemoval?.();
+ expect(props.onFocusedRemoval).toHaveBeenCalledOnce();
+ expect(original.open("target")).toBe(true);
+ view.rerender(
+ ,
+ );
+ expect(original.open("target")).toBe(false);
+ expect(original.openThread?.()).toBe(false);
+ original.onFocusedRemoval?.();
+ expect(props.onFocusedRemoval).toHaveBeenCalledOnce();
+ expect(commands.at(-1)?.openThread?.()).toBe(true);
+ expect(commands.at(-1)?.message?.id).toBe("two");
+ expect(commands.at(-1)?.open("target")).toBe(true);
+ view.unmount();
+ expect(commands.at(-1)?.open("target")).toBe(false);
+ expect(commands.at(-1)?.openThread?.()).toBe(false);
+ commands.at(-1)?.onFocusedRemoval?.();
+ expect(props.onFocusedRemoval).toHaveBeenCalledOnce();
+ expect(props.openThread).toHaveBeenCalledTimes(2);
+ },
+);
it("gives only one decorator the host summary and restores the fallback on removal", () => {
const renderMessageSummary = vi.fn(() => (
diff --git a/src/features/conversation/ComposerAccessories.tsx b/src/features/conversation/ComposerAccessories.tsx
index 5e936bb6bc..fdf5422061 100644
--- a/src/features/conversation/ComposerAccessories.tsx
+++ b/src/features/conversation/ComposerAccessories.tsx
@@ -9,9 +9,11 @@ import { ContributionBoundary, contributionKey } from "./ContributionBoundary";
export function ComposerAccessories({
registry,
+ placement,
...props
}: ComposerAccessoryProps & {
registry: ContributionReader;
+ placement?: ComposerAccessory["placement"];
}) {
const entries = useSyncExternalStore(
registry.subscribe,
@@ -21,7 +23,11 @@ export function ComposerAccessories({
const order = (entry: ComposerAccessory) =>
Number.isFinite(entry.order) ? (entry.order ?? 0) : 0;
const ordered = entries
- .filter((entry) => (entry.placement === "message") === !!props.message)
+ .filter(
+ (entry) =>
+ (entry.placement ?? "composer") ===
+ (placement ?? (props.message ? "message" : "composer")),
+ )
.sort(
(a, b) =>
order(a) - order(b) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0),
diff --git a/src/features/conversation/Conversation.tsx b/src/features/conversation/Conversation.tsx
index e54cb8c6de..403eafed10 100644
--- a/src/features/conversation/Conversation.tsx
+++ b/src/features/conversation/Conversation.tsx
@@ -1,3 +1,4 @@
+import { ActivityIndicatorPresentation } from "./typing-presentation";
import {
createContext,
useCallback,
@@ -233,7 +234,11 @@ function Provider({
onOpenMediaReview,
],
);
- return {children} ;
+ return (
+
+ {children}
+
+ );
}
function ConversationMediaReview(props: MediaReview) {
@@ -293,7 +298,6 @@ export function ConversationComposer(props: Own) {
label: withPeople
? "Message this conversation"
: "Message your agents",
- activityClickOpensPanel: true,
}
: {})}
chatPresentation={shared.ownSoloConversation}
diff --git a/src/features/conversation/EmbeddedThread.test.tsx b/src/features/conversation/EmbeddedThread.test.tsx
index d2d1b44407..fab9062dcc 100644
--- a/src/features/conversation/EmbeddedThread.test.tsx
+++ b/src/features/conversation/EmbeddedThread.test.tsx
@@ -4,6 +4,7 @@ import {
} from "./ConversationHost";
import { ConversationPresentation } from "./ConversationPresentation";
// @vitest-environment jsdom
+const emptyTyping: never[] = [];
import "@testing-library/jest-dom/vitest";
import {
act,
@@ -171,6 +172,7 @@ function fixture(
snapshot: () => profiles,
subscribe: () => () => {},
},
+ typing: { subscribe: () => () => {}, snapshot: () => emptyTyping },
agentChoices: createAgentLibrary(undefined).queries,
messages: { retry() {} },
media: () => undefined,
@@ -365,9 +367,9 @@ it("offers recovery for a failed edit left in the outbox", () => {
} as unknown as OutgoingEvent,
]);
render( );
- expect(screen.getByRole("status")).toHaveTextContent(
- "Message edit: Relay refused the edit.",
- );
+ expect(
+ screen.getByText("Message edit: Relay refused the edit."),
+ ).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Retry message update" }));
expect(h.outbox.retry).toHaveBeenCalledExactlyOnceWith("edit");
});
diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts
index 176d265dd0..95f52f4f3a 100644
--- a/src/features/conversation/contracts.ts
+++ b/src/features/conversation/contracts.ts
@@ -149,6 +149,10 @@ export type ComposerAccessoryProps = Readonly<{
threadRootId?: string | undefined;
/** Present only for message-placed accessories; the exact triggering row. */
message?: ChannelMessage;
+ /** Agents already represented by subsequent content in this request's visible run. */
+ representedAgents?: readonly string[] | undefined;
+ /** Host geometry for response-side accessories. */
+ messageLayout?: "timeline" | "thread" | "continuation" | undefined;
/** A single selected decorator may wrap the host summary with its preview.
* Render without activity when idle; the host owns reply counts/navigation. */
renderMessageSummary?:
@@ -165,8 +169,9 @@ export type ComposerAccessoryProps = Readonly<{
open(target: string): boolean;
}>;
export type ComposerAccessory = Readonly<{
- /** Defaults to the composer. Message accessories render beside reactions. */
- placement?: "composer" | "message";
+ /** Defaults to the composer. Message accessories sit beside reactions;
+ * response accessories occupy an incoming row after the triggering message. */
+ placement?: "composer" | "message" | "response";
/** Opt into decorating the message summary; only the first ordered owner wins. */
decoratesMessageSummary?: boolean;
id: string;
diff --git a/src/features/conversation/service.tsx b/src/features/conversation/service.tsx
index e7953f867a..f086fc82a2 100644
--- a/src/features/conversation/service.tsx
+++ b/src/features/conversation/service.tsx
@@ -279,7 +279,7 @@ export class ConversationService extends Service implements Conversation {
validate(value);
if (
value.placement !== undefined &&
- !["composer", "message"].includes(value.placement)
+ !["composer", "message", "response"].includes(value.placement)
)
throw new Error("Invalid accessory placement");
this.accessoryEntries.register(this.ctx, value);
diff --git a/src/features/conversation/typing-presentation.tsx b/src/features/conversation/typing-presentation.tsx
index 33898a536d..67e1c3ea3c 100644
--- a/src/features/conversation/typing-presentation.tsx
+++ b/src/features/conversation/typing-presentation.tsx
@@ -66,20 +66,72 @@ export function TypingPresentation({
{children}
);
}
-export function useTypingReplacement(value: Replacement, enabled: boolean) {
- const context = useContext(Presentation);
+export function useTypingReplacement(
+ value: Replacement | undefined,
+ enabled: boolean,
+) {
+ useReplacement(useContext(Presentation), value, enabled);
+}
+const ActivityPresentation = createContext<
+ ReturnType | undefined
+>(undefined);
+export function ActivityIndicatorPresentation({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ const [store] = useState(createPresentation);
+ return (
+
+ {children}
+
+ );
+}
+export function useActivityTypingReplacement(
+ value: Replacement | undefined,
+ enabled: boolean,
+ participants?: readonly string[],
+) {
+ const store = useContext(ActivityPresentation);
+ useReplacement(
+ store ? { store, active: true } : undefined,
+ value,
+ enabled,
+ participants,
+ );
+}
+function useReplacement(
+ context:
+ | { store: ReturnType; active: boolean }
+ | undefined,
+ value: Replacement | undefined,
+ enabled: boolean,
+ participants?: readonly string[],
+) {
const store = context?.store;
const active = context?.active;
- const { session, channelId, threadRootId, pubkey } = value;
+ const { session, channelId, threadRootId, pubkey } = value ?? {};
+ const participantKeys = (participants ?? (pubkey ? [pubkey] : [])).join(":");
useLayoutEffect(() => {
- if (active && enabled)
- return store?.register({
- session,
- channelId,
- threadRootId,
- pubkey,
- });
- }, [store, active, enabled, session, channelId, threadRootId, pubkey]);
+ if (!active || !enabled || !session || !channelId || !participantKeys)
+ return;
+ const releases = participantKeys
+ .split(":")
+ .map((pubkey) =>
+ store?.register({ session, channelId, threadRootId, pubkey }),
+ );
+ return () => {
+ for (const release of releases) release?.();
+ };
+ }, [
+ store,
+ active,
+ enabled,
+ session,
+ channelId,
+ threadRootId,
+ participantKeys,
+ ]);
}
export function useReplacedTypers(
session: object | undefined,
@@ -87,6 +139,12 @@ export function useReplacedTypers(
threadRootId: string | undefined,
) {
const context = useContext(Presentation);
+ const activityStore = useContext(ActivityPresentation);
+ const activityEntries = useSyncExternalStore(
+ activityStore?.subscribe ?? noopSubscribe,
+ activityStore?.snapshot ?? emptySnapshot,
+ emptySnapshot,
+ );
const entries = useSyncExternalStore(
context?.store.subscribe ?? noopSubscribe,
context?.store.snapshot ?? emptySnapshot,
@@ -95,17 +153,22 @@ export function useReplacedTypers(
return useMemo(
() =>
new Set(
- context?.active
- ? entries
- .filter(
- (entry) =>
- entry.session === session &&
- entry.channelId === channelId &&
- entry.threadRootId === threadRootId,
- )
- .map((entry) => entry.pubkey)
- : [],
+ [...(context?.active ? entries : []), ...activityEntries]
+ .filter(
+ (entry) =>
+ entry.session === session &&
+ entry.channelId === channelId &&
+ entry.threadRootId === threadRootId,
+ )
+ .map((entry) => entry.pubkey),
),
- [context?.active, entries, session, channelId, threadRootId],
+ [
+ context?.active,
+ entries,
+ activityEntries,
+ session,
+ channelId,
+ threadRootId,
+ ],
);
}
diff --git a/src/features/messages/ActivityGroupPreview.test.tsx b/src/features/messages/ActivityGroupPreview.test.tsx
new file mode 100644
index 0000000000..e636ea7608
--- /dev/null
+++ b/src/features/messages/ActivityGroupPreview.test.tsx
@@ -0,0 +1,265 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
+import { useState } from "react";
+import { afterEach, expect, it, vi } 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 {
+ activityPresentation,
+ useActivityPresentation,
+} from "./activity-presentation";
+import { MessageRow } from "./MessageRow";
+
+stubAvatarBrowserApis();
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+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 hidden 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.queryByRole("button")).toBeNull();
+ act(() => {
+ f.local
+ .begin({
+ scope: "test",
+ agent: "agent",
+ channelId: "channel",
+ messageId: "next-request",
+ threadRootId: "request",
+ })
+ .started();
+ });
+ expect(screen.queryByRole("button")).toBeNull();
+ } 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();
+ function Preview() {
+ const presentation = useActivityPresentation(
+ [step],
+ f.activity,
+ new Set(["agent"]),
+ step.threadRootId,
+ );
+ return (
+ undefined}
+ onOpenLink={() => false}
+ day={false}
+ retry={undefined}
+ />
+ );
+ }
+ render( );
+ 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();
+ }
+});
+
+it("opens contained history and keeps focus on the collapse control", () => {
+ const history = [
+ step,
+ {
+ ...step,
+ id: "helper-step",
+ authorId: "helper",
+ content: "Checking the helper layout",
+ },
+ ];
+ const presentation = activityPresentation(
+ history,
+ undefined,
+ new Set(["agent", "helper"]),
+ );
+ const group = presentation.groups[0];
+ function Preview() {
+ const [expanded, setExpanded] = useState(false);
+ if (!group) throw new Error("Missing activity group");
+ return (
+
+
undefined}
+ renderHistory={(row) => {
+ const activity = presentation.byRow.get(row.id);
+ if (!activity) throw new Error("Missing history presentation");
+ return (
+ undefined}
+ onOpenLink={() => false}
+ day={false}
+ retry={undefined}
+ />
+ );
+ }}
+ expanded={expanded}
+ onToggle={() => setExpanded((value) => !value)}
+ />
+
+ );
+ }
+ render( );
+ const toggle = screen.getByRole("button", { name: /Show activity history/ });
+ fireEvent.click(toggle, { detail: 0 });
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+ expect(toggle).toHaveFocus();
+ expect(screen.getByText("Checking the layout")).toBeVisible();
+ expect(screen.getByText("Checking the helper layout")).toBeVisible();
+ fireEvent.click(toggle, { detail: 0 });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(toggle).toHaveFocus();
+ expect(screen.queryByText("Checking the layout")).toBeNull();
+});
+
+it("keeps the primary avatar on the activity bubble without repeating an anchored byline", () => {
+ const history = [{ ...step, id: "one" }, step];
+ const renderGroup = (rows: ChannelMessage[]) => {
+ const group = activityPresentation(rows, undefined, new Set(["agent"]))
+ .groups[0];
+ if (!group) throw new Error("Missing group");
+ return (
+ undefined}
+ expanded={false}
+ onToggle={() => {}}
+ />
+ );
+ };
+ const view = render(renderGroup(history));
+ expect(screen.getByRole("img", { name: "Studio assistant" })).toBeVisible();
+ expect(screen.getByText("Studio assistant", { exact: true })).toBeVisible();
+ const { audience: _, ...reply } = step;
+ view.rerender(renderGroup([{ ...reply, id: "reply" }, ...history]));
+ expect(screen.queryByText("Studio assistant", { exact: true })).toBeNull();
+ expect(screen.getByRole("img", { name: "Studio assistant" })).toBeVisible();
+ view.rerender(
+ renderGroup([
+ { ...reply, id: "reply" },
+ { ...step, id: "one" },
+ { ...step, authorId: "helper" },
+ ]),
+ );
+ expect(
+ screen.getByRole("img", { name: "Studio assistant, helper" }),
+ ).toBeVisible();
+ view.rerender(
+ renderGroup([
+ { ...reply, id: "reply" },
+ { ...reply, id: "user", authorId: "human" },
+ ...history,
+ ]),
+ );
+ expect(screen.getByText("Studio assistant", { exact: true })).toBeVisible();
+});
diff --git a/src/features/messages/ActivityGroupPreview.tsx b/src/features/messages/ActivityGroupPreview.tsx
new file mode 100644
index 0000000000..1d0e6b05b2
--- /dev/null
+++ b/src/features/messages/ActivityGroupPreview.tsx
@@ -0,0 +1,157 @@
+import { useLayoutEffect, useRef, type ReactNode } from "react";
+import { CaretUpIcon } from "../../shared/design-system/icons";
+import { AgentResponsePreview } from "./AgentResponsePreview";
+import { useActivityIndicator } from "./use-activity-indicator";
+import type { RelaySession } from "../relay/session";
+import { ShimmerText } from "../../shared/design-system/ui/ShimmerText";
+import type { ActivityPresentation } from "./activity-presentation";
+import { Avatar } from "../../shared/design-system/ui/Avatar";
+import type { ChannelMessage, Profile } from "../relay/contracts";
+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,
+ session,
+ profiles,
+ media,
+ expanded,
+ onToggle,
+ layout = "timeline",
+ renderHistory,
+}: {
+ group: ActivityPresentation;
+ session?: RelaySession | undefined;
+ profiles: ReadonlyMap;
+ media: (url: string, size?: "small") => string | undefined;
+ expanded: boolean;
+ onToggle: () => void;
+ layout?: "timeline" | "thread";
+ renderHistory?: (row: ChannelMessage) => ReactNode;
+}) {
+ const container = useRef(null);
+ useLayoutEffect(() => {
+ const node = container.current;
+ return () => {
+ const scope = node?.closest("[data-message-scroller]");
+ const focused = node?.ownerDocument.activeElement;
+ if (!node || !scope || !focused || !node.contains(focused)) return;
+ const top = node.getBoundingClientRect().top;
+ queueMicrotask(() => {
+ if (
+ !scope.isConnected ||
+ scope.ownerDocument.activeElement !== scope.ownerDocument.body
+ )
+ return;
+ const buttons = [
+ ...scope.querySelectorAll(
+ "[data-message-id] button:not([disabled])",
+ ),
+ ];
+ const target = buttons
+ .filter(
+ (button) =>
+ button.getClientRects().length &&
+ !button.closest("[inert], [aria-hidden='true']"),
+ )
+ .sort(
+ (a, b) =>
+ Math.abs(a.getBoundingClientRect().top - top) -
+ Math.abs(b.getBoundingClientRect().top - top),
+ )[0];
+ (target ?? scope).focus({ preventScroll: true });
+ });
+ };
+ }, []);
+ const { latest: row, history, participants, done, working } = group;
+ const live = useActivityIndicator(
+ session,
+ row,
+ !done && !group.followingMessage,
+ participants,
+ group.requestId,
+ );
+ if (done && !group.retained) return null;
+ const primary = history[0]?.authorId ?? row.authorId;
+ const profile = profiles.get(primary);
+ const name = profile?.name ?? primary.slice(0, 10);
+ const text = Array.from(messagePreviewText(row.content).replace(/\s+/g, " "));
+ const preview =
+ text.length > 280 ? `${text.slice(0, 279).join("")}…` : text.join("");
+ const content = (
+
+
+
{
+ event.currentTarget.focus({ preventScroll: true });
+ onToggle();
+ }}
+ >
+ {expanded && (
+
+
+ }
+ />
+
+ )}
+
+ {expanded ? (
+ "Collapse"
+ ) : (
+ {preview}
+ )}
+
+
+
+
+ );
+ return (
+ ({
+ id,
+ name: profiles.get(id)?.name ?? id.slice(0, 10),
+ picture: avatarMedia(profiles.get(id)?.picture, media),
+ }))}
+ stackPrevious={!group.standalone}
+ picture={avatarMedia(profile?.picture, media)}
+ layout={layout}
+ >
+
+ {content}
+ {expanded && renderHistory && (
+
+ {history.map((entry) => (
+
{renderHistory(entry)}
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/features/messages/ActivityStepPreview.module.css b/src/features/messages/ActivityStepPreview.module.css
new file mode 100644
index 0000000000..88cdc251dc
--- /dev/null
+++ b/src/features/messages/ActivityStepPreview.module.css
@@ -0,0 +1,16 @@
+.stepPreview {
+ display: flex;
+ width: fit-content;
+ align-items: center;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ height: calc(var(--text-message) * var(--text-message--line-height));
+}
+.stepText {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/src/features/messages/ActivityStepPreview.tsx b/src/features/messages/ActivityStepPreview.tsx
new file mode 100644
index 0000000000..c96ded3d7c
--- /dev/null
+++ b/src/features/messages/ActivityStepPreview.tsx
@@ -0,0 +1,29 @@
+import { TypingDots } from "../../shared/design-system/ui/TypingDots";
+import { ShimmerText } from "../../shared/design-system/ui/ShimmerText";
+import styles from "./ActivityStepPreview.module.css";
+
+/** The request's typing footprint opens into its real current step. */
+export function ActivityStepPreview({
+ text,
+ state,
+}: {
+ text?: string | undefined;
+ state: "waiting" | "working";
+}) {
+ const characters = Array.from(text?.replace(/\s+/g, " ").trim() ?? "");
+ const label =
+ characters.length > 280
+ ? `${characters.slice(0, 279).join("")}…`
+ : characters.join("");
+ return (
+
+ {!label || label === "Sending a message" ? (
+
+ ) : (
+
+ {label}
+
+ )}
+
+ );
+}
diff --git a/src/features/messages/AgentResponsePreview.tsx b/src/features/messages/AgentResponsePreview.tsx
new file mode 100644
index 0000000000..ed5ce56abb
--- /dev/null
+++ b/src/features/messages/AgentResponsePreview.tsx
@@ -0,0 +1,85 @@
+import type { ReactNode } from "react";
+import { Avatar } from "../../shared/design-system/ui/Avatar";
+import { RobotIcon } from "../../shared/design-system/icons";
+import styles from "./Messages.module.css";
+
+/** Incoming message geometry for work that has not published a response yet. */
+export function AgentResponsePreview({
+ name,
+ picture,
+ participants = [],
+ layout = "timeline",
+ children,
+ stackPrevious = false,
+}: {
+ stackPrevious?: boolean;
+ name: string;
+ picture?: string | undefined;
+ participants?: readonly {
+ id: string;
+ name: string;
+ picture?: string | undefined;
+ }[];
+ layout?: "timeline" | "thread" | "continuation" | undefined;
+ children: ReactNode;
+}) {
+ const avatars: typeof participants = participants.length
+ ? participants.slice(0, 3)
+ : [{ id: "primary", name, picture }];
+ return (
+
+
+
+
+
+
agent.name).join(", ")
+ : name
+ }
+ >
+ {avatars.map((agent) => (
+
+
+
+ ))}
+
+
+ {!stackPrevious && (
+
+ )}
+
{children}
+
+
+
+ );
+}
diff --git a/src/features/messages/ChannelTimeline.report.test.tsx b/src/features/messages/ChannelTimeline.report.test.tsx
index d5dedce065..6cba06c2a2 100644
--- a/src/features/messages/ChannelTimeline.report.test.tsx
+++ b/src/features/messages/ChannelTimeline.report.test.tsx
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import "../../../tests/sonner-dom";
+import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
act,
@@ -94,7 +95,11 @@ afterEach(() => {
localStorage.clear();
});
-function mount(publish: (event: RelayEvent) => Promise) {
+function mount(
+ publish: (event: RelayEvent) => Promise,
+ targetEvent = target,
+ initial = [targetEvent, neighbor],
+) {
let live: LiveCallbacks | undefined;
const owner = createRelaySession(
{
@@ -109,7 +114,7 @@ function mount(publish: (event: RelayEvent) => Promise) {
);
owners.push(owner);
owner.session.channels.ensure("c");
- live?.receive([roster(relay, "c", [viewer.pubkey], 1), target, neighbor]);
+ live?.receive([roster(relay, "c", [viewer.pubkey], 1), ...initial]);
const tree = () => (
@@ -125,10 +130,18 @@ function mount(publish: (event: RelayEvent) => Promise) {
);
const result = render(tree());
const row = () =>
- document.querySelector(`[data-message-id="${target.id}"]`);
+ document.querySelector(
+ `[data-message-id="${targetEvent.id}"]`,
+ );
const neighborRow = () =>
document.querySelector(`[data-message-id="${neighbor.id}"]`);
return {
+ receive(event: RelayEvent) {
+ act(() => {
+ live?.receive([event]);
+ result.rerender(tree());
+ });
+ },
row,
neighborRow,
// Scroll the target out of the virtualizer buffer.
@@ -149,10 +162,13 @@ function mount(publish: (event: RelayEvent) => Promise) {
async function openReport(row: HTMLElement) {
const user = userEvent.setup();
- const trigger = within(row).getByRole("button", {
+ const more = within(row).queryByRole("button", {
name: "More message actions",
});
- await user.click(trigger);
+ const trigger =
+ more ?? within(row).getByRole("button", { name: /Working on the report/ });
+ if (more) await user.click(trigger);
+ else fireEvent.contextMenu(trigger);
await user.click(await screen.findByRole("menuitem", { name: "Report" }));
await screen.findByRole("dialog", { name: "Report message" });
return { user, trigger };
@@ -322,3 +338,49 @@ it("closing the image viewer before the report notice ends keeps the row pinned
await waitFor(() => expect(h.row()).toBeNull());
expect(h.neighborRow()).not.toBeNull();
});
+
+it.each(["draft", "pending"] as const)(
+ "retains the contained report owner through completion with a %s report",
+ async (phase) => {
+ let release!: () => void;
+ const publish = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+ const step = message(other, "c", "Working on the report", 2, [
+ ["audience", "agents"],
+ ]);
+ const h = mount(publish, step, [step]);
+ await userEvent.click(
+ screen.getByRole("button", { name: /Show activity history/ }),
+ );
+ const original = h.row();
+ if (!original) throw new Error("Missing contained activity");
+ const { user } = await openReport(original);
+ await user.click(screen.getByRole("radio", { name: "Spam" }));
+ const context = screen.getByRole("textbox", {
+ name: "Additional context (optional)",
+ });
+ await user.type(context, "Keep this draft");
+ try {
+ if (phase === "pending") {
+ await user.click(screen.getByRole("button", { name: "Submit report" }));
+ await waitFor(() => expect(publish).toHaveBeenCalledOnce());
+ }
+ h.receive(message(other, "c", "Finished the work", 3));
+ expect(h.row()).toBe(original);
+ expect(
+ screen.getByRole("dialog", { name: "Report message" }),
+ ).toBeTruthy();
+ expect(context).toHaveValue("Keep this draft");
+ } finally {
+ if (phase === "pending") await act(async () => release());
+ }
+ if (phase === "pending")
+ expect(
+ await screen.findByText("Report submitted to community moderators"),
+ ).toBeTruthy();
+ },
+);
diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx
index 755c1dcfb9..efa17c733a 100644
--- a/src/features/messages/ChannelTimeline.test.tsx
+++ b/src/features/messages/ChannelTimeline.test.tsx
@@ -24,6 +24,11 @@ import type { ChannelMessage, ChannelWindow } from "../relay/contracts";
// metrics reproduce the event ordering measured separately in Chromium/WebKit.
// Reading geometry/dwell has its own real-hook boundary suite. This fixture
// deliberately supplies only the DOM shape needed for positioning.
+// Activity rendering/subscriptions have their own mounted integration suite.
+vi.mock("./TypingIndicator", () => ({
+ TypingIndicator: () => null,
+ useConversationAgents: () => ({ agents: [] }),
+}));
vi.mock("./use-reading", () => ({
useReading: vi.fn(),
readingPositioned: vi.fn(),
@@ -179,12 +184,15 @@ function setup({
for (const [callback, targets] of observers)
if (targets.has(target)) callback();
};
- const mutations = new Map<() => void, Map>();
+ const mutations = new Map<
+ (records: MutationRecord[]) => void,
+ Map
+ >();
vi.stubGlobal(
"MutationObserver",
class {
targets = new Map();
- constructor(callback: () => void) {
+ constructor(callback: (records: MutationRecord[]) => void) {
mutations.set(callback, this.targets);
}
observe(target: unknown, options: MutationObserverInit) {
@@ -200,7 +208,9 @@ function setup({
for (const [callback, targets] of mutations) {
const options = targets.get(list);
if (options?.attributes && options.attributeFilter?.includes("style"))
- callback();
+ callback([
+ { type: "attributes", attributeName: "style" } as MutationRecord,
+ ]);
}
};
const element = {
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index 373efe74ce..cba0772fc5 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -1,16 +1,18 @@
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: The history region must support keyboard scrolling.
+import { TypingIndicator, useConversationAgents } from "./TypingIndicator";
+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 {
+ activityDisplayRows,
+ useActivityPresentation,
+} from "./activity-presentation";
import { DayDivider } from "./MessageTimestamp";
import type { ConversationExtensions } from "../conversation/contracts";
import type { RelaySession } from "../relay/session";
@@ -25,8 +27,15 @@ import {
} from "react";
import { Virtualizer, type VirtualizerHandle } from "virtua";
import { MessageRow, type MessageLinkOrigin } from "./MessageRow";
-import { continuesMessageGroup } from "./message-grouping";
-import type { Attachment, ChannelWindow } from "../relay/contracts";
+import {
+ continuesMessageGroup,
+ publicMessageNeighbor,
+} from "./message-grouping";
+import type {
+ Attachment,
+ ChannelMessage,
+ ChannelWindow,
+} from "../relay/contracts";
import { useRowProfiles } from "../relay/react";
import { geometryFor, geometrySignature } from "./geometry";
import { readView, writeView } from "../../shared/view-state";
@@ -140,8 +149,6 @@ export type ChannelTimelineProps = {
chatPresentation?: boolean | undefined;
/** False: rows open existing threads but offer none to start; see MessageRow. */
startsThreads?: boolean | undefined;
- /** A following composer shows conversation activity; the jump button leaves it a lane. */
- activityLane?: boolean | undefined;
onOpenLink(url: string, origin?: MessageLinkOrigin): boolean;
canOpenLink?: ((target: string) => boolean) | undefined;
revealMessageId?: string | undefined;
@@ -201,7 +208,6 @@ function Timeline({
onOpenMediaReview,
chatPresentation = false,
startsThreads = true,
- activityLane = false,
}: ChannelTimelineProps) {
useMessageSelectionCopy();
const [initialPosition] = useState(() =>
@@ -233,14 +239,57 @@ function Timeline({
agentPubkeys,
],
);
- const rows = useMemo(() => membershipRows(visibleRows), [visibleRows]);
+ const chronologicalRows = useMemo(
+ () => membershipRows(visibleRows),
+ [visibleRows],
+ );
const [expandedActivity, setExpandedActivity] = useState>(
() => new Set(),
);
- const activity = useMemo(
- () => (chatPresentation ? activityGroups(rows) : []),
- [rows, chatPresentation],
+ // Holders are counted per row: a report notice and an image viewer can hold
+ // the same row, and releasing one must not drop the other's pin.
+ const [pinnedIds, setPinnedIds] = useState>(
+ () => new Map(),
+ );
+ const keepRowMounted = useCallback((id: string) => {
+ setPinnedIds((ids) => new Map(ids).set(id, (ids.get(id) ?? 0) + 1));
+ return () =>
+ setPinnedIds((ids) => {
+ const next = new Map(ids);
+ const count = (ids.get(id) ?? 0) - 1;
+ if (count > 0) next.set(id, count);
+ else next.delete(id);
+ return next;
+ });
+ }, []);
+ const presentation = useActivityPresentation(
+ chronologicalRows,
+ queries.agentActivity,
+ agentPubkeys,
+ undefined,
+ pinnedIds,
+ );
+ const activity = presentation.groups;
+ const { agents: liveAgents } = useConversationAgents(
+ queries,
+ channelId,
+ undefined,
+ );
+ const targetId =
+ inlineTarget?.messageId ??
+ (navigation?.target.kind === "conversation"
+ ? navigation.target.messageId
+ : undefined);
+ const rows = useMemo(
+ () =>
+ activityDisplayRows(
+ chronologicalRows,
+ activity,
+ targetId ?? revealMessageId,
+ ),
+ [chronologicalRows, activity, targetId, revealMessageId],
);
+ const lastPublicMessage = publicMessageNeighbor(rows, rows.length, -1);
// 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,54 +297,74 @@ 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]));
}
const disclosures = new Map(
- activity.map((group) => [
- group.ids[0],
- {
- ...group,
- expanded: group.ids
- .slice(0, -RECENT_ACTIVITY_COUNT)
- .some((id) => expandedActivity.has(id)),
- },
- ]),
+ activity
+ .filter((group) => !group.done || group.retained)
+ .map((group) => [
+ group.ids[0],
+ {
+ ...group,
+ expanded: group.ids.some((id) => expandedActivity.has(id)),
+ },
+ ]),
+ );
+ const containedOwnerCache = useRef(new Map());
+ const nextContainedOwners = new Map(
+ activity
+ .filter(
+ (group) =>
+ (!group.done || group.retained) &&
+ group.ids.some((id) => expandedActivity.has(id)),
+ )
+ .flatMap((group) => group.ids.map((id) => [id, group.ids[0]] as const)),
);
+ // Profile/status refreshes can rebuild the projection without moving rows.
+ // Only restart positioning effects when their virtual ownership changes.
+ if (
+ nextContainedOwners.size !== containedOwnerCache.current.size ||
+ [...nextContainedOwners].some(
+ ([id, owner]) => containedOwnerCache.current.get(id) !== owner,
+ )
+ )
+ containedOwnerCache.current = nextContainedOwners;
+ const containedOwners = containedOwnerCache.current;
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);
const [geometry] = useState(() => geometryFor(queries.channels));
const [focusedMessageId, setFocusedMessageId] = useState();
const [focusedActivityId, setFocusedActivityId] = useState();
- // Holders are counted per row: a report notice and an image viewer can hold
- // the same row, and releasing one must not drop the other's pin.
- const [pinnedIds, setPinnedIds] = useState>(
- () => new Map(),
- );
- const keepRowMounted = useCallback((id: string) => {
- setPinnedIds((ids) => new Map(ids).set(id, (ids.get(id) ?? 0) + 1));
- return () =>
- setPinnedIds((ids) => {
- const next = new Map(ids);
- const count = (ids.get(id) ?? 0) - 1;
- if (count > 0) next.set(id, count);
- else next.delete(id);
- return next;
- });
- }, []);
// Folding, stacking, and cached measurements share the same visibility.
const foldedActivity = new Set(
[...hiddenActivity].filter(
(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,12 +373,31 @@ function Timeline({
const keptIndices = rows.flatMap((row, index) =>
row.id === focusedMessageId ||
row.id === focusedActivityId ||
- pinnedIds.has(row.id)
+ (disclosures.get(row.id)?.ids[0] === focusedActivityId &&
+ !!focusedActivityId) ||
+ pinnedIds.has(row.id) ||
+ disclosures
+ .get(row.id)
+ ?.ids.some((id) => pinnedIds.has(id) || id === focusedMessageId)
? [index]
: [],
);
const scroller = useRef(null);
const edge = useRef(null);
+ const activityTail = useRef(null);
+ useLayoutEffect(() => {
+ const tail = activityTail.current;
+ const element = scroller.current;
+ if (!tail || !element || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(() => {
+ if (settled.current && follow.current) {
+ layoutCorrection.current = true;
+ element.scrollTop = element.scrollHeight;
+ }
+ });
+ observer.observe(tail);
+ return () => observer.disconnect();
+ }, []);
// Keep pagination space through EOF so removing its control cannot change
// Virtua's start margin in the same measurement cycle as a prepend.
const [reserveHistorySpace, setReserveHistorySpace] = useState(false);
@@ -464,17 +552,17 @@ function Timeline({
setShowJumpToLatest(false);
setNewMessageCount(0);
layoutCorrection.current = true;
+ const tailHeight =
+ activityTail.current?.getBoundingClientRect().height ?? 0;
handle.current.scrollToIndex(rows.length - 1, {
align: "end",
+ ...(tailHeight ? { offset: tailHeight } : {}),
});
}, [rows.length]);
const inlineSignal = inlineTarget?.signal;
- const targetId =
- inlineTarget?.messageId ??
- (navigation?.target.kind === "conversation"
- ? navigation.target.messageId
- : undefined);
- const targetIndex = rows.findIndex((row) => row.id === targetId);
+ const targetIndex = rows.findIndex(
+ (row) => row.id === (containedOwners.get(targetId ?? "") ?? targetId),
+ );
// The reveal keeps its row in place through late measurement until any newer
// intent: reader input, a send or Jump to latest.
const targetIntent = useRef(undefined);
@@ -513,9 +601,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]);
@@ -593,7 +679,11 @@ function Timeline({
useLayoutEffect(() => {
const element = scroller.current;
const list = element?.querySelector("ol");
- const index = rows.findIndex((row) => row.id === reader.current.id);
+ const index = rows.findIndex(
+ (row) =>
+ row.id ===
+ (containedOwners.get(reader.current.id ?? "") ?? reader.current.id),
+ );
if (index < 0) heldReader.current = undefined;
if (
prepend ||
@@ -684,7 +774,7 @@ function Timeline({
element.ownerDocument.addEventListener("pointerup", done);
element.ownerDocument.addEventListener("pointercancel", done);
return () => release(true);
- }, [rows, prepend]);
+ }, [rows, prepend, containedOwners]);
useLayoutEffect(() => {
const element = scroller.current;
if (!element) return;
@@ -773,7 +863,8 @@ function Timeline({
revealMessageId && revealed.current !== revealMessageId
? rows.findIndex(
(row) =>
- row.id === revealMessageId ||
+ row.id ===
+ (containedOwners.get(revealMessageId) ?? revealMessageId) ||
row.membershipRows?.some(
(member) => member.id === revealMessageId,
),
@@ -841,25 +932,50 @@ function Timeline({
const index = anchor
? rows.findIndex(
(row) =>
- row.id === anchor.id ||
+ row.id === (containedOwners.get(anchor.id) ?? anchor.id) ||
row.membershipRows?.some((member) => member.id === anchor.id),
)
: -1;
const row = rows[index];
if (anchor && row) {
- restoredAnchor.current = { id: row.id, y: anchor.y };
+ restoredAnchor.current = {
+ id: containedOwners.has(anchor.id) ? anchor.id : row.id,
+ y: anchor.y,
+ };
+ const element = scroller.current;
+ const target = containedOwners.has(anchor.id)
+ ? element?.querySelector(
+ `[data-message-id="${CSS.escape(anchor.id)}"]`,
+ )
+ : undefined;
+ // A contained step shares its virtual row with earlier history.
+ // Once mounted, include that inner offset in the saved placement.
+ const innerOffset =
+ target && element
+ ? target.getBoundingClientRect().top -
+ element.getBoundingClientRect().top +
+ element.scrollTop -
+ size.edgeHeight -
+ handle.current.getItemOffset(index)
+ : 0;
handle.current.scrollToIndex(index, {
align: "start",
- offset: -anchor.y,
+ offset: innerOffset - anchor.y,
});
} else handle.current.scrollTo(restore.offset);
follow.current = false;
} else if (follow.current) {
layoutCorrection.current = true;
- handle.current.scrollToIndex(
- revealIndex >= 0 ? revealIndex : rows.length - 1,
- { align: "end" },
- );
+ if (revealIndex >= 0)
+ handle.current.scrollToIndex(revealIndex, { align: "end" });
+ else {
+ const tailHeight =
+ activityTail.current?.getBoundingClientRect().height ?? 0;
+ handle.current.scrollToIndex(rows.length - 1, {
+ align: "end",
+ ...(tailHeight ? { offset: tailHeight } : {}),
+ });
+ }
if (revealIndex >= 0) {
revealed.current = revealMessageId;
pendingReveal.current = undefined;
@@ -886,8 +1002,12 @@ function Timeline({
// this parent height. Observing the parent box would create skipped
// resize notifications; watch only Virtua's committed height instead.
let height = list.style.height;
- observer = new MutationObserver(() => {
- if (list.style.height === height) return;
+ observer = new MutationObserver((records) => {
+ if (
+ list.style.height === height &&
+ !records.some((record) => record.type === "childList")
+ )
+ return;
height = list.style.height;
// Capture a native clamp while its shrink is still observable.
// Another append can grow the list before the queued scroll event.
@@ -906,6 +1026,10 @@ function Timeline({
observer.observe(list, {
attributes: true,
attributeFilter: ["style"],
+ childList:
+ !!restore?.anchor && containedOwners.has(restore.anchor.id),
+ subtree:
+ !!restore?.anchor && containedOwners.has(restore.anchor.id),
});
}
}
@@ -940,6 +1064,7 @@ function Timeline({
revealedSignal,
updateJumpToLatest,
revealMessageId,
+ containedOwners,
]);
// After positioning reads it: a load that ends without rows lands no page.
useLayoutEffect(() => {
@@ -1015,6 +1140,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)}
@@ -1135,9 +1261,6 @@ function Timeline({
visible={showJumpToLatest}
newMessageCount={newMessageCount}
onClick={jumpToLatest}
- activityLane={
- activityLane ? { session: queries, channelId } : undefined
- }
/>
{width > 0 && (
{
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
@@ -1166,49 +1295,101 @@ function Timeline({
(rows[index - 1]?.createdAt ?? 0) * 1000,
).toDateString() !==
new Date(row.createdAt * 1000).toDateString();
+ const renderMessage = (row: ChannelMessage, inside = false) => {
+ const recordedActivity = presentation.byRow.get(row.id);
+ return (
+
+ );
+ };
return (
{disclosure && (
- {day &&
}
-
{
- event.currentTarget.focus({ preventScroll: true });
- // Opening history is explicit reading intent, even at
- // the bottom: new activity must not pull it away.
+ {!disclosure.expanded &&
+ (() => {
+ const first = rows.findIndex(
+ (item) => item.id === disclosure.ids[0],
+ );
+ const before = rows[first - 1];
+ return (
+ (!before ||
+ new Date(before.createdAt * 1000).toDateString() !==
+ new Date(
+ row.createdAt * 1000,
+ ).toDateString()) && (
+
+ )
+ );
+ })()}
+ renderMessage(entry, true)}
+ profiles={profiles}
+ media={queries.media}
+ expanded={disclosure.expanded}
+ onToggle={() => {
intent.current++;
follow.current = false;
userScrolled.current = true;
restoredAnchor.current = undefined;
const next = new Set(expandedActivity);
- for (const id of disclosure.expanded
- ? disclosure.ids
- : disclosure.ids.slice(0, -RECENT_ACTIVITY_COUNT)) {
+ for (const id of disclosure.ids) {
if (disclosure.expanded) next.delete(id);
else next.add(id);
}
setExpandedActivity(next);
}}
- >
- {disclosure.expanded
- ? "Show fewer"
- : `Show ${disclosure.ids.length - RECENT_ACTIVITY_COUNT} previous ${disclosure.ids.length - RECENT_ACTIVITY_COUNT === 1 ? "step" : "steps"}`}
-
-
+ />
)}
{row.membership ? (
@@ -1225,45 +1406,30 @@ function Timeline({
canOpenLink={canOpenLink}
/>
) : (
-
+ renderMessage(row)
)}
- {index === rows.length - 1 && (
-
- )}
);
})}
)}
+
{!rows.length &&
!window.hasMore &&
window.status === "ready" &&
diff --git a/src/features/messages/ChannelTimeline.viewport.test.tsx b/src/features/messages/ChannelTimeline.viewport.test.tsx
index d2806592f2..cfe5db58b4 100644
--- a/src/features/messages/ChannelTimeline.viewport.test.tsx
+++ b/src/features/messages/ChannelTimeline.viewport.test.tsx
@@ -1,7 +1,16 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ within,
+} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
+import { stubAvatarBrowserApis } from "../agents/avatar-testing";
+stubAvatarBrowserApis();
import { ChannelTimeline } from "./ChannelTimeline";
import { createRelaySession } from "../relay/session";
import { writeView } from "../../shared/view-state";
@@ -13,6 +22,7 @@ const viewport = vi.hoisted(() => ({
size: 0,
start: 0,
caches: [] as unknown[],
+ scrollToIndex: vi.fn(),
}));
// Control the independent virtualizer's public measurement/shift contract.
// React itself owns the real timeline's refs, render and effect ordering.
@@ -42,7 +52,7 @@ vi.mock("virtua", async () => {
findItemIndex: () => viewport.start,
getItemOffset: () => 0,
scrollTo: vi.fn(),
- scrollToIndex: vi.fn(),
+ scrollToIndex: viewport.scrollToIndex,
}),
[],
);
@@ -66,7 +76,11 @@ vi.mock("./MessageRow", () => ({
stackNext: boolean;
keepMounted: (id: string) => () => void;
}) => (
-
+
{row.content}
keepMounted(row.id)}>
Retain {row.id}
@@ -121,11 +135,12 @@ function activityFixture() {
rows: ChannelMessage[],
inlineTarget?: { messageId: string; signal: AbortSignal },
chatPresentation = true,
+ queries = owner.session,
) => (
{
+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(
+ within(screen.getByRole("img", { name: "agent, reviewer" })).getByTitle(
+ "reviewer",
+ ),
+ ).toBeVisible();
+ expect(screen.queryByText("Update 10")).toBeNull();
+ } finally {
+ owner.dispose();
+ }
+});
+
+it("shows the first three distinct agents in the visible identity cluster while retaining its full label", () => {
+ 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",
+ })),
+ ),
+ );
+ const compact = within(
+ screen.getByRole("img", { name: "one, two, three, four" }),
+ );
+ expect(compact.queryByText("+1")).toBeNull();
+ expect(compact.getAllByTitle("one")).toHaveLength(1);
+ expect(compact.getByTitle("two")).toBeVisible();
+ expect(compact.getByTitle("three")).toBeVisible();
+ expect(compact.queryByTitle("four")).toBeNull();
+ const compactAvatar = compact.getAllByTitle("one")[0];
+ if (!compactAvatar) throw new Error("Missing compact agent avatar");
+ expect(
+ compactAvatar.compareDocumentPosition(compact.getByTitle("two")) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ compact
+ .getByTitle("two")
+ .compareDocumentPosition(compact.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,44 +269,109 @@ 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.each([1, 4])(
+ "hides a %i-step completed run while keeping subsequent work in channels",
+ (count) => {
+ const { owner, updates, content } = activityFixture();
+ try {
+ const rows = updates(count * 2 + 1);
+ const view = render(content(rows, undefined, false));
+ expect(screen.queryByText("Update 0")).toBeNull();
+ expect(screen.getByText(`Update ${count * 2}`)).toBeVisible();
+ const middle = rows[count];
+ if (!middle) throw new Error("Missing fixture reply");
+ const { audience: _audience, ...publicReply } = middle;
+ view.rerender(
+ content([
+ ...rows.slice(0, count),
+ { ...publicReply, content: "Main reply" },
+ ...rows.slice(count + 1),
+ ]),
+ );
+ expect(screen.queryByText("Update 0")).toBeNull();
+ const label = `${count} completed ${count === 1 ? "step" : "steps"}`;
+ expect(screen.queryByText(label)).toBeNull();
+ const reply = screen.getByText("Main reply");
+ expect(reply).toBeVisible();
+ expect(screen.getByText(`Update ${count * 2}`)).toBeVisible();
+ expect(
+ screen.getAllByRole("button", { name: /Show activity history/ }),
+ ).toHaveLength(1);
+ } finally {
+ owner.dispose();
+ }
+ },
+);
+
+it("keeps ongoing typing in the reply group after completed history", () => {
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();
- const middle = rows[4];
- if (!middle) throw new Error("Missing fixture reply");
- const { audience: _audience, ...publicReply } = middle;
- view.rerender(
- content([
- ...rows.slice(0, 4),
- { ...publicReply, content: "Main reply" },
- ...rows.slice(5),
- ]),
+ const history = updates(2);
+ const last = history.at(-1);
+ if (!last) throw new Error("Missing activity");
+ const { audience: _audience, ...reply } = last;
+ const activity = owner.session.agentActivity.snapshot();
+ const snapshot = {
+ ...activity,
+ local: [
+ {
+ scope: "test",
+ agent: reply.authorId,
+ channelId: "alpha",
+ messageId: "request",
+ threadRootId: "request",
+ state: "working" as const,
+ startedAt: 1,
+ },
+ ],
+ };
+ const queries = {
+ ...owner.session,
+ agentActivity: {
+ ...owner.session.agentActivity,
+ snapshot: () => snapshot,
+ },
+ };
+ const view = render(
+ content(
+ [...history, { ...reply, id: "reply", content: "Main reply" }],
+ undefined,
+ true,
+ queries,
+ ),
+ );
+ expect(screen.queryByText("2 completed steps")).toBeNull();
+ expect(
+ screen.getByRole("button", { name: /Activity: .*working/ }),
+ ).toBeVisible();
+ expect(
+ view.container.querySelector(
+ "[data-conversation-activity] [data-agent-response-preview]",
+ ),
+ ).toHaveAttribute("data-stack-previous", "true");
+ expect(screen.getByText("Main reply").closest("li")).toHaveAttribute(
+ "data-stack-next",
+ "true",
);
- expect(screen.getByText("Update 0")).toBeVisible();
- expect(screen.getByText("Main reply")).toBeVisible();
- expect(screen.getByText("Update 5")).toBeVisible();
- expect(screen.queryByRole("button", { name: /previous steps/ })).toBeNull();
} 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 +382,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 +397,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 +418,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 +427,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 +444,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 +458,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 +466,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,14 +478,56 @@ 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();
}
},
);
+it("restores a contained activity step at its saved offset rather than the group start", () => {
+ const { owner, updates, content } = activityFixture();
+ const frames: FrameRequestCallback[] = [];
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
+ frames.push(callback);
+ return frames.length;
+ });
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
+ function (this: HTMLElement) {
+ return {
+ top: this.dataset.messageId === "activity-4" ? 240 : 0,
+ left: 0,
+ right: 800,
+ bottom: 0,
+ width: 800,
+ height: 0,
+ x: 0,
+ y: 0,
+ toJSON() {},
+ };
+ },
+ );
+ writeView("activity-folding", "scroll:alpha", {
+ offset: 220,
+ bottom: false,
+ anchor: { id: "activity-4", y: 20 },
+ });
+ try {
+ render(content(updates(9)));
+ viewport.scrollToIndex.mockClear();
+ act(() => {
+ for (const callback of frames.splice(0)) callback(0);
+ });
+ expect(viewport.scrollToIndex).toHaveBeenCalledWith(0, {
+ align: "start",
+ offset: 220,
+ });
+ } finally {
+ owner.dispose();
+ }
+});
+
it("waits for the first non-empty window to resolve a hidden saved activity anchor", () => {
const { owner, updates, content } = activityFixture();
writeView("activity-folding", "scroll:alpha", {
@@ -347,10 +538,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 +561,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 +575,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 +594,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 +932,57 @@ it.each([
owner.dispose();
}
});
+
+it("hides 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.queryByText("3 completed steps")).toBeNull();
+ expect(screen.queryByText("Update 2")).toBeNull();
+ expect(screen.getByText("Here is the result.")).toBeVisible();
+ } finally {
+ owner.dispose();
+ }
+});
+
+it("reveals a directly linked completed step without restoring the completed summary", () => {
+ const f = activityFixture();
+ const steps = f.updates(2);
+ const first = steps[0];
+ if (!first) throw new Error("Missing step");
+ const { audience: _audience, ...publicRow } = first;
+ const rows = [
+ ...steps,
+ {
+ ...publicRow,
+ id: "reply",
+ content: "Finished",
+ createdAt: 110,
+ },
+ ];
+ const view = render(f.content(rows));
+ expect(screen.queryByText("Update 1")).toBeNull();
+ view.rerender(
+ f.content(rows, {
+ messageId: "activity-1",
+ signal: new AbortController().signal,
+ }),
+ );
+ expect(screen.getByText("Update 1")).toBeVisible();
+ expect(screen.queryByText("Update 0")).toBeNull();
+ expect(screen.queryByRole("button", { name: /activity history/ })).toBeNull();
+ view.unmount();
+ f.owner.dispose();
+});
diff --git a/src/features/messages/JumpToLatestButton.tsx b/src/features/messages/JumpToLatestButton.tsx
index 84957a1bb3..8074aeed79 100644
--- a/src/features/messages/JumpToLatestButton.tsx
+++ b/src/features/messages/JumpToLatestButton.tsx
@@ -1,23 +1,16 @@
import { ArrowDownIcon } from "../../shared/design-system/icons/index";
import { Button } from "../../shared/design-system/ui/Button";
-import { useActivityLaneShown, type ActivityLane } from "./activity-lane";
import styles from "./Messages.module.css";
-export type { ActivityLane } from "./activity-lane";
-
export function JumpToLatestButton({
visible,
newMessageCount,
onClick,
- activityLane,
}: {
visible: boolean;
newMessageCount: number;
onClick(): void;
- activityLane?: ActivityLane | undefined;
}) {
- // One element whether or not a lane applies, so a gate flip keeps focus.
- const lane = useActivityLaneShown(activityLane);
const label =
newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
@@ -28,7 +21,6 @@ export function JumpToLatestButton({
inert={!visible}
aria-hidden={!visible}
data-visible={visible}
- data-activity-lane={lane || undefined}
>
{
type MediaKind = "image" | "video";
type ReviewFixtureOptions = {
kind: MediaKind;
+ typingAgent?: ReturnType;
openComments?: boolean;
replies?: (
viewer: ReturnType,
@@ -55,6 +56,7 @@ function mime(kind: MediaKind) {
async function setupReview({
kind,
+ typingAgent,
openComments = true,
replies: buildReplies = () => [],
extraRootTags = [],
@@ -102,7 +104,20 @@ async function setupReview({
signed(viewer, template),
);
const publish = vi.fn(async () => {});
- const events = [root, ...replies];
+ const events = [
+ root,
+ ...replies,
+ ...(typingAgent
+ ? [
+ signed(typingAgent, {
+ kind: 0,
+ content: JSON.stringify({ name: "Comment agent", is_agent: true }),
+ tags: [],
+ created_at: 1,
+ }),
+ ]
+ : []),
+ ];
const owner = createRelaySession(
{
viewer: viewer.pubkey,
@@ -111,6 +126,8 @@ async function setupReview({
writer: { sign, publish },
async query(filters) {
return filters.flatMap((filter) => {
+ if (filter.kinds?.includes(0))
+ return events.filter((event) => event.kind === 0);
if (filter.kinds?.includes(39002) || filter.kinds?.includes(39000))
return [
roster(relay, "one", [viewer.pubkey]),
@@ -791,3 +808,24 @@ it("opens image comments by default when the thread contains replies", async ()
screen.getByRole("button", { name: "Hide comments" }),
).toBeInTheDocument();
});
+
+it.each(["image", "video"] as const)(
+ "shows incoming agent typing in %s comments before any reply arrives",
+ async (kind) => {
+ const typingAgent = keypair();
+ const { owner, root } = await setupReview({ kind, typingAgent });
+ const agent = typingAgent.pubkey;
+ await owner.session.profiles.ensure([agent], "background");
+ const typing = [{ pubkey: agent, channelId: "one", threadRootId: root.id }];
+ vi.spyOn(owner.session.typing, "snapshot").mockReturnValue(typing);
+ // Closing and reopening the comments mounts the real host with the new signal.
+ fireEvent.click(screen.getByRole("button", { name: "Hide comments" }));
+ fireEvent.click(screen.getByRole("button", { name: "Show comments" }));
+ const comments = await screen.findByRole("region", {
+ name: "Media comments",
+ });
+ expect(
+ within(comments).getByRole("button", { name: /Activity:.*working/ }),
+ ).toBeVisible();
+ },
+);
diff --git a/src/features/messages/MediaReviewViewer.tsx b/src/features/messages/MediaReviewViewer.tsx
index 1b56255b72..3c175b79a9 100644
--- a/src/features/messages/MediaReviewViewer.tsx
+++ b/src/features/messages/MediaReviewViewer.tsx
@@ -1,3 +1,4 @@
+import { TypingIndicator } from "./TypingIndicator";
import { workflowLabel } from "../relay/workflow-attribution";
import { MessageEditScope } from "./MessageEditScope";
import { useReviewSidebarMotion } from "./use-review-sidebar-motion";
@@ -37,6 +38,7 @@ import { useKnownAgentPubkeys } from "../agents/use-known";
import { MessageComposer } from "./MessageComposer";
import { ImageReviewStage } from "./ImageReviewStage";
import { MessageRow } from "./MessageRow";
+import { useActivityPresentation } from "./activity-presentation";
import { parseMediaTimeReply } from "./media-timecode";
import { VideoReviewReactions } from "./VideoReviewReactions";
import styles from "./Messages.module.css";
@@ -455,6 +457,8 @@ function ReviewShell({
{rootId && source ? (
["replies"];
selectedComment?: string | undefined;
limited: boolean;
@@ -598,6 +606,11 @@ function ReviewComments({
}) {
const profiles = useRowProfiles(session.profiles, replies);
const agentPubkeys = useKnownAgentPubkeys(session, profiles);
+ const activity = useActivityPresentation(
+ replies,
+ session.agentActivity,
+ agentPubkeys,
+ );
const comments = useRef(null);
useEffect(() => {
if (selectedComment)
@@ -622,37 +635,54 @@ function ReviewComments({
className={styles.mediaReviewThread}
aria-label="Media comments"
>
- {replies.map((row, index) => (
-
- false}
- day={
- index === 0 ||
- new Date(
- (replies[index - 1]?.createdAt ?? 0) * 1000,
- ).toDateString() !==
- new Date(row.createdAt * 1000).toDateString()
- }
- retry={session.messages.retry}
- onOpenMediaReview={(_rowId, attachment, seconds) =>
- selectAttachment(attachment, seconds)
- }
- {...(seek ? { onMediaTime: seek } : {})}
- />
-
- ))}
+ {replies.map((row, index) => {
+ const recordedActivity = activity.byRow.get(row.id);
+ return (
+
+ false}
+ day={
+ index === 0 ||
+ new Date(
+ (replies[index - 1]?.createdAt ?? 0) * 1000,
+ ).toDateString() !==
+ new Date(row.createdAt * 1000).toDateString()
+ }
+ retry={session.messages.retry}
+ onOpenMediaReview={(_rowId, attachment, seconds) =>
+ selectAttachment(attachment, seconds)
+ }
+ {...(seek ? { onMediaTime: seek } : {})}
+ />
+
+ );
+ })}
+
{!replies.length && (
No comments yet. Add the first below.
diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx
index 40232d3538..f9a2b7b038 100644
--- a/src/features/messages/MessageComposer.test.tsx
+++ b/src/features/messages/MessageComposer.test.tsx
@@ -494,6 +494,48 @@ function mount(
};
}
+it.each([undefined, "thread-root"])(
+ "shows only human typing above the composer in scope %s",
+ (threadRootId) => {
+ const scope = threadRootId ? { threadRootId } : {};
+ const h = mount(scope);
+ const human = "c".repeat(64);
+ h.setProfiles(
+ new Map([
+ [human, { name: "Sam" }],
+ [first.pubkey, { name: "Assistant", isAgent: true }],
+ ]),
+ );
+ const entries = [
+ { pubkey: human, channelId: "channel", ...scope },
+ { pubkey: first.pubkey, channelId: "channel", ...scope },
+ {
+ pubkey: second.pubkey,
+ channelId: "channel",
+ threadRootId: "another-thread",
+ },
+ ];
+ h.retarget({
+ session: {
+ ...h.session,
+ typing: { ...h.session.typing, snapshot: () => entries },
+ },
+ });
+ const status = within(h.container).getByRole("status", {
+ name: "Conversation activity",
+ });
+ expect(status).toHaveTextContent(/^Sam is typing$/);
+ expect(
+ within(h.container).getByText("Sam is typing", {
+ selector: "span[aria-hidden] span",
+ }),
+ ).toBeVisible();
+ expect(
+ within(h.container).queryByRole("button", { name: /Activity:/ }),
+ ).toBeNull();
+ },
+);
+
it("publishes typing for text edits but not caret-driven token expansion", () => {
const text = `[Honey](${profileTarget(first.pubkey)})`;
writeView("scope", "draft:channel", text);
diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx
index 1c5a754fd2..29419d4ae1 100644
--- a/src/features/messages/MessageComposer.tsx
+++ b/src/features/messages/MessageComposer.tsx
@@ -6,6 +6,7 @@ import { ComposerChatCards } from "./ComposerChatCards";
import { useConversationPresentation } from "../conversation/ConversationPresentation";
import { TypingPresentation } from "../conversation/typing-presentation";
import { useTypingBroadcast } from "./useTypingBroadcast";
+import { TypingIndicator } from "./TypingIndicator";
import { useEffectEvent } from "react";
import { useMessageEditScope } from "./MessageEditScope";
import { useMessageDeletion } from "./MessageManagement";
@@ -48,7 +49,6 @@ import { rememberAgentsPreference } from "./mention-preferences";
import { useAgentChoices } from "../agents/use-choices";
import { ownSolo } from "../solo/labels";
import { soloHasPeople, soloRoster, useSoloHasPeople } from "../solo/people";
-import { TypingIndicator } from "./TypingIndicator";
import {
ArrowUpIcon,
PaperclipIcon,
@@ -237,7 +237,6 @@ export type MessageComposerProps = {
/** Sending needs at least one tagged recipient. */
requireMention?: boolean | undefined;
chatPresentation?: boolean | undefined;
- activityClickOpensPanel?: boolean | undefined;
trailingTool?: ReactNode;
inviteAgents?: boolean | undefined;
onSend?: (id: string) => void;
@@ -395,7 +394,6 @@ function Composer({
shown = true,
requireMention = false,
chatPresentation = false,
- activityClickOpensPanel = false,
inviteAgents = false,
trailingTool,
}: MessageComposerProps) {
@@ -2066,12 +2064,10 @@ function Composer({
{!cached && !submission && (
)}
{host && extensions?.accessories && (
diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx
index 334bdc731f..fc659f4124 100644
--- a/src/features/messages/MessageRow.test.tsx
+++ b/src/features/messages/MessageRow.test.tsx
@@ -1437,13 +1437,20 @@ it.each([true, false])(
},
);
-it.each([
- { startsThreads: true, replyCount: 0, thread: true },
- { startsThreads: false, replyCount: 0, thread: false },
- { startsThreads: false, replyCount: 23, thread: true },
-])(
+it.each(
+ [
+ { startsThreads: true, replyCount: 0, thread: true },
+ { startsThreads: false, replyCount: 0, thread: false },
+ { startsThreads: false, replyCount: 23, thread: true },
+ ].flatMap((scenario) =>
+ (["message", "response"] as const).map((placement) => ({
+ ...scenario,
+ placement,
+ })),
+ ),
+)(
"gives message accessories a thread only where the row has one to open (%o)",
- ({ startsThreads, replyCount, thread }) => {
+ ({ startsThreads, replyCount, thread, placement }) => {
const subscribe = () => () => {};
const channels = { channels: [], status: "ready" };
const noTyping: never[] = [];
@@ -1468,7 +1475,7 @@ it.each([
pluginId: "test",
revision: "1",
title: "Agent activity",
- placement: "message" as const,
+ placement,
decoratesMessageSummary: true,
component: ({
renderMessageSummary,
@@ -1490,6 +1497,7 @@ it.each([
row: { ...row, replyCount },
session,
scope: "test",
+ viewer: row.authorId,
onOpenThread: () => {},
startsThreads,
extensions: {
@@ -1501,10 +1509,16 @@ it.each([
},
});
try {
- expect(seen).toHaveBeenLastCalledWith(thread, thread);
+ expect(seen).toHaveBeenLastCalledWith(
+ placement === "message" && thread,
+ thread,
+ );
+ expect(
+ !!screen.getByText("Brain working").closest("[data-viewer-message]"),
+ ).toBe(placement === "message");
expect(screen.getByText("Brain working")).toBeInTheDocument();
expect(!!screen.queryByRole("button", { name: /^View thread/ })).toBe(
- thread,
+ placement === "message" ? thread : replyCount > 0,
);
} finally {
view.unmount();
@@ -2710,6 +2724,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);
@@ -2753,43 +2817,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();
@@ -2798,13 +2837,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");
@@ -2823,9 +2858,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();
}
@@ -3246,7 +3279,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 a0014427cd..7b21b8cdad 100644
--- a/src/features/messages/MessageRow.tsx
+++ b/src/features/messages/MessageRow.tsx
@@ -1,3 +1,7 @@
+import { AgentResponsePreview } from "./AgentResponsePreview";
+import { useActivityIndicator } from "./use-activity-indicator";
+import type { ActivityRowPresentation } 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 +12,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";
@@ -100,6 +100,9 @@ import { avatarMedia } from "../../shared/avatar-source";
export type MessageLinkOrigin = Readonly<{ rootId: string }>;
export type MessageRowProps = {
row: ChannelMessage;
+ recordedActivity?: ActivityRowPresentation | undefined;
+ representedAgents?: readonly string[] | undefined;
+ staticMentions?: boolean | undefined;
getThreadRoot?: (() => ChannelMessage | undefined) | undefined;
session?: RelaySession | undefined;
scope?: string | undefined;
@@ -192,6 +195,7 @@ function MessageRowContent({
viewer = session?.viewer,
stackPrevious = layout === "continuation",
stackNext = false,
+ representedAgents,
alignViewer = true,
mediaMode = "inline",
@@ -211,8 +215,19 @@ function MessageRowContent({
row.channelId,
row.threadRootId ?? row.id,
);
+ const responseAccessory = () =>
+ !!extensions?.accessories
+ ?.snapshot()
+ .some((entry) => entry.placement === "response");
+ const hasResponseAccessory = useSyncExternalStore(
+ extensions?.accessories?.subscribe ?? noSubscribe,
+ responseAccessory,
+ responseAccessory,
+ );
const { agents: threadAgents, waiting: threadWaiting } = useThreadAgents(
- onOpenThread && (row.replyCount > 0 || (startsThreads && !row.threadRootId))
+ !hasResponseAccessory &&
+ onOpenThread &&
+ (row.replyCount > 0 || (startsThreads && !row.threadRootId))
? session
: undefined,
row.channelId,
@@ -728,7 +743,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) ||
@@ -736,11 +750,13 @@ function MessageRowContent({
onOpenThread && (
0 || undefined}
+ data-activity-summary={activity ? "" : undefined}
+ data-reply-connector={
+ (!activity && row.replyCount > 0) || undefined
+ }
data-first-participant-shape={
agentPubkeys?.has(row.participants[0] ?? "")
? "squircle"
@@ -758,8 +774,8 @@ function MessageRowContent({
}}
>
+ {active && session && scope && extensions?.accessories && (
+
+ menuTrigger.current?.focus({ preventScroll: true })
+ }
+ openThread={
+ summaryThread
+ ? () => {
+ summaryThread(row.id, row.threadRootId ?? row.id);
+ return true;
+ }
+ : undefined
+ }
+ canOpen={canOpenLink ?? (() => false)}
+ open={onOpenLink}
+ />
+ )}
);
return renderMessage ? (
@@ -1337,6 +1379,17 @@ export const MessageRow = memo(function AudienceMessageRow(
function AgentsMessageRow(props: MessageRowProps) {
const { row, session, media, participantProfiles, agentPubkeys } = props;
const [open, setOpen] = useState(false);
+ const activity = props.recordedActivity;
+ const bubble =
+ activity?.container === "bubble" || activity?.container === "standalone";
+ const live = useActivityIndicator(
+ session,
+ row,
+ activity?.ownsLive ?? false,
+ undefined,
+ activity?.requestId,
+ );
+ const active = live || (activity?.active ?? false);
const keepMounted = props.keepMounted;
useEffect(() => {
if (open) return keepMounted?.(row.id);
@@ -1368,9 +1421,22 @@ function AgentsMessageRow(props: MessageRowProps) {
const to = recipients.map((pubkey) =>
person(pubkey, directory.profiles.get(pubkey)),
);
- const preview = messagePreviewText(body.content);
+ const excerpt = Array.from(
+ messagePreviewText(body.content).replace(/\s+/g, " "),
+ );
+ const preview =
+ excerpt.length > 280
+ ? `${excerpt.slice(0, 279).join("")}…`
+ : excerpt.join("");
+ const addressedPreview = Array.from(
+ `${to.length ? `${to.map((who) => `@${who.name}`).join(", ")} ` : ""}${preview}`,
+ );
+ const visiblePreview =
+ addressedPreview.length > 280
+ ? `${addressedPreview.slice(0, 279).join("")}…`
+ : addressedPreview.join("");
const avatar = (who: typeof author) => (
-
+
);
- return (
+ const content = (
0 || undefined}
+ data-layout={props.layout ?? "timeline"}
+ data-stack-previous={props.stackPrevious || undefined}
+ data-stack-next={props.stackNext || undefined}
data-viewer-message={
(props.alignViewer ?? true) &&
!row.workflowOwnerId &&
@@ -1405,9 +1475,8 @@ function AgentsMessageRow(props: MessageRowProps) {
}
data-open={open || undefined}
>
- {props.day && }
- {/* Keep the address column and trailing time stable while the
- details open and close. */}
+ {props.day && !bubble && }
+ {/* Keep the address column stable while details open and close. */}
who.name).join(", ")}` : ""}: ${preview}`}
>
- {avatar(author)}
- {to.length > 0 && (
- <>
-
- {to.map(avatar)}
- >
- )}
+ {!bubble && avatar(author)}
+ {open && bubble && "Collapse"}
{!open && (
- {preview}
+
+ {visiblePreview}
+
)}
@@ -1461,14 +1523,33 @@ function AgentsMessageRow(props: MessageRowProps) {
{message}
-
-
-
)}
/>
);
+ return bubble ? (
+ <>
+ {props.day && }
+
+ {content}
+
+ >
+ ) : (
+ content
+ );
}
// Subscribe only for mounted thread buttons. Evidence and reading remain session-owned;
diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css
index dd4a75bdf7..12e122b8aa 100644
--- a/src/features/messages/Messages.module.css
+++ b/src/features/messages/Messages.module.css
@@ -808,7 +808,7 @@ html[data-keyboard-navigation] .attachmentImage:focus-visible {
font-weight: var(--type-weight-regular);
margin-top: var(--space-1);
}
-.messageBody [data-thread-summary]:not([data-activity-summary]) {
+.messageBody [data-thread-summary] {
padding: var(--space-1);
padding-right: var(--space-1h);
}
@@ -2813,16 +2813,7 @@ html[data-keyboard-navigation] .rawDiff:focus-visible {
var(--channel-preview-inset, 0px)
);
}
-/* Leave a separate floating lane when the composer has an activity pill. */
-.jumpToLatest[data-activity-lane] {
- top: calc(
- 100% +
- var(--history-bottom-inset) -
- var(--space-3) -
- 2 *
- var(--space-6)
- );
-}
+
.jumpToLatestSurface {
@apply glass-secondary;
display: flex;
@@ -3201,18 +3192,11 @@ html[data-keyboard-navigation] .rawDiff:focus-visible {
display: none;
}
-/* Agent coordination shares the conversation's measure. Avatars and quiet
- type distinguish it without a second rail or a full message bubble. */
+/* Agent activity shares the conversation measure with compact, content-sized rows. */
.activityDisclosure {
width: min(100%, var(--timeline-measure));
margin-inline: auto;
- padding-block: var(--space-1);
-}
-.activityChevron {
- transform: rotate(-90deg);
-}
-.activityDisclosure [aria-expanded="true"] .activityChevron {
- transform: rotate(0deg);
+ padding-block: 0;
}
.activityExpansionRoot {
display: contents;
@@ -3232,99 +3216,127 @@ html[data-keyboard-navigation] .rawDiff:focus-visible {
.activityExpansion {
transition: grid-template-rows var(--duration-settle) var(--easing-enter);
}
- .activityChevron {
- transition: transform var(--duration-state) var(--easing-enter);
- }
}
.audienceRow {
+ position: relative;
+ --activity-avatar-size: var(--size-control-sm);
+ --activity-gap: var(--space-2);
+ --message-side-space: calc(var(--activity-avatar-size) + var(--activity-gap));
container: audience-row / inline-size;
width: min(100%, var(--timeline-measure));
margin-inline: auto;
- padding-block: var(--space-1);
+ padding-block: var(--space-half) 0;
+}
+.audienceRow[data-layout="timeline"],
+.feed .audienceRow[data-layout="continuation"] {
+ --activity-avatar-size: var(--size-control);
+ --activity-gap: var(--space-3);
+}
+.audienceRow[data-stack-next] {
+ padding-block-end: 0;
+}
+.audienceRow[data-stack-previous] {
+ padding-block-start: var(--space-half);
}
.audienceLine {
+ position: relative;
display: flex;
- flex: 1;
align-items: center;
- gap: var(--space-1h);
- inline-size: 100%;
+ gap: var(--space-2);
+ inline-size: fit-content;
+ max-inline-size: calc(100% - var(--space-3));
+ align-self: flex-start;
+ margin-inline-start: var(--space-3);
min-inline-size: 0;
- padding: var(--space-1);
+ padding: var(--space-1) var(--space-1h) var(--space-1) var(--space-1);
border: 0;
border-radius: var(--radius-control);
background: none;
cursor: pointer;
- color: var(--text-subtle);
- font: inherit;
+ color: var(--text-standard);
font-size: var(--text-caption);
line-height: var(--text-caption--line-height);
letter-spacing: var(--text-caption--letter-spacing);
+ font-weight: var(--type-weight-regular);
text-align: start;
}
-.audienceEntry:has(.audienceLine:hover) {
- background: var(--affordance-subtle-hover);
-}
-html[data-keyboard-navigation] .audienceLine:focus-visible {
+html[data-keyboard-navigation] .audienceEntry:has(.audienceLine:focus-visible) {
outline: 2px solid var(--border-focus);
outline-offset: -2px;
}
.audienceEntry {
+ position: relative;
+ font-size: var(--text-body-sm);
+ box-sizing: border-box;
+ padding-inline-start: var(--message-side-space);
+ /* Match the message body reservation and its prose bubble's reading measure. */
+ max-inline-size: min(
+ 100%,
+ calc(
+ var(--message-side-space) +
+ min(
+ 85%,
+ 46rem,
+ max(60%, calc(100% - 2 * var(--message-side-space) - var(--space-4))),
+ calc(60ch + 2 * var(--space-4))
+ )
+ )
+ );
display: flex;
- align-items: flex-start;
+ flex-direction: column;
+ align-items: stretch;
border-radius: var(--radius-control);
}
-.audienceTime {
- flex: none;
- padding: var(--space-2) var(--space-1);
- color: var(--text-subtle);
- font-size: var(--text-caption);
- line-height: var(--text-caption--line-height);
- letter-spacing: var(--text-caption--letter-spacing);
- white-space: nowrap;
-}
+/* The detail replaces the preview in the same text column, with the avatar
+ aligned to the parent bubble's text inset throughout expansion. */
.audienceRow:has(.audienceBody) .audienceEntry {
+ display: grid;
+ grid-template-columns: calc(
+ var(--space-3) +
+ var(--space-1) +
+ 1.5rem +
+ var(--space-2)
+ ) minmax(0, 1fr);
+ align-items: start;
cursor: pointer;
- background: var(--affordance-subtle);
-}
-.audienceRow:has(.audienceBody) .audienceEntry:hover {
- background: var(--affordance-subtle-hover);
}
.audienceRow:has(.audienceBody) .audienceLine {
- flex: none;
- inline-size: auto;
- padding-inline-end: var(--space-1h);
- background: none;
+ grid-area: 1 / 1;
+ inline-size: calc(1.5rem + var(--space-1) + var(--space-1h));
}
-/* Open, the text keeps the line's size and colour and starts where its
- preview was, so expanding only lets it run on. */
.audiencePanel {
- flex: 1;
+ grid-area: 1 / 2;
min-inline-size: 0;
}
+/* Base UI measures the full text; the collapsed endpoint is one avatar row. */
.audienceBody {
- --activity-content-inset: calc(
- (
- var(--size-control-sm) *
- 0.75 -
- var(--text-caption) *
- var(--text-caption--line-height)
- ) /
- 2 +
- var(--space-1)
- );
- /* The preview hands off to a readable first line immediately. Only the
- additional space expands, avoiding a blank frame at either endpoint. */
- --activity-reveal-min: calc(
- var(--activity-content-inset) +
- var(--text-caption) *
- var(--text-caption--line-height)
- );
- color: var(--text-subtle);
+ box-sizing: border-box;
+ height: var(--collapsible-panel-height);
+ min-height: calc(var(--space-6) + 2 * var(--space-1));
+ overflow: clip;
+ color: var(--text-standard);
+}
+.audienceBody:is([data-starting-style], [data-ending-style]) {
+ height: calc(var(--space-6) + 2 * var(--space-1));
+}
+@media (prefers-reduced-motion: no-preference) {
+ html:not([data-keyboard-navigation]) .audienceBody {
+ transition: height var(--duration-state) var(--easing-enter);
+ }
}
.audienceContent {
- padding-block-end: var(--space-2);
- padding-block-start: var(--activity-content-inset);
- color: var(--text-subtle);
+ /* Preserve the preview baseline: its line is centered in a 24px avatar. */
+ padding: calc(
+ var(--space-1) +
+ (
+ var(--space-6) -
+ var(--text-caption) *
+ var(--text-caption--line-height)
+ ) /
+ 2
+ )
+ var(--space-1h) var(--space-1) 0;
+ color: var(--text-standard);
}
.audienceBody .message[data-layout="continuation"] {
width: 100%;
@@ -3349,44 +3361,20 @@ html[data-keyboard-navigation] .audienceLine:focus-visible {
.audienceAvatar {
display: grid;
flex: none;
- inline-size: calc(var(--size-control-sm) * 0.75);
- block-size: calc(var(--size-control-sm) * 0.75);
+ inline-size: var(--space-6);
+ block-size: var(--space-6);
}
-.audienceArrow {
- flex: none;
- color: var(--text-subtle);
-}
-.audiencePreview {
- flex: 1;
+.audiencePreviewText {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-
-/* Retain the expanded columns during exit, so wrapping and the preview do
- not replace the closing details before their space has folded away. */
-.audienceRow:has(.audienceBody) .audiencePreview {
+.audienceRow:has(.audienceBody) .audiencePreviewText {
display: none;
}
-
-/* At narrow widths or enlarged text, give the details the full reading width
- while keeping the avatars and clock on their original header line. */
-@container audience-row (max-width: 28rem) {
- .audienceRow:has(.audienceBody) .audienceEntry {
- flex-wrap: wrap;
- }
- .audienceBody {
- --activity-content-inset: var(--space-1);
- order: 1;
- flex-basis: 100%;
- }
- .audienceContent {
- padding: var(--space-1) var(--space-1) var(--space-2);
- }
- .audienceTime {
- margin-inline-start: auto;
- }
+.audienceLine:hover {
+ background: var(--affordance-subtle-hover);
}
/* Returning from a thread only paints its already-visible origin. The wash
@@ -3514,3 +3502,199 @@ html[data-keyboard-navigation]
min-width: 0;
max-width: calc(100% - var(--size-control));
}
+
+html[data-keyboard-navigation] .activityExpansion {
+ transition: none;
+}
+
+.audienceLine[data-collapse] {
+ color: var(--text-subtle);
+}
+
+/* Extend the native button hit target, keeping its painted surface content-sized. */
+.audienceRow[data-collapse] > .audienceEntry,
+.audienceLine[data-collapse] {
+ position: static;
+}
+.audienceLine[data-collapse]::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ cursor: pointer;
+}
+
+/* Standalone activity uses the normal incoming identity and bubble geometry. */
+.standaloneActivity .audienceRow {
+ container-type: normal;
+ width: auto;
+ margin: 0;
+ padding: 0;
+}
+.standaloneActivity .audienceRow .audienceEntry {
+ padding-inline-start: 0;
+ max-inline-size: 100%;
+ display: block;
+}
+.standaloneActivity .audienceRow .audienceLine {
+ inline-size: fit-content;
+ max-inline-size: 100%;
+ margin: 0;
+ padding: 0;
+ font: inherit;
+ letter-spacing: inherit;
+ min-height: calc(var(--text-message) * var(--text-message--line-height));
+}
+.standaloneActivity .audienceRow .audiencePreviewText {
+ display: block;
+}
+.containedActivityHistory .audienceRow .audienceEntry {
+ max-inline-size: 100%;
+ padding-inline-start: 0;
+}
+.standaloneActivity .audienceRow[data-open] .audienceEntry {
+ display: flex;
+}
+.containedActivityHistory .audienceRow .audienceLine {
+ margin: 0;
+ padding: 0;
+}
+.standaloneActivity .audienceRow[data-open] .audienceLine {
+ order: 1;
+ color: var(--text-subtle);
+}
+.standaloneActivity .audienceBody .message[data-layout="continuation"] {
+ container-type: normal;
+ padding-inline-end: 0;
+}
+
+/* The pending response and a recorded step share a normal message line box. */
+.message[data-agent-response-preview] .messageBubble {
+ background: transparent;
+ box-shadow: inset 0 0 0 1px var(--border-standard);
+ font-size: var(--text-message);
+ line-height: var(--text-message--line-height);
+ letter-spacing: var(--text-message--letter-spacing);
+ font-weight: var(--text-message--font-weight);
+}
+
+.standaloneActivity .audienceBody {
+ min-height: calc(var(--text-message) * var(--text-message--line-height));
+}
+.standaloneActivity
+ .audienceBody:is([data-starting-style], [data-ending-style]) {
+ height: calc(var(--text-message) * var(--text-message--line-height));
+}
+.standaloneActivity .audienceContent {
+ padding: 0;
+}
+.standaloneActivity .audienceBody .message[data-layout="continuation"] .text {
+ font-size: var(--text-message);
+ line-height: var(--text-message--line-height);
+ letter-spacing: var(--text-message--letter-spacing);
+ font-weight: var(--text-message--font-weight);
+}
+
+/* Keep a working team inside the same footprint as one message avatar. */
+.agentAvatarGroup {
+ position: relative;
+ display: block;
+ width: 2.5rem;
+ height: 2.5rem;
+}
+.message:not([data-layout="timeline"]) .agentAvatarGroup {
+ width: 2rem;
+ height: 2rem;
+}
+.agentAvatarGroupItem {
+ position: absolute;
+ inset: 0;
+ transform-origin: top left;
+ transform: scale(1);
+ opacity: 1;
+ transition:
+ transform var(--duration-state) var(--easing-settle),
+ opacity var(--duration-state) var(--easing-enter);
+}
+.agentAvatarGroup[data-count="2"] .agentAvatarGroupItem:first-child {
+ transform: scale(0.62);
+}
+.agentAvatarGroup[data-count="2"] .agentAvatarGroupItem:nth-child(2) {
+ transform: translate(38%, 38%) scale(0.62);
+}
+.agentAvatarGroup[data-count="3"] .agentAvatarGroupItem:first-child {
+ transform: translateX(22%) scale(0.56);
+}
+.agentAvatarGroup[data-count="3"] .agentAvatarGroupItem:nth-child(2) {
+ transform: translateY(44%) scale(0.56);
+}
+.agentAvatarGroup[data-count="3"] .agentAvatarGroupItem:nth-child(3) {
+ transform: translate(44%, 44%) scale(0.56);
+}
+@starting-style {
+ .agentAvatarGroup[data-count] .agentAvatarGroupItem:not(:first-child) {
+ opacity: 0;
+ transform: translate(22%, 22%) scale(0.52);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .agentAvatarGroupItem {
+ transition: none;
+ }
+}
+
+.activityDisclosureIcon {
+ flex: none;
+ width: var(--space-4);
+ height: var(--space-4);
+ color: var(--text-subtle);
+}
+.standaloneActivity[data-contained-history]
+ .audienceLine[data-collapse]::after {
+ display: none;
+}
+.containedActivityHistory {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-half);
+ padding-block-start: var(--space-2);
+}
+.containedActivityHistory .audienceRow:has(.audienceBody) .audienceEntry {
+ display: grid;
+ grid-template-columns: calc(var(--space-6) + var(--space-2)) minmax(0, 1fr);
+}
+
+/* A message interaction uses the bubble's type and inset, not control sizing. */
+.activityBubbleTrigger {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ max-width: 100%;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ letter-spacing: inherit;
+ text-align: start;
+ cursor: pointer;
+}
+
+/* The painted bubble and its interactive area must have the same bounds. */
+.messageBubble:has(> .activityBubbleTrigger) {
+ padding: 0;
+}
+.messageBubble > .activityBubbleTrigger {
+ width: 100%;
+ padding: var(--space-2) var(--space-4);
+ border-radius: inherit;
+}
+
+/* Activity interactions paint their shared bubble, never a second pill inside it. */
+.message[data-agent-response-preview] .audienceLine:hover {
+ background: none;
+}
+@media (hover: hover) and (pointer: fine) {
+ .message[data-agent-response-preview] .messageBubble:hover {
+ background: var(--affordance-subtle-hover);
+ }
+}
diff --git a/src/features/messages/ThreadPanel.identity.test.tsx b/src/features/messages/ThreadPanel.identity.test.tsx
index c26ce37a8f..07fab9575d 100644
--- a/src/features/messages/ThreadPanel.identity.test.tsx
+++ b/src/features/messages/ThreadPanel.identity.test.tsx
@@ -21,6 +21,11 @@ import {
signed,
} from "../relay/testing";
import { ThreadPanel } from "./ThreadPanel";
+import { createAgentActivity } from "../agents/activity";
+import { createLocalAgentActivity } from "../agents/local-activity";
+import { stubAvatarBrowserApis } from "../agents/avatar-testing";
+
+stubAvatarBrowserApis();
vi.mock("./MessageMarkdown", () => ({
MessageMarkdown: ({ row }: { row: ChannelMessage }) => (
@@ -172,3 +177,65 @@ it.each([false, true])(
owner.dispose();
},
);
+
+it("keeps a standalone activity root subscribed to its request through completion", async () => {
+ const agent = keypair();
+ const root = message(agent, "a", "Checking root activity", 1, [
+ ["audience", "agents"],
+ ]);
+ const wire = scriptedTransport(keypair().pubkey, keypair().pubkey);
+ let traffic!: LiveCallbacks;
+ const owner = createRelaySession({
+ ...wire.transport,
+ subscribe(callbacks) {
+ traffic = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ query(filters, signal) {
+ return filters[0]?.kinds?.includes(30315)
+ ? Promise.resolve([])
+ : wire.transport.query(filters, signal);
+ },
+ });
+ traffic.receive([root]);
+ const local = createLocalAgentActivity();
+ const activity = createAgentActivity(
+ true,
+ () => {},
+ () => true,
+ undefined,
+ undefined,
+ { source: local.reader, scope: "root-test" },
+ );
+ const release = activity.queries.activate();
+ const work = local.begin({
+ scope: "root-test",
+ agent: agent.pubkey,
+ channelId: "a",
+ messageId: root.id,
+ threadRootId: root.id,
+ });
+ work.started();
+ try {
+ render(
+ {}}
+ onOpenLink={() => false}
+ />,
+ );
+ const preview = await screen.findByText("Checking root activity");
+ expect(preview).toHaveAttribute("data-active", "true");
+ act(() => work.finish({ ok: true }));
+ expect(preview).not.toHaveAttribute("data-active");
+ } finally {
+ cleanup();
+ release();
+ activity.dispose();
+ owner.dispose();
+ }
+});
diff --git a/src/features/messages/ThreadPanel.nesting.test.tsx b/src/features/messages/ThreadPanel.nesting.test.tsx
index df85742179..5298dff23f 100644
--- a/src/features/messages/ThreadPanel.nesting.test.tsx
+++ b/src/features/messages/ThreadPanel.nesting.test.tsx
@@ -1,4 +1,6 @@
// @vitest-environment jsdom
+const emptyTyping: never[] = [];
+const emptyProfiles = new Map();
import "@testing-library/jest-dom/vitest";
import {
act,
@@ -138,7 +140,12 @@ function setup(messageId = "root") {
};
const session = {
thread: () => view,
- profiles: { ensure: async () => {} },
+ profiles: {
+ ensure: async () => {},
+ subscribe: () => () => {},
+ snapshot: () => emptyProfiles,
+ },
+ typing: { subscribe: () => () => {}, snapshot: () => emptyTyping },
agentChoices: createAgentLibrary(undefined).queries,
messages: { retry: () => {} },
media: () => undefined,
diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx
index b7070327f9..0b98ef7d5f 100644
--- a/src/features/messages/ThreadPanel.test.tsx
+++ b/src/features/messages/ThreadPanel.test.tsx
@@ -12,7 +12,6 @@ import {
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import { afterEach, expect, it, vi } from "vitest";
-import { useActivityLanePublisher } from "./activity-lane";
import { ThreadPanel, type ThreadPanelProps } from "./ThreadPanel";
import type { MessageRowProps } from "./MessageRow";
import type { MessageComposerProps } from "./MessageComposer";
@@ -31,6 +30,11 @@ import { formatPublicKey } from "../../shared/identity/public-key";
// Real React owns effects, refs and subscriptions. Only independent child UI is
// reduced here; MessageRow/MessageComposer retain their own mounted suites.
+// Activity rendering/subscriptions have their own mounted integration suite.
+vi.mock("./TypingIndicator", () => ({
+ TypingIndicator: () => null,
+ useConversationAgents: () => ({ agents: [] }),
+}));
vi.mock("../relay/react", () => {
const profiles = new Map();
return { useRowProfiles: () => profiles };
@@ -85,7 +89,6 @@ vi.mock("./MessageComposer", () => ({
scope,
onSend,
ownSoloConversation,
- activityClickOpensPanel,
disabled,
placeholder,
focusRequest,
@@ -98,7 +101,6 @@ vi.mock("./MessageComposer", () => ({
data-channel={channelId}
data-scope={scope}
data-own-solo={ownSoloConversation}
- data-direct-activity={activityClickOpensPanel}
data-disabled={disabled}
data-placeholder={placeholder}
data-focus-request={focusRequest}
@@ -863,55 +865,6 @@ it("counts replies recovered after a completed traversal, not initial history pa
expect(screen.getByRole("button", { name: "2 new messages" })).toBeVisible();
});
-it("reserves the jump button's activity lane only for this thread's composer in a live channel", () => {
- const h = messagesHarness();
- const listeners = new Set<() => void>();
- const subscribe = (listener: () => void) => {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
- };
- let list: ChannelList = {
- status: "ready",
- channels: [{ id: "channel", name: "General" }],
- };
- Object.assign(h.session, {
- channels: { list: () => list, subscribeList: subscribe },
- });
- // The composer is mocked here; publish what its TypingIndicator would.
- function Shown({ threadRootId }: { threadRootId: string | undefined }) {
- useActivityLanePublisher(h.session, "channel", threadRootId, true);
- return null;
- }
- h.render();
- h.scroll(500);
- const lane = () =>
- screen
- .getByRole("button", { name: "Jump to latest" })
- .closest("[data-visible]");
- const live = lane();
- expect(live).not.toHaveAttribute("data-activity-lane");
- // Channel-level activity shows in the channel composer, not this thread's.
- const channelLevel = render( );
- expect(live).not.toHaveAttribute("data-activity-lane");
- channelLevel.unmount();
- const thread = render( );
- expect(live).toHaveAttribute("data-activity-lane");
- // A cached channel's composer mounts no TypingIndicator, so no lane; the
- // gate flip keeps the same button mounted.
- list = {
- status: "ready",
- channels: [{ id: "channel", name: "General", cached: true }],
- };
- act(() => {
- for (const listener of listeners) listener();
- });
- expect(lane()).toBe(live);
- expect(live).not.toHaveAttribute("data-activity-lane");
- thread.unmount();
-});
-
it("transfers keyboard focus to the thread history before removing the jump button", () => {
const h = messagesHarness();
h.render();
@@ -1422,20 +1375,14 @@ it("names the conversation a thread lives in as a link, only when its host can o
expect(within(thread).queryByText("#Alpha")).toBeNull();
});
-it("passes readiness and direct Activity through to thread composition, and gives a Solo thread none", () => {
+it("passes readiness through to thread composition, and gives a Solo thread none", () => {
const h = messagesHarness();
- const view = render(
- ,
- );
+ const view = render( );
const composer = screen.getByRole("region", { name: "Composer" });
- expect(composer).toHaveAttribute("data-direct-activity", "true");
expect(composer).toHaveAttribute("data-disabled", "true");
- view.rerender(
- ,
- );
+ view.rerender( );
expect(composer).toHaveAttribute("data-disabled", "false");
view.rerender( );
- expect(composer).not.toHaveAttribute("data-direct-activity");
// Solo agents answer at top level: its threads are read-only.
view.rerender( );
expect(
@@ -1739,6 +1686,45 @@ it("a retained reader reveals its exact selected message, again for each new req
expect(revealedReply()).toBe(false);
});
+it("reopens collapsed activity for a new reveal of the same step", async () => {
+ const h = messagesHarness();
+ const replies = ["d", "e"].map((id, index) => ({
+ ...row,
+ id: id.repeat(64),
+ content: `Agent step ${index}`,
+ audience: "agents" as const,
+ threadRootId: row.id,
+ replyToId: row.id,
+ createdAt: 2 + index,
+ }));
+ const target = replies[1];
+ if (!target) throw new Error("Missing activity fixture target");
+ h.snapshot.replies = replies;
+ h.snapshot.target = target;
+ h.snapshot.targetStatus = "ready";
+ const props = h.props as ThreadPanelProps;
+ Object.assign(props, {
+ messageId: target.id,
+ retainThread: true,
+ revealSelected: true,
+ revealRequest: 1,
+ });
+ h.render();
+ fireEvent.click(
+ await screen.findByRole("button", { name: /Collapse activity history/ }),
+ );
+ expect(
+ screen.getByRole("button", { name: /Show activity history/ }),
+ ).toHaveAttribute("aria-expanded", "false");
+ props.revealRequest = 2;
+ h.rerenderProps();
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: /Collapse activity history/ }),
+ ).toHaveAttribute("aria-expanded", "true"),
+ );
+});
+
it("a new reveal request reopens a branch the reader collapsed, and live updates leave it closed", async () => {
const h = messagesHarness();
const reply = (id: string, content: string, replyParentId: string) => ({
@@ -1840,3 +1826,38 @@ it("releases bottom following after a jump that included child-only resizing", (
h.render();
expect(h.element.scrollTop).toBe(2000);
});
+
+it("reveals a completed step selected by an exact thread link", async () => {
+ const h = messagesHarness();
+ const target = {
+ ...row,
+ id: "d".repeat(64),
+ content: "Completed tool result",
+ audience: "agents" as const,
+ threadRootId: row.id,
+ replyParentId: row.id,
+ createdAt: 2,
+ };
+ h.snapshot.replies = [
+ target,
+ {
+ ...row,
+ id: "e".repeat(64),
+ content: "Final response",
+ threadRootId: row.id,
+ replyParentId: row.id,
+ createdAt: 3,
+ },
+ ];
+ h.snapshot.target = target;
+ h.snapshot.targetStatus = "ready";
+ Object.assign(h.props, {
+ messageId: target.id,
+ retainThread: true,
+ revealSelected: true,
+ revealRequest: 1,
+ });
+ h.render();
+ expect(await screen.findByText("Completed tool result")).toBeVisible();
+ expect(screen.queryByRole("button", { name: /activity history/ })).toBeNull();
+});
diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx
index 0d1d381a3d..e86bba9ca5 100644
--- a/src/features/messages/ThreadPanel.tsx
+++ b/src/features/messages/ThreadPanel.tsx
@@ -1,4 +1,14 @@
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: The thread region supports keyboard scrolling and Escape.
+import { TypingIndicator, useConversationAgents } from "./TypingIndicator";
+import { ActivityExpansion } from "./ActivityExpansion";
+import { ActivityGroupPreview } from "./ActivityGroupPreview";
+import {
+ activityPresentation,
+ activityDisplayRows,
+ useActivitySnapshot,
+} from "./activity-presentation";
+import { activityRuns } from "./activity-groups";
+import { DayDivider } from "./MessageTimestamp";
import { workflowLabel } from "../relay/workflow-attribution";
import { usePanelTabHost } from "../panels/PanelWorkspace";
import { MessageEditScope } from "./MessageEditScope";
@@ -31,7 +41,10 @@ import type { ThreadView } from "../relay/threads";
import { useRowProfiles } from "../relay/react";
import { rowProfileIds } from "../relay/membership";
import { MessageRow, type MessageLinkOrigin } from "./MessageRow";
-import { continuesMessageGroup } from "./message-grouping";
+import {
+ continuesMessageGroup,
+ publicMessageNeighbor,
+} from "./message-grouping";
import { MessageComposer } from "./MessageComposer";
import { ChannelJoinNotice } from "./ChannelJoinNotice";
import { useListedChannel } from "../relay/listed-channel";
@@ -54,7 +67,6 @@ export type ThreadPanelProps = {
channelId: string;
/** Solo agents answer at top level, so a Solo thread is read-only. */
soloConversation?: boolean | undefined;
- activityClickOpensPanel?: boolean | undefined;
disabled?: boolean | undefined;
messageId: string;
/** Channel workspaces retain the editor across same-thread link navigation. */
@@ -366,7 +378,6 @@ function OwnedThreadPanel({
onOpenMediaReview,
canOpenLink,
soloConversation,
- activityClickOpensPanel,
disabled,
replyRequest,
focusRequest,
@@ -426,7 +437,6 @@ function OwnedThreadPanel({
) : view ? (
!!listed?.cached,
- );
const [joinNoticeHeight, setJoinNoticeHeight] = useState(0);
const [joining, setJoining] = useState(false);
const snapshot = useSyncExternalStore(
@@ -530,6 +533,7 @@ function ThreadMessages({
view.snapshot,
view.snapshot,
);
+ const activitySnapshot = useActivitySnapshot(session.agentActivity);
const resolvedRoot = snapshot.root?.id;
const reportRoot = useRef(onResolvedRoot);
reportRoot.current = onResolvedRoot;
@@ -574,6 +578,25 @@ function ThreadMessages({
[session.unread, channelId, snapshot.root?.id, messageId],
);
useSyncExternalStore(subscribeUnread, unreadSnapshot, unreadSnapshot);
+ const [expandedActivity, setExpandedActivity] = useState>(
+ new Set(),
+ );
+ const activityVisit = useRef([]);
+ useLayoutEffect(() => {
+ const request = [messageId, navigation?.signal, revealRequest];
+ if (
+ !messageId ||
+ request.every((part, index) => part === activityVisit.current[index]) ||
+ !snapshot.replies.some((row) => row.id === messageId)
+ )
+ return;
+ activityVisit.current = request;
+ const group = [...tree.children.values()]
+ .flatMap((rows) => activityRuns(rows))
+ .find((group) => group.some((row) => row.id === messageId));
+ const first = group?.[0]?.id;
+ if (first) setExpandedActivity((current) => new Set([...current, first]));
+ }, [messageId, navigation?.signal, revealRequest, snapshot.replies, tree]);
const [expanded, setExpanded] = useState>(new Set());
const [replyParent, setReplyParent] = useState();
const resolveName = useChannelIdentityNames(session, channelId);
@@ -692,7 +715,7 @@ function ThreadMessages({
// within this commit/layout expansion; a later user expansion must not restore it.
const selectedBranchRef = useMemo(() => {
let restore = false;
- return (branch: HTMLLIElement | null) => {
+ return (branch: HTMLElement | null) => {
if (!branch) return;
const row = branch.querySelector("[data-message-id]");
if (
@@ -879,6 +902,26 @@ function ThreadMessages({
request: number;
}>();
const rootId = snapshot.root?.id;
+ const rootPresentation = activityPresentation(
+ snapshot.root?.audience === "agents"
+ ? [snapshot.root, ...snapshot.replies]
+ : snapshot.replies,
+ activitySnapshot,
+ agentPubkeys,
+ rootId,
+ );
+ const rootActivity = rootId ? rootPresentation.byRow.get(rootId) : undefined;
+ const { agents: liveAgents } = useConversationAgents(
+ session,
+ channelId,
+ rootId,
+ );
+ const lastMessage = publicMessageNeighbor(rows, rows.length, -1);
+ const joinsLive =
+ !!lastMessage &&
+ !lastMessage.membership &&
+ lastMessage.audience !== "agents" &&
+ lastMessage.authorId === liveAgents[0];
const hasMediaComments =
(snapshot.root?.replyCount ?? 0) > 0 ||
snapshot.replies.length > 0 ||
@@ -1227,14 +1270,49 @@ function ThreadMessages({
? new Date(snapshot.root.createdAt * 1000).toDateString()
: undefined;
function renderReplies(parent: string | undefined, depth = 0): ReactNode {
- const siblings = tree.children.get(parent) ?? [];
+ const chronologicalSiblings = tree.children.get(parent) ?? [];
+ const presentation = activityPresentation(
+ chronologicalSiblings,
+ activitySnapshot,
+ agentPubkeys,
+ parent ?? rootId,
+ );
+ const groups = presentation.groups.filter((group) =>
+ group.ids.every((id) => !tree.children.get(id)?.length),
+ );
+ const siblings = activityDisplayRows(
+ chronologicalSiblings,
+ groups,
+ messageId,
+ );
+ const activeGroups = groups.filter((group) => !group.done);
+ const groupsByFirst = new Map(
+ activeGroups.map((group) => [group.ids[0], group]),
+ );
+ const groupedIds = new Set(activeGroups.flatMap((group) => group.ids));
+ const hidden = new Set(
+ activeGroups.flatMap((group) =>
+ expandedActivity.has(group.ids[0] ?? "") ? [] : group.ids,
+ ),
+ );
return siblings.map((row, index) => {
- const nextRow = siblings[index + 1];
+ const group = groupsByFirst.get(row.id);
+ if (hidden.has(row.id) && !group) return null;
+ const nextRow =
+ row.audience === "agents"
+ ? siblings[index + 1]
+ : publicMessageNeighbor(siblings, index, 1);
const children = tree.children.get(row.id);
const continuation =
+ !!previousReply &&
previousReply !== snapshot.root &&
previousParent === parent &&
- continuesMessageGroup(previousReply, row);
+ continuesMessageGroup(
+ row.audience === "agents"
+ ? previousReply
+ : publicMessageNeighbor(siblings, index, -1),
+ row,
+ );
// A collapsed branch breaks bubble grouping, not calendar continuity.
const currentDay = new Date(row.createdAt * 1000).toDateString();
const day = previousDay !== currentDay;
@@ -1249,41 +1327,70 @@ function ThreadMessages({
const unreadLabel = unreadCount
? `${unreadCount} new in available replies`
: undefined;
+ const renderMessage = (row: ChannelMessage, inside = false) => {
+ const recordedActivity = presentation.byRow.get(row.id);
+ return (
+ 0}
+ retry={session.messages.retry}
+ {...(canSeekVideo ? { onMediaTime: handleMediaTime } : {})}
+ {...(onOpenMediaReview && rootId
+ ? { onOpenMediaReview: openRootMedia }
+ : {})}
+ />
+ );
+ };
const message = (
- 0}
- retry={session.messages.retry}
- {...(canSeekVideo ? { onMediaTime: handleMediaTime } : {})}
- {...(onOpenMediaReview && rootId
- ? { onOpenMediaReview: openRootMedia }
- : {})}
- />
+
+ {renderMessage(row)}
+
);
return (
@@ -1293,6 +1400,39 @@ function ThreadMessages({
)}
+ {group && (
+ <>
+ {day && hidden.has(row.id) && (
+
+ )}
+ (
+
+ {renderMessage(entry, true)}
+
+ )}
+ profiles={profiles}
+ media={session.media}
+ layout="thread"
+ expanded={expandedActivity.has(group.ids[0] ?? "")}
+ onToggle={() => {
+ follow.current = false;
+ targetAnchor.current = undefined;
+ setExpandedActivity((current) => {
+ const next = new Set(current);
+ const first = group.ids[0] ?? row.id;
+ if (next.has(first)) next.delete(first);
+ else next.add(first);
+ return next;
+ });
+ }}
+ />
+ >
+ )}
-
+
{snapshot.root ? (
+ {!positioning && rootId && (
+
+ )}
{requireReadyRoot &&
snapshot.root &&
@@ -1610,7 +1768,6 @@ function ThreadMessages({
)}
{composerRoot && (
[data-agent-response-preview] {
+ width: min(100%, var(--timeline-measure, 52rem));
+}
+
.dots {
display: flex;
flex-shrink: 0;
@@ -64,34 +67,8 @@
}
}
-.avatars {
- display: inline-flex;
- align-items: center;
- padding-inline-end: var(--space-1);
-}
-
-.avatar {
- display: inline-flex;
- flex-shrink: 0;
- width: 1.5rem;
- height: 1.5rem;
-}
-
-.avatars .avatar {
- padding: var(--space-half);
- margin-inline-end: calc(-1 * var(--space-1));
- background: var(--surface-panel);
- border-radius: var(--radius-control);
- box-shadow: var(--shadow-sm);
-}
-
-.typing [data-agent-activity-trigger] {
- pointer-events: auto;
- padding: var(--space-1);
- min-height: var(--size-control-sm);
-}
-
.humans {
+ @apply text-caption;
display: flex;
align-items: center;
gap: var(--space-2);
@@ -103,13 +80,20 @@
border-radius: var(--radius-control);
}
-.overflow {
- padding-inline-start: var(--space-2);
+.typing[data-in-flow] {
+ width: min(100%, var(--timeline-measure, 52rem));
+ margin-inline: auto;
+ padding: 0;
}
-@media (max-width: 40.625rem) {
- .typing {
- inset-inline: var(--space-3);
- inline-size: min(calc(100% - 2 * var(--space-3)), 52rem);
- }
+/* Human presence stays anchored above the composer, outside the scrolling feed. */
+.typing[data-audience="humans"] {
+ position: absolute;
+ inset-block-end: calc(100% + var(--space-half));
+ inset-inline: var(--space-4);
+ inline-size: min(calc(100% - 2 * var(--space-4)), 52rem);
+ margin-inline: auto;
+ z-index: 1;
+ padding: 0;
+ pointer-events: none;
}
diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx
new file mode 100644
index 0000000000..af38a65bb5
--- /dev/null
+++ b/src/features/messages/TypingIndicator.stop.test.tsx
@@ -0,0 +1,239 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { act, cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import { AgentsContext } from "../agents/react";
+import type { Agents } from "../agents/service";
+import { stubAvatarBrowserApis } from "../agents/avatar-testing";
+import { TypingIndicator } from "./TypingIndicator";
+import {
+ agent,
+ other,
+ root,
+ secondRoot,
+ fixture,
+ entry,
+} from "./typing-indicator-testing";
+stubAvatarBrowserApis();
+afterEach(cleanup);
+it("stops the exact controllable request from the general popover and permits retry after failure", async () => {
+ const f = fixture();
+ const user = userEvent.setup();
+ let rejectStop!: (error: Error) => void;
+ const pending = new Promise((_resolve, reject) => {
+ rejectStop = reject;
+ });
+ const interrupt = vi
+ .fn()
+ .mockReturnValueOnce(pending)
+ .mockResolvedValue(undefined);
+ const session = {
+ ...f.session,
+ agentActivity: { ...f.session.agentActivity, interrupt },
+ };
+ f.update([], {
+ local: [
+ {
+ scope: "test",
+ agent,
+ channelId: "channel",
+ threadRootId: root,
+ messageId: "request-to-stop",
+ startedAt: 1,
+ state: "working",
+ stoppable: true,
+ },
+ {
+ scope: "test",
+ agent: other,
+ channelId: "channel",
+ threadRootId: secondRoot,
+ messageId: "other-request",
+ startedAt: 1,
+ state: "working",
+ stoppable: true,
+ },
+ ],
+ });
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Activity: Agent working" }),
+ );
+ const stop = screen.getByRole("button", { name: "Stop Agent" });
+ expect(screen.queryByRole("button", { name: "Stop Other" })).toBeNull();
+ try {
+ await user.click(stop);
+ expect(interrupt).toHaveBeenCalledExactlyOnceWith(
+ agent,
+ "channel",
+ "request-to-stop",
+ );
+ expect(stop).toHaveAttribute("aria-disabled", "true");
+ } finally {
+ await act(async () => {
+ rejectStop(new Error("Try again"));
+ });
+ }
+ await user.click(stop);
+ expect(interrupt).toHaveBeenCalledTimes(2);
+ f.update([entry()], { local: [] });
+ expect(screen.queryByRole("button", { name: "Stop Agent" })).toBeNull();
+});
+
+it("stops hosted work in its known conversation and keeps Stop pending until the work ends", async () => {
+ const f = fixture();
+ const user = userEvent.setup();
+ const interrupt = vi.fn(async () => true);
+ const hosted = {
+ subscribe: () => () => {},
+ snapshot: () => undefined,
+ interruptible: () => true,
+ interrupt,
+ } as unknown as Agents;
+ f.update([], {
+ turns: [
+ {
+ agent,
+ channelId: "channel",
+ turnId: "turn",
+ state: "working",
+ timestamp: 100,
+ },
+ ],
+ records: [
+ {
+ id: "record",
+ agent,
+ channelIds: ["channel"],
+ createdAt: 1,
+ receivedAt: 100,
+ kind: "turn_started",
+ plaintext: JSON.stringify({
+ kind: "turn_started",
+ channelId: "channel",
+ turnId: "turn",
+ seq: 0,
+ timestamp: "2026-10-29T12:00:00Z",
+ payload: { threadRootEventId: root },
+ }),
+ },
+ ],
+ });
+ render(
+
+
+ ,
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Activity: Agent working" }),
+ );
+ const stop = screen.getByRole("button", {
+ name: "Stop Agent in Thread · First task",
+ });
+ await user.click(stop);
+ expect(interrupt).toHaveBeenCalledExactlyOnceWith(agent, {
+ channelId: "channel",
+ threadRootId: root,
+ });
+ expect(stop).toHaveAttribute("aria-disabled", "true");
+ f.update([entry()], { turns: [], records: [] });
+ expect(
+ screen.queryByRole("button", { name: "Stop Agent in Thread · First task" }),
+ ).toBeNull();
+});
+
+it("identifies each hosted thread and stops only the selected work", async () => {
+ const f = fixture();
+ const user = userEvent.setup();
+ const interrupt = vi.fn(async () => true);
+ const hosted = {
+ subscribe: () => () => {},
+ snapshot: () => undefined,
+ interruptible: () => true,
+ interrupt,
+ } as unknown as Agents;
+ const tasks = [
+ { root, title: "First task", file: "first.txt" },
+ { root: secondRoot, title: "Second task", file: "second.txt" },
+ ];
+ f.update([], {
+ turns: tasks.map((task) => ({
+ agent,
+ channelId: "channel",
+ turnId: task.root,
+ state: "working",
+ timestamp: 100,
+ })),
+ records: tasks.flatMap((task) =>
+ [
+ { kind: "turn_started", payload: { threadRootEventId: task.root } },
+ {
+ kind: "acp_read",
+ payload: {
+ method: "session/update",
+ params: {
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: task.file,
+ title: "Read file",
+ kind: "read",
+ status: "in_progress",
+ locations: [{ path: task.file }],
+ },
+ },
+ },
+ },
+ ].map((event, index) => ({
+ id: task.root + index,
+ agent,
+ channelIds: ["channel"],
+ createdAt: 1,
+ receivedAt: 100,
+ kind: event.kind,
+ plaintext: JSON.stringify({
+ ...event,
+ channelId: "channel",
+ turnId: task.root,
+ seq: index,
+ timestamp: "2026-10-29T12:00:00Z",
+ }),
+ })),
+ ),
+ });
+ render(
+
+ true}
+ />
+ ,
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Activity: Agent working" }),
+ );
+ const rows = screen.getAllByRole("button", { name: "Open thread for Agent" });
+ for (const [index, task] of tasks.entries()) {
+ expect(rows[index]).toHaveTextContent("Thread · " + task.title);
+ expect(rows[index]).toHaveTextContent("Reading " + task.file);
+ expect(rows[index]).not.toHaveTextContent(
+ index === 0 ? "Second task" : "First task",
+ );
+ await user.click(
+ screen.getByRole("button", {
+ name: "Stop Agent in Thread · " + task.title,
+ }),
+ );
+ expect(interrupt).toHaveBeenNthCalledWith(index + 1, agent, {
+ channelId: "channel",
+ threadRootId: task.root,
+ });
+ }
+});
diff --git a/src/features/messages/TypingIndicator.test.tsx b/src/features/messages/TypingIndicator.test.tsx
index 1d4c1e1760..bfa127c4d6 100644
--- a/src/features/messages/TypingIndicator.test.tsx
+++ b/src/features/messages/TypingIndicator.test.tsx
@@ -1,7 +1,6 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import {
- act,
cleanup,
fireEvent,
render,
@@ -11,78 +10,32 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
-import type { RelaySession } from "../relay/session";
-import type { TypingEntry } from "../relay/typing";
import {
profileActivityViewTarget,
claimProfileTranscript,
profileTranscriptRequest,
} from "../profiles/target";
+import { ActivityIndicatorPresentation } from "../conversation/typing-presentation";
+import { ActivityGroupPreview } from "./ActivityGroupPreview";
+import { activityPresentation } from "./activity-presentation";
+import type { ChannelMessage } from "../relay/contracts";
import { TypingIndicator } from "./TypingIndicator";
+import { stubAvatarBrowserApis } from "../agents/avatar-testing";
+stubAvatarBrowserApis();
afterEach(cleanup);
-const agent = "a".repeat(64),
- human = "b".repeat(64),
- other = "c".repeat(64);
-const root = "d".repeat(64),
- secondRoot = "e".repeat(64);
-type Snapshot = ReturnType;
-function fixture() {
- const listeners = new Set<() => void>();
- let typing: readonly TypingEntry[] = [];
- let activity: Snapshot = {
- status: "listening",
- typing: [],
- records: [],
- turns: [],
- trimmed: 0,
- };
- const subscribe = (listener: () => void) => {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
- };
- const profiles = new Map([
- [agent, { name: "Agent" }],
- [human, { name: "Human", isAgent: true }],
- [other, { name: "Other" }],
- ]);
- const library = { identities: [{ pubkey: agent }, { pubkey: other }] };
- const rows = [
- { id: root, content: "First task" },
- { id: secondRoot, content: "Second task" },
- ];
- const ensure = vi.fn();
- const session = {
- typing: { snapshot: () => typing, subscribe },
- agentActivity: { snapshot: () => activity, subscribe },
- profiles: { snapshot: () => profiles, subscribe, ensure },
- agentChoices: { snapshot: () => library, subscribe },
- channels: {
- window: () => ({ rows }),
- subscribeWindow: (_id: string, fn: () => void) => subscribe(fn),
- ensure,
- },
- } as unknown as RelaySession;
- return {
- session,
- ensure,
- update(entries: readonly TypingEntry[], patch: Partial = {}) {
- act(() => {
- typing = entries;
- activity = { ...activity, ...patch };
- for (const listener of listeners) listener();
- });
- },
- };
-}
-const entry = (
- pubkey = agent,
- threadRootId: string | undefined = root,
-): TypingEntry => ({ pubkey, channelId: "channel", threadRootId });
+import {
+ agent,
+ human,
+ other,
+ root,
+ secondRoot,
+ fixture,
+ entry,
+ type Snapshot,
+} from "./typing-indicator-testing";
-it("opens one avatar by hover, with separate thread and activity actions and keyboard focus return", async () => {
+it("shows an incoming typing preview without invented details, retaining navigation and focus return", async () => {
const f = fixture(),
user = userEvent.setup(),
open = vi.fn(() => true);
@@ -98,13 +51,29 @@ it("opens one avatar by hover, with separate thread and activity actions and key
const trigger = screen.getByRole("button", {
name: "Activity: Agent working",
});
- expect(trigger).toHaveTextContent("A");
+ expect(screen.getByRole("img", { name: "Agent" })).toBeVisible();
+ expect(trigger.querySelector('[data-state="working"]')).not.toBeNull();
expect(trigger).not.toHaveTextContent("Working");
await user.hover(trigger);
let dialog = await screen.findByRole("dialog", { name: "Working now" });
+ expect(
+ within(dialog).getByText("Only typing updates are reaching this app."),
+ ).toBeVisible();
+ f.update([entry()], { status: "connecting" });
+ expect(
+ within(dialog).getByText("Only typing updates are reaching this app."),
+ ).toBeVisible();
+ f.update([entry()], { status: "interrupted" });
+ expect(
+ within(dialog).getByText("Only typing updates are reaching this app."),
+ ).toBeVisible();
+ f.update([entry()], { status: "listening" });
expect(within(dialog).getByText("Thread · First task")).toBeVisible();
+ expect(
+ within(dialog).queryByRole("button", { name: "View Agent activity" }),
+ ).toBeNull();
await user.click(
- within(dialog).getByRole("button", { name: "View Agent activity" }),
+ within(dialog).getByRole("button", { name: "View full activity" }),
);
const transcript = profileActivityViewTarget(agent, { transcript: true });
expect(open).toHaveBeenLastCalledWith(transcript, {
@@ -187,6 +156,9 @@ it.each([
);
const dialog = await screen.findByRole("dialog", { name: "Working now" });
expect(within(dialog).getByText(label, { exact: true })).toBeVisible();
+ expect(
+ within(dialog).queryByText("Only typing updates are reaching this app."),
+ ).toBeNull();
expect(dialog).not.toHaveTextContent("report.txt");
},
);
@@ -220,6 +192,14 @@ it("shows stable agent ordering, scopes humans exactly, and clears after work st
expect(
screen.getByRole("button", { name: "Activity: Agent working" }),
).toBeVisible();
+ view.rerender(
+ ,
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(/^Agent is working$/);
+ expect(screen.queryByText("Human is typing")).toBeNull();
+ expect(
+ screen.getByRole("button", { name: "Activity: Agent working" }),
+ ).toBeVisible();
f.update([]);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
expect(screen.queryByRole("dialog")).toBeNull();
@@ -238,8 +218,12 @@ it("keeps a non-navigable status when the plugin is unavailable and does not clo
});
await user.click(trigger);
let dialog = await screen.findByRole("dialog");
- expect(within(dialog).getByText("Working")).toBeVisible();
- expect(within(dialog).queryByRole("button")).toBeNull();
+ expect(
+ within(dialog).getByText("Only typing updates are reaching this app."),
+ ).toBeVisible();
+ expect(
+ within(dialog).getByRole("button", { name: "View full activity" }),
+ ).toBeDisabled();
view.rerender(
{
expect(
screen.queryByRole("button", { name: "Open thread for Agent" }),
).toBeNull();
- await user.click(screen.getByRole("button", { name: "View Agent activity" }));
+ await user.click(screen.getByRole("button", { name: "View full activity" }));
expect(open).toHaveBeenLastCalledWith(
profileActivityViewTarget(agent, { transcript: true }),
{
@@ -445,8 +429,17 @@ it("opens the sole known conversation without attributing unknown telemetry to t
await user.click(
screen.getByRole("button", { name: "Activity: Agent working" }),
);
- expect(screen.getByText(/Work with unconfirmed thread/)).toBeVisible();
- await user.click(screen.getByRole("button", { name: "View Agent activity" }));
+ expect(
+ within(screen.getByRole("dialog", { name: "Working now" })).getByText(
+ /Work with unconfirmed thread/,
+ ),
+ ).toBeVisible();
+ expect(
+ within(screen.getByRole("dialog")).getByText(
+ "No current step details received.",
+ ),
+ ).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "View full activity" }));
expect(open).toHaveBeenLastCalledWith(
profileActivityViewTarget(agent),
undefined,
@@ -520,13 +513,13 @@ it("hands navigation a retained trigger instead of the disposable popup action",
name: "Activity: Agent working",
});
await user.click(trigger);
- await user.click(screen.getByRole("button", { name: "View Agent activity" }));
+ await user.click(screen.getByRole("button", { name: "View full activity" }));
expect(captured).toEqual([trigger]);
expect(captured[0]).toBeInTheDocument();
});
it.each(["click", "Enter", "Space"])(
- "Solo chat single-agent %s opens Activity directly while hover retains its preview",
+ "Solo chat single-agent %s opens the popover before full Activity",
async (activation) => {
const f = fixture(),
user = userEvent.setup(),
@@ -536,7 +529,6 @@ it.each(["click", "Enter", "Space"])(
true}
openActivity={open}
/>,
@@ -554,6 +546,10 @@ it.each(["click", "Enter", "Space"])(
trigger.focus();
await user.keyboard(activation === "Enter" ? "{Enter}" : " ");
}
+ expect(open).not.toHaveBeenCalled();
+ await user.click(
+ screen.getByRole("button", { name: "View full activity" }),
+ );
expect(open).toHaveBeenCalledExactlyOnceWith(
profileActivityViewTarget(agent, { transcript: true }),
{ rootId: root },
@@ -574,7 +570,6 @@ it("Solo chat multiple-agent activation keeps the chooser and unavailable Activi
true}
openActivity={open}
/>,
@@ -592,7 +587,6 @@ it("Solo chat multiple-agent activation keeps the chooser and unavailable Activi
false}
openActivity={open}
/>,
@@ -615,7 +609,6 @@ it("Solo chat ArrowDown exposes retained thread navigation without opening Activ
true}
openActivity={open}
/>,
@@ -631,6 +624,10 @@ it("Solo chat ArrowDown exposes retained thread navigation without opening Activ
await waitFor(() => expect(dialog).toHaveFocus());
expect(open).not.toHaveBeenCalled();
await user.tab();
+ expect(
+ within(dialog).getByRole("button", { name: "View full activity" }),
+ ).toHaveFocus();
+ await user.tab();
expect(
within(dialog).getByRole("button", { name: "Open thread for Agent" }),
).toHaveFocus();
@@ -641,3 +638,209 @@ it("Solo chat ArrowDown exposes retained thread navigation without opening Activ
);
expect(trigger).toHaveFocus();
});
+
+it.each([
+ { team: false, local: false },
+ { team: true, local: false },
+ { team: false, local: true },
+ { team: true, local: true },
+])(
+ "hands live signals to visible activity and restores dots when it leaves (team=$team, local=$local)",
+ ({ team, local }) => {
+ const f = fixture();
+ const row: ChannelMessage = {
+ id: "step",
+ channelId: "channel",
+ authorId: agent,
+ audience: "agents",
+ createdAt: 100,
+ content: "Checking the fix",
+ mentions: [],
+ attachments: [],
+ reactions: [],
+ participants: [],
+ replyCount: 0,
+ };
+ const { audience: _audience, ...request } = row;
+ const group = activityPresentation(
+ [
+ { ...request, id: root, authorId: human },
+ { ...row, id: "first", authorId: team ? other : agent },
+ row,
+ ],
+ undefined,
+ new Set([agent]),
+ ).groups[0];
+ if (!group) throw new Error("Missing activity group");
+ const renderFlow = (show: boolean) => (
+
+ {show && (
+ undefined}
+ expanded={false}
+ onToggle={() => {}}
+ />
+ )}
+
+
+ );
+ const pulses = [
+ { pubkey: agent, channelId: "channel" },
+ ...(team ? [{ pubkey: other, channelId: "channel" }] : []),
+ ];
+ const work: NonNullable = local
+ ? pulses.map(({ pubkey }) => ({
+ scope: "test",
+ agent: pubkey,
+ channelId: "channel",
+ messageId: root,
+ threadRootId: root,
+ state: "working",
+ startedAt: 1,
+ }))
+ : [];
+ f.update(pulses, { local: work });
+ const view = render(renderFlow(false));
+ expect(
+ screen.getByRole("button", {
+ name: team ? "Activity: 2 agents working" : "Activity: Agent working",
+ }),
+ ).toBeVisible();
+ view.rerender(renderFlow(true));
+ expect(
+ screen.queryByRole("button", { name: /Activity: .*working/ }),
+ ).toBeNull();
+ expect(screen.getByText("Checking the fix")).toHaveAttribute(
+ "data-active",
+ "true",
+ );
+ if (!local) {
+ f.update([...pulses, entry(agent, secondRoot)]);
+ expect(
+ screen.getByRole("button", { name: "Activity: Agent working" }),
+ ).toBeVisible();
+ expect(screen.getByText("Checking the fix")).toHaveAttribute(
+ "data-active",
+ "true",
+ );
+ }
+ f.update(
+ [{ pubkey: agent, channelId: "channel", threadRootId: secondRoot }],
+ {
+ local: work
+ .filter((entry) => entry.agent === agent)
+ .map((entry) => ({
+ ...entry,
+ messageId: secondRoot,
+ threadRootId: secondRoot,
+ })),
+ },
+ );
+ expect(
+ screen.getByRole("button", { name: "Activity: Agent working" }),
+ ).toBeVisible();
+ expect(screen.getByText("Checking the fix")).not.toHaveAttribute(
+ "data-active",
+ );
+ f.update([{ pubkey: agent, channelId: "channel" }], { local: [] });
+ view.rerender(renderFlow(false));
+ expect(
+ screen.getByRole("button", { name: "Activity: Agent working" }),
+ ).toBeVisible();
+ },
+);
+
+it("groups an in-flow typing preview without repeating the preceding agent's byline", () => {
+ const f = fixture();
+ f.update([{ pubkey: agent, channelId: "channel" }]);
+ const view = render(
+ ,
+ );
+ expect(screen.getByRole("img", { name: "Agent" })).toBeVisible();
+ expect(screen.queryByText("Agent", { exact: true })).toBeNull();
+ expect(
+ view.container.querySelector("[data-agent-response-preview]"),
+ ).toHaveAttribute("data-stack-previous", "true");
+});
+
+it("uses the agent typing bubble for a public agent outside the viewer’s choices", async () => {
+ const f = fixture();
+ f.update([entry("public-agent")]);
+ render(
+ ,
+ );
+ const trigger = screen.getByRole("button", {
+ name: "Activity: Sam’s assistant working",
+ });
+ expect(screen.getByRole("img", { name: "Sam’s assistant" })).toBeVisible();
+ expect(trigger.querySelector('[data-state="working"]')).not.toBeNull();
+ await userEvent.setup().click(trigger);
+ expect(
+ within(await screen.findByRole("dialog")).getByText(
+ "Only typing updates are reaching this app.",
+ ),
+ ).toBeVisible();
+ for (const status of [
+ "connecting",
+ "interrupted",
+ "disabled",
+ "unavailable",
+ ] as const) {
+ f.update([entry("public-agent")], { status });
+ expect(
+ within(screen.getByRole("dialog")).getByText(
+ "Only typing updates are reaching this app.",
+ ),
+ ).toBeVisible();
+ }
+ f.update([]);
+ expect(
+ screen.queryByRole("button", { name: "Activity: Sam’s assistant working" }),
+ ).toBeNull();
+});
+
+it.each(["done", "error", "stopped", "superseded"] as const)(
+ "keeps a public agent’s %s local work terminal despite stale typing",
+ (state) => {
+ const f = fixture();
+ const entries = [entry("public-agent"), entry(human)];
+ const local = {
+ agent: "public-agent",
+ scope: "scope",
+ channelId: "channel",
+ messageId: "request",
+ threadRootId: root,
+ startedAt: 1,
+ state: "working" as const,
+ };
+ f.update(entries, { local: [local] });
+ render(
+ ,
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Sam’s assistant is working",
+ );
+ f.update(entries, { local: [{ ...local, state }] });
+ expect(
+ screen.queryByRole("button", { name: /Sam’s assistant working/ }),
+ ).toBeNull();
+ expect(screen.getByRole("status")).toHaveTextContent("Human is typing");
+ expect(screen.getByRole("status")).not.toHaveTextContent("Sam’s assistant");
+ },
+);
diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx
index 184eb52dca..949bdeb687 100644
--- a/src/features/messages/TypingIndicator.tsx
+++ b/src/features/messages/TypingIndicator.tsx
@@ -1,7 +1,9 @@
+import messageStyles from "./Messages.module.css";
import { useReplacedTypers } from "../conversation/typing-presentation";
import { useChannelIdentityNames } from "../identity-names/react";
import {
useCallback,
+ useContext,
useEffect,
useRef,
useMemo,
@@ -15,9 +17,22 @@ import {
profileActivityViewTarget,
} from "../profiles/target";
import { currentActivity } from "../agents/current-activity";
-import { Avatar } from "../../shared/design-system/ui/Avatar";
-import { Button } from "../../shared/design-system/ui/Button";
+import { localWorkLabel, type LocalWork } from "../agents/local-activity";
+import { AgentResponsePreview } from "./AgentResponsePreview";
+import { ActivityStepPreview } from "./ActivityStepPreview";
import { AgentActivityRow } from "../agents/AgentActivityRow";
+import {
+ NavigationCard,
+ NavigationItem,
+} from "../../shared/design-system/ui/NavigationItem";
+import {
+ ArrowUpRightIcon,
+ OctagonIcon,
+} from "../../shared/design-system/icons";
+import { Button } from "../../shared/design-system/ui/Button";
+import { AgentsContext, useAgentsSnapshot } from "../agents/react";
+import { conversationWorkStops } from "../agents/work-stop";
+import { useWorkStops } from "../agents/use-work-stops";
import activityStyles from "../agents/ActivityRows.module.css";
import {
PopoverRoot,
@@ -29,14 +44,13 @@ import type { ComposerSession } from "./composer-session";
import type { MessageLinkOrigin } from "./MessageRow";
import styles from "./TypingIndicator.module.css";
import { avatarMedia } from "../../shared/avatar-source";
-import { useActivityLanePublisher } from "./activity-lane";
const noSubscribe = () => () => {};
const noActivity = () => undefined;
const noRows: readonly never[] = [];
/** Agents working in one conversation, over existing stores; no reads or capture. */
-function useConversationAgents(
+export function useConversationAgents(
session: ComposerSession & Partial>,
channelId: string,
threadRootId: string | undefined,
@@ -54,29 +68,47 @@ function useConversationAgents(
session.agentChoices.subscribe,
session.agentChoices.snapshot,
);
- const ownedAgents = useMemo(
- () => new Set(choices.identities.map(({ pubkey }) => pubkey)),
- [choices],
+ const profiles = useSyncExternalStore(
+ session.profiles.subscribe,
+ session.profiles.snapshot,
);
+ // Public identity classifies typing only; it grants no access to step details.
+ const knownAgents = useMemo(() => {
+ const known = new Set(choices.identities.map(({ pubkey }) => pubkey));
+ for (const entry of entries)
+ if (profiles.get(entry.pubkey)?.isAgent) known.add(entry.pubkey);
+ return known;
+ }, [choices, entries, profiles]);
const details = useMemo(
() =>
- currentActivity(activity, entries, ownedAgents, channelId, threadRootId),
- [activity, entries, ownedAgents, channelId, threadRootId],
+ currentActivity(activity, entries, knownAgents, channelId, threadRootId),
+ [activity, entries, knownAgents, channelId, threadRootId],
);
const agents = [...details.keys()].filter((key) => !replaced.has(key)).sort();
- return { details, entries, replaced, agents };
+ return {
+ activity,
+ details,
+ entries,
+ replaced,
+ agents,
+ knownAgents,
+ };
}
-/** Floating conversation activity over existing stores; no reads or capture. */
+/** Incoming conversation activity over existing stores; no reads or capture. */
export function TypingIndicator({
session,
channelId,
threadRootId,
canOpenActivity,
- clickOpensPanel = false,
openActivity,
+ previousAuthor,
+ inFlow = false,
+ audience = "all",
}: {
- clickOpensPanel?: boolean | undefined;
+ audience?: "all" | "humans" | "agents";
+ previousAuthor?: string | undefined;
+ inFlow?: boolean;
session: ComposerSession & Partial>;
channelId: string;
threadRootId?: string | undefined;
@@ -85,20 +117,40 @@ export function TypingIndicator({
| ((target: string, origin?: MessageLinkOrigin) => boolean)
| undefined;
}) {
- const { details, entries, replaced, agents } = useConversationAgents(
- session,
- channelId,
- threadRootId,
+ const {
+ activity,
+ details,
+ entries,
+ replaced,
+ agents: activeAgents,
+ knownAgents,
+ } = useConversationAgents(session, channelId, threadRootId);
+ const agents = audience === "humans" ? [] : activeAgents;
+ const hostedAgents = useContext(AgentsContext);
+ useAgentsSnapshot(hostedAgents);
+ const controls = new Map(
+ agents.map((agent) => [
+ agent,
+ conversationWorkStops(
+ activity,
+ agent,
+ channelId,
+ threadRootId,
+ session.agentActivity,
+ hostedAgents,
+ ),
+ ]),
+ );
+ const { pending, stop } = useWorkStops(
+ [...controls.values()].flatMap((rows) => rows.map((row) => row.control)),
);
- // The history's jump button beside this composer reserves the lane the pill uses.
- useActivityLanePublisher(session, channelId, threadRootId, agents.length > 0);
const resolveName = useChannelIdentityNames(session, channelId);
const profiles = useSyncExternalStore(
session.profiles.subscribe,
session.profiles.snapshot,
);
// Read only the already-loaded channel window to name threads. No ensure/read.
- const needsRows = details.size > 0 && !threadRootId;
+ const needsRows = agents.length > 0 && !threadRootId;
const subscribeRows = useCallback(
(listener: () => void) =>
needsRows
@@ -118,30 +170,18 @@ export function TypingIndicator({
const firstAgent = agents[0];
const humans = entries.filter(
(entry) =>
+ audience !== "agents" &&
entry.channelId === channelId &&
entry.threadRootId === threadRootId &&
+ !knownAgents.has(entry.pubkey) &&
!details.has(entry.pubkey) &&
!replaced.has(entry.pubkey),
);
const name = (pubkey: string) =>
resolveName(pubkey, profiles.get(pubkey)?.name ?? pubkey.slice(0, 10));
- const avatar = (pubkey: string) => {
- const picture = profiles.get(pubkey)?.picture;
- return (
-
-
-
- );
- };
- const scopeLabel = (pubkey: string) => {
+ const scopeLabel = (pubkey: string, workRoot?: string) => {
if (threadRootId) return undefined;
- const roots = details.get(pubkey)?.roots ?? [];
+ const roots = workRoot ? [workRoot] : (details.get(pubkey)?.roots ?? []);
const labels = roots.slice(0, 2).map((root) => {
if (root === null) return "Channel conversation";
if (!root) return "Work with unconfirmed thread";
@@ -171,15 +211,22 @@ export function TypingIndicator({
(roots.length === 1 && (roots[0] === null || !!roots[0]));
return profileActivityViewTarget(pubkey, { transcript: known });
};
- const directTarget =
- clickOpensPanel && agents.length === 1 && firstAgent
- ? activityTarget(firstAgent)
- : undefined;
+ const directTarget = firstAgent ? activityTarget(firstAgent) : undefined;
const agentStatus = (
pubkey: string,
navigate: (target: string, origin?: MessageLinkOrigin) => void,
+ work?: LocalWork,
+ workRoot?: string,
) => {
- const detail = details.get(pubkey);
+ const detail = workRoot
+ ? currentActivity(
+ activity,
+ entries,
+ knownAgents,
+ channelId,
+ workRoot,
+ ).get(pubkey)
+ : details.get(pubkey);
if (!detail) return null;
const target = activityTarget(pubkey);
const canOpen = !!target && !!openActivity && !!canOpenActivity?.(target);
@@ -187,11 +234,25 @@ export function TypingIndicator({
(value) => typeof value === "string",
);
const conversationRoot =
- threadRootId ?? (knownRoots.length === 1 ? knownRoots[0] : undefined);
+ workRoot ??
+ work?.threadRootId ??
+ threadRootId ??
+ (knownRoots.length === 1 ? knownRoots[0] : undefined);
const conversation = conversationRoot
? `buzz://message?${new URLSearchParams({ channel: channelId, id: conversationRoot, thread: conversationRoot })}`
: `buzz://channel/${encodeURIComponent(channelId)}`;
const picture = profiles.get(pubkey)?.picture;
+ const status = work
+ ? localWorkLabel(work)
+ : detail.detailAvailability === "available"
+ ? detail.label
+ : detail.detailAvailability === "no-current-step"
+ ? "No current step details received."
+ : "Only typing updates are reaching this app.";
+ const scope =
+ detail.roots.length === 1 && detail.roots[0] === null
+ ? undefined
+ : scopeLabel(pubkey, workRoot);
const viewActivity =
canOpen && target
? () => navigate(target, activityOrigin(pubkey))
@@ -203,8 +264,8 @@ export function TypingIndicator({
@@ -213,11 +274,10 @@ export function TypingIndicator({
navigate(conversation) : undefined}
- onOpenActivity={viewActivity}
/>
);
};
@@ -244,37 +304,105 @@ export function TypingIndicator({
{[workingText, typingText].filter(Boolean).join(". ")}
{(agents.length > 0 || humans.length > 0) && (
-
- {agents.length > 0 && (
-
- openProfileTranscript(target, (next) =>
- openActivity(next, origin),
- ))
- }
- directTarget={
- directTarget && canOpenActivity?.(directTarget)
- ? directTarget
- : undefined
- }
- directOrigin={firstAgent ? activityOrigin(firstAgent) : undefined}
+
+ {firstAgent && (
+
({
+ id,
+ name: name(id),
+ picture: avatarMedia(profiles.get(id)?.picture, session.media),
+ }))}
+ picture={avatarMedia(
+ profiles.get(firstAgent)?.picture,
+ session.media,
+ )}
+ layout={threadRootId ? "thread" : "timeline"}
>
- {(navigate) =>
- agents.map((pubkey) => (
- {agentStatus(pubkey, navigate)}
- ))
- }
-
+
+
+ >
+ }
+ openTarget={
+ openActivity &&
+ ((target, origin) =>
+ openProfileTranscript(target, (next) =>
+ openActivity(next, origin),
+ ))
+ }
+ directTarget={
+ directTarget && canOpenActivity?.(directTarget)
+ ? directTarget
+ : undefined
+ }
+ directOrigin={
+ firstAgent ? activityOrigin(firstAgent) : undefined
+ }
+ >
+ {(navigate) =>
+ agents.flatMap((pubkey) => {
+ const requests = controls.get(pubkey) ?? [];
+ if (!requests.length)
+ return (
+ {agentStatus(pubkey, navigate)}
+ );
+ return requests.map(
+ ({ control, work, threadRootId: root }) => (
+
+ {agentStatus(pubkey, navigate, work, root)}
+
+ void stop(control, name(pubkey))}
+ >
+
+ {pending.has(control.key) ? "Stopping…" : "Stop"}
+
+
+
+ ),
+ );
+ })
+ }
+
+
)}
{humans.length > 0 && (
@@ -294,16 +422,14 @@ export function TypingIndicator({
function AgentChooser({
label,
- avatars,
- overflow,
+ content,
openTarget,
directTarget,
directOrigin,
children,
}: {
label: string;
- avatars: ReactNode;
- overflow: number;
+ content: ReactNode;
openTarget?:
| ((target: string, origin?: MessageLinkOrigin) => boolean)
| undefined;
@@ -356,10 +482,6 @@ function AgentChooser({
modal={false}
open={open}
onOpenChange={(nextOpen, details) => {
- if (directTarget && details.reason === "trigger-press") {
- details.cancel();
- return;
- }
// Base UI tracks compositionstart/end; also honor the native key flags
// when composition began before this hover popup mounted.
if (
@@ -378,9 +500,10 @@ function AgentChooser({
ref={trigger}
openOnHover="preview"
render={
- {
- if (!directTarget) return;
- // Keep hover preview separate from explicit click/keyboard activation.
- event.preventDefault();
- navigate(directTarget, directOrigin);
- }}
>
-
- {avatars}
- {overflow > 0 && (
- +{overflow}
- )}
-
-
+ {content}
+
}
/>
- Working now
+ }
+ disabled={!directTarget || !openTarget}
+ onClick={() => {
+ if (directTarget) navigate(directTarget, directOrigin);
+ }}
+ />
{children(navigate)}
diff --git a/src/features/messages/activity-groups.test.ts b/src/features/messages/activity-groups.test.ts
index 2a2f932369..8320870348 100644
--- a/src/features/messages/activity-groups.test.ts
+++ b/src/features/messages/activity-groups.test.ts
@@ -1,5 +1,9 @@
import { expect, it } from "vitest";
-import { activityGroups } from "./activity-groups";
+import {
+ activityDisplayRows,
+ activityPresentation,
+} from "./activity-presentation";
+import { activityRuns } from "./activity-groups";
import type { ChannelMessage } from "../relay/contracts";
const activity = (
@@ -22,11 +26,59 @@ const activity = (
const run = (prefix: string, count = 6) =>
Array.from({ length: count }, (_, index) => activity(`${prefix}${index}`));
-it("folds only runs longer than five and includes different agents in a run", () => {
- expect(activityGroups(run("a", 5))).toEqual([]);
+it("retires superseded tool updates while the steered request keeps working", () => {
+ const { audience: _audience, ...request } = activity("request", {
+ authorId: "human",
+ });
+ const steer = { ...request, id: "steer" };
+ const oldStep = activity("old-step", { replyParentId: request.id });
+ const newStep = activity("new-step", { replyParentId: steer.id });
+ const rows = [request, oldStep, steer, newStep];
+ const presentation = activityPresentation(
+ rows,
+ {
+ status: "listening",
+ typing: [],
+ records: [],
+ turns: [],
+ trimmed: 0,
+ local: [
+ {
+ scope: "test",
+ agent: "agent",
+ channelId: "solo",
+ threadRootId: "request",
+ messageId: "request",
+ startedAt: 1,
+ state: "superseded",
+ },
+ {
+ scope: "test",
+ agent: "agent",
+ channelId: "solo",
+ threadRootId: "request",
+ messageId: "steer",
+ startedAt: 2,
+ state: "working",
+ },
+ ],
+ },
+ new Set(["agent"]),
+ );
+ expect(activityDisplayRows(rows, presentation.groups)).toEqual([
+ request,
+ steer,
+ newStep,
+ ]);
+ expect(presentation.byRow.get(newStep.id)?.active).toBe(true);
+});
+
+it("folds consecutive activity into one preview and includes different agents in a run", () => {
+ const single = run("a", 1);
+ expect(activityRuns(single)).toEqual([single]);
const rows = run("a");
rows[2] = activity("other-agent", { authorId: "other" });
- expect(activityGroups(rows)).toEqual([{ ids: rows.map((row) => row.id) }]);
+ expect(activityRuns(rows)).toEqual([rows]);
});
it.each(["human", "agent", "membership"])(
@@ -46,9 +98,9 @@ it.each(["human", "agent", "membership"])(
},
}
: { ...ordinary, authorId: kind };
- expect(activityGroups([...before, separator, ...after])).toEqual([
- { ids: before.map((row) => row.id) },
- { ids: after.map((row) => row.id) },
+ expect(activityRuns([...before, separator, ...after])).toEqual([
+ before,
+ after,
]);
},
);
@@ -63,9 +115,52 @@ it("splits groups across local days and channels", () => {
...row,
channelId: "other",
}));
- expect(activityGroups([...today, ...tomorrow, ...elsewhere])).toEqual(
- [today, tomorrow, elsewhere].map((rows) => ({
- ids: rows.map((row) => row.id),
- })),
- );
+ expect(activityRuns([...today, ...tomorrow, ...elsewhere])).toEqual([
+ today,
+ tomorrow,
+ elsewhere,
+ ]);
+});
+
+it.each([1, 2])(
+ "hides %i completed steps while keeping the reply and unfinished work in place",
+ (count) => {
+ const history = run("step", count);
+ const { audience: _, ...reply } = activity("reply");
+ const rows = [...history, reply];
+ const completed = activityPresentation(rows, undefined, new Set(["agent"]));
+ expect(
+ activityDisplayRows(rows, completed.groups).map((row) => row.id),
+ ).toEqual(["reply"]);
+ const otherReply = { ...reply, authorId: "human" };
+ const pending = [...history, otherReply];
+ expect(
+ activityDisplayRows(
+ pending,
+ activityPresentation(pending, undefined, new Set(["agent"])).groups,
+ ),
+ ).toEqual(pending);
+ },
+);
+
+it("hands unparented thread replies to the latest request without overriding explicit reply links", () => {
+ const { audience: _requestAudience, ...request } = activity("request", {
+ authorId: "human",
+ threadRootId: "root",
+ });
+ const { audience: _replyAudience, ...reply } = activity("reply", {
+ agentEnvelope: true,
+ threadRootId: "root",
+ });
+ const projected = (patch: Partial) =>
+ activityPresentation(
+ [request, { ...reply, ...patch }],
+ undefined,
+ new Set(["agent"]),
+ "root",
+ ).representedAgents;
+ expect(projected({}).get("request")).toEqual(["agent"]);
+ expect(projected({}).get("root")).toBeUndefined();
+ expect(projected({ replyParentId: "root" }).get("root")).toEqual(["agent"]);
+ expect(projected({ replyParentId: "root" }).get("request")).toBeUndefined();
});
diff --git a/src/features/messages/activity-groups.ts b/src/features/messages/activity-groups.ts
index 71ee4218e1..0bd2d406ab 100644
--- a/src/features/messages/activity-groups.ts
+++ b/src/features/messages/activity-groups.ts
@@ -1,18 +1,12 @@
import type { ChannelMessage } from "../relay/contracts";
-export const RECENT_ACTIVITY_COUNT = 5;
-
-export type ActivityGroup = {
- ids: string[];
-};
-
/** Only consecutive agent-only messages on one local day fold together.
* Human messages, public agent replies and membership notices stay visible. */
-export function activityGroups(
+export function activityRuns(
rows: readonly ChannelMessage[],
-): ActivityGroup[] {
- const groups: ActivityGroup[] = [];
- let group: ActivityGroup | undefined;
+): ChannelMessage[][] {
+ const groups: ChannelMessage[][] = [];
+ let group: ChannelMessage[] | undefined;
let previous: ChannelMessage | undefined;
for (const row of rows) {
if (row.audience !== "agents" || row.membership) {
@@ -24,12 +18,12 @@ export function activityGroups(
new Date(previous.createdAt * 1000).toDateString() !==
new Date(row.createdAt * 1000).toDateString()
) {
- group = { ids: [] };
+ group = [];
groups.push(group);
}
- group.ids.push(row.id);
+ group.push(row);
}
previous = row;
}
- return groups.filter((group) => group.ids.length > RECENT_ACTIVITY_COUNT);
+ return groups;
}
diff --git a/src/features/messages/activity-lane.ts b/src/features/messages/activity-lane.ts
deleted file mode 100644
index 5ea6e1771f..0000000000
--- a/src/features/messages/activity-lane.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
-import type { ComposerSession } from "./composer-session";
-
-/** The conversation whose composer, a following sibling of this history, shows activity. */
-export type ActivityLane = Readonly<{
- session: ComposerSession;
- channelId: string;
- threadRootId?: string | undefined;
-}>;
-
-// The composer's TypingIndicator publishes whether it shows working agents; the
-// history's jump button reads it. Deriving the pill twice would miss the
-// composer's replacement context and subscribe the button to every typing
-// tick. Mounted presentation only: no data, timers, or reads.
-type Lane = { shown: number; listeners: Set<() => void> };
-const lanes = new WeakMap>();
-const key = (channelId: string, threadRootId: string | undefined) =>
- `${channelId}\n${threadRootId ?? ""}`;
-function lane(
- session: ComposerSession,
- channelId: string,
- threadRootId: string | undefined,
-) {
- const byKey = lanes.get(session) ?? new Map();
- lanes.set(session, byKey);
- const id = key(channelId, threadRootId);
- const entry = byKey.get(id) ?? { shown: 0, listeners: new Set<() => void>() };
- byKey.set(id, entry);
- return entry;
-}
-function release(entry: Lane, session: ComposerSession, id: string) {
- if (entry.shown === 0 && entry.listeners.size === 0)
- lanes.get(session)?.delete(id);
-}
-
-/** Publish, while `shown`, that this conversation's composer shows working agents. */
-export function useActivityLanePublisher(
- session: ComposerSession,
- channelId: string,
- threadRootId: string | undefined,
- shown: boolean,
-) {
- useLayoutEffect(() => {
- if (!shown) return;
- const entry = lane(session, channelId, threadRootId);
- entry.shown += 1;
- for (const listener of entry.listeners) listener();
- return () => {
- entry.shown -= 1;
- for (const listener of entry.listeners) listener();
- release(entry, session, key(channelId, threadRootId));
- };
- }, [session, channelId, threadRootId, shown]);
-}
-
-const noSubscribe = () => () => {};
-const notShown = () => false;
-/** Whether a mounted composer shows working agents in the lane's conversation. */
-export function useActivityLaneShown(target: ActivityLane | undefined) {
- const session = target?.session;
- const channelId = target?.channelId;
- const threadRootId = target?.threadRootId;
- const subscribe = useCallback(
- (listener: () => void) => {
- if (!session || channelId === undefined) return noSubscribe();
- const entry = lane(session, channelId, threadRootId);
- entry.listeners.add(listener);
- return () => {
- entry.listeners.delete(listener);
- release(entry, session, key(channelId, threadRootId));
- };
- },
- [session, channelId, threadRootId],
- );
- const snapshot = useCallback(
- () =>
- !!session &&
- channelId !== undefined &&
- (lanes.get(session)?.get(key(channelId, threadRootId))?.shown ?? 0) > 0,
- [session, channelId, threadRootId],
- );
- return useSyncExternalStore(subscribe, snapshot, notShown);
-}
diff --git a/src/features/messages/activity-presentation.ts b/src/features/messages/activity-presentation.ts
new file mode 100644
index 0000000000..8ff65c1b12
--- /dev/null
+++ b/src/features/messages/activity-presentation.ts
@@ -0,0 +1,224 @@
+import { useMemo, useRef, useSyncExternalStore } from "react";
+import type { ChannelMessage } from "../relay/contracts";
+import type { RelaySession } from "../relay/session";
+import { requestWork } from "../agents/request-work";
+import { continuesMessageGroup } from "./message-grouping";
+import { activityRuns } from "./activity-groups";
+
+type Activity = RelaySession["agentActivity"];
+type Snapshot = ReturnType;
+type ActivityRun = {
+ ids: string[];
+ history: ChannelMessage[];
+ latest: ChannelMessage;
+ participants: string[];
+ requestId: string | undefined;
+ followingMessage: ChannelMessage | undefined;
+};
+export type ActivityPresentation = ActivityRun & {
+ done: boolean;
+ retained?: boolean;
+ working: boolean;
+ standalone: boolean;
+};
+export type ActivityRowPresentation = {
+ requestId: string | undefined;
+ active: boolean;
+ ownsLive: boolean;
+ container: "history" | "bubble" | "standalone";
+};
+const noSubscribe = () => () => {};
+const noActivity = () => undefined;
+export function useActivitySnapshot(activity: Activity | undefined) {
+ return useSyncExternalStore(
+ activity?.subscribe ?? noSubscribe,
+ activity?.snapshot ?? noActivity,
+ );
+}
+
+/** Status belongs to the triggering request, never to all work in its thread. */
+function activityStatus(group: ActivityRun, snapshot: Snapshot | undefined) {
+ const { latest, participants, followingMessage: response, requestId } = group;
+ const closed =
+ !!response &&
+ response.audience !== "agents" &&
+ !response.membership &&
+ response.channelId === latest.channelId &&
+ response.threadRootId === latest.threadRootId &&
+ response.createdAt >= latest.createdAt &&
+ participants.includes(response.authorId);
+ const work = snapshot
+ ? requestWork(snapshot, latest.channelId, [
+ ...(requestId ? [requestId] : []),
+ ...group.ids,
+ ])
+ : [];
+ const done =
+ closed ||
+ participants.every((agent) =>
+ work.some(
+ (entry) =>
+ entry.agent === agent &&
+ (entry.state === "done" ||
+ entry.state === "ended" ||
+ entry.state === "superseded"),
+ ),
+ );
+ const working = new Set(
+ done
+ ? []
+ : work.filter((entry) => entry.working).map((entry) => entry.agent),
+ );
+ return { done, working, completedReply: closed ? response : undefined };
+}
+
+/** Shared projection for timeline and thread; hosts own scrolling and disclosure state. */
+export function activityPresentation(
+ rows: readonly ChannelMessage[],
+ snapshot: Snapshot | undefined,
+ agents: ReadonlySet | undefined,
+ rootId?: string,
+) {
+ const following = new Map(
+ rows.map((row, index) => [row.id, rows[index + 1]]),
+ );
+ const preceding = new Map(
+ rows.map((row, index) => [row.id, rows[index - 1]]),
+ );
+ const requests = new Map();
+ if (rootId) requests.set(rootId, rootId);
+ const representedAgents = new Map();
+ let request = rootId;
+ for (const row of rows) {
+ if (
+ row.audience !== "agents" &&
+ !row.agentEnvelope &&
+ !row.membership &&
+ agents &&
+ !agents.has(row.authorId)
+ )
+ request = row.id;
+ // A thread root is a scope, not necessarily the latest triggering request.
+ // Preserve explicit reply links; otherwise follow the preceding request.
+ const parentRequest = row.replyParentId
+ ? requests.get(row.replyParentId)
+ : undefined;
+ const rowRequest =
+ row.agentEnvelope || agents?.has(row.authorId)
+ ? (parentRequest ?? request)
+ : request;
+ if (rowRequest) requests.set(row.id, rowRequest);
+ if (
+ rowRequest &&
+ !row.membership &&
+ (row.agentEnvelope || agents?.has(row.authorId))
+ ) {
+ const represented = representedAgents.get(rowRequest) ?? [];
+ if (!represented.includes(row.authorId)) represented.push(row.authorId);
+ representedAgents.set(rowRequest, represented);
+ }
+ }
+ const groups: ActivityPresentation[] = [];
+ const bubbleAnchors = new Set();
+ const byRow = new Map();
+ for (const history of activityRuns(rows)) {
+ const first = history[0];
+ const latest = history.at(-1);
+ if (!first || !latest) continue;
+ const participants = [...new Set(history.map((row) => row.authorId))];
+ const group: ActivityRun = {
+ ids: history.map((row) => row.id),
+ history,
+ latest,
+ participants,
+ requestId: requests.get(first.id) ?? first.threadRootId,
+ followingMessage: following.get(latest.id),
+ };
+ const { done, working, completedReply } = activityStatus(group, snapshot);
+ const before = preceding.get(first.id);
+ const anchor =
+ completedReply?.authorId === first.authorId ? completedReply : before;
+ const standalone =
+ !anchor ||
+ !!anchor.membership ||
+ anchor.audience === "agents" ||
+ anchor.authorId !== first.authorId ||
+ anchor.threadRootId !== first.threadRootId ||
+ (anchor === before && !continuesMessageGroup(anchor, first));
+ if (!standalone && !done && before) bubbleAnchors.add(before.id);
+ const lastByAgent = new Map(history.map((row) => [row.authorId, row.id]));
+ for (const row of history) {
+ const last = lastByAgent.get(row.authorId) === row.id;
+ byRow.set(row.id, {
+ requestId: group.requestId,
+ active: last && working.has(row.authorId),
+ ownsLive: last && !done && !group.followingMessage,
+ container:
+ row.id === first.id && standalone
+ ? "standalone"
+ : !done || standalone
+ ? "bubble"
+ : "history",
+ });
+ }
+ groups.push({
+ ...group,
+ done,
+ working: working.size > 0,
+ standalone,
+ });
+ }
+ return { groups, byRow, representedAgents, bubbleAnchors };
+}
+export function useActivityPresentation(
+ rows: readonly ChannelMessage[],
+ activity: Activity | undefined,
+ agents: ReadonlySet | undefined,
+ rootId?: string,
+ pinnedIds?: ReadonlyMap,
+) {
+ const snapshot = useActivitySnapshot(activity);
+ const projection = useMemo(
+ () => activityPresentation(rows, snapshot, agents, rootId),
+ [rows, snapshot, agents, rootId],
+ );
+ const prior = useRef(new Map());
+ const groups = useMemo(() => {
+ const groups = projection.groups.map((group) => {
+ const key = group.ids[0] ?? "";
+ const previous = prior.current.get(key);
+ // Retain the same contained tree, not merely its individual message IDs:
+ // report dialogs and their pending submissions belong to that tree.
+ return previous &&
+ group.done &&
+ group.ids.some((id) => pinnedIds?.has(id))
+ ? {
+ ...previous,
+ done: true,
+ working: false,
+ retained: true,
+ followingMessage: group.followingMessage,
+ }
+ : group;
+ });
+ prior.current = new Map(groups.map((group) => [group.ids[0] ?? "", group]));
+ return groups;
+ }, [projection, pinnedIds]);
+ return { ...projection, groups };
+}
+
+/** Finished work stays in Activity rather than the conversation flow. */
+export function activityDisplayRows(
+ rows: readonly T[],
+ groups: readonly ActivityPresentation[],
+ revealedId?: string,
+): readonly T[] {
+ const completed = new Set(
+ groups
+ .filter((group) => group.done && !group.retained)
+ .flatMap((group) => group.ids),
+ );
+ return completed.size
+ ? rows.filter((row) => row.id === revealedId || !completed.has(row.id))
+ : rows;
+}
diff --git a/src/features/messages/message-grouping.test.ts b/src/features/messages/message-grouping.test.ts
index 412ad5ecb0..da5863983c 100644
--- a/src/features/messages/message-grouping.test.ts
+++ b/src/features/messages/message-grouping.test.ts
@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import type { ChannelMessage } from "../relay/contracts";
-import { continuesMessageGroup } from "./message-grouping";
+import {
+ continuesMessageGroup,
+ publicMessageNeighbor,
+} from "./message-grouping";
const first: ChannelMessage = {
id: "first",
@@ -72,3 +75,29 @@ it("keeps automation bylines on every row, even for a common signer and owner",
false,
);
});
+
+it("continues a reply into activity but starts the next public reply separately", () => {
+ const activity = { ...first, id: "activity", audience: "agents" as const };
+ expect(continuesMessageGroup(first, activity)).toBe(true);
+ expect(continuesMessageGroup(activity, { ...activity, id: "next" })).toBe(
+ true,
+ );
+ expect(continuesMessageGroup(activity, first)).toBe(false);
+});
+
+it.each([-1, 1] as const)(
+ "does not bridge another author's visible activity in direction %s",
+ (direction) => {
+ const rows = [
+ first,
+ { ...first, id: "work", authorId: "agent", audience: "agents" as const },
+ { ...first, id: "followup" },
+ ];
+ const index = direction === 1 ? 0 : 2;
+ expect(publicMessageNeighbor(rows, index, direction)).toBeUndefined();
+ rows[1] = { ...first, id: "work", audience: "agents" };
+ expect(publicMessageNeighbor(rows, index, direction)?.id).toBe(
+ direction === 1 ? "followup" : "first",
+ );
+ },
+);
diff --git a/src/features/messages/message-grouping.ts b/src/features/messages/message-grouping.ts
index dc1a66f2c0..9e6bd20362 100644
--- a/src/features/messages/message-grouping.ts
+++ b/src/features/messages/message-grouping.ts
@@ -15,10 +15,29 @@ export function continuesMessageGroup(
!previous.sentFromThread &&
previous.channelId === current.channelId &&
previous.authorId === current.authorId &&
- previous.audience === current.audience &&
+ (previous.audience === current.audience || current.audience === "agents") &&
current.createdAt >= previous.createdAt &&
current.createdAt - previous.createdAt <= 5 * 60 &&
new Date(previous.createdAt * 1000).toDateString() ===
new Date(current.createdAt * 1000).toDateString()
);
}
+
+/** Only same-author activity can bridge two public messages. */
+export function publicMessageNeighbor(
+ rows: readonly ChannelMessage[],
+ index: number,
+ direction: -1 | 1,
+) {
+ for (
+ let next = index + direction;
+ next >= 0 && next < rows.length;
+ next += direction
+ ) {
+ const row = rows[next];
+ if (!row) continue;
+ if (row.audience !== "agents") return row;
+ if (rows[index] && row.authorId !== rows[index]?.authorId) return undefined;
+ }
+ return undefined;
+}
diff --git a/src/features/messages/typing-indicator-testing.ts b/src/features/messages/typing-indicator-testing.ts
new file mode 100644
index 0000000000..f6ca609dda
--- /dev/null
+++ b/src/features/messages/typing-indicator-testing.ts
@@ -0,0 +1,69 @@
+import { act } from "@testing-library/react";
+import { vi } from "vitest";
+import type { RelaySession } from "../relay/session";
+import type { TypingEntry } from "../relay/typing";
+export const agent = "a".repeat(64),
+ human = "b".repeat(64),
+ other = "c".repeat(64);
+export const root = "d".repeat(64),
+ secondRoot = "e".repeat(64);
+export type Snapshot = ReturnType;
+export function fixture() {
+ const listeners = new Set<() => void>();
+ let typing: readonly TypingEntry[] = [];
+ let activity: Snapshot = {
+ status: "listening",
+ typing: [],
+ records: [],
+ turns: [],
+ trimmed: 0,
+ };
+ const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ };
+ const profiles = new Map([
+ [agent, { name: "Agent" }],
+ [human, { name: "Human" }],
+ [
+ "public-agent",
+ { name: "Sam’s assistant", isAgent: true, ownerPubkey: "sam" },
+ ],
+ [other, { name: "Other" }],
+ ]);
+ const library = { identities: [{ pubkey: agent }, { pubkey: other }] };
+ const rows = [
+ { id: root, content: "First task" },
+ { id: secondRoot, content: "Second task" },
+ ];
+ const ensure = vi.fn();
+ const session = {
+ viewer: "viewer",
+ typing: { snapshot: () => typing, subscribe },
+ agentActivity: { snapshot: () => activity, subscribe },
+ profiles: { snapshot: () => profiles, subscribe, ensure },
+ agentChoices: { snapshot: () => library, subscribe },
+ channels: {
+ window: () => ({ rows }),
+ subscribeWindow: (_id: string, fn: () => void) => subscribe(fn),
+ ensure,
+ },
+ } as unknown as RelaySession;
+ return {
+ session,
+ ensure,
+ update(entries: readonly TypingEntry[], patch: Partial = {}) {
+ act(() => {
+ typing = entries;
+ activity = { ...activity, ...patch };
+ for (const listener of listeners) listener();
+ });
+ },
+ };
+}
+export const entry = (
+ pubkey = agent,
+ threadRootId: string | undefined = root,
+): TypingEntry => ({ pubkey, channelId: "channel", threadRootId });
diff --git a/src/features/messages/use-activity-indicator.ts b/src/features/messages/use-activity-indicator.ts
new file mode 100644
index 0000000000..4b263ebdd7
--- /dev/null
+++ b/src/features/messages/use-activity-indicator.ts
@@ -0,0 +1,63 @@
+import { useSyncExternalStore } from "react";
+import type { RelaySession } from "../relay/session";
+import type { ChannelMessage } from "../relay/contracts";
+import { currentActivity } from "../agents/current-activity";
+import { useActivityTypingReplacement } from "../conversation/typing-presentation";
+const noSubscribe = () => () => {};
+const noActivity = () => undefined;
+const empty: ReturnType = [];
+const noTyping = () => empty;
+
+/** A current, rendered activity row owns the live signal for its exact scope. */
+export function useActivityIndicator(
+ session: RelaySession | undefined,
+ row: ChannelMessage,
+ eligible: boolean,
+ participants?: readonly string[],
+ requestId?: string,
+) {
+ const typing = useSyncExternalStore(
+ session?.typing?.subscribe ?? noSubscribe,
+ session?.typing?.snapshot ?? noTyping,
+ );
+ const activity = useSyncExternalStore(
+ session?.agentActivity?.subscribe ?? noSubscribe,
+ session?.agentActivity?.snapshot ?? noActivity,
+ );
+ const represented = new Set(participants ?? [row.authorId]);
+ const detail = eligible
+ ? currentActivity(
+ activity,
+ typing,
+ represented,
+ row.channelId,
+ row.threadRootId,
+ )
+ : undefined;
+ // Top-level harness work uses its triggering request as the root, while its
+ // published channel activity has no thread root. Match that known request,
+ // never an arbitrary working thread in the same channel.
+ const matches = (root: string | null | undefined) =>
+ root === (row.threadRootId ?? null) ||
+ (!row.threadRootId && !!requestId && root === requestId);
+ const liveDetails = [...(detail ?? [])].filter(
+ ([agent, value]) => represented.has(agent) && value.roots.some(matches),
+ );
+ const liveAgents = liveDetails
+ .filter(([, value]) => value.roots.every(matches))
+ .map(([agent]) => agent);
+ const live = liveDetails.length > 0;
+ useActivityTypingReplacement(
+ session
+ ? {
+ session,
+ channelId: row.channelId,
+ threadRootId: row.threadRootId,
+ pubkey: row.authorId,
+ }
+ : undefined,
+ live,
+ liveAgents,
+ );
+ return live;
+}
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index 06c97b2590..b4b1cd0ffc 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -1568,6 +1568,8 @@ need their own values in each mode.
Received message bubbles use `bubble-received`, slightly stronger than quiet
control fills. Reply connectors and thread spines use `border-message-thread`
to remain visible on conversation surfaces in both modes.
+Agent activity bubbles use a transparent fill with a 1px inset `border-standard`
+stroke, keeping their dimensions consistent with filled message bubbles.
### Accent color
@@ -1625,7 +1627,8 @@ that adds no useful information.
### Activity dots
-`TypingDots` owns the shared decorative activity motion. `state="waiting"`
+`TypingDots` owns the shared decorative activity motion. Use `size="message"`
+for 4px dots with 4px gaps inside a message line; compact indicators retain their existing sizes. `state="waiting"`
breathes all three dots together over 2.4 seconds for a received request waiting
to start. `state="working"` (the default) preserves the 1.4-second staggered fade.
Switching state keeps the same footprint. These named component rhythms are
@@ -1665,7 +1668,8 @@ Existing palette utilities and older role names are compatibility APIs while
callers migrate, not the default for new UI. Tailwind's stock palette is removed.
`ShimmerText` gives a short live-work label (an agent's current step) a slow
-sheen between the subtle and standard text roles. Use it only while that work is
+sheen from metadata text to standard text, with a distinct 1.8s sweep.
+Use it only while that work is
live, never on finished, failed or waiting text. Reduced motion shows plain text.
Activity indicators that appear while work is live (the message activity pill,
diff --git a/src/shared/design-system/ui/ShimmerText.module.css b/src/shared/design-system/ui/ShimmerText.module.css
index fe18ac6cfc..2b0cd433f3 100644
--- a/src/shared/design-system/ui/ShimmerText.module.css
+++ b/src/shared/design-system/ui/ShimmerText.module.css
@@ -6,15 +6,15 @@
.text[data-active] {
background: linear-gradient(
100deg,
- var(--text-subtle) 20%,
- var(--text-standard) 45%,
- var(--text-subtle) 70%
+ var(--text-metadata) 30%,
+ var(--text-standard) 50%,
+ var(--text-metadata) 70%
);
- background-size: 240% 100%;
+ background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
- animation: text-shimmer 2.4s linear infinite;
+ animation: text-shimmer 1.8s linear infinite;
}
}
diff --git a/src/shared/design-system/ui/TypingDots.module.css b/src/shared/design-system/ui/TypingDots.module.css
index 6dbfae8fc7..4f7eb7ba95 100644
--- a/src/shared/design-system/ui/TypingDots.module.css
+++ b/src/shared/design-system/ui/TypingDots.module.css
@@ -55,3 +55,11 @@
width: calc(var(--space-1) - 1px);
height: calc(var(--space-1) - 1px);
}
+
+.dots[data-size="message"] {
+ gap: var(--space-1);
+}
+.dots[data-size="message"] > span {
+ width: var(--space-1);
+ height: var(--space-1);
+}
diff --git a/src/shared/design-system/ui/TypingDots.tsx b/src/shared/design-system/ui/TypingDots.tsx
index cea8526422..c23f781511 100644
--- a/src/shared/design-system/ui/TypingDots.tsx
+++ b/src/shared/design-system/ui/TypingDots.tsx
@@ -6,7 +6,7 @@ export function TypingDots({
size = "default",
}: {
state?: "waiting" | "working";
- size?: "default" | "small";
+ size?: "default" | "small" | "message";
}) {
return (
{
+ const key = generateSecretKey();
+ const pubkey = getPublicKey(key);
+ await page.route("**/agent-library", (route) =>
+ route.fulfill({
+ json: {
+ definitions: [],
+ identities: [{ pubkey, name: "Working agent" }],
+ },
+ }),
+ );
+ app.serveProfile(key, { name: "Working agent", is_agent: true });
+ await open(page, app);
+ const root = app.append("primary", "alpha", "Activity lifecycle request");
+ let history = page.getByRole("region", { name: "Channel message history" });
+ if (inThread) {
+ const row = history.locator(`[data-message-id="${root.id}"]`);
+ await hoverMessage(row);
+ await row.getByRole("button", { name: "Reply", exact: true }).click();
+ history = page.getByRole("region", { name: "Thread messages" });
+ for (let i = 0; i < 25; i++)
+ app.append(
+ "primary",
+ "alpha",
+ `Earlier reply ${i}\n\nDetails for this reply.`,
+ true,
+ true,
+ root.id,
+ );
+ await expect(
+ history.getByText("Earlier reply 24", { exact: false }),
+ ).toBeVisible();
+ await history.evaluate((el) => {
+ el.scrollTop = el.scrollHeight;
+ el.dispatchEvent(new Event("scroll"));
+ });
+ } else await end(page);
+ await settle(page, history);
+ const tags = [
+ ["h", "alpha"],
+ ...(inThread
+ ? [
+ ["e", root.id, "", "root"],
+ ["e", root.id, "", "reply"],
+ ]
+ : []),
+ ];
+ let timestamp = Math.floor(Date.now() / 1000);
+ const publish = (kind, content, extra = []) =>
+ app.relay.publish(
+ "primary",
+ finalizeEvent(
+ {
+ kind,
+ content,
+ tags: [...tags, ...extra],
+ created_at: timestamp++,
+ },
+ key,
+ ),
+ );
+ publish(20002, "");
+ const trigger = history.getByRole("button", {
+ name: "Activity: Working agent working",
+ });
+ await expect(trigger).toBeVisible();
+ await expect
+ .poll(() =>
+ history.evaluate((el) =>
+ el.querySelector("[data-conversation-activity]")
+ ? el.scrollHeight - el.clientHeight - el.scrollTop
+ : Number.POSITIVE_INFINITY,
+ ),
+ )
+ .toBeLessThan(2);
+ await trigger.focus();
+ const original = await trigger.elementHandle();
+ app.append(
+ "primary",
+ "alpha",
+ "Another person adds a message",
+ true,
+ true,
+ inThread ? root.id : undefined,
+ );
+ await expect(
+ history.getByText("Another person adds a message", { exact: true }),
+ ).toBeVisible();
+ await expect(trigger).toBeFocused();
+ expect(await original.evaluate((el) => el.isConnected)).toBe(true);
+
+ publish(9, "Checking the lifecycle", [["audience", "agents"]]);
+ const disclosure = history.getByRole("button", {
+ name: /Show activity history:.*Checking the lifecycle/,
+ });
+ await disclosure.click();
+ const collapse = history.getByRole("button", {
+ name: /Collapse activity history/,
+ });
+ await collapse.focus();
+ publish(9, "Completed lifecycle work");
+ await expect(
+ history.getByText("Completed lifecycle work", { exact: true }),
+ ).toBeVisible();
+ await expect(collapse).toHaveCount(0);
+ await expect
+ .poll(() =>
+ history.evaluate(
+ (el) =>
+ el.contains(document.activeElement) &&
+ document.activeElement !== document.body,
+ ),
+ )
+ .toBe(true);
+ });
+}
diff --git a/tests/fixtures/message-gallery/examples.ts b/tests/fixtures/message-gallery/examples.ts
index 1ba8db9a4d..c5bfdfa91a 100644
--- a/tests/fixtures/message-gallery/examples.ts
+++ b/tests/fixtures/message-gallery/examples.ts
@@ -83,21 +83,27 @@ export const groups: Group[] = [
{
id: "agent-coordination",
title: "Agent coordination",
- description: "Every compact entry keeps its author's avatar visible.",
+ description:
+ "Compact activity rows use author avatars, inline mentions, and expandable text.",
examples: [
{
id: "repeated-agent",
title: "Consecutive agent updates",
description:
- "Author icons remain visible before and after expanding a row.",
+ "Individual rows keep previews within the message width. Timeline group folding is exercised separately.",
rows: [
+ message(
+ "coordination-intro",
+ "I’ll investigate the missing icons and check the rendered result.",
+ { authorId: agent },
+ ),
message("coordination-first", "Investigating the missing icons.", {
authorId: agent,
audience: "agents",
}),
message(
"coordination-next",
- "Found the cause.\n\nThe fix is ready to try.",
+ "Found the cause. The activity rows were rendering their own avatars rather than using the conversation’s message grouping. I’m updating the shared grouping and matching the width of these previews to the surrounding messages.\n\nThe fix is ready to try.",
{
authorId: agent,
audience: "agents",