From 2ab62fd4e8fe8178b4d579fe452adce064786177 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 23 Aug 2026 10:18:02 -0700 Subject: [PATCH] Make external agent activity window optional Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- .../channels/ui/AgentSessionThreadPanel.tsx | 321 ++++++++++-------- .../features/channels/ui/BotActivityBar.tsx | 264 ++++++++------ .../ui/ChannelComposerActivityAccessory.tsx | 5 + .../src/features/channels/ui/ChannelPane.tsx | 3 + .../features/channels/ui/ChannelPane.types.ts | 4 + .../features/channels/ui/ChannelScreen.tsx | 9 +- desktop/tests/e2e/channels.spec.ts | 13 + 7 files changed, 364 insertions(+), 255 deletions(-) diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index a0d5c9402d..0a79c07333 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -4,6 +4,7 @@ import { Octagon, Settings, Sparkles, + SquareArrowOutUpRight, TerminalSquare, } from "lucide-react"; import { toast } from "sonner"; @@ -29,6 +30,7 @@ import { import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; import { useStableArrayShallow } from "@/shared/hooks/useStableReference"; import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; +import { openAgentActivityWindow } from "@/shared/api/agentActivityWindow"; import type { Channel } from "@/shared/api/types"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; @@ -257,6 +259,27 @@ export function AgentSessionThreadPanel({ const headerScopeLabel = `${viewLabel} ยท ${scopeLabel}`; const animateActivity = useTranscriptAnimationEnabled(); const showTimestamps = useTranscriptTimestampsEnabled(); + async function handleOpenExternalWindow() { + if (!sessionChannelId || isDedicatedActivityWindow) { + return; + } + + try { + const openedNativeWindow = await openAgentActivityWindow( + sessionChannelId, + agent.pubkey, + ); + if (!openedNativeWindow) { + toast.error( + "External activity windows are only available in the desktop app.", + ); + } + } catch (error) { + console.error("Failed to open agent activity window:", error); + toast.error("Couldn't open the agent activity window."); + } + } + async function handleInterruptTurn() { if (!channel) { return; @@ -278,152 +301,164 @@ export function AgentSessionThreadPanel({ const agentHeaderActions = ( - {isLive ? ( - - - - - event.preventDefault()} + + + + + event.preventDefault()} + > + {!isDedicatedActivityWindow && sessionChannelId ? ( + <> + { + void handleOpenExternalWindow(); + }} + > + + Open in external window + + + + ) : null} + { + event.preventDefault(); + handleRawFeedChange(!showRawFeed); + }} + title={ + showRawFeed + ? "Hide raw JSON-RPC payloads." + : channel + ? "Show raw JSON-RPC payloads for this channel." + : "Show raw JSON-RPC payloads for this agent." + } + > + + + + Raw + + + Show raw JSON-RPC activity. + + + + { + event.preventDefault(); + setTranscriptAnimationEnabled(!animateActivity); + }} + title={ + showRawFeed + ? "Raw activity rows don't animate in." + : animateActivity + ? "Stop animating new activity rows." + : "Animate new activity rows as they arrive." + } + > + + + + Show Animations + + + + { + event.preventDefault(); + setTranscriptTimestampsEnabled(!showTimestamps); + }} + title={ + showTimestamps + ? "Hide per-row activity timestamps." + : "Show a timestamp under each activity row." + } + > + + + + Show Timestamps + + + + + { + void handleInterruptTurn(); + }} + title={ + canStopCurrentTurn + ? "Interrupt the current ACP turn without stopping the agent process." + : isWorking + ? "Only locally managed agents can be interrupted from this community." + : "Available while the agent is working." + } + > + + + + Stop current turn + + {!canStopCurrentTurn ? ( - Show raw JSON-RPC activity. + {isWorking + ? "Only available for locally managed agents." + : "Available while the agent is working."} - - - { - event.preventDefault(); - setTranscriptAnimationEnabled(!animateActivity); - }} - title={ - showRawFeed - ? "Raw activity rows don't animate in." - : animateActivity - ? "Stop animating new activity rows." - : "Animate new activity rows as they arrive." - } - > - - - - Show Animations - - - - { - event.preventDefault(); - setTranscriptTimestampsEnabled(!showTimestamps); - }} - title={ - showTimestamps - ? "Hide per-row activity timestamps." - : "Show a timestamp under each activity row." - } - > - - - - Show Timestamps - - - - - { - void handleInterruptTurn(); - }} - title={ - canStopCurrentTurn - ? "Interrupt the current ACP turn without stopping the agent process." - : isWorking - ? "Only locally managed agents can be interrupted from this community." - : "Available while the agent is working." - } - > - - - - Stop current turn - - {!canStopCurrentTurn ? ( - - {isWorking - ? "Only available for locally managed agents." - : "Available while the agent is working."} - - ) : null} - - - - - ) : null} + ) : null} + + + + ); diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index cfa84f02e3..228dbfe42e 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Loader2 } from "lucide-react"; +import { ExternalLink, Loader2 } from "lucide-react"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { @@ -10,6 +10,12 @@ import { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/shared/ui/context-menu"; import { DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, Popover, @@ -25,6 +31,10 @@ type BotActivityBarProps = { agents: BotActivityAgent[]; channelId?: string | null; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; + onOpenAgentSessionExternal: ( + pubkey: string, + channelId?: string | null, + ) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; workingBotPubkeys: string[]; @@ -38,6 +48,7 @@ export function BotActivityComposerAction({ agents, channelId = null, onOpenAgentSession, + onOpenAgentSessionExternal, openAgentSessionPubkey, profiles, workingBotPubkeys, @@ -161,116 +172,147 @@ export function BotActivityComposerAction({ : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; return ( - - - - - event.preventDefault()} - side="top" - sideOffset={8} - > -
- Agents working -
-
- {workingAgents.map((agent) => { - const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); - - return ( - - ); - })} -
-
-
+ ) : null} + + {isInline ? ( + + {visibleStatusLabel} + + ) : ( + "working" + )} + + {isInline ? null : ( + + )} + + + + event.preventDefault()} + side="top" + sideOffset={8} + > +
+ Agents working +
+
+ {workingAgents.map((agent) => { + const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); + + return ( + + ); + })} +
+
+ + + {workingAgents.map((agent) => ( + { + clearHoverTimer(); + setOpen(false); + onOpenAgentSessionExternal(agent.pubkey, channelId); + }} + > + + + Open in external window + {workingAgents.length > 1 ? ( + + {agent.name} + + ) : null} + + + ))} + + ); } diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx index f99888f011..034a6718cd 100644 --- a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx +++ b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx @@ -13,6 +13,9 @@ type ChannelComposerActivityAccessoryProps = { onOpenAgentSession: ComponentProps< typeof BotActivityComposerAction >["onOpenAgentSession"]; + onOpenAgentSessionExternal: ComponentProps< + typeof BotActivityComposerAction + >["onOpenAgentSessionExternal"]; openAgentSessionPubkey: ComponentProps< typeof BotActivityComposerAction >["openAgentSessionPubkey"]; @@ -27,6 +30,7 @@ export function ChannelComposerActivityAccessory({ channel, currentPubkey, onOpenAgentSession, + onOpenAgentSessionExternal, openAgentSessionPubkey, profiles, typingPubkeys, @@ -48,6 +52,7 @@ export function ChannelComposerActivityAccessory({ agents={agents} channelId={channel?.id ?? null} onOpenAgentSession={onOpenAgentSession} + onOpenAgentSessionExternal={onOpenAgentSessionExternal} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} workingBotPubkeys={workingBotPubkeys} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed4..8b8a4101ce 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -118,6 +118,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandThreadReplies, onJoinChannel, onOpenAgentSession, + onOpenAgentSessionExternal, onOpenDm, onOpenMembers, onOpenProfilePanel, @@ -743,6 +744,7 @@ export const ChannelPane = React.memo(function ChannelPane({ channel={activeChannel} currentPubkey={currentPubkey} onOpenAgentSession={onOpenAgentSession} + onOpenAgentSessionExternal={onOpenAgentSessionExternal} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} typingPubkeys={typingPubkeys} @@ -845,6 +847,7 @@ export const ChannelPane = React.memo(function ChannelPane({ agents={activityAgents} channelId={activeChannel?.id ?? null} onOpenAgentSession={onOpenAgentSession} + onOpenAgentSessionExternal={onOpenAgentSessionExternal} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} workingBotPubkeys={threadComposerBotTypingPubkeys} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073..62b9f8a645 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -96,6 +96,10 @@ export type ChannelPaneProps = { onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; + onOpenAgentSessionExternal: ( + pubkey: string, + channelId?: string | null, + ) => void; onOpenDm?: (pubkeys: string[]) => Promise | void; onOpenMembers?: () => void; onOpenProfilePanel: ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 8c38e237ad..ab565ced57 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -587,7 +587,7 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, }); - const handleOpenAgentActivity = React.useCallback( + const handleOpenAgentActivityExternal = React.useCallback( (pubkey: string, channelId?: string | null) => { const destinationChannelId = channelId ?? activeChannelId; if (!destinationChannelId) return; @@ -610,6 +610,12 @@ export function ChannelScreen({ }, [activeChannelId, handleOpenAgentSession], ); + const handleOpenAgentActivity = React.useCallback( + (pubkey: string, channelId?: string | null) => { + handleOpenAgentSession(pubkey, channelId ?? activeChannelId); + }, + [activeChannelId, handleOpenAgentSession], + ); const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = useChannelProfilePanel({ closeAgentSession: handleCloseAgentSession, @@ -943,6 +949,7 @@ export function ChannelScreen({ onMarkRead={handleMessageMarkRead} onExpandThreadReplies={handleExpandThreadReplies} onOpenAgentSession={handleOpenAgentActivity} + onOpenAgentSessionExternal={handleOpenAgentActivityExternal} onOpenDm={handleOpenDm} onOpenProfilePanel={handleOpenProfilePanel} onResetThreadPanelWidth={handleThreadPanelWidthReset} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3e..4e9037f45b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2081,6 +2081,9 @@ test("shows and clears activity indicators for active channel agents", async ({ await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible(); await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0); await page.getByTestId("agent-session-settings-menu-trigger").click(); + await expect( + page.getByTestId("agent-session-open-external-window"), + ).toContainText("Open in external window"); await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible(); await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled(); await page.keyboard.press("Escape"); @@ -2088,6 +2091,16 @@ test("shows and clears activity indicators for active channel agents", async ({ "No ACP activity yet", ); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); + await page.getByTestId("auxiliary-panel-close").click(); + await page + .getByTestId("bot-activity-composer-trigger") + .click({ button: "right" }); + await expect( + page.getByTestId( + `bot-activity-open-external-${TEST_IDENTITIES.alice.pubkey}`, + ), + ).toContainText("Open in external window"); + await page.keyboard.press("Escape"); await page.evaluate((pubkey) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({