diff --git a/src/bundled/agents/AgentDetails.tsx b/src/bundled/agents/AgentDetails.tsx index cb7af9a06b..a5fbd6f1d5 100644 --- a/src/bundled/agents/AgentDetails.tsx +++ b/src/bundled/agents/AgentDetails.tsx @@ -21,6 +21,7 @@ import type { Agents, AgentConversation, RegisteredAgentType, + TranscriptRequest, } from "../../features/agents/service"; import { AgentArchiveRetry, @@ -78,6 +79,8 @@ export function AgentDetails({ messageAction, conversation, initialActivity, + initialTranscript, + close, communities, }: { agents: Agents; @@ -90,6 +93,9 @@ export function AgentDetails({ messageAction: (variant: "glass" | "subtle") => ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean; + initialTranscript?: TranscriptRequest | undefined; + /** Closes the panel showing the agent. */ + close?: () => void; communities?: CommunityReader | undefined; }) { const { pack, src: backdrop } = useAgentPortraitSource(agent, agent.relay); @@ -375,6 +381,8 @@ export function AgentDetails({ channels={channels} conversation={conversation} initialActivity={initialActivity} + initialTranscript={initialTranscript} + close={close} settingsHost={settings.settingsHost} onDiscard={settings.discard} memories={memories} diff --git a/src/bundled/agents/AgentTabs.tsx b/src/bundled/agents/AgentTabs.tsx index 75a44c2e9a..07b2429c6f 100644 --- a/src/bundled/agents/AgentTabs.tsx +++ b/src/bundled/agents/AgentTabs.tsx @@ -6,6 +6,7 @@ import type { AgentSettingsHost, Agents, RegisteredAgentType, + TranscriptRequest, } from "../../features/agents/service"; import type { AgentMemories } from "../../features/agents/MemoryPanel"; import type { CommunityReader } from "../../features/communities/service"; @@ -27,6 +28,8 @@ export function AgentTabs(props: { settingsContent: ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean | undefined; + initialTranscript?: TranscriptRequest | undefined; + close?: (() => void) | undefined; onDiscard?: () => void; }) { const settingsContent = ( diff --git a/src/bundled/builderlab/adapter-type/Activity.tsx b/src/bundled/builderlab/adapter-type/Activity.tsx index 0607dec8bd..999f0f4848 100644 --- a/src/bundled/builderlab/adapter-type/Activity.tsx +++ b/src/bundled/builderlab/adapter-type/Activity.tsx @@ -86,6 +86,7 @@ function ActivityFeed({ transcript={transcript} agentName={agent.name} state={(turnId) => state.get(turnId)} + follow={false} before={ <> diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index e3d9161ce3..a654ddd2a2 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -352,6 +352,55 @@ it("lists saved conversations and shows one's transcript, or that it is gone", a expect(save).not.toHaveBeenCalled(); }); +it("opens the conversation's transcript when opened from its activity, once", async () => { + const root = "e".repeat(64); + localStorage.setItem( + "buzz.claude-code.sessions.v1", + JSON.stringify({ + [agent().key]: { [`channel/${root}`]: { id: "kept", seen: 0, at: 2 } }, + }), + ); + const transcript = JSON.stringify({ + type: "assistant", + uuid: "u1", + timestamp: "2026-10-09T10:00:05Z", + message: { content: [{ type: "text", text: "Two files." }] }, + }); + const Activity = tab( + installed({ files: { "./-Users-me--buzz/kept.jsonl": transcript } }).type, + 1, + ); + const conversation = { channelId: "channel", rootId: root }; + const { unmount } = render( + undefined }} + />, + ); + const user = userEvent.setup(); + expect(await screen.findByText("Two files.")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Back to profile" })); + expect( + screen.getByRole("button", { + name: "View transcript: channel", + }), + ).toBeVisible(); + expect(screen.queryByText("Two files.")).toBeNull(); + unmount(); + // Opened from the profile itself, it lists conversations instead. + render( + , + ); + expect( + screen.getByRole("button", { + name: "View transcript: channel", + }), + ).toBeVisible(); + expect(screen.queryByText("Two files.")).toBeNull(); +}); + it("shows the transcript and that sessions may be missing when some history cannot be read", async () => { localStorage.setItem( "buzz.claude-code.sessions.v1", diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 9acbc56f87..bd5ec0ba2f 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -704,9 +704,13 @@ export function createTabs({ function ActivityTab({ agent, conversation, + initialTranscript, + close, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; + initialTranscript?: AgentViewProps["initialTranscript"]; + close?: AgentViewProps["close"]; }) { const sessions = useSyncExternalStore( (listener) => runtime.subscribe(listener), @@ -731,7 +735,12 @@ export function createTabs({ const running = busy.get(key); return running === undefined ? undefined : running ? "Working" : "Idle"; }; - const { open, show, back } = useOpenTranscript(conversation, label); + const { open, show, back } = useOpenTranscript( + conversation, + label, + initialTranscript && { initial: here, ...initialTranscript }, + close, + ); return (
{conversation && ( @@ -803,6 +812,7 @@ export function createTabs({ onBack={back} > { + const root = "e".repeat(64); + const transcript = "#general · thread eeeeeeee"; + const opened = () => { + const f = fixture({ + saved: { + [JSON.stringify(["channel", root])]: { + threadId: "kept", + workspace: "/w", + at: 2, + }, + }, + }); + const close = vi.fn(); + const done = vi.fn(); + // Each call is a new opening, as each Working now click is. + let openings = 0; + const panel = (done?: () => void) => ( + undefined} + tabMenu={{ + key: "channel", + closeAll: () => false, + closeOthers: () => false, + }} + items={[ + { + id: "profile", + label: "Sol", + close: () => undefined, + content: ( + + ), + }, + ]} + /> + ); + return { close, done, panel }; + }; + + it("shows the transcript, and closing it closes the panel", async () => { + const { close, done, panel } = opened(); + render(panel(done)); + expect( + await screen.findByRole("tab", { name: transcript }), + ).toHaveAttribute("aria-selected", "true"); + expect(done).toHaveBeenCalled(); + await userEvent.click( + screen.getByRole("button", { name: `Close ${transcript} tab` }), + ); + expect(close).toHaveBeenCalledOnce(); + }); + + it("keeps the panel when the profile's other tabs are closed", async () => { + const user = userEvent.setup(); + const { close, done, panel } = opened(); + render(panel(done)); + await screen.findByRole("tab", { name: transcript }); + await user.pointer({ + target: screen.getByRole("tab", { name: "Sol" }), + keys: "[MouseRight]", + }); + await user.click( + await screen.findByRole("menuitem", { name: "Close other tabs" }), + ); + expect(screen.queryByRole("tab", { name: transcript })).toBeNull(); + expect(close).not.toHaveBeenCalled(); + }); + + it("shows the transcript again when the kept profile is opened again", async () => { + const user = userEvent.setup(); + const { done, panel } = opened(); + const view = render(panel(done)); + await screen.findByRole("tab", { name: transcript }); + await user.pointer({ + target: screen.getByRole("tab", { name: "Sol" }), + keys: "[MouseRight]", + }); + await user.click( + await screen.findByRole("menuitem", { name: "Close other tabs" }), + ); + // The panel offered its request, so it no longer asks. + view.rerender(panel()); + expect(screen.queryByRole("tab", { name: transcript })).toBeNull(); + // Working now opens the same profile again: a new request. + const again = vi.fn(); + view.rerender(panel(again)); + expect( + await screen.findByRole("tab", { name: transcript }), + ).toHaveAttribute("aria-selected", "true"); + expect(again).toHaveBeenCalledOnce(); + }); +}); + it("finds the thread's Codex session another build started", async () => { const root = "f".repeat(64); const tag = sessionTag("a".repeat(64), { channelId: "channel", root }); diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index 685b7532b2..3bf837d656 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -814,9 +814,13 @@ export function createTabs( function ActivityTab({ agent, conversation, + initialTranscript, + close, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; + initialTranscript?: AgentViewProps["initialTranscript"]; + close?: AgentViewProps["close"]; }) { const sessions = useSyncExternalStore(runtime.subscribe, () => runtime.sessions(agent.key), @@ -832,7 +836,12 @@ export function createTabs( const recent = conversations .filter((item) => item.key !== here?.key) .slice(0, CONVERSATION_LIMIT); - const { open, show, back } = useOpenTranscript(conversation, label); + const { open, show, back } = useOpenTranscript( + conversation, + label, + initialTranscript && { initial: here, ...initialTranscript }, + close, + ); const saved = new Set(conversations.map((row) => row.key)); const views = new Map(sessions.map((view) => [view.key, view])); const unbound = sessions.filter((view) => !saved.has(view.key)); @@ -935,7 +944,7 @@ export function createTabs( backLabel="Back to profile" onBack={back} > - + )}
diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index 3655802fab..058af2e6da 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -811,9 +811,13 @@ export function createTabs({ function ActivityTab({ agent, conversation, + initialTranscript, + close, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; + initialTranscript?: AgentViewProps["initialTranscript"]; + close?: AgentViewProps["close"]; }) { const workspace = readConfig(agent.config).workspace; const live = useSyncExternalStore( @@ -862,7 +866,12 @@ export function createTabs({ row.title ? titled(agent.name, row.title) : `${row.channelId.slice(0, 8)} · ${row.root ? `thread ${row.root.slice(0, 8)}` : "channel"}`; - const { open, show, back } = useOpenTranscript(conversation, label); + const { open, show, back } = useOpenTranscript( + conversation, + label, + initialTranscript && { initial: here, ...initialTranscript }, + close, + ); const rows = (recent.value ?? []) .filter((row) => row.sessionId !== here?.sessionId) .slice(0, RECENT_LIMIT) @@ -955,6 +964,7 @@ export function createTabs({ onBack={back} > { expect(save).not.toHaveBeenCalled(); }); +it("settles a transcript request without opening it once Pi's late listing arrives", async () => { + const runtime = fakeRuntime(); + const { ActivityTab } = createTabs({ setup: new PiSetup(fakePi()), runtime }); + const done = vi.fn(); + render( + , + ); + expect(done).toHaveBeenCalled(); + const here = await screen.findByRole("region", { name: "This thread" }); + expect( + within(here).getByRole("button", { name: /^View transcript/ }), + ).toBeVisible(); + expect(runtime.transcript).not.toHaveBeenCalled(); + expect(screen.queryByText("Hello from chan/root")).toBeNull(); +}); + it("lists Pi's conversations in the Activity tab, and shows a transcript", async () => { const runtime = fakeRuntime(); const { ActivityTab } = createTabs({ setup: new PiSetup(fakePi()), runtime }); diff --git a/src/bundled/pi/tabs.tsx b/src/bundled/pi/tabs.tsx index 11ec9b2700..5e4f46fdbe 100644 --- a/src/bundled/pi/tabs.tsx +++ b/src/bundled/pi/tabs.tsx @@ -689,9 +689,11 @@ export function createTabs({ function ActivityTab({ agent, conversation, + initialTranscript, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; + initialTranscript?: AgentViewProps["initialTranscript"]; }) { const working = useSyncExternalStore( (listener) => runtime.subscribe(listener), @@ -704,6 +706,12 @@ export function createTabs({ agent.key, ); const places = listing.value; + // Pi lists its conversations only after the tab shows, so its transcript + // would open late, under whatever the reader turned to meanwhile. The + // request is settled without opening it. + const opening = initialTranscript?.opening; + // biome-ignore lint/correctness/useExhaustiveDependencies: once per opening. + useEffect(() => initialTranscript?.done(), [opening]); if (!places) return listing.status === "error" ? (
@@ -817,6 +825,7 @@ export function createTabs({ onBack={back} > function renderAgentActivity( tabs: { title: string; role?: string }[], rootId?: string, + transcript: "opened" | "shown again" | false = false, ) { const owner = createRelaySession(null); const snapshot = { @@ -370,21 +374,33 @@ function renderAgentActivity( id: title.toLowerCase(), title, role, - component: ({ + component: function Echo({ conversation, + initialTranscript, }: { conversation?: { channelId: string; rootId?: string }; - }) => ( - <> -

Echo {title.toLowerCase()}

- {conversation && ( -

- Opened from {conversation.channelId}/ - {conversation.rootId ?? "channel"} -

- )} - - ), + initialTranscript?: TranscriptRequest; + }) { + // Like a type's Activity tab: it acts on the request as it shows. + const [first] = useState(!!initialTranscript); + // biome-ignore lint/correctness/useExhaustiveDependencies: once per opening. + useEffect( + () => initialTranscript?.done(), + [initialTranscript?.opening], + ); + return ( + <> +

Echo {title.toLowerCase()}

+ {first &&

Transcript first

} + {conversation && ( +

+ Opened from {conversation.channelId}/ + {conversation.rootId ?? "channel"} +

+ )} + + ); + }, })), } as unknown as RegisteredAgentType; const agent = { @@ -406,11 +422,16 @@ function renderAgentActivity( memberships: () => [], configHistory: () => undefined, } as unknown as Agents; - render( + const target = + profileActivityViewTarget(key, { transcript: !!transcript }) ?? ""; + // Like Working now: the opener asks as it opens the target. + if (transcript === "opened") openProfileTranscript(target, () => true); + const panel = (mount: number) => ( true), }} close={() => {}} - />, + /> + ); + let mounts = 0; + const view = render(panel(mounts)); + return Object.assign( + () => { + cleanup(); + owner.dispose(); + }, + { + target, + /** Remounts the retained profile, as on returning to its channel. */ + remount: () => view.rerender(panel(++mounts)), + }, ); - return () => { - cleanup(); - owner.dispose(); - }; } it("opens an agent's activity on its type's Activity tab, the profile's only one", async () => { @@ -454,6 +484,85 @@ it("opens an agent's activity on its type's Activity tab, the profile's only one } }); +it("asks the Activity tab for its transcript only when the opener knows the conversation", () => { + const tabs = [{ title: "Activity", role: "activity" }]; + // Work in several threads opens the plain destination, never a transcript. + let dispose = renderAgentActivity(tabs); + try { + expect(screen.getByText("Echo activity")).toBeVisible(); + expect(screen.queryByText("Transcript first")).toBeNull(); + } finally { + dispose(); + } + dispose = renderAgentActivity(tabs, "thread-root", "opened"); + try { + expect(screen.getByText("Transcript first")).toBeVisible(); + } finally { + dispose(); + } + // Its retained tab remounting, as on returning to the channel, asks nothing. + dispose = renderAgentActivity(tabs, "thread-root", "shown again"); + try { + expect(screen.getByText("Echo activity")).toBeVisible(); + expect(screen.queryByText("Transcript first")).toBeNull(); + } finally { + dispose(); + } +}); + +it("asks for the transcript once, not again on returning from another section", async () => { + const dispose = renderAgentActivity( + [ + { title: "Settings", role: "settings" }, + { title: "Activity", role: "activity" }, + ], + "thread-root", + "opened", + ); + try { + expect(screen.getByText("Transcript first")).toBeVisible(); + const user = userEvent.setup(); + await user.click(screen.getByRole("tab", { name: "Identity" })); + await user.click(screen.getByRole("tab", { name: "Activity" })); + expect(screen.getByText("Echo activity")).toBeVisible(); + expect(screen.queryByText("Transcript first")).toBeNull(); + } finally { + dispose(); + } +}); + +it("keeps a new opening for a profile on another section to itself, so a remount does not reopen it", async () => { + const sections = [ + { title: "Settings", role: "settings" }, + { title: "Activity", role: "activity" }, + ]; + const user = userEvent.setup(); + let dispose = renderAgentActivity(sections, "thread-root", "shown again"); + try { + await user.click(screen.getByRole("tab", { name: "Identity" })); + // Working now opens the retained profile again while it shows Identity. + act(() => void openProfileTranscript(dispose.target, () => true)); + dispose.remount(); + expect(screen.getByText("Echo activity")).toBeVisible(); + expect(screen.queryByText("Transcript first")).toBeNull(); + } finally { + dispose(); + } + dispose = renderAgentActivity(sections, "thread-root", "shown again"); + try { + await user.click(screen.getByRole("tab", { name: "Identity" })); + act(() => void openProfileTranscript(dispose.target, () => true)); + // Not remounted, the profile still has the opening for its Activity tab. + await user.click(screen.getByRole("tab", { name: "Activity" })); + expect(screen.getByText("Transcript first")).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "Identity" })); + await user.click(screen.getByRole("tab", { name: "Activity" })); + expect(screen.queryByText("Transcript first")).toBeNull(); + } finally { + dispose(); + } +}); + it("opens an agent's activity on its type's first tab when the type has no Activity tab", () => { const dispose = renderAgentActivity([ { title: "Settings", role: "settings" }, diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 659c88cb92..b415be9724 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -29,7 +29,11 @@ import { Button } from "../../shared/design-system/ui/Button"; import { Tabs } from "../../shared/design-system/ui/Tabs"; import type { PanelProps } from "../../features/panels/service"; import { + claimProfileTranscript, + profileActivityTranscript, profileActivityViewKey, + profileTranscriptRequest, + subscribeProfileTranscripts, profileAgentHint, profilePanelKey, profileTarget, @@ -47,7 +51,7 @@ import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow"; import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; import { AgentDelete } from "../../features/agents/AgentManage"; import { useAgent, useChannelChoices } from "../../features/agents/react"; -import type { Agents } from "../../features/agents/service"; +import type { Agents, TranscriptRequest } from "../../features/agents/service"; import type { CommunityReader } from "../../features/communities/service"; import { agentSections } from "../../features/agents/tabs"; import { useAgentSettingsHost } from "../agents/AgentSettingsHost"; @@ -73,6 +77,35 @@ export function ProfilePanel({ }) { const connection = useRelayConnection(relay); const activityKey = profileActivityViewKey(target); + const transcript = profileActivityTranscript(target); + const opening = transcript ? "transcript" : activityKey ? "activity" : "info"; + // Asked once per opening. This profile takes the opening as soon as it is + // shown, even on another section, so remounting with the channel finds + // nothing to reopen; changing sections back after it was offered does not + // ask again either. + const asked = useSyncExternalStore(subscribeProfileTranscripts, () => + transcript ? profileTranscriptRequest(target) : undefined, + ); + const [held, setHeld] = useState(); + const [offered, setOffered] = useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: each new opening is claimed. + useEffect(() => { + const claimed = claimProfileTranscript(target); + if (claimed !== undefined) setHeld(claimed); + }, [asked, target]); + const pending = asked ?? held; + const request = + pending !== undefined && pending > offered ? pending : undefined; + const initialTranscript = useMemo( + () => + request === undefined + ? undefined + : { + opening: request, + done: () => setOffered((last) => Math.max(last, request)), + }, + [request], + ); const pubkey = profilePanelKey(target) ?? activityKey; const owned = useAgent(agents, pubkey ?? ""); if (!pubkey) return

Unsupported profile.

; @@ -87,10 +120,11 @@ export function ProfilePanel({ ) return ( { + const turn = (id: string, at: number): TranscriptTurn => ({ + turnId: id, + channelId: "channel", + threadRootId: null, + triggeringEventIds: [], + newSession: false, + config: [], + startedAt: at, + endedAt: at + 1000, + partial: false, + items: [], + }); + let height = 1000; + const scrolling = () => { + const view = document.createElement("div"); + view.style.overflowY = "auto"; + Object.defineProperty(view, "scrollHeight", { get: () => height }); + Object.defineProperty(view, "clientHeight", { get: () => 200 }); + return document.body.appendChild(view); + }; + const view = scrolling(); + const show = (turns: TranscriptTurn[], follow?: boolean) => ( + "ended"} + {...(follow === undefined ? {} : { follow })} + /> + ); + const { rerender, unmount } = render(show([turn("a", 1)]), { + container: view, + }); + expect(view.scrollTop).toBe(1000); + height = 1400; + rerender(show([turn("a", 1), turn("b", 2)])); + expect(view.scrollTop).toBe(1400); + view.scrollTop = 100; + fireEvent.scroll(view); + height = 1800; + rerender(show([turn("a", 1), turn("b", 2), turn("c", 3)])); + expect(view.scrollTop).toBe(100); + unmount(); + const shared = scrolling(); + render(show([turn("a", 1)], false), { container: shared }); + expect(shared.scrollTop).toBe(0); + view.remove(); + shared.remove(); +}); diff --git a/src/features/agents/ActivityTranscript.tsx b/src/features/agents/ActivityTranscript.tsx index d200efa973..387e9b004d 100644 --- a/src/features/agents/ActivityTranscript.tsx +++ b/src/features/agents/ActivityTranscript.tsx @@ -1,4 +1,4 @@ -import { useMemo, type ReactNode } from "react"; +import { useLayoutEffect, useMemo, useRef, type ReactNode } from "react"; import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { @@ -86,21 +86,59 @@ const thread = (turn: TranscriptTurn) => ? `Thread ${turn.threadRootId.slice(0, 8)}` : "Thread unknown"; -/** Turn-by-turn transcript of the loaded activity. */ +/** The nearest ancestor that scrolls, which the host supplies. */ +function scroller(element: HTMLElement | null) { + for (let node = element?.parentElement; node; node = node.parentElement) + if (/auto|scroll/.test(getComputedStyle(node).overflowY)) return node; + return undefined; +} +/** How near the end still counts as reading the latest. */ +const END_SLACK = 32; + +/** Opens at the latest step and keeps it in view as the transcript grows, + * unless the reader has scrolled up to read earlier turns. */ +function useFollowLatest(content: unknown, enabled: boolean) { + const timeline = useRef(null); + const following = useRef(true); + useLayoutEffect(() => { + const view = enabled ? scroller(timeline.current) : undefined; + if (!view) return; + const read = () => { + following.current = + view.scrollHeight - view.scrollTop - view.clientHeight <= END_SLACK; + }; + view.addEventListener("scroll", read, { passive: true }); + return () => view.removeEventListener("scroll", read); + }, [enabled]); + // biome-ignore lint/correctness/useExhaustiveDependencies: content is the change to follow. + useLayoutEffect(() => { + const view = enabled && following.current && scroller(timeline.current); + if (view) view.scrollTop = view.scrollHeight; + }, [content, enabled]); + return timeline; +} + +/** Turn-by-turn transcript of the loaded activity, oldest first, opened at + * the latest. */ export function ActivityTranscript({ transcript, agentName, state, before, + follow = true, }: { transcript: Transcript; agentName: string; state: (turnId: string) => TurnState | undefined; /** Paging controls shown above the oldest loaded turn. */ before?: ReactNode; + /** Scroll the host to the latest step; off where the transcript shares + * its scroll with other content. */ + follow?: boolean; }) { + const timeline = useFollowLatest(transcript, follow); return ( -
+
{before} {!transcript.turns.length && (

diff --git a/src/features/agents/OpenedConversation.test.tsx b/src/features/agents/OpenedConversation.test.tsx new file mode 100644 index 0000000000..9996a35ca3 --- /dev/null +++ b/src/features/agents/OpenedConversation.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { useOpenedSession, useOpenTranscript } from "./OpenedConversation"; + +type Row = { key: string; channelId: string; root?: string }; +const conversation = { channelId: "channel", rootId: "root" }; +const recent: Row = { key: "recent", channelId: "other" }; +const found: Row = { key: "found", channelId: "channel", root: "root" }; + +it("keeps the reader's choice when the opened conversation's lookup finishes later", async () => { + let resolve: (row: Row) => void = () => {}; + const lookup = new Promise((done) => { + resolve = done; + }); + const { result } = renderHook(() => { + const { session } = useOpenedSession( + [recent], + conversation, + () => lookup, + ); + return useOpenTranscript(conversation, (row) => row.key, { + initial: session, + opening: 1, + done() {}, + }); + }); + expect(result.current.open).toBeUndefined(); + act(() => result.current.show(recent)); + try { + expect(result.current.open).toBe(recent); + } finally { + await act(async () => resolve(found)); + } + expect(result.current.open).toBe(recent); +}); + +it("never opens a transcript found only after the conversation was shown", async () => { + const done = vi.fn(); + const { result } = renderHook(() => { + const { session } = useOpenedSession( + [recent], + conversation, + async () => found, + ); + return useOpenTranscript(conversation, (row) => row.key, { + initial: session, + opening: 1, + done, + }); + }); + await act(async () => {}); + expect(result.current.open).toBeUndefined(); + expect(done).toHaveBeenCalled(); +}); + +it("opens the known conversation's transcript once, not again after Back", () => { + const { result } = renderHook(() => { + const { session } = useOpenedSession( + [found], + conversation, + async () => undefined, + ); + return useOpenTranscript(conversation, (row) => row.key, { + initial: session, + opening: 1, + done() {}, + }); + }); + expect(result.current.open).toBe(found); + act(() => result.current.back()); + expect(result.current.open).toBeUndefined(); +}); + +it("opens the transcript again for a new opening in the same conversation", () => { + const settled = vi.fn(); + const { result, rerender } = renderHook( + ({ opening }: { opening: number | undefined }) => { + const { session } = useOpenedSession( + [found], + conversation, + async () => undefined, + ); + // A callback made anew each render, as an inline one is. + return useOpenTranscript( + conversation, + (row) => row.key, + opening === undefined + ? undefined + : { initial: session, opening, done: () => settled(opening) }, + ); + }, + { initialProps: { opening: 1 as number | undefined } }, + ); + expect(result.current.open).toBe(found); + act(() => result.current.back()); + rerender({ opening: 1 }); + expect(result.current.open).toBeUndefined(); + // Offered and settled: rendering on without a request leaves it closed. + rerender({ opening: undefined }); + expect(result.current.open).toBeUndefined(); + rerender({ opening: 2 }); + expect(result.current.open).toBe(found); + // The same opening, rendered again after Back, does not reopen it. + act(() => result.current.back()); + rerender({ opening: 2 }); + expect(result.current.open).toBeUndefined(); + expect(settled.mock.calls).toEqual([[1], [2]]); +}); diff --git a/src/features/agents/OpenedConversation.tsx b/src/features/agents/OpenedConversation.tsx index 8026357ffa..8530cc87c0 100644 --- a/src/features/agents/OpenedConversation.tsx +++ b/src/features/agents/OpenedConversation.tsx @@ -84,27 +84,60 @@ export function useOpenedSession( /** The transcript a type's tab shows over itself as a panel subview, titled * `title(row)`. It closes when the profile is retargeted to another - * conversation, and viewing it again brings its tab back in a tabbed host. */ + * conversation, and viewing it again brings its tab back in a tabbed host. + * `request` asks for the conversation's transcript first. It opens by itself + * only if already known when the conversation is shown, since one found later + * must not take the reader's focus; `request.done` reports that it was offered. + * A new `request.opening` asks again, even for the conversation already shown. + * Closing that tab calls `close`, since it was all that was asked for, unless + * the host is only clearing the profile's own detail tabs. */ export function useOpenTranscript( conversation: AgentConversation | undefined, title: (row: T) => string, + request?: { initial: T | undefined; opening: number; done(): void }, + close?: () => void, ) { const place = conversation ? `${conversation.channelId}/${conversation.rootId ?? ""}` : ""; - const [shown, setShown] = useState<{ place: string; row: T }>(); + const [shown, setShown] = useState<{ + place: string; + row: T; + initial?: true; + }>(); // Forgotten, not just hidden: returning to the first conversation must not - // reopen it unasked. - if (shown && shown.place !== place) setShown(undefined); + // reopen it unasked. A new request opens it again in the same one. + const opening = request?.opening; + const [offered, setOffered] = useState<{ place: string; opening?: number }>({ + place: "", + }); + const moved = offered.place !== place; + if (moved || (opening !== undefined && opening !== offered.opening)) { + setOffered({ place, ...(opening !== undefined && { opening }) }); + const asked = + place && request?.initial !== undefined + ? { place, row: request.initial, initial: true as const } + : undefined; + if (moved || asked) setShown(asked); + } const tabs = usePanelTabHost(); + // A transcript already open in its tab is brought forward when asked again. + // biome-ignore lint/correctness/useExhaustiveDependencies: once per opening. + useEffect(() => { + if (!request) return; + if (tabs && shown?.initial && shown.place === place) + tabs.activate(tabs.owner, title(shown.row)); + request.done(); + }, [opening]); return { open: shown?.place === place ? shown.row : undefined, show(row: T) { setShown({ place, row }); tabs?.activate(tabs.owner, title(row)); }, - back() { - setShown(undefined); + back(options?: { keepOwner?: boolean }) { + if (tabs && close && shown?.initial && !options?.keepOwner) close(); + else setShown(undefined); }, }; } diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 690c713de3..fb657ed09e 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -242,10 +242,19 @@ export type AgentConversation = Readonly<{ channelId: string; rootId?: string; }>; +/** A panel's ask for a conversation's transcript. `opening` identifies it: a + * new number is a new opening, even of the conversation already shown. */ +export type TranscriptRequest = Readonly<{ opening: number; done(): void }>; export type AgentViewProps = { agent: Agent; /** Where the agent was opened from, when it was opened from a conversation. */ conversation?: AgentConversation; + /** Present while the panel asks to show that conversation's transcript + * first: an Activity tab does so, if it knows it then, and calls `done` + * once it has, so the panel asks only once. */ + initialTranscript?: TranscriptRequest; + /** Closes the panel showing the tab. */ + close?: () => void; /** Replaces the type's whole config. Spread `agent.config` to change one * field, or tabs that each save a part will erase each other's fields. A * hosted type's tabs save to its provider instead; for them this rejects. */ diff --git a/src/features/agents/tabs.tsx b/src/features/agents/tabs.tsx index 61f4188225..c2f4c2850d 100644 --- a/src/features/agents/tabs.tsx +++ b/src/features/agents/tabs.tsx @@ -12,6 +12,7 @@ import type { AgentSettingsHost, Agents, RegisteredAgentType, + TranscriptRequest, } from "./service"; import { AgentSaveButton, useAgentSaveState } from "./AgentSaveButton"; import { AgentConfigHistory } from "./AgentConfigHistory"; @@ -29,6 +30,8 @@ export function agentSections({ agent, type, conversation, + initialTranscript, + close, settingsHost, memories, communities, @@ -39,6 +42,10 @@ export function agentSections({ agent: Agent; type: RegisteredAgentType | undefined; conversation?: AgentConversation | undefined; + /** Asks to show the conversation's transcript first; see AgentViewProps. */ + initialTranscript?: TranscriptRequest | undefined; + /** Closes the panel the sections are shown in. */ + close?: (() => void) | undefined; settingsHost?: AgentSettingsHost; settingsSection?: "identity" | "runtime"; settingsContent?: ReactNode; @@ -77,6 +84,10 @@ export function agentSections({ ? { settingsContent: content } : {})} {...(conversation && { conversation })} + {...(role === "activity" && + conversation && + initialTranscript && { initialTranscript })} + {...(role === "activity" && close && { close })} save={(config) => settingsHost?.pendingSave ? settingsHost.pendingSave(config) diff --git a/src/features/messages/TypingIndicator.test.tsx b/src/features/messages/TypingIndicator.test.tsx index 369fa9190a..1d4c1e1760 100644 --- a/src/features/messages/TypingIndicator.test.tsx +++ b/src/features/messages/TypingIndicator.test.tsx @@ -13,7 +13,11 @@ 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 } from "../profiles/target"; +import { + profileActivityViewTarget, + claimProfileTranscript, + profileTranscriptRequest, +} from "../profiles/target"; import { TypingIndicator } from "./TypingIndicator"; afterEach(cleanup); @@ -102,9 +106,13 @@ it("opens one avatar by hover, with separate thread and activity actions and key await user.click( within(dialog).getByRole("button", { name: "View Agent activity" }), ); - expect(open).toHaveBeenLastCalledWith(profileActivityViewTarget(agent), { + const transcript = profileActivityViewTarget(agent, { transcript: true }); + expect(open).toHaveBeenLastCalledWith(transcript, { rootId: root, }); + // The click asks the profile it opens for the transcript, once. + expect(profileTranscriptRequest(transcript ?? "")).toBeDefined(); + claimProfileTranscript(transcript ?? ""); trigger.focus(); await user.keyboard("{Enter}"); dialog = await screen.findByRole("dialog", { name: "Working now" }); @@ -279,9 +287,8 @@ it("never chooses one thread from simultaneous work and deduplicates public and screen.getByRole("button", { name: "Activity: Agent working" }), ); const dialog = await screen.findByRole("dialog"); - expect( - within(dialog).getAllByRole("button", { name: /^Open conversation/ }), - ).toHaveLength(1); + // The channel conversation is already shown, so the one row opens activity. + expect(within(dialog).getAllByRole("button")).toHaveLength(1); await user.click( within(dialog).getByRole("button", { name: "View Agent activity" }), ); @@ -308,10 +315,16 @@ it("opens activity for the composer's explicit thread", async () => { await user.click( screen.getByRole("button", { name: "Activity: Agent working" }), ); + expect( + screen.queryByRole("button", { name: "Open thread for Agent" }), + ).toBeNull(); await user.click(screen.getByRole("button", { name: "View Agent activity" })); - expect(open).toHaveBeenLastCalledWith(profileActivityViewTarget(agent), { - rootId: root, - }); + expect(open).toHaveBeenLastCalledWith( + profileActivityViewTarget(agent, { transcript: true }), + { + rootId: root, + }, + ); }); it("retains one explicit live region from idle through work and back to idle", () => { @@ -346,7 +359,7 @@ it("keeps another owner's self-declared agent as ordinary typing without activit }); it.each([null, undefined])( - "opens channel activity and conversation for root %s", + "opens channel activity from its row for root %s", async (threadRootId) => { const f = fixture(), user = userEvent.setup(), @@ -394,16 +407,15 @@ it.each([null, undefined])( screen.getByRole("button", { name: "View Agent activity" }), ); expect(open).toHaveBeenLastCalledWith( - profileActivityViewTarget(agent), + profileActivityViewTarget(agent, { + transcript: threadRootId === null, + }), undefined, ); - await user.click( - screen.getByRole("button", { name: "Activity: Agent working" }), - ); - await user.click( - screen.getByRole("button", { name: "Open conversation for Agent" }), - ); - expect(open).toHaveBeenLastCalledWith("buzz://channel/channel", undefined); + // Opening the channel already shown would do nothing. + expect( + screen.queryByRole("button", { name: "Open conversation for Agent" }), + ).toBeNull(); }, ); @@ -543,7 +555,7 @@ it.each(["click", "Enter", "Space"])( await user.keyboard(activation === "Enter" ? "{Enter}" : " "); } expect(open).toHaveBeenCalledExactlyOnceWith( - profileActivityViewTarget(agent), + profileActivityViewTarget(agent, { transcript: true }), { rootId: root }, ); expect( diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 587ec82e74..184eb52dca 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -10,7 +10,10 @@ import { type ComponentProps, type ReactNode, } from "react"; -import { profileActivityViewTarget } from "../profiles/target"; +import { + openProfileTranscript, + 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"; @@ -160,9 +163,17 @@ export function TypingIndicator({ const rootId = threadRootId ?? (roots.length === 1 ? roots[0] : undefined); return rootId ? { rootId } : undefined; }; + // Only work in one known conversation opens straight to its transcript. + const activityTarget = (pubkey: string) => { + const roots = details.get(pubkey)?.roots ?? []; + const known = + !!threadRootId || + (roots.length === 1 && (roots[0] === null || !!roots[0])); + return profileActivityViewTarget(pubkey, { transcript: known }); + }; const directTarget = clickOpensPanel && agents.length === 1 && firstAgent - ? profileActivityViewTarget(firstAgent) + ? activityTarget(firstAgent) : undefined; const agentStatus = ( pubkey: string, @@ -170,7 +181,7 @@ export function TypingIndicator({ ) => { const detail = details.get(pubkey); if (!detail) return null; - const target = profileActivityViewTarget(pubkey); + const target = activityTarget(pubkey); const canOpen = !!target && !!openActivity && !!canOpenActivity?.(target); const knownRoots = detail.roots.filter( (value) => typeof value === "string", @@ -181,6 +192,23 @@ export function TypingIndicator({ ? `buzz://message?${new URLSearchParams({ channel: channelId, id: conversationRoot, thread: conversationRoot })}` : `buzz://channel/${encodeURIComponent(channelId)}`; const picture = profiles.get(pubkey)?.picture; + const viewActivity = + canOpen && target + ? () => navigate(target, activityOrigin(pubkey)) + : undefined; + // Going to the conversation already shown would do nothing, so the row + // opens its activity instead. + if (viewActivity && conversationRoot === threadRootId) + return ( + + ); return ( navigate(conversation) : undefined} - onOpenActivity={ - canOpen && target - ? () => navigate(target, activityOrigin(pubkey)) - : undefined - } + onOpenActivity={viewActivity} /> ); }; @@ -231,7 +255,13 @@ export function TypingIndicator({ } avatars={agents.slice(0, 4).map(avatar)} overflow={Math.max(0, agents.length - 4)} - openTarget={openActivity} + openTarget={ + openActivity && + ((target, origin) => + openProfileTranscript(target, (next) => + openActivity(next, origin), + )) + } directTarget={ directTarget && canOpenActivity?.(directTarget) ? directTarget diff --git a/src/features/panels/PanelSubview.tsx b/src/features/panels/PanelSubview.tsx index c54aa270f3..a5160a0a11 100644 --- a/src/features/panels/PanelSubview.tsx +++ b/src/features/panels/PanelSubview.tsx @@ -68,7 +68,8 @@ export function PanelSubviewHost({ export function PanelSubview(props: { title: string; backLabel: string; - onBack(): void; + /** `keepOwner`: a tabbed host is clearing the owner's detail tabs. */ + onBack(options?: { keepOwner?: boolean }): void; children: ReactNode; }) { const tabbed = !!usePanelTabHost(); diff --git a/src/features/panels/PanelWorkspace.tsx b/src/features/panels/PanelWorkspace.tsx index 80c3334a2f..a53efefc60 100644 --- a/src/features/panels/PanelWorkspace.tsx +++ b/src/features/panels/PanelWorkspace.tsx @@ -39,7 +39,9 @@ type Item = { toolbar?: ReactNode; }; type Identity = { label: string; icon?: ReactNode }; -type Local = { id: string; label: string; owner: string; close(): void }; +/** `keepOwner`: the owner stays open; only its detail tabs are cleared. */ +type Close = (options?: { keepOwner?: boolean }) => void; +type Local = { id: string; label: string; owner: string; close: Close }; const TabHost = createContext<{ owner: string; register(tab: Local): () => void; @@ -439,7 +441,7 @@ export function PanelWorkspace({ if (!closed && !details.length) return false; // Focus follows the chosen detail's removal to its owner. if (local) closingTab.current = local.id; - for (const tab of details) tab.close(); + for (const tab of details) tab.close({ keepOwner: true }); return true; }), } @@ -545,7 +547,7 @@ export function PanelLocalTab({ children, }: { title: string; - close(): void; + close: Close; children: ReactNode; }) { const host = usePanelTabHost(); @@ -560,7 +562,7 @@ export function PanelLocalTab({ id, label: title, owner, - close: () => onClose.current(), + close: (options) => onClose.current(options), }); }, [register, owner, id, title]); const slot = host?.slots.get(id); diff --git a/src/features/profiles/target.test.ts b/src/features/profiles/target.test.ts index c4ecf97432..0d30ea083b 100644 --- a/src/features/profiles/target.test.ts +++ b/src/features/profiles/target.test.ts @@ -1,6 +1,10 @@ import { expect, it } from "vitest"; import { + claimProfileTranscript, + openProfileTranscript, + profileTranscriptRequest, profileAgentHint, + profileActivityTranscript, profileActivityViewTarget, profileActivityViewKey, profileKey, @@ -66,3 +70,34 @@ it("recognizes only the exact local profile Activity destination", () => { ]) expect(profileActivityViewKey(invalid)).toBeUndefined(); }); + +it("marks only the transcript Activity destination as asking for it", () => { + const key = "ab".repeat(32); + const plain = profileActivityViewTarget(key) ?? ""; + const transcript = profileActivityViewTarget(key, { transcript: true }) ?? ""; + expect(transcript).not.toBe(plain); + expect(profileActivityViewKey(transcript)).toBe(key); + expect(profileActivityTranscript(transcript)).toBe(true); + expect(profileActivityTranscript(plain)).toBe(false); + expect(profileActivityTranscript(`${transcript}&owner=true`)).toBe(false); +}); + +it("asks for a transcript once per opening until its profile claims it", () => { + const key = "cd".repeat(32); + const plain = profileActivityViewTarget(key) ?? ""; + const transcript = profileActivityViewTarget(key, { transcript: true }) ?? ""; + expect(openProfileTranscript(plain, () => true)).toBe(true); + expect(profileTranscriptRequest(plain)).toBeUndefined(); + // An opening that fails leaves nothing for a later remount to act on. + expect(openProfileTranscript(transcript, () => false)).toBe(false); + expect(profileTranscriptRequest(transcript)).toBeUndefined(); + expect(openProfileTranscript(transcript, () => true)).toBe(true); + const first = profileTranscriptRequest(transcript); + expect(first).toBeDefined(); + expect(claimProfileTranscript(transcript)).toBe(first); + expect(profileTranscriptRequest(transcript)).toBeUndefined(); + expect(claimProfileTranscript(transcript)).toBeUndefined(); + // Opening the same target again is a new opening. + openProfileTranscript(transcript, () => true); + expect(claimProfileTranscript(transcript)).toBeGreaterThan(first ?? 0); +}); diff --git a/src/features/profiles/target.ts b/src/features/profiles/target.ts index 98c2e6c975..632ce7849e 100644 --- a/src/features/profiles/target.ts +++ b/src/features/profiles/target.ts @@ -35,19 +35,71 @@ export function profileKey(target: string): string | undefined { } } -/** App-local Activity destination, never a public identity or authority claim. */ -export function profileActivityViewTarget(pubkey: string): string | undefined { +/** App-local Activity destination, never a public identity or authority claim. + * `transcript` is for an opener that knows which one conversation the work is + * in: Activity then shows that conversation's transcript first. */ +export function profileActivityViewTarget( + pubkey: string, + options?: { transcript: boolean }, +): string | undefined { return profileTarget(pubkey) - ? `buzz:profile?agent=${pubkey.toLowerCase()}&tab=activity` + ? `buzz:profile?agent=${pubkey.toLowerCase()}&tab=activity${options?.transcript ? "&open=transcript" : ""}` : undefined; } export function profileActivityViewKey(target: string): string | undefined { try { const agent = new URL(target).searchParams.get("agent"); - return agent && target === profileActivityViewTarget(agent) + return agent && + (target === profileActivityViewTarget(agent) || + target === profileActivityViewTarget(agent, { transcript: true })) ? agent : undefined; } catch { return undefined; } } +/** Transcript destinations an opener has just opened, not yet claimed by the + * profile shown for them. Each opening is numbered, so a profile can tell a + * new one from one it already has. */ +const transcriptRequests = new Map(); +const transcriptListeners = new Set<() => void>(); +let transcriptOpenings = 0; +const changed = () => { + for (const listener of transcriptListeners) listener(); +}; +export function subscribeProfileTranscripts(listener: () => void) { + transcriptListeners.add(listener); + return () => void transcriptListeners.delete(listener); +} +/** Opens `target` through `open`, asking the profile it opens to show its + * transcript once. A target that is only shown again, as when its retained + * tab remounts on returning to the channel, asks for nothing. */ +export function openProfileTranscript( + target: string, + open: (target: string) => boolean, +): boolean { + if (!profileActivityTranscript(target)) return open(target); + transcriptRequests.set(target, ++transcriptOpenings); + const opened = open(target); + if (!opened) transcriptRequests.delete(target); + changed(); + return opened; +} +/** The opening still asking `target`'s profile for its transcript. */ +export function profileTranscriptRequest(target: string): number | undefined { + return transcriptRequests.get(target); +} +/** Takes `target`'s opening for the profile now showing it, so a profile + * mounted later, as on returning to the channel, finds nothing to act on. */ +export function claimProfileTranscript(target: string): number | undefined { + const opening = transcriptRequests.get(target); + if (transcriptRequests.delete(target)) changed(); + return opening; +} +/** Whether an Activity destination asks for its conversation's transcript. */ +export function profileActivityTranscript(target: string): boolean { + const agent = profileActivityViewKey(target); + return ( + !!agent && target === profileActivityViewTarget(agent, { transcript: true }) + ); +} diff --git a/tests/browser/agent-activity.spec.mjs b/tests/browser/agent-activity.spec.mjs index b85a5255db..2cb5fdf1c9 100644 --- a/tests/browser/agent-activity.spec.mjs +++ b/tests/browser/agent-activity.spec.mjs @@ -28,18 +28,23 @@ const openWorkingNow = async (page, scope) => { await expect(popup).toBeVisible(); return popup; }; -// Agents without a loaded profile fall back to their key prefix. +// Agents without a loaded profile fall back to their key prefix. A row whose +// conversation is already shown opens the agent's activity instead, so match +// the row itself rather than its separate activity icon. const agentRow = (popup, agent) => - popup.getByRole("button", { - name: new RegExp( - `^Open (?:thread|conversation) for .*${agent.slice(0, 10)}`, - ), - }); + popup + .getByRole("button", { + name: new RegExp( + `^(?:Open (?:thread|conversation) for|View) .*${agent.slice(0, 10)}`, + ), + }) + .and(popup.locator('[data-variant="row"]')); const agentActivityAction = (popup, agent) => popup.getByRole("button", { name: new RegExp(`^View .*${agent.slice(0, 10)}.* activity$`), }); -// The activity icon is revealed by hovering or focusing its row, like the sidebar. +// The activity icon is revealed by hovering or focusing its row, like the sidebar; +// a row that opens activity itself is the action. const viewAgentActivity = async (page, scope, agent) => { const popup = await openWorkingNow(page, scope); await agentRow(popup, agent).hover(); @@ -204,28 +209,29 @@ test("channel activity consumes telemetry, opens each agent's profile, and reset await expect( popup.getByRole("heading", { name: "Working now", exact: true }), ).toBeVisible(); - // Observer turns carry no thread identity: rows open the channel, not a guess. + // Observer turns carry no thread identity, and the channel is already shown: + // rows open each agent's activity instead of guessing a thread. for (const agent of [first, second]) { await expect(agentRow(popup, agent)).toHaveAccessibleName( - /^Open conversation for /, + /^View .+ activity$/, ); await expect(agentRow(popup, agent)).toContainText("Working"); await expect(agentRow(popup, agent)).toContainText( "Work with unconfirmed thread", ); - await expect(agentActivityAction(popup, agent)).toBeVisible(); + await expect(agentActivityAction(popup, agent)).toHaveCount(1); } await page.keyboard.press("Escape"); await expect(popup).toHaveCount(0); await page.mouse.move(0, 0); - // Keyboard path: real key input opens the popup and reaches the activity icon. + // Keyboard path: real key input opens the popup and reaches the agent's row. await trigger.focus(); await page.keyboard.press("Enter"); await expect(popup).toBeVisible(); // Keyboard opening focuses the first row a frame later. Rows can be in either - // order, so wait for that focus, then Tab to this agent's icon. + // order, so wait for that focus, then Tab to this agent's row. await expect( - popup.getByRole("button", { name: /^Open conversation for / }).first(), + popup.getByRole("button", { name: /^View .+ activity$/ }).first(), ).toBeFocused(); const firstAction = agentActivityAction(popup, first); for (let step = 0; step < 3 && !(await isFocused(firstAction)); step++) @@ -583,10 +589,11 @@ test.describe("thread activity", () => { ); }; await expectAboveForm(); - // The thread control's row names the exact thread; no channel-wide guess. + // The thread control's row is already in its thread, so it opens activity; + // no channel-wide guess. await openWorkingNow(page, thread); await expect(agentRow(popup, agent)).toHaveAccessibleName( - /^Open thread for /, + /^View .+ activity$/, ); await expect(agentRow(popup, agent)).not.toContainText("Thread ·"); await closePopup(); @@ -629,9 +636,13 @@ test.describe("thread activity", () => { await expect(agentRow(popup, agent)).toBeFocused(); await page.keyboard.press("Enter"); await expect(popup).toHaveCount(0); - await expect( - thread.getByRole("region", { name: "Thread messages", exact: true }), - ).toContainText(root.content); + await expectProfile(page, agent); + await page + .locator( + '.buzz-tabs-item:not(:has([data-tab-value^="thread:"])) > button:not([role="tab"])', + ) + .click(); + await openThread(); await page.setViewportSize({ width: 390, height: 844 }); sendTyping(root.id); await expect(threadTrigger).toBeVisible(); @@ -678,10 +689,11 @@ test.describe("thread activity", () => { expect(workingBox).toEqual( expect.objectContaining({ width: 26, height: 15 }), ); - // Channel-level work opens the channel conversation, never an inferred thread. + // Channel-level work is in the conversation already shown, never an + // inferred thread, so its row opens the agent's activity. await openWorkingNow(page, composer); await expect(agentRow(popup, agent)).toHaveAccessibleName( - /^Open conversation for /, + /^View .+ activity$/, ); await expect(agentRow(popup, agent)).toContainText("Channel conversation"); const channelStatus = composer.locator("[data-agent-working-status]"); @@ -807,7 +819,7 @@ test.describe("thread activity", () => { await openWorkingNow(page, thread); for (const name of ["Worker 1", "Worker 2"]) await expect( - popup.getByRole("button", { name: `Open thread for ${name}` }), + popup.getByRole("button", { name: `View ${name} activity` }), ).toHaveCount(1); await closePopup(); await expect(humanTyping(thread)).toHaveCount(0); @@ -905,7 +917,7 @@ test.describe("thread activity", () => { await openWorkingNow(page, composer); for (const name of ["Worker 1", "Worker 2"]) await expect( - popup.getByRole("button", { name: `Open conversation for ${name}` }), + popup.getByRole("button", { name: `View ${name} activity` }), ).toHaveCount(1); await closePopup(); publish(human); diff --git a/tests/browser/local-agent-activity.spec.mjs b/tests/browser/local-agent-activity.spec.mjs index 47ca2f7917..1db88a6a24 100644 --- a/tests/browser/local-agent-activity.spec.mjs +++ b/tests/browser/local-agent-activity.spec.mjs @@ -692,11 +692,7 @@ test("the published-activity tab shows turns its agent published", async ({ .getByRole("button", { name: /^Activity: .+ working$/ }) .hover(); const popup = page.getByRole("dialog", { name: "Working now", exact: true }); - await popup - .getByRole("button", { - name: /^Open (?:thread|conversation) for Local tester/, - }) - .hover(); + // The channel is already shown, so the agent's row opens its activity. await popup .getByRole("button", { name: /^View .*Local tester.* activity$/ }) .click();