From 46dbbb4c15abcb8ac9cd24667ed923d481490899 Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 11:55:39 -0400 Subject: [PATCH 1/6] feat(agents): open the latest transcript from agent activity The transcript now opens at its latest step and follows new steps while the reader stays at the end; the order stays oldest first, matching the conversation beside it. Opening an agent from the composer's Working now indicator, when its work is in one known conversation, shows that conversation's transcript directly; closing it closes the profile panel. A Working now row for work in the conversation already shown opens its activity instead of navigating nowhere. Ambiguous or unconfirmed work keeps the overview, and a transcript the reader chooses is never replaced by a lookup that finishes later. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- src/bundled/agents/AgentDetails.tsx | 7 +++ src/bundled/agents/AgentTabs.tsx | 2 + .../builderlab/adapter-type/Activity.tsx | 1 + src/bundled/claude-code/tabs.test.tsx | 49 +++++++++++++++++ src/bundled/claude-code/tabs.tsx | 11 +++- src/bundled/codex/tabs.test.tsx | 44 ++++++++++++++++ src/bundled/codex/tabs.tsx | 11 +++- src/bundled/goose/tabs.tsx | 11 +++- src/bundled/pi/tabs.tsx | 17 +++++- src/bundled/profiles/ProfilePanel.test.tsx | 24 ++++++++- src/bundled/profiles/ProfilePanel.tsx | 17 +++++- .../agents/ActivityTranscript.test.tsx | 52 ++++++++++++++++++- src/features/agents/ActivityTranscript.tsx | 44 ++++++++++++++-- .../agents/OpenedConversation.test.tsx | 47 +++++++++++++++++ src/features/agents/OpenedConversation.tsx | 24 +++++++-- src/features/agents/service.ts | 5 ++ src/features/agents/tabs.tsx | 12 +++++ .../messages/TypingIndicator.test.tsx | 45 +++++++++------- src/features/messages/TypingIndicator.tsx | 35 ++++++++++--- src/features/profiles/target.test.ts | 12 +++++ src/features/profiles/target.ts | 22 ++++++-- 21 files changed, 448 insertions(+), 44 deletions(-) create mode 100644 src/features/agents/OpenedConversation.test.tsx diff --git a/src/bundled/agents/AgentDetails.tsx b/src/bundled/agents/AgentDetails.tsx index cb7af9a06b..6ae93f3200 100644 --- a/src/bundled/agents/AgentDetails.tsx +++ b/src/bundled/agents/AgentDetails.tsx @@ -78,6 +78,8 @@ export function AgentDetails({ messageAction, conversation, initialActivity, + initialTranscript, + close, communities, }: { agents: Agents; @@ -90,6 +92,9 @@ export function AgentDetails({ messageAction: (variant: "glass" | "subtle") => ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean; + initialTranscript?: boolean; + /** Closes the panel showing the agent. */ + close?: () => void; communities?: CommunityReader | undefined; }) { const { pack, src: backdrop } = useAgentPortraitSource(agent, agent.relay); @@ -375,6 +380,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..02556a5af6 100644 --- a/src/bundled/agents/AgentTabs.tsx +++ b/src/bundled/agents/AgentTabs.tsx @@ -27,6 +27,8 @@ export function AgentTabs(props: { settingsContent: ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean | undefined; + initialTranscript?: boolean | 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..b825a9c2ce 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( + , + ); + 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..efada21955 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 ? here : undefined, + close, + ); return (
{conversation && ( diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index a015050067..0e64703079 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -384,6 +384,50 @@ it("brings an open transcript's tab back, and closes it when the profile moves", expect(tab(transcript)).toHaveAttribute("aria-selected", "true"); }); +it("opened from activity, shows the transcript, and closing it closes the panel", async () => { + const root = "e".repeat(64); + const f = fixture({ + saved: { + [JSON.stringify(["channel", root])]: { + threadId: "kept", + workspace: "/w", + at: 2, + }, + }, + }); + const close = vi.fn(); + render( + undefined} + items={[ + { + id: "profile", + label: "Sol", + close: () => undefined, + content: ( + + ), + }, + ]} + />, + ); + const transcript = "#general · thread eeeeeeee"; + expect(await screen.findByRole("tab", { name: transcript })).toHaveAttribute( + "aria-selected", + "true", + ); + await userEvent.click( + screen.getByRole("button", { name: `Close ${transcript} tab` }), + ); + expect(close).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..3da7d57637 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 ? here : undefined, + 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)); diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index 3655802fab..785954b203 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 ? here : undefined, + close, + ); const rows = (recent.value ?? []) .filter((row) => row.sessionId !== here?.sessionId) .slice(0, RECENT_LIMIT) diff --git a/src/bundled/pi/tabs.tsx b/src/bundled/pi/tabs.tsx index 11ec9b2700..774878ddc7 100644 --- a/src/bundled/pi/tabs.tsx +++ b/src/bundled/pi/tabs.tsx @@ -689,9 +689,13 @@ export function createTabs({ function ActivityTab({ agent, conversation, + initialTranscript, + close, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; + initialTranscript?: AgentViewProps["initialTranscript"]; + close?: AgentViewProps["close"]; }) { const working = useSyncExternalStore( (listener) => runtime.subscribe(listener), @@ -725,6 +729,8 @@ export function createTabs({ @@ -734,11 +740,15 @@ export function createTabs({ function PlaceList({ agent, conversation, + initialTranscript, + close, places, working, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; + initialTranscript?: AgentViewProps["initialTranscript"]; + close?: AgentViewProps["close"]; places: readonly Place[]; working: readonly number[]; }) { @@ -749,7 +759,12 @@ export function createTabs({ ); const status = (place: Place) => working.includes(place.conversation) ? "Working" : undefined; - const { open, show, back } = useOpenTranscript(conversation, label); + const { open, show, back } = useOpenTranscript( + conversation, + label, + initialTranscript ? here : undefined, + close, + ); const recent = places .filter((place) => place.key !== here?.key) .slice(0, CONVERSATION_LIMIT); diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index cc09d43497..5e76924462 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -349,6 +349,7 @@ it("exposes thinking through the named profile avatar across tabs", async () => function renderAgentActivity( tabs: { title: string; role?: string }[], rootId?: string, + transcript = false, ) { const owner = createRelaySession(null); const snapshot = { @@ -372,11 +373,14 @@ function renderAgentActivity( role, component: ({ conversation, + initialTranscript, }: { conversation?: { channelId: string; rootId?: string }; + initialTranscript?: boolean; }) => ( <>

Echo {title.toLowerCase()}

+ {initialTranscript &&

Transcript first

} {conversation && (

Opened from {conversation.channelId}/ @@ -410,7 +414,7 @@ function renderAgentActivity( { + 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", true); + try { + expect(screen.getByText("Transcript first")).toBeVisible(); + } 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..ddc0aac7ce 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -29,6 +29,7 @@ import { Button } from "../../shared/design-system/ui/Button"; import { Tabs } from "../../shared/design-system/ui/Tabs"; import type { PanelProps } from "../../features/panels/service"; import { + profileActivityTranscript, profileActivityViewKey, profileAgentHint, profilePanelKey, @@ -73,6 +74,8 @@ export function ProfilePanel({ }) { const connection = useRelayConnection(relay); const activityKey = profileActivityViewKey(target); + const transcript = profileActivityTranscript(target); + const opening = transcript ? "transcript" : activityKey ? "activity" : "info"; const pubkey = profilePanelKey(target) ?? activityKey; const owned = useAgent(agents, pubkey ?? ""); if (!pubkey) return

Unsupported profile.

; @@ -87,10 +90,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..4f92c4fed7 --- /dev/null +++ b/src/features/agents/OpenedConversation.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { expect, it } 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, session); + }); + 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("opens the found conversation's transcript once, not again after Back", async () => { + const { result } = renderHook(() => { + const { session } = useOpenedSession( + [recent], + conversation, + async () => found, + ); + return useOpenTranscript(conversation, (row) => row.key, session); + }); + await act(async () => {}); + expect(result.current.open).toBe(found); + act(() => result.current.back()); + expect(result.current.open).toBeUndefined(); +}); diff --git a/src/features/agents/OpenedConversation.tsx b/src/features/agents/OpenedConversation.tsx index 8026357ffa..550cd34acf 100644 --- a/src/features/agents/OpenedConversation.tsx +++ b/src/features/agents/OpenedConversation.tsx @@ -84,27 +84,45 @@ 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. + * `initial`, once found, opens by itself once per conversation unless the + * reader has already opened one; closing + * that tab calls `close`, since it was all that was asked for. */ export function useOpenTranscript( conversation: AgentConversation | undefined, title: (row: T) => string, + initial?: T, + 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); + // Kept after Back, so closing it shows the profile rather than reopening. + const [offered, setOffered] = useState(""); + if (initial !== undefined && place && offered !== place) { + setOffered(place); + setShown({ place, row: initial, initial: true }); + } const tabs = usePanelTabHost(); return { open: shown?.place === place ? shown.row : undefined, show(row: T) { + // The reader's own choice wins over a lookup still finishing. + setOffered(place); setShown({ place, row }); tabs?.activate(tabs.owner, title(row)); }, back() { - setShown(undefined); + if (tabs && close && shown?.initial) close(); + else setShown(undefined); }, }; } diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 690c713de3..4389430f58 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -246,6 +246,11 @@ export type AgentViewProps = { agent: Agent; /** Where the agent was opened from, when it was opened from a conversation. */ conversation?: AgentConversation; + /** Opened from that conversation's activity: an Activity tab shows the + * conversation's transcript first. */ + initialTranscript?: boolean; + /** Closes the panel showing the tab, when it was opened from activity. */ + 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..3b33b307d7 100644 --- a/src/features/agents/tabs.tsx +++ b/src/features/agents/tabs.tsx @@ -29,6 +29,8 @@ export function agentSections({ agent, type, conversation, + initialTranscript, + close, settingsHost, memories, communities, @@ -39,6 +41,10 @@ export function agentSections({ agent: Agent; type: RegisteredAgentType | undefined; conversation?: AgentConversation | undefined; + /** Opened for the conversation's transcript, its work's one known place. */ + initialTranscript?: boolean | undefined; + /** Closes the panel the sections are shown in. */ + close?: (() => void) | undefined; settingsHost?: AgentSettingsHost; settingsSection?: "identity" | "runtime"; settingsContent?: ReactNode; @@ -77,6 +83,12 @@ export function agentSections({ ? { settingsContent: content } : {})} {...(conversation && { conversation })} + {...(role === "activity" && + conversation && + initialTranscript && { + initialTranscript: true, + ...(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..8a30436013 100644 --- a/src/features/messages/TypingIndicator.test.tsx +++ b/src/features/messages/TypingIndicator.test.tsx @@ -102,9 +102,12 @@ 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), { - rootId: root, - }); + expect(open).toHaveBeenLastCalledWith( + profileActivityViewTarget(agent, { transcript: true }), + { + rootId: root, + }, + ); trigger.focus(); await user.keyboard("{Enter}"); dialog = await screen.findByRole("dialog", { name: "Working now" }); @@ -279,9 +282,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 +310,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 +354,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 +402,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 +550,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..81eee9457e 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -160,9 +160,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 +178,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 +189,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} /> ); }; diff --git a/src/features/profiles/target.test.ts b/src/features/profiles/target.test.ts index c4ecf97432..a8f4f03a95 100644 --- a/src/features/profiles/target.test.ts +++ b/src/features/profiles/target.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { profileAgentHint, + profileActivityTranscript, profileActivityViewTarget, profileActivityViewKey, profileKey, @@ -66,3 +67,14 @@ 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); +}); diff --git a/src/features/profiles/target.ts b/src/features/profiles/target.ts index 98c2e6c975..bcd0a27628 100644 --- a/src/features/profiles/target.ts +++ b/src/features/profiles/target.ts @@ -35,19 +35,33 @@ 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; } } +/** 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 }) + ); +} From 44fac11b5fa5d103195e8f9255de7dfff061de33 Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 12:27:02 -0400 Subject: [PATCH 2/6] test(browser): follow Working now rows that open activity Rows whose conversation is already shown now open the agent's activity directly, so the browser journeys assert that row instead of the old navigate-plus-icon pair. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- tests/browser/agent-activity.spec.mjs | 56 +++++++++++++-------- tests/browser/local-agent-activity.spec.mjs | 6 +-- 2 files changed, 35 insertions(+), 27 deletions(-) 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(); From 65cc403272977e4e97ec9ec534fa23ec82b3c895 Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 13:14:07 -0400 Subject: [PATCH 3/6] fix(agents): keep the opened-from-activity transcript to one offer Addresses review on #1830: - Close other tabs clears the profile's detail tabs without closing the profile: the workspace marks that cleanup with keepOwner, and the auto-opened transcript only closes its panel on a real close. - The profile panel asks for the transcript once per conversation, so leaving Activity for another section and returning does not reopen it. - Only a transcript known when the conversation is shown opens by itself; one a lookup finds later never takes the reader's focus or choice. - Each opened transcript keys its view, so it starts following its latest step even after the reader scrolled up in the previous one. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- src/bundled/agents/AgentDetails.tsx | 2 +- src/bundled/agents/AgentTabs.tsx | 2 +- src/bundled/claude-code/tabs.test.tsx | 2 +- src/bundled/claude-code/tabs.tsx | 3 +- src/bundled/codex/tabs.test.tsx | 110 +++++++++++------- src/bundled/codex/tabs.tsx | 4 +- src/bundled/goose/tabs.tsx | 3 +- src/bundled/pi/tabs.tsx | 3 +- src/bundled/profiles/ProfilePanel.test.tsx | 55 ++++++--- src/bundled/profiles/ProfilePanel.tsx | 13 ++- .../agents/OpenedConversation.test.tsx | 31 ++++- src/features/agents/OpenedConversation.tsx | 28 +++-- src/features/agents/service.ts | 9 +- src/features/agents/tabs.tsx | 10 +- src/features/panels/PanelSubview.tsx | 3 +- src/features/panels/PanelWorkspace.tsx | 10 +- 16 files changed, 191 insertions(+), 97 deletions(-) diff --git a/src/bundled/agents/AgentDetails.tsx b/src/bundled/agents/AgentDetails.tsx index 6ae93f3200..989f3e03d3 100644 --- a/src/bundled/agents/AgentDetails.tsx +++ b/src/bundled/agents/AgentDetails.tsx @@ -92,7 +92,7 @@ export function AgentDetails({ messageAction: (variant: "glass" | "subtle") => ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean; - initialTranscript?: boolean; + initialTranscript?: (() => void) | undefined; /** Closes the panel showing the agent. */ close?: () => void; communities?: CommunityReader | undefined; diff --git a/src/bundled/agents/AgentTabs.tsx b/src/bundled/agents/AgentTabs.tsx index 02556a5af6..12290b81ff 100644 --- a/src/bundled/agents/AgentTabs.tsx +++ b/src/bundled/agents/AgentTabs.tsx @@ -27,7 +27,7 @@ export function AgentTabs(props: { settingsContent: ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean | undefined; - initialTranscript?: boolean | undefined; + initialTranscript?: (() => void) | undefined; close?: (() => void) | undefined; onDiscard?: () => void; }) { diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index b825a9c2ce..882360aebd 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -376,7 +376,7 @@ it("opens the conversation's transcript when opened from its activity, once", as agent={agent()} save={vi.fn()} conversation={conversation} - initialTranscript + initialTranscript={() => undefined} />, ); const user = userEvent.setup(); diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index efada21955..84b5af6303 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -738,7 +738,7 @@ export function createTabs({ const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript ? here : undefined, + initialTranscript && { initial: here, done: initialTranscript }, close, ); return ( @@ -812,6 +812,7 @@ export function createTabs({ onBack={back} > { +describe("opened from activity", () => { const root = "e".repeat(64); - const f = fixture({ - saved: { - [JSON.stringify(["channel", root])]: { - threadId: "kept", - workspace: "/w", - at: 2, + 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(); + const panel = (initialTranscript?: () => 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(); }); - const close = vi.fn(); - render( - undefined} - items={[ - { - id: "profile", - label: "Sol", - close: () => undefined, - content: ( - - ), - }, - ]} - />, - ); - const transcript = "#general · thread eeeeeeee"; - expect(await screen.findByRole("tab", { name: transcript })).toHaveAttribute( - "aria-selected", - "true", - ); - await userEvent.click( - screen.getByRole("button", { name: `Close ${transcript} tab` }), - ); - expect(close).toHaveBeenCalledOnce(); }); it("finds the thread's Codex session another build started", async () => { diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index 3da7d57637..f4adb8d64b 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -839,7 +839,7 @@ export function createTabs( const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript ? here : undefined, + initialTranscript && { initial: here, done: initialTranscript }, close, ); const saved = new Set(conversations.map((row) => row.key)); @@ -944,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 785954b203..fb78bbe451 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -869,7 +869,7 @@ export function createTabs({ const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript ? here : undefined, + initialTranscript && { initial: here, done: initialTranscript }, close, ); const rows = (recent.value ?? []) @@ -964,6 +964,7 @@ export function createTabs({ onBack={back} > ( - <> -

Echo {title.toLowerCase()}

- {initialTranscript &&

Transcript first

} - {conversation && ( -

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

- )} - - ), + initialTranscript?: () => void; + }) { + // Like a type's Activity tab: it acts on the request as it shows. + const [first] = useState(!!initialTranscript); + useEffect(() => initialTranscript?.(), [initialTranscript]); + return ( + <> +

Echo {title.toLowerCase()}

+ {first &&

Transcript first

} + {conversation && ( +

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

+ )} + + ); + }, })), } as unknown as RegisteredAgentType; const agent = { @@ -476,6 +482,27 @@ it("asks the Activity tab for its transcript only when the opener knows the conv } }); +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", + true, + ); + 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("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 ddc0aac7ce..446aab570a 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -76,6 +76,11 @@ export function ProfilePanel({ const activityKey = profileActivityViewKey(target); const transcript = profileActivityTranscript(target); const opening = transcript ? "transcript" : activityKey ? "activity" : "info"; + // Asked once per conversation: changing sections must not reopen it. + const place = context ? `${context.channelId}/${context.rootId ?? ""}` : ""; + const [asked, setAsked] = useState(); + const initialTranscript = + transcript && asked !== place ? () => setAsked(place) : undefined; const pubkey = profilePanelKey(target) ?? activityKey; const owned = useAgent(agents, pubkey ?? ""); if (!pubkey) return

Unsupported profile.

; @@ -94,7 +99,7 @@ export function ProfilePanel({ agents={agents} communities={communities} initialActivity={!!activityKey} - initialTranscript={transcript} + initialTranscript={initialTranscript} pubkey={pubkey} relay={relay} session={connection.session} @@ -107,7 +112,7 @@ export function ProfilePanel({ void) | undefined; pubkey: string; relay: RelayData; session: RelaySession; @@ -197,7 +202,7 @@ function ProfileDetails({ agents: Agents | undefined; communities: CommunityReader | undefined; initialTab: "info" | "activity"; - initialTranscript: boolean; + initialTranscript: (() => void) | undefined; agentHint: boolean; session: RelaySession; pubkey: string; diff --git a/src/features/agents/OpenedConversation.test.tsx b/src/features/agents/OpenedConversation.test.tsx index 4f92c4fed7..ee17d0bc30 100644 --- a/src/features/agents/OpenedConversation.test.tsx +++ b/src/features/agents/OpenedConversation.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { useOpenedSession, useOpenTranscript } from "./OpenedConversation"; type Row = { key: string; channelId: string; root?: string }; @@ -19,7 +19,10 @@ it("keeps the reader's choice when the opened conversation's lookup finishes lat conversation, () => lookup, ); - return useOpenTranscript(conversation, (row) => row.key, session); + return useOpenTranscript(conversation, (row) => row.key, { + initial: session, + done() {}, + }); }); expect(result.current.open).toBeUndefined(); act(() => result.current.show(recent)); @@ -31,16 +34,36 @@ it("keeps the reader's choice when the opened conversation's lookup finishes lat expect(result.current.open).toBe(recent); }); -it("opens the found conversation's transcript once, not again after Back", async () => { +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, session); + return useOpenTranscript(conversation, (row) => row.key, { + initial: session, + 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, + done() {}, + }); + }); expect(result.current.open).toBe(found); act(() => result.current.back()); expect(result.current.open).toBeUndefined(); diff --git a/src/features/agents/OpenedConversation.tsx b/src/features/agents/OpenedConversation.tsx index 550cd34acf..17108b40f5 100644 --- a/src/features/agents/OpenedConversation.tsx +++ b/src/features/agents/OpenedConversation.tsx @@ -85,13 +85,15 @@ 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. - * `initial`, once found, opens by itself once per conversation unless the - * reader has already opened one; closing - * that tab calls `close`, since it was all that was asked for. */ + * `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. + * 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, - initial?: T, + request?: { initial: T | undefined; done(): void }, close?: () => void, ) { const place = conversation @@ -104,24 +106,26 @@ export function useOpenTranscript( }>(); // Forgotten, not just hidden: returning to the first conversation must not // reopen it unasked. - if (shown && shown.place !== place) setShown(undefined); - // Kept after Back, so closing it shows the profile rather than reopening. const [offered, setOffered] = useState(""); - if (initial !== undefined && place && offered !== place) { + if (offered !== place) { setOffered(place); - setShown({ place, row: initial, initial: true }); + setShown( + place && request?.initial !== undefined + ? { place, row: request.initial, initial: true } + : undefined, + ); } + const done = request?.done; + useEffect(() => done?.(), [done]); const tabs = usePanelTabHost(); return { open: shown?.place === place ? shown.row : undefined, show(row: T) { - // The reader's own choice wins over a lookup still finishing. - setOffered(place); setShown({ place, row }); tabs?.activate(tabs.owner, title(row)); }, - back() { - if (tabs && close && shown?.initial) close(); + 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 4389430f58..ee7c0229c7 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -246,10 +246,11 @@ export type AgentViewProps = { agent: Agent; /** Where the agent was opened from, when it was opened from a conversation. */ conversation?: AgentConversation; - /** Opened from that conversation's activity: an Activity tab shows the - * conversation's transcript first. */ - initialTranscript?: boolean; - /** Closes the panel showing the tab, when it was opened from activity. */ + /** Present while the panel asks to show that conversation's transcript + * first: an Activity tab does so, if it knows it then, and calls this once + * it has, so the panel asks only once. */ + initialTranscript?: () => void; + /** 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 diff --git a/src/features/agents/tabs.tsx b/src/features/agents/tabs.tsx index 3b33b307d7..ad6bc7f3a4 100644 --- a/src/features/agents/tabs.tsx +++ b/src/features/agents/tabs.tsx @@ -41,8 +41,8 @@ export function agentSections({ agent: Agent; type: RegisteredAgentType | undefined; conversation?: AgentConversation | undefined; - /** Opened for the conversation's transcript, its work's one known place. */ - initialTranscript?: boolean | undefined; + /** Asks to show the conversation's transcript first; see AgentViewProps. */ + initialTranscript?: (() => void) | undefined; /** Closes the panel the sections are shown in. */ close?: (() => void) | undefined; settingsHost?: AgentSettingsHost; @@ -85,10 +85,8 @@ export function agentSections({ {...(conversation && { conversation })} {...(role === "activity" && conversation && - initialTranscript && { - initialTranscript: true, - ...(close && { close }), - })} + initialTranscript && { initialTranscript })} + {...(role === "activity" && close && { close })} save={(config) => settingsHost?.pendingSave ? settingsHost.pendingSave(config) 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); From e1e6dea9338955a9f5243b845ec2df38ec42ead0 Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 14:09:01 -0400 Subject: [PATCH 4/6] fix(agents): ask for an opened transcript once per opening Addresses review on #1830: - Working now's opening records a one-shot transcript request beside the target, not in it, so panel identity is unchanged. The profile panel reads it through a subscription, and the Activity tab settles it, so a retained profile remounting on return to its channel asks for nothing. - Pi lists its conversations only after its tab shows, so it settles the request without opening it rather than opening the transcript late, under the reader's focus. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- src/bundled/pi/tabs.test.tsx | 20 ++++++++++++ src/bundled/pi/tabs.tsx | 19 +++-------- src/bundled/profiles/ProfilePanel.test.tsx | 21 +++++++++--- src/bundled/profiles/ProfilePanel.tsx | 17 +++++++--- .../messages/TypingIndicator.test.tsx | 19 +++++++---- src/features/messages/TypingIndicator.tsx | 13 ++++++-- src/features/profiles/target.test.ts | 18 +++++++++++ src/features/profiles/target.ts | 32 +++++++++++++++++++ 8 files changed, 127 insertions(+), 32 deletions(-) diff --git a/src/bundled/pi/tabs.test.tsx b/src/bundled/pi/tabs.test.tsx index fc67ed59a2..2bdfce9812 100644 --- a/src/bundled/pi/tabs.test.tsx +++ b/src/bundled/pi/tabs.test.tsx @@ -520,6 +520,26 @@ it("saves a key on Enter without saving the agent's settings", async () => { 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 0806321b93..e80f1e04c3 100644 --- a/src/bundled/pi/tabs.tsx +++ b/src/bundled/pi/tabs.tsx @@ -690,12 +690,10 @@ export function createTabs({ agent, conversation, initialTranscript, - close, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; initialTranscript?: AgentViewProps["initialTranscript"]; - close?: AgentViewProps["close"]; }) { const working = useSyncExternalStore( (listener) => runtime.subscribe(listener), @@ -708,6 +706,10 @@ 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. + useEffect(() => initialTranscript?.(), [initialTranscript]); if (!places) return listing.status === "error" ? (
@@ -729,8 +731,6 @@ export function createTabs({ @@ -740,15 +740,11 @@ export function createTabs({ function PlaceList({ agent, conversation, - initialTranscript, - close, places, working, }: { agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; - initialTranscript?: AgentViewProps["initialTranscript"]; - close?: AgentViewProps["close"]; places: readonly Place[]; working: readonly number[]; }) { @@ -759,12 +755,7 @@ export function createTabs({ ); const status = (place: Place) => working.includes(place.conversation) ? "Working" : undefined; - const { open, show, back } = useOpenTranscript( - conversation, - label, - initialTranscript && { initial: here, done: initialTranscript }, - close, - ); + const { open, show, back } = useOpenTranscript(conversation, label); const recent = places .filter((place) => place.key !== here?.key) .slice(0, CONVERSATION_LIMIT); diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index 6ffa6458a6..569f3c935e 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -10,6 +10,7 @@ import { createRelaySession } from "../../features/relay/session"; import type { RelayData } from "../../features/relay/service"; import { bindNames } from "../../features/identity-names/service"; import { + openProfileTranscript, profileTarget, profileActivityViewTarget, } from "../../features/profiles/target"; @@ -350,7 +351,7 @@ it("exposes thinking through the named profile avatar across tabs", async () => function renderAgentActivity( tabs: { title: string; role?: string }[], rootId?: string, - transcript = false, + transcript: "opened" | "shown again" | false = false, ) { const owner = createRelaySession(null); const snapshot = { @@ -416,11 +417,15 @@ function renderAgentActivity( memberships: () => [], configHistory: () => undefined, } as unknown as Agents; + const target = + profileActivityViewTarget(key, { transcript: !!transcript }) ?? ""; + // Like Working now: the opener asks as it opens the target. + if (transcript === "opened") openProfileTranscript(target, () => true); render( { @@ -489,7 +502,7 @@ it("asks for the transcript once, not again on returning from another section", { title: "Activity", role: "activity" }, ], "thread-root", - true, + "opened", ); try { expect(screen.getByText("Transcript first")).toBeVisible(); diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 446aab570a..359bdf5b50 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -31,6 +31,9 @@ import type { PanelProps } from "../../features/panels/service"; import { profileActivityTranscript, profileActivityViewKey, + profileTranscriptRequested, + settleProfileTranscript, + subscribeProfileTranscripts, profileAgentHint, profilePanelKey, profileTarget, @@ -76,11 +79,15 @@ export function ProfilePanel({ const activityKey = profileActivityViewKey(target); const transcript = profileActivityTranscript(target); const opening = transcript ? "transcript" : activityKey ? "activity" : "info"; - // Asked once per conversation: changing sections must not reopen it. - const place = context ? `${context.channelId}/${context.rootId ?? ""}` : ""; - const [asked, setAsked] = useState(); - const initialTranscript = - transcript && asked !== place ? () => setAsked(place) : undefined; + // Asked once per opening: neither changing sections nor remounting with + // the channel reopens it. + const requested = useSyncExternalStore( + subscribeProfileTranscripts, + () => transcript && profileTranscriptRequested(target), + ); + const initialTranscript = requested + ? () => settleProfileTranscript(target) + : undefined; const pubkey = profilePanelKey(target) ?? activityKey; const owned = useAgent(agents, pubkey ?? ""); if (!pubkey) return

Unsupported profile.

; diff --git a/src/features/messages/TypingIndicator.test.tsx b/src/features/messages/TypingIndicator.test.tsx index 8a30436013..5e7da50dc9 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, + profileTranscriptRequested, + settleProfileTranscript, +} from "../profiles/target"; import { TypingIndicator } from "./TypingIndicator"; afterEach(cleanup); @@ -102,12 +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, { transcript: true }), - { - rootId: root, - }, - ); + const transcript = profileActivityViewTarget(agent, { transcript: true }); + expect(open).toHaveBeenLastCalledWith(transcript, { + rootId: root, + }); + // The click asks the profile it opens for the transcript, once. + expect(profileTranscriptRequested(transcript ?? "")).toBe(true); + settleProfileTranscript(transcript ?? ""); trigger.focus(); await user.keyboard("{Enter}"); dialog = await screen.findByRole("dialog", { name: "Working now" }); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 81eee9457e..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"; @@ -252,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/profiles/target.test.ts b/src/features/profiles/target.test.ts index a8f4f03a95..03198f3564 100644 --- a/src/features/profiles/target.test.ts +++ b/src/features/profiles/target.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "vitest"; import { + openProfileTranscript, profileAgentHint, profileActivityTranscript, profileActivityViewTarget, @@ -7,6 +8,8 @@ import { profileKey, profilePanelKey, profileTarget, + profileTranscriptRequested, + settleProfileTranscript, } from "./target"; it("round-trips only exact public identity locators", () => { const key = "ab".repeat(32); @@ -78,3 +81,18 @@ it("marks only the transcript Activity destination as asking for it", () => { expect(profileActivityTranscript(plain)).toBe(false); expect(profileActivityTranscript(`${transcript}&owner=true`)).toBe(false); }); + +it("asks for a transcript only while an opening of its target is unsettled", () => { + const key = "cd".repeat(32); + const plain = profileActivityViewTarget(key) ?? ""; + const transcript = profileActivityViewTarget(key, { transcript: true }) ?? ""; + expect(openProfileTranscript(plain, () => true)).toBe(true); + expect(profileTranscriptRequested(plain)).toBe(false); + // An opening that fails leaves nothing for a later remount to act on. + expect(openProfileTranscript(transcript, () => false)).toBe(false); + expect(profileTranscriptRequested(transcript)).toBe(false); + expect(openProfileTranscript(transcript, () => true)).toBe(true); + expect(profileTranscriptRequested(transcript)).toBe(true); + settleProfileTranscript(transcript); + expect(profileTranscriptRequested(transcript)).toBe(false); +}); diff --git a/src/features/profiles/target.ts b/src/features/profiles/target.ts index bcd0a27628..5269dd69ce 100644 --- a/src/features/profiles/target.ts +++ b/src/features/profiles/target.ts @@ -58,6 +58,38 @@ export function profileActivityViewKey(target: string): string | undefined { return undefined; } } +/** Transcript destinations an opener has just opened, not yet shown. */ +const transcriptRequests = new Set(); +const transcriptListeners = new Set<() => void>(); +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.add(target); + const opened = open(target); + if (!opened) transcriptRequests.delete(target); + changed(); + return opened; +} +/** Whether an opener still asks `target`'s profile for its transcript. */ +export function profileTranscriptRequested(target: string): boolean { + return transcriptRequests.has(target); +} +/** The request has been offered; opening the target again asks anew. */ +export function settleProfileTranscript(target: string): void { + if (transcriptRequests.delete(target)) changed(); +} /** Whether an Activity destination asks for its conversation's transcript. */ export function profileActivityTranscript(target: string): boolean { const agent = profileActivityViewKey(target); From 15317ca4195d74f920009acf16f7665eda89fc6e Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 14:49:43 -0400 Subject: [PATCH 5/6] fix(agents): let each opening ask for its transcript exactly once The profile shown for a transcript target now claims the opening as soon as it sees it, even while it shows another section, so a remount on returning to the channel finds nothing to reopen. Each opening is numbered, and the Activity tab treats a new one as a new request, so opening a kept profile again after its tabs were closed shows the transcript again. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- src/bundled/codex/tabs.test.tsx | 24 +++++++++ src/bundled/profiles/ProfilePanel.test.tsx | 54 ++++++++++++++++--- src/bundled/profiles/ProfilePanel.tsx | 35 ++++++++---- .../agents/OpenedConversation.test.tsx | 37 ++++++++++++- src/features/agents/OpenedConversation.tsx | 32 +++++++---- src/features/agents/service.ts | 3 +- .../messages/TypingIndicator.test.tsx | 8 +-- src/features/profiles/target.test.ts | 21 +++++--- src/features/profiles/target.ts | 22 +++++--- 9 files changed, 187 insertions(+), 49 deletions(-) diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index 6b8b142bd1..ecaefc2a81 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -456,6 +456,30 @@ describe("opened from activity", () => { 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 () => { diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index 569f3c935e..a447ce3cf0 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -421,8 +421,9 @@ function renderAgentActivity( profileActivityViewTarget(key, { transcript: !!transcript }) ?? ""; // Like Working now: the opener asks as it opens the target. if (transcript === "opened") openProfileTranscript(target, () => true); - render( + 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 () => { @@ -516,6 +526,38 @@ it("asks for the transcript once, not again on returning from another section", } }); +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 359bdf5b50..5cf2870f54 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -29,10 +29,10 @@ 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, - profileTranscriptRequested, - settleProfileTranscript, + profileTranscriptRequest, subscribeProfileTranscripts, profileAgentHint, profilePanelKey, @@ -79,15 +79,30 @@ export function ProfilePanel({ const activityKey = profileActivityViewKey(target); const transcript = profileActivityTranscript(target); const opening = transcript ? "transcript" : activityKey ? "activity" : "info"; - // Asked once per opening: neither changing sections nor remounting with - // the channel reopens it. - const requested = useSyncExternalStore( - subscribeProfileTranscripts, - () => transcript && profileTranscriptRequested(target), + // 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 + : () => setOffered((last) => Math.max(last, request)), + [request], ); - const initialTranscript = requested - ? () => settleProfileTranscript(target) - : undefined; const pubkey = profilePanelKey(target) ?? activityKey; const owned = useAgent(agents, pubkey ?? ""); if (!pubkey) return

Unsupported profile.

; diff --git a/src/features/agents/OpenedConversation.test.tsx b/src/features/agents/OpenedConversation.test.tsx index ee17d0bc30..d121627bc7 100644 --- a/src/features/agents/OpenedConversation.test.tsx +++ b/src/features/agents/OpenedConversation.test.tsx @@ -7,6 +7,8 @@ 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" }; +// One request keeps one `done` across renders; a new one is a new request. +const settled = () => {}; it("keeps the reader's choice when the opened conversation's lookup finishes later", async () => { let resolve: (row: Row) => void = () => {}; @@ -21,7 +23,7 @@ it("keeps the reader's choice when the opened conversation's lookup finishes lat ); return useOpenTranscript(conversation, (row) => row.key, { initial: session, - done() {}, + done: settled, }); }); expect(result.current.open).toBeUndefined(); @@ -61,10 +63,41 @@ it("opens the known conversation's transcript once, not again after Back", () => ); return useOpenTranscript(conversation, (row) => row.key, { initial: session, - done() {}, + done: settled, }); }); expect(result.current.open).toBe(found); act(() => result.current.back()); expect(result.current.open).toBeUndefined(); }); + +it("opens the transcript again for a new request in the same conversation", () => { + const { result, rerender } = renderHook( + ({ done }: { done: (() => void) | undefined }) => { + const { session } = useOpenedSession( + [found], + conversation, + async () => undefined, + ); + return useOpenTranscript( + conversation, + (row) => row.key, + done && { initial: session, done }, + ); + }, + { initialProps: { done: settled as (() => void) | undefined } }, + ); + expect(result.current.open).toBe(found); + act(() => result.current.back()); + // Offered and settled: rendering on without a request leaves it closed. + rerender({ done: undefined }); + expect(result.current.open).toBeUndefined(); + const again = vi.fn(); + rerender({ done: again }); + expect(result.current.open).toBe(found); + expect(again).toHaveBeenCalledOnce(); + // The same request, rendered again after Back, does not reopen it. + act(() => result.current.back()); + rerender({ done: again }); + expect(result.current.open).toBeUndefined(); +}); diff --git a/src/features/agents/OpenedConversation.tsx b/src/features/agents/OpenedConversation.tsx index 17108b40f5..1f542f0d26 100644 --- a/src/features/agents/OpenedConversation.tsx +++ b/src/features/agents/OpenedConversation.tsx @@ -88,6 +88,8 @@ export function useOpenedSession( * `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. + * `done` identifies the request: the same function across renders is one + * request, and a new one 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( @@ -105,19 +107,29 @@ export function useOpenTranscript( initial?: true; }>(); // Forgotten, not just hidden: returning to the first conversation must not - // reopen it unasked. - const [offered, setOffered] = useState(""); - if (offered !== place) { - setOffered(place); - setShown( + // reopen it unasked. A new request opens it again in the same one. + const done = request?.done; + const [offered, setOffered] = useState<{ place: string; done?: () => void }>({ + place: "", + }); + const moved = offered.place !== place; + if (moved || (done && done !== offered.done)) { + setOffered({ place, ...(done && { done }) }); + const asked = place && request?.initial !== undefined - ? { place, row: request.initial, initial: true } - : undefined, - ); + ? { place, row: request.initial, initial: true as const } + : undefined; + if (moved || asked) setShown(asked); } - const done = request?.done; - useEffect(() => done?.(), [done]); const tabs = usePanelTabHost(); + // A transcript already open in its tab is brought forward when asked again. + // biome-ignore lint/correctness/useExhaustiveDependencies: once per request. + useEffect(() => { + if (!done) return; + if (tabs && shown?.initial && shown.place === place) + tabs.activate(tabs.owner, title(shown.row)); + done(); + }, [done]); return { open: shown?.place === place ? shown.row : undefined, show(row: T) { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index ee7c0229c7..ab1326e619 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -248,7 +248,8 @@ export type AgentViewProps = { 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 this once - * it has, so the panel asks only once. */ + * it has, so the panel asks only once. It stays the same function for one + * opening; a new one is a new opening, even of the conversation shown. */ initialTranscript?: () => void; /** Closes the panel showing the tab. */ close?: () => void; diff --git a/src/features/messages/TypingIndicator.test.tsx b/src/features/messages/TypingIndicator.test.tsx index 5e7da50dc9..1d4c1e1760 100644 --- a/src/features/messages/TypingIndicator.test.tsx +++ b/src/features/messages/TypingIndicator.test.tsx @@ -15,8 +15,8 @@ import type { RelaySession } from "../relay/session"; import type { TypingEntry } from "../relay/typing"; import { profileActivityViewTarget, - profileTranscriptRequested, - settleProfileTranscript, + claimProfileTranscript, + profileTranscriptRequest, } from "../profiles/target"; import { TypingIndicator } from "./TypingIndicator"; @@ -111,8 +111,8 @@ it("opens one avatar by hover, with separate thread and activity actions and key rootId: root, }); // The click asks the profile it opens for the transcript, once. - expect(profileTranscriptRequested(transcript ?? "")).toBe(true); - settleProfileTranscript(transcript ?? ""); + expect(profileTranscriptRequest(transcript ?? "")).toBeDefined(); + claimProfileTranscript(transcript ?? ""); trigger.focus(); await user.keyboard("{Enter}"); dialog = await screen.findByRole("dialog", { name: "Working now" }); diff --git a/src/features/profiles/target.test.ts b/src/features/profiles/target.test.ts index 03198f3564..0d30ea083b 100644 --- a/src/features/profiles/target.test.ts +++ b/src/features/profiles/target.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "vitest"; import { + claimProfileTranscript, openProfileTranscript, + profileTranscriptRequest, profileAgentHint, profileActivityTranscript, profileActivityViewTarget, @@ -8,8 +10,6 @@ import { profileKey, profilePanelKey, profileTarget, - profileTranscriptRequested, - settleProfileTranscript, } from "./target"; it("round-trips only exact public identity locators", () => { const key = "ab".repeat(32); @@ -82,17 +82,22 @@ it("marks only the transcript Activity destination as asking for it", () => { expect(profileActivityTranscript(`${transcript}&owner=true`)).toBe(false); }); -it("asks for a transcript only while an opening of its target is unsettled", () => { +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(profileTranscriptRequested(plain)).toBe(false); + expect(profileTranscriptRequest(plain)).toBeUndefined(); // An opening that fails leaves nothing for a later remount to act on. expect(openProfileTranscript(transcript, () => false)).toBe(false); - expect(profileTranscriptRequested(transcript)).toBe(false); + expect(profileTranscriptRequest(transcript)).toBeUndefined(); expect(openProfileTranscript(transcript, () => true)).toBe(true); - expect(profileTranscriptRequested(transcript)).toBe(true); - settleProfileTranscript(transcript); - expect(profileTranscriptRequested(transcript)).toBe(false); + 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 5269dd69ce..632ce7849e 100644 --- a/src/features/profiles/target.ts +++ b/src/features/profiles/target.ts @@ -58,9 +58,12 @@ export function profileActivityViewKey(target: string): string | undefined { return undefined; } } -/** Transcript destinations an opener has just opened, not yet shown. */ -const transcriptRequests = new Set(); +/** 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(); }; @@ -76,19 +79,22 @@ export function openProfileTranscript( open: (target: string) => boolean, ): boolean { if (!profileActivityTranscript(target)) return open(target); - transcriptRequests.add(target); + transcriptRequests.set(target, ++transcriptOpenings); const opened = open(target); if (!opened) transcriptRequests.delete(target); changed(); return opened; } -/** Whether an opener still asks `target`'s profile for its transcript. */ -export function profileTranscriptRequested(target: string): boolean { - return transcriptRequests.has(target); +/** The opening still asking `target`'s profile for its transcript. */ +export function profileTranscriptRequest(target: string): number | undefined { + return transcriptRequests.get(target); } -/** The request has been offered; opening the target again asks anew. */ -export function settleProfileTranscript(target: string): void { +/** 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 { From a45f4d8e8d29dc01d84ecb7269dbc3c042c100f0 Mon Sep 17 00:00:00 2001 From: tulsi Date: Wed, 28 Oct 2026 15:21:42 -0400 Subject: [PATCH 6/6] refactor(agents): name each transcript opening explicitly A transcript request now carries its numbered opening beside its acknowledgement, and the Activity tab compares that number rather than the callback's identity. An inline callback made anew each render is one opening, so it can no longer reopen the transcript after Back. Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: tulsi --- src/bundled/agents/AgentDetails.tsx | 3 +- src/bundled/agents/AgentTabs.tsx | 3 +- src/bundled/claude-code/tabs.test.tsx | 2 +- src/bundled/claude-code/tabs.tsx | 2 +- src/bundled/codex/tabs.test.tsx | 8 +++-- src/bundled/codex/tabs.tsx | 2 +- src/bundled/goose/tabs.tsx | 2 +- src/bundled/pi/tabs.test.tsx | 2 +- src/bundled/pi/tabs.tsx | 4 ++- src/bundled/profiles/ProfilePanel.test.tsx | 9 +++-- src/bundled/profiles/ProfilePanel.tsx | 11 +++--- .../agents/OpenedConversation.test.tsx | 34 +++++++++++-------- src/features/agents/OpenedConversation.tsx | 21 ++++++------ src/features/agents/service.ts | 10 +++--- src/features/agents/tabs.tsx | 3 +- 15 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/bundled/agents/AgentDetails.tsx b/src/bundled/agents/AgentDetails.tsx index 989f3e03d3..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, @@ -92,7 +93,7 @@ export function AgentDetails({ messageAction: (variant: "glass" | "subtle") => ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean; - initialTranscript?: (() => void) | undefined; + initialTranscript?: TranscriptRequest | undefined; /** Closes the panel showing the agent. */ close?: () => void; communities?: CommunityReader | undefined; diff --git a/src/bundled/agents/AgentTabs.tsx b/src/bundled/agents/AgentTabs.tsx index 12290b81ff..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,7 +28,7 @@ export function AgentTabs(props: { settingsContent: ReactNode; conversation?: AgentConversation | undefined; initialActivity?: boolean | undefined; - initialTranscript?: (() => void) | undefined; + initialTranscript?: TranscriptRequest | undefined; close?: (() => void) | undefined; onDiscard?: () => void; }) { diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 882360aebd..a654ddd2a2 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -376,7 +376,7 @@ it("opens the conversation's transcript when opened from its activity, once", as agent={agent()} save={vi.fn()} conversation={conversation} - initialTranscript={() => undefined} + initialTranscript={{ opening: 1, done: () => undefined }} />, ); const user = userEvent.setup(); diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 84b5af6303..bd5ec0ba2f 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -738,7 +738,7 @@ export function createTabs({ const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript && { initial: here, done: initialTranscript }, + initialTranscript && { initial: here, ...initialTranscript }, close, ); return ( diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index ecaefc2a81..04b2e3cf00 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -399,7 +399,9 @@ describe("opened from activity", () => { }); const close = vi.fn(); const done = vi.fn(); - const panel = (initialTranscript?: () => void) => ( + // Each call is a new opening, as each Working now click is. + let openings = 0; + const panel = (done?: () => void) => ( undefined} @@ -417,7 +419,9 @@ describe("opened from activity", () => { ), diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index f4adb8d64b..3bf837d656 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -839,7 +839,7 @@ export function createTabs( const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript && { initial: here, done: initialTranscript }, + initialTranscript && { initial: here, ...initialTranscript }, close, ); const saved = new Set(conversations.map((row) => row.key)); diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index fb78bbe451..058af2e6da 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -869,7 +869,7 @@ export function createTabs({ const { open, show, back } = useOpenTranscript( conversation, label, - initialTranscript && { initial: here, done: initialTranscript }, + initialTranscript && { initial: here, ...initialTranscript }, close, ); const rows = (recent.value ?? []) diff --git a/src/bundled/pi/tabs.test.tsx b/src/bundled/pi/tabs.test.tsx index 2bdfce9812..9b72434ba9 100644 --- a/src/bundled/pi/tabs.test.tsx +++ b/src/bundled/pi/tabs.test.tsx @@ -528,7 +528,7 @@ it("settles a transcript request without opening it once Pi's late listing arriv , ); expect(done).toHaveBeenCalled(); diff --git a/src/bundled/pi/tabs.tsx b/src/bundled/pi/tabs.tsx index e80f1e04c3..5e4f46fdbe 100644 --- a/src/bundled/pi/tabs.tsx +++ b/src/bundled/pi/tabs.tsx @@ -709,7 +709,9 @@ export function createTabs({ // 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. - useEffect(() => initialTranscript?.(), [initialTranscript]); + const opening = initialTranscript?.opening; + // biome-ignore lint/correctness/useExhaustiveDependencies: once per opening. + useEffect(() => initialTranscript?.done(), [opening]); if (!places) return listing.status === "error" ? (
diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index a447ce3cf0..b5c0482d20 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -19,6 +19,7 @@ import type { Agent, Agents, RegisteredAgentType, + TranscriptRequest, } from "../../features/agents/service"; import { agentDirectory } from "../../features/identity-names/testing"; @@ -378,11 +379,15 @@ function renderAgentActivity( initialTranscript, }: { conversation?: { channelId: string; rootId?: string }; - initialTranscript?: () => void; + initialTranscript?: TranscriptRequest; }) { // Like a type's Activity tab: it acts on the request as it shows. const [first] = useState(!!initialTranscript); - useEffect(() => initialTranscript?.(), [initialTranscript]); + // biome-ignore lint/correctness/useExhaustiveDependencies: once per opening. + useEffect( + () => initialTranscript?.done(), + [initialTranscript?.opening], + ); return ( <>

Echo {title.toLowerCase()}

diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 5cf2870f54..b415be9724 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -51,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"; @@ -100,7 +100,10 @@ export function ProfilePanel({ () => request === undefined ? undefined - : () => setOffered((last) => Math.max(last, request)), + : { + opening: request, + done: () => setOffered((last) => Math.max(last, request)), + }, [request], ); const pubkey = profilePanelKey(target) ?? activityKey; @@ -163,7 +166,7 @@ function OwnedAgentProfile({ agents: Agents; communities: CommunityReader | undefined; initialActivity: boolean; - initialTranscript: (() => void) | undefined; + initialTranscript: TranscriptRequest | undefined; pubkey: string; relay: RelayData; session: RelaySession; @@ -224,7 +227,7 @@ function ProfileDetails({ agents: Agents | undefined; communities: CommunityReader | undefined; initialTab: "info" | "activity"; - initialTranscript: (() => void) | undefined; + initialTranscript: TranscriptRequest | undefined; agentHint: boolean; session: RelaySession; pubkey: string; diff --git a/src/features/agents/OpenedConversation.test.tsx b/src/features/agents/OpenedConversation.test.tsx index d121627bc7..9996a35ca3 100644 --- a/src/features/agents/OpenedConversation.test.tsx +++ b/src/features/agents/OpenedConversation.test.tsx @@ -7,8 +7,6 @@ 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" }; -// One request keeps one `done` across renders; a new one is a new request. -const settled = () => {}; it("keeps the reader's choice when the opened conversation's lookup finishes later", async () => { let resolve: (row: Row) => void = () => {}; @@ -23,7 +21,8 @@ it("keeps the reader's choice when the opened conversation's lookup finishes lat ); return useOpenTranscript(conversation, (row) => row.key, { initial: session, - done: settled, + opening: 1, + done() {}, }); }); expect(result.current.open).toBeUndefined(); @@ -46,6 +45,7 @@ it("never opens a transcript found only after the conversation was shown", async ); return useOpenTranscript(conversation, (row) => row.key, { initial: session, + opening: 1, done, }); }); @@ -63,7 +63,8 @@ it("opens the known conversation's transcript once, not again after Back", () => ); return useOpenTranscript(conversation, (row) => row.key, { initial: session, - done: settled, + opening: 1, + done() {}, }); }); expect(result.current.open).toBe(found); @@ -71,33 +72,38 @@ it("opens the known conversation's transcript once, not again after Back", () => expect(result.current.open).toBeUndefined(); }); -it("opens the transcript again for a new request in the same conversation", () => { +it("opens the transcript again for a new opening in the same conversation", () => { + const settled = vi.fn(); const { result, rerender } = renderHook( - ({ done }: { done: (() => void) | undefined }) => { + ({ 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, - done && { initial: session, done }, + opening === undefined + ? undefined + : { initial: session, opening, done: () => settled(opening) }, ); }, - { initialProps: { done: settled as (() => void) | undefined } }, + { 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({ done: undefined }); + rerender({ opening: undefined }); expect(result.current.open).toBeUndefined(); - const again = vi.fn(); - rerender({ done: again }); + rerender({ opening: 2 }); expect(result.current.open).toBe(found); - expect(again).toHaveBeenCalledOnce(); - // The same request, rendered again after Back, does not reopen it. + // The same opening, rendered again after Back, does not reopen it. act(() => result.current.back()); - rerender({ done: again }); + 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 1f542f0d26..8530cc87c0 100644 --- a/src/features/agents/OpenedConversation.tsx +++ b/src/features/agents/OpenedConversation.tsx @@ -88,14 +88,13 @@ export function useOpenedSession( * `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. - * `done` identifies the request: the same function across renders is one - * request, and a new one asks again, even for the conversation already shown. + * 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; done(): void }, + request?: { initial: T | undefined; opening: number; done(): void }, close?: () => void, ) { const place = conversation @@ -108,13 +107,13 @@ export function useOpenTranscript( }>(); // Forgotten, not just hidden: returning to the first conversation must not // reopen it unasked. A new request opens it again in the same one. - const done = request?.done; - const [offered, setOffered] = useState<{ place: string; done?: () => void }>({ + const opening = request?.opening; + const [offered, setOffered] = useState<{ place: string; opening?: number }>({ place: "", }); const moved = offered.place !== place; - if (moved || (done && done !== offered.done)) { - setOffered({ place, ...(done && { done }) }); + 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 } @@ -123,13 +122,13 @@ export function useOpenTranscript( } const tabs = usePanelTabHost(); // A transcript already open in its tab is brought forward when asked again. - // biome-ignore lint/correctness/useExhaustiveDependencies: once per request. + // biome-ignore lint/correctness/useExhaustiveDependencies: once per opening. useEffect(() => { - if (!done) return; + if (!request) return; if (tabs && shown?.initial && shown.place === place) tabs.activate(tabs.owner, title(shown.row)); - done(); - }, [done]); + request.done(); + }, [opening]); return { open: shown?.place === place ? shown.row : undefined, show(row: T) { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index ab1326e619..fb657ed09e 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -242,15 +242,17 @@ 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 this once - * it has, so the panel asks only once. It stays the same function for one - * opening; a new one is a new opening, even of the conversation shown. */ - initialTranscript?: () => void; + * 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 diff --git a/src/features/agents/tabs.tsx b/src/features/agents/tabs.tsx index ad6bc7f3a4..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"; @@ -42,7 +43,7 @@ export function agentSections({ type: RegisteredAgentType | undefined; conversation?: AgentConversation | undefined; /** Asks to show the conversation's transcript first; see AgentViewProps. */ - initialTranscript?: (() => void) | undefined; + initialTranscript?: TranscriptRequest | undefined; /** Closes the panel the sections are shown in. */ close?: (() => void) | undefined; settingsHost?: AgentSettingsHost;