From 8f4fca9b83e7fbfb5b35f7a30b769d74609eabef Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 12 Jun 2026 20:52:34 -0400 Subject: [PATCH 1/3] feat(profile): show active turn badges on agent profile panel and popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the existing active-turn data (from useActiveAgentTurns) in two additional locations: the slide-out profile panel and the hover popover. Both show pulsing "Working in #channel · Xm Ys" badges that tick every second and navigate to the channel on click (panel) or display read-only (popover). Adds useActiveAgentTurnsBridge in UserProfilePanel so the turns store is populated even when the Agents page hasn't been visited. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/profile/ui/UserProfilePanel.tsx | 21 ++++++++ .../profile/ui/UserProfilePanelSections.tsx | 48 +++++++++++++++++++ .../profile/ui/UserProfilePopover.tsx | 41 ++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index ba65d4559..f58ecee61 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -11,6 +11,7 @@ import { useRelayAgentsQuery, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; @@ -176,6 +177,17 @@ export function UserProfilePanel({ ); const isBot = Boolean(relayAgent || managedAgent); const isOwner = useIsManagedAgent(isBot ? pubkey : null); + + // Populate the active-turns store for this agent so useActiveAgentTurns works + // even if the Agents page hasn't been visited yet. + const bridgeAgents = React.useMemo( + () => + managedAgent + ? [{ pubkey: managedAgent.pubkey, status: managedAgent.status }] + : [], + [managedAgent], + ); + useActiveAgentTurnsBridge(bridgeAgents); const canEditAgent = isOwner === true && managedAgent !== undefined; const memoryQuery = useAgentMemoryQuery(pubkey, { enabled: isOwner === true, @@ -201,6 +213,14 @@ export function UserProfilePanel({ [pubkeyLower, relayAgent, managedAgent, channelsQuery.data], ); + const channelIdToName = React.useMemo(() => { + const map: Record = {}; + for (const channel of channelsQuery.data ?? []) { + map[channel.id] = channel.name; + } + return map; + }, [channelsQuery.data]); + const prevPubkeyRef = React.useRef(pubkey); if (prevPubkeyRef.current !== pubkey) { prevPubkeyRef.current = pubkey; @@ -297,6 +317,7 @@ export function UserProfilePanel({ canEditAgent={canEditAgent} canViewActivity={canViewActivity} channelCount={profileChannels.length} + channelIdToName={channelIdToName} channelsLoading={channelsQuery.isLoading} displayName={displayName} followMutation={followMutation} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 05a97e82f..6d30e4f0d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -23,6 +23,9 @@ import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { @@ -36,6 +39,8 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; +import { Badge } from "@/shared/ui/badge"; const RUNTIME_LABELS: Record = { goose: "Goose", @@ -59,6 +64,7 @@ export type ProfileSummaryViewProps = { canEditAgent: boolean; canViewActivity: boolean; channelCount: number; + channelIdToName: Record; channelsLoading: boolean; displayName: string; followMutation: ReturnType; @@ -90,6 +96,7 @@ export function ProfileSummaryView({ canEditAgent, canViewActivity, channelCount, + channelIdToName, channelsLoading, displayName, followMutation, @@ -116,6 +123,9 @@ export function ProfileSummaryView({ unfollowMutation, userStatus, }: ProfileSummaryViewProps) { + const { goChannel } = useAppNavigation(); + const activeTurns = useActiveAgentTurns(isBot ? pubkey : null); + const metadataFields = [ ...buildPublicFields({ pubkey, @@ -161,6 +171,20 @@ export function ProfileSummaryView({ /> ) : null} + {activeTurns.length > 0 ? ( +
+ {activeTurns.map(({ channelId, observedAt }) => ( + + ))} +
+ ) : null} + {showMemoriesIngress || showChannelsIngress || canViewActivity ? (
{showMemoriesIngress ? ( @@ -211,6 +235,30 @@ export function ProfileSummaryView({ ); } +function ProfileWorkingBadge({ + channelId, + name, + observedAt, + onNavigate, +}: { + channelId: string; + name: string; + observedAt: number; + onNavigate: (channelId: string) => void; +}) { + const now = useNow(1000); + + return ( + onNavigate(channelId)} + > + Working in #{name} · {formatElapsed(now - observedAt)} + + ); +} + // ── Hero & metadata ────────────────────────────────────────────────────────── function ProfileHero({ diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d6e3d277c..d750fa105 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -6,8 +6,11 @@ import { useRelayAgentsQuery, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; @@ -16,6 +19,7 @@ import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; +import { useNow } from "@/shared/lib/useNow"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -90,6 +94,15 @@ export function UserProfilePopover({ const profile = profileQuery.data; const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; + const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null); + const channelsQuery = useChannelsQuery(); + const channelIdToName = React.useMemo(() => { + const map: Record = {}; + for (const channel of channelsQuery.data ?? []) { + map[channel.id] = channel.name; + } + return map; + }, [channelsQuery.data]); const clearHoverTimer = React.useCallback(() => { if (hoverTimerRef.current !== null) { @@ -243,6 +256,18 @@ export function UserProfilePopover({ ) : null} + {activeTurns.length > 0 ? ( +
+ {activeTurns.map(({ channelId, observedAt }) => ( + + ))} +
+ ) : null} + {profile?.about ? (

{profile.about} @@ -268,3 +293,19 @@ export function UserProfilePopover({ ); } + +function PopoverWorkingBadge({ + name, + observedAt, +}: { + name: string; + observedAt: number; +}) { + const now = useNow(1000); + + return ( + + Working in #{name} · {formatElapsed(now - observedAt)} + + ); +} From 20fa4046f3e69cebc274bfc76ed43fbbfbd46165 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 12 Jun 2026 21:03:59 -0400 Subject: [PATCH 2/3] fix(profile): add observer bridge for active turn data availability Add useManagedAgentObserverBridge alongside useActiveAgentTurnsBridge so the relay subscription registers the viewed agent's pubkey. Without this, events won't populate the turns store if the user opens the profile without having visited the Agents page first. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/profile/ui/UserProfilePanel.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f58ecee61..f71a5f575 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -12,6 +12,7 @@ import { useManagedAgentsQuery, } from "@/features/agents/hooks"; import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; +import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; @@ -188,6 +189,7 @@ export function UserProfilePanel({ [managedAgent], ); useActiveAgentTurnsBridge(bridgeAgents); + useManagedAgentObserverBridge(bridgeAgents); const canEditAgent = isOwner === true && managedAgent !== undefined; const memoryQuery = useAgentMemoryQuery(pubkey, { enabled: isOwner === true, From 702a988ba2c77d42125c4300677b98c3c8eb9ae4 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 12 Jun 2026 22:41:59 -0400 Subject: [PATCH 3/3] test(profile): add e2e screenshot spec for active turn badges Cover the profile panel (single and multi-channel) and hover popover surfaces that render the active-turn badges. Seeds a Charlie-authored message in #agents so the message avatar opens the managed-agent profile, since existing seeds had no bot-authored row in a channel without index assertions. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 1 + desktop/src/testing/e2eBridge.ts | 19 ++- .../profile-active-turn-screenshots.spec.ts | 144 ++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 desktop/tests/e2e/profile-active-turn-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index d9b0ee127..3dd4e6c2f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ "**/channel-controls-screenshots.spec.ts", "**/team-management-screenshots.spec.ts", "**/active-turn-screenshots.spec.ts", + "**/profile-active-turn-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/video-attachment.spec.ts", "**/mentions.spec.ts", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 2a99941fb..b8afdef20 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2169,7 +2169,24 @@ function getMockMessageStore(channelId: string): RelayEvent[] { sig: "mocksig".repeat(20).slice(0, 128), }, ] - : []; + : channelId === "94a444a4-c0a3-5966-ab05-530c6ddc2301" + ? [ + // Charlie is a `bot` member of #agents (see channel seed), so this + // message renders with role="bot" — the surface whose avatar opens + // a managed-agent profile panel / hover popover with active-turn + // badges. #agents has no message-row index assertions, so seeding + // here is safe for existing specs. + { + id: "mock-agents-charlie", + pubkey: CHARLIE_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - 90, + kind: 9, + tags: [["h", channelId]], + content: "Indexing the channel catalog now.", + sig: "mocksig".repeat(20).slice(0, 128), + }, + ] + : []; mockMessages.set(channelId, seeded); return seeded; diff --git a/desktop/tests/e2e/profile-active-turn-screenshots.spec.ts b/desktop/tests/e2e/profile-active-turn-screenshots.spec.ts new file mode 100644 index 000000000..0406bb39f --- /dev/null +++ b/desktop/tests/e2e/profile-active-turn-screenshots.spec.ts @@ -0,0 +1,144 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/profile-active-turns"; + +// Charlie is a `bot` member of #agents and authors the seeded "Indexing the +// channel catalog now." message (see e2eBridge.ts). Seeding a managed agent +// with this same pubkey makes the message avatar open a managed-agent profile +// panel — the surface that renders the active-turn badges. +const AGENT_PUBKEY = + "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; + +// Channel IDs the seeded turns point at. The badge labels resolve these to +// #general / #engineering via the channels query. +const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const CHANNEL_ENGINEERING = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"; + +function seedAgent() { + return { + managedAgents: [ + { + pubkey: AGENT_PUBKEY, + name: "Charlie", + status: "running" as const, + channelNames: ["agents"], + }, + ], + }; +} + +async function waitForBridge(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown }) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function", + null, + { timeout: 10_000 }, + ); +} + +async function openAgentsChannel(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForBridge(page); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); +} + +async function seedActiveTurns( + page: import("@playwright/test").Page, + turns: { channelId: string; turnId: string }[], +) { + await page.evaluate( + ({ pubkey, seeds }) => { + const win = window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + }; + for (const { channelId, turnId } of seeds) { + win.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: pubkey, + channelId, + turnId, + }); + } + }, + { pubkey: AGENT_PUBKEY, seeds: turns }, + ); +} + +// The agent's avatar is the popover trigger inside its message row; clicking it +// opens the profile panel, hovering opens the popover. +function agentAvatar(page: import("@playwright/test").Page) { + return page.getByTestId("message-row").last().getByRole("button").first(); +} + +test.describe("profile active turn indicator screenshots", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("01 — profile panel: agent working in one channel", async ({ page }) => { + await installMockBridge(page, seedAgent()); + await openAgentsChannel(page); + await seedActiveTurns(page, [ + { channelId: CHANNEL_GENERAL, turnId: "turn-101" }, + ]); + + await agentAvatar(page).click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText("Working in #general", { + timeout: 5_000, + }); + + await panel.screenshot({ + path: `${SHOTS}/01-profile-panel-single-channel.png`, + }); + }); + + test("02 — profile panel: agent working in two channels", async ({ + page, + }) => { + await installMockBridge(page, seedAgent()); + await openAgentsChannel(page); + await seedActiveTurns(page, [ + { channelId: CHANNEL_GENERAL, turnId: "turn-201" }, + { channelId: CHANNEL_ENGINEERING, turnId: "turn-202" }, + ]); + + await agentAvatar(page).click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText("Working in #general", { + timeout: 5_000, + }); + await expect(panel).toContainText("Working in #engineering"); + + await panel.screenshot({ + path: `${SHOTS}/02-profile-panel-multi-channel.png`, + }); + }); + + test("03 — hover popover: agent working", async ({ page }) => { + await installMockBridge(page, seedAgent()); + await openAgentsChannel(page); + await seedActiveTurns(page, [ + { channelId: CHANNEL_GENERAL, turnId: "turn-301" }, + ]); + + await agentAvatar(page).hover(); + + const popover = page.getByTestId("user-profile-popover"); + await expect(popover).toBeVisible({ timeout: 5_000 }); + await expect(popover).toContainText("Working in #general"); + + await popover.screenshot({ + path: `${SHOTS}/03-popover-working.png`, + }); + }); +});