feat(profile): show active turn badges on agent profile panel and popover (#1026)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-13 12:43:26 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 65b6d79423
commit a32681fd48
6 changed files with 275 additions and 1 deletions
+1
View File
@@ -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",
@@ -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<string, string> = {};
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}
@@ -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<string, string> = {
goose: "Goose",
@@ -59,6 +64,7 @@ export type ProfileSummaryViewProps = {
canEditAgent: boolean;
canViewActivity: boolean;
channelCount: number;
channelIdToName: Record<string, string>;
channelsLoading: boolean;
displayName: string;
followMutation: ReturnType<typeof useFollowMutation>;
@@ -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 ? (
<div className="flex flex-wrap justify-center gap-1.5">
{activeTurns.map(({ channelId, observedAt }) => (
<ProfileWorkingBadge
key={channelId}
channelId={channelId}
name={channelIdToName[channelId] ?? channelId}
observedAt={observedAt}
onNavigate={goChannel}
/>
))}
</div>
) : null}
{showMemoriesIngress || showChannelsIngress || canViewActivity ? (
<section className="space-y-2">
{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 (
<Badge
className="cursor-pointer motion-safe:animate-pulse normal-case tracking-normal hover:opacity-80"
variant="default"
onClick={() => onNavigate(channelId)}
>
Working in #{name} · {formatElapsed(now - observedAt)}
</Badge>
);
}
// ── Hero & metadata ──────────────────────────────────────────────────────────
function ProfileHero({
@@ -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<string, string> = {};
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({
</div>
) : null}
{activeTurns.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{activeTurns.map(({ channelId, observedAt }) => (
<PopoverWorkingBadge
key={channelId}
name={channelIdToName[channelId] ?? channelId}
observedAt={observedAt}
/>
))}
</div>
) : null}
{profile?.about ? (
<p className="text-xs leading-relaxed text-muted-foreground">
{profile.about}
@@ -268,3 +293,19 @@ export function UserProfilePopover({
</Popover>
);
}
function PopoverWorkingBadge({
name,
observedAt,
}: {
name: string;
observedAt: number;
}) {
const now = useNow(1000);
return (
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary motion-safe:animate-pulse">
Working in #{name} · {formatElapsed(now - observedAt)}
</span>
);
}
+18 -1
View File
@@ -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;
@@ -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`,
});
});
});