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/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index ba65d4559..f71a5f575 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -11,6 +11,8 @@ import { useRelayAgentsQuery, 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"; @@ -176,6 +178,18 @@ 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); + useManagedAgentObserverBridge(bridgeAgents); const canEditAgent = isOwner === true && managedAgent !== undefined; const memoryQuery = useAgentMemoryQuery(pubkey, { enabled: isOwner === true, @@ -201,6 +215,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 +319,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)} + + ); +} 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`, + }); + }); +});