mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
65b6d79423
commit
a32681fd48
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user