From f5cb8d2d023834d3773947e411079e26f2c9e6f3 Mon Sep 17 00:00:00 2001 From: erwinblom Date: Sun, 26 Jul 2026 11:25:01 +0200 Subject: [PATCH] feat(desktop): enrich agent cards with activity Show reliable runtime state plus current or last completed activity on the existing agent cards. Co-authored-by: erwinblom Signed-off-by: erwinblom --- desktop/playwright.config.ts | 1 + .../agents/activeAgentTurnsStore.test.mjs | 76 +++++++ .../features/agents/activeAgentTurnsStore.ts | 113 +++++++++- .../agents/lib/agentCardStatus.test.mjs | 72 ++++++ .../features/agents/lib/agentCardStatus.ts | 26 +++ .../features/agents/ui/AgentIdentityCard.tsx | 5 + .../agents/ui/UnifiedAgentsSection.tsx | 210 +++++++++++++++++- .../agent-card-activity-screenshots.spec.ts | 105 +++++++++ 8 files changed, 586 insertions(+), 22 deletions(-) create mode 100644 desktop/src/features/agents/lib/agentCardStatus.test.mjs create mode 100644 desktop/src/features/agents/lib/agentCardStatus.ts create mode 100644 desktop/tests/e2e/agent-card-activity-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e3745338a2..35c679a80d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -47,6 +47,7 @@ export default defineConfig({ "**/local-archive-screenshots.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", + "**/agent-card-activity-screenshots.spec.ts", "**/edit-agent.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index c0e0b5c01d..e24a9962cf 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -6,6 +6,7 @@ import { syncActiveAgentTurnsFromObserver, getActiveTurnsForAgent, getActiveTurnsByChannel, + getLastCompletedTurnForAgent, resetActiveAgentTurnsStore, subscribeActiveAgentTurns, saveActiveAgentTurnsForCommunity, @@ -85,6 +86,81 @@ describe("activeAgentTurnsStore", () => { }); }); + describe("last completed turn", () => { + it("records only real turn_completed events", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 1, + kind: "turn_error", + channelId: "failed-channel", + timestamp: "2024-01-01T00:00:01Z", + }), + ]); + assert.equal(getLastCompletedTurnForAgent(AGENT), null); + + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 2, + kind: "turn_completed", + channelId: "completed-channel", + timestamp: "2024-01-01T00:00:02Z", + }), + ]); + assert.equal( + getLastCompletedTurnForAgent(AGENT)?.channelId, + "completed-channel", + ); + }); + + it("ignores stale out-of-order completions", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 20, + kind: "turn_completed", + channelId: "newest-channel", + timestamp: "2024-01-01T00:00:20Z", + }), + ]); + const newest = getLastCompletedTurnForAgent(AGENT); + + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 10, + kind: "turn_completed", + channelId: "stale-channel", + timestamp: "2024-01-01T00:00:10Z", + }), + ]); + + assert.equal( + getLastCompletedTurnForAgent(AGENT), + newest, + "a stale completion must not replace the newest stable summary", + ); + assert.equal(newest?.channelId, "newest-channel"); + }); + + it("keeps the last completion across a community save and restore", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 1, + kind: "turn_completed", + channelId: "saved-channel", + timestamp: "2024-01-01T00:00:01Z", + }), + ]); + saveActiveAgentTurnsForCommunity("completion-community"); + resetActiveAgentTurnsStore(); + assert.equal(getLastCompletedTurnForAgent(AGENT), null); + + restoreActiveAgentTurnsForCommunity("completion-community"); + assert.equal( + getLastCompletedTurnForAgent(AGENT)?.channelId, + "saved-channel", + ); + }); + }); + describe("seq restart detection", () => { it("processes post-restart events whose timestamp climbs past the watermark", () => { // Process events up to seq 50. diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 07ad4fa6b8..4517f1a7c9 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -46,6 +46,12 @@ export type ActiveTurnSummary = { anchorAt: number; }; +/** Most recent real turn completion surfaced to agent cards. */ +export type CompletedTurnSummary = { + channelId: string | null; + completedAt: number; +}; + /** One channel with active agent work, aggregated across agents. */ export type ActiveChannelTurnSummary = { channelId: string; @@ -57,6 +63,10 @@ export type ActiveChannelTurnSummary = { // Module-level state: agentPubkey → turnId → ActiveTurn const activeTurnsByAgent = new Map>(); +const lastCompletedByAgent = new Map< + string, + { channelId: string | null; completedAt: number } +>(); const listeners = new Set<() => void>(); // Per-agent clock offset: the desktop clock minus the agent-host clock, in @@ -79,6 +89,7 @@ const clockOffsetByAgent = new Map(); // Cached snapshots for useSyncExternalStore reference stability. // Only regenerated when the underlying turn map for an agent actually changes. const cachedTurnSummaries = new Map(); +const cachedCompletedTurnSummaries = new Map(); let cachedChannelTurnSummaries: ActiveChannelTurnSummary[] | null = null; // Composite watermark per agent: the newest observer event processed, by @@ -99,6 +110,7 @@ let pruneInterval: ReturnType | null = null; function invalidateCache(agentKey: string) { cachedTurnSummaries.delete(agentKey); + cachedCompletedTurnSummaries.delete(agentKey); cachedChannelTurnSummaries = null; } @@ -233,6 +245,19 @@ function recordTerminal(agentKey: string, turnId: string, terminalAt: number) { } } +function recordCompletedTurn( + agentPubkey: string, + channelId: string | null, + completedAt: number, +) { + if (!Number.isFinite(completedAt)) return; + const key = normalizePubkey(agentPubkey); + const previous = lastCompletedByAgent.get(key); + if (previous && previous.completedAt >= completedAt) return; + lastCompletedByAgent.set(key, { channelId, completedAt }); + invalidateCache(key); +} + function endTurn( agentPubkey: string, turnId: string | null, @@ -355,6 +380,19 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { } break; case "turn_completed": + recordCompletedTurn( + agentPubkey, + event.channelId ?? null, + Date.parse(event.timestamp), + ); + endTurn( + agentPubkey, + event.turnId ?? null, + event.channelId ?? null, + Date.parse(event.timestamp), + ); + notifyListeners(); + return; case "turn_error": case "agent_panic": endTurn( @@ -452,6 +490,30 @@ export function getActiveTurnsForAgent( return result; } +/** + * Returns the newest real `turn_completed` event for an agent. The completion + * clock is translated into desktop time with the same skew estimate used by + * active-turn elapsed counters. + */ +export function getLastCompletedTurnForAgent( + agentPubkey: string | null | undefined, +): CompletedTurnSummary | null { + if (!agentPubkey) return null; + const key = normalizePubkey(agentPubkey); + const completed = lastCompletedByAgent.get(key); + if (!completed) return null; + + const cached = cachedCompletedTurnSummaries.get(key); + if (cached) return cached; + + const summary = { + channelId: completed.channelId, + completedAt: completed.completedAt + (clockOffsetByAgent.get(key) ?? 0), + }; + cachedCompletedTurnSummaries.set(key, summary); + return summary; +} + const EMPTY_TURNS: ActiveTurnSummary[] = []; const EMPTY_CHANNEL_TURNS: ActiveChannelTurnSummary[] = []; @@ -531,6 +593,17 @@ export function useActiveAgentTurns( return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot); } +/** Hook: returns the newest real `turn_completed` event for one agent. */ +export function useLastCompletedTurn( + agentPubkey: string | null | undefined, +): CompletedTurnSummary | null { + const getSnapshot = React.useCallback( + () => getLastCompletedTurnForAgent(agentPubkey), + [agentPubkey], + ); + return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot); +} + /** * Hook: returns channels with active agent work across all tracked agents. * Re-renders when the channel set changes — not when the clock ticks. @@ -575,15 +648,17 @@ export function useActiveAgentTurnsBridge( } /** - * Clears all live turn state (active turns, offsets, watermarks, tombstones). - * Intentionally preserves `savedByCommunity` — community-switch snapshots - * must survive the reset that runs between save and restore. + * Clears all derived turn state (active turns, last completion, offsets, + * watermarks, tombstones). Intentionally preserves `savedByCommunity` — + * community-switch snapshots must survive the reset between save and restore. */ export function resetActiveAgentTurnsStore() { activeTurnsByAgent.clear(); + lastCompletedByAgent.clear(); lastProcessed.clear(); clockOffsetByAgent.clear(); cachedTurnSummaries.clear(); + cachedCompletedTurnSummaries.clear(); cachedChannelTurnSummaries = null; terminalAtByAgent.clear(); notifyListeners(); @@ -595,6 +670,7 @@ export function resetActiveAgentTurnsStore() { type TurnsStoreSnapshot = { turns: Map>; + completions: Map; offsets: Map; watermarks: Map; terminals: Map>; @@ -605,15 +681,19 @@ const savedByCommunity = new Map(); /** * Snapshot the current active-turns state under `communityId` so it can be - * restored when the user switches back. If both the turns map and the - * tombstone map are empty there is nothing worth restoring — discard any - * previously-saved snapshot instead. + * restored when the user switches back. If the active, completion, and + * tombstone maps are all empty there is nothing worth restoring — discard any + * previously saved snapshot instead. * - * Deep-clones all four maps so subsequent mutations on the live maps do not + * Clones all five maps so subsequent mutations on the live maps do not * corrupt the snapshot. */ export function saveActiveAgentTurnsForCommunity(communityId: string): void { - if (activeTurnsByAgent.size === 0 && terminalAtByAgent.size === 0) { + if ( + activeTurnsByAgent.size === 0 && + lastCompletedByAgent.size === 0 && + terminalAtByAgent.size === 0 + ) { savedByCommunity.delete(communityId); return; } @@ -632,6 +712,7 @@ export function saveActiveAgentTurnsForCommunity(communityId: string): void { // Shallow-clone scalar maps (primitives as values). const offsets = new Map(clockOffsetByAgent); const watermarks = new Map(lastProcessed); + const completions = new Map(lastCompletedByAgent); // Deep-clone terminalAtByAgent: outer map + inner per-agent maps. const terminals = new Map>(); @@ -639,14 +720,20 @@ export function saveActiveAgentTurnsForCommunity(communityId: string): void { terminals.set(agentKey, new Map(tombstones)); } - savedByCommunity.set(communityId, { turns, offsets, watermarks, terminals }); + savedByCommunity.set(communityId, { + turns, + completions, + offsets, + watermarks, + terminals, + }); } /** * Restore a previously saved active-turns snapshot for `communityId` into the * module maps. No-op when no snapshot exists. * - * Clears all four module maps before writing so the function is + * Clears all five module maps before writing so the function is * self-contained — it replaces rather than merging, regardless of whether the * caller pre-cleared. At the primary call site (`useCommunityInit`) the maps * are already empty after `resetCommunityState()`, but this guard makes the @@ -667,6 +754,7 @@ export function restoreActiveAgentTurnsForCommunity(communityId: string): void { // Clear before writing so this is a replace, not a merge. activeTurnsByAgent.clear(); + lastCompletedByAgent.clear(); clockOffsetByAgent.clear(); lastProcessed.clear(); terminalAtByAgent.clear(); @@ -685,6 +773,10 @@ export function restoreActiveAgentTurnsForCommunity(communityId: string): void { clockOffsetByAgent.set(agentKey, offset); } + for (const [agentKey, completion] of snap.completions) { + lastCompletedByAgent.set(agentKey, { ...completion }); + } + for (const [agentKey, event] of snap.watermarks) { lastProcessed.set(agentKey, event); } @@ -694,6 +786,7 @@ export function restoreActiveAgentTurnsForCommunity(communityId: string): void { } cachedTurnSummaries.clear(); + cachedCompletedTurnSummaries.clear(); cachedChannelTurnSummaries = null; notifyListeners(); } diff --git a/desktop/src/features/agents/lib/agentCardStatus.test.mjs b/desktop/src/features/agents/lib/agentCardStatus.test.mjs new file mode 100644 index 0000000000..ffe1a85cbb --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardStatus.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + deriveAgentCardStatus, + formatAgentCardActivityChannel, +} from "./agentCardStatus.ts"; + +describe("deriveAgentCardStatus", () => { + it("shows working only for an active runtime with real working activity", () => { + assert.equal( + deriveAgentCardStatus({ + hasError: false, + isWorking: true, + status: "running", + }), + "working", + ); + }); + + it("shows available for an active idle runtime", () => { + assert.equal( + deriveAgentCardStatus({ + hasError: false, + isWorking: false, + status: "deployed", + }), + "available", + ); + }); + + it("prioritizes an inactive runtime error over stale working activity", () => { + assert.equal( + deriveAgentCardStatus({ + hasError: true, + isWorking: true, + status: "stopped", + }), + "error", + ); + }); + + it("shows off for an inactive runtime without an error", () => { + assert.equal( + deriveAgentCardStatus({ + hasError: false, + isWorking: true, + status: "not_deployed", + }), + "off", + ); + }); + + it("shows off for a persona without a spawned runtime", () => { + assert.equal( + deriveAgentCardStatus({ + hasError: false, + isWorking: false, + status: null, + }), + "off", + ); + }); +}); + +describe("formatAgentCardActivityChannel", () => { + it("shows a channel name only when the viewer has a resolved visible name", () => { + assert.equal(formatAgentCardActivityChannel("general"), "#general"); + assert.equal(formatAgentCardActivityChannel(undefined), "activiteit"); + assert.equal(formatAgentCardActivityChannel(""), "activiteit"); + }); +}); diff --git a/desktop/src/features/agents/lib/agentCardStatus.ts b/desktop/src/features/agents/lib/agentCardStatus.ts new file mode 100644 index 0000000000..710b997132 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardStatus.ts @@ -0,0 +1,26 @@ +import type { ManagedAgent } from "@/shared/api/types"; + +export type AgentCardStatus = "working" | "available" | "error" | "off"; + +export function deriveAgentCardStatus({ + hasError, + isWorking, + status, +}: { + hasError: boolean; + isWorking: boolean; + status: ManagedAgent["status"] | null; +}): AgentCardStatus { + const isRuntimeActive = status === "running" || status === "deployed"; + if (!isRuntimeActive) { + return hasError ? "error" : "off"; + } + return isWorking ? "working" : "available"; +} + +export function formatAgentCardActivityChannel( + channelName: string | null | undefined, +) { + const visibleName = channelName?.trim(); + return visibleName ? `#${visibleName}` : "activiteit"; +} diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index 6f13a84ae8..4750c2d9d5 100644 --- a/desktop/src/features/agents/ui/AgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -6,6 +6,7 @@ import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; type AgentIdentityCardProps = { actions?: ReactNode; + activity?: ReactNode; ariaLabel: string; avatar?: ReactNode; avatarUrl?: string | null; @@ -19,6 +20,7 @@ type AgentIdentityCardProps = { export function AgentIdentityCard({ actions, + activity, ariaLabel, avatar, avatarUrl, @@ -78,6 +80,9 @@ export function AgentIdentityCard({ ) : null} {statusBadge} + {activity ? ( +
{activity}
+ ) : null} ); diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 5b6da92c21..9fb7cc0b03 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -1,14 +1,29 @@ import * as React from "react"; import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { isChannelOpenable } from "@/features/agents/useOpenAgentActivity"; +import { + useAgentWorking, + type AgentWorkingChannel, +} from "@/features/agents/agentWorkingSignal"; +import { useLastCompletedTurn } from "@/features/agents/activeAgentTurnsStore"; +import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; +import { + type AgentCardStatus, + deriveAgentCardStatus, + formatAgentCardActivityChannel, +} from "@/features/agents/lib/agentCardStatus"; import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; +import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; import { DropdownMenu, @@ -274,10 +289,12 @@ function AgentPersonaCard({ ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy : null; const opensRuntimeTab = Boolean(agent && friendlyError && !isActive); + const cardRuntime = useAgentCardRuntime(agent, Boolean(friendlyError)); return ( - - Restart required - - ) : null + } /> ); @@ -357,9 +372,11 @@ function StandaloneAgentCard({ )?.copy; const isActive = isManagedAgentActive(agent); const opensRuntimeTab = Boolean(friendlyError && !isActive); + const cardRuntime = useAgentCardRuntime(agent, Boolean(friendlyError)); return ( - - Restart required - - ) : null + } /> ); } +function useAgentCardRuntime( + agent: ManagedAgent | undefined, + hasError: boolean, +) { + const working = useAgentWorking(agent?.pubkey); + const lastCompleted = useLastCompletedTurn(agent?.pubkey); + const channelsQuery = useChannelsQuery(); + const { openAgentActivity } = useOpenAgentActivity(); + const visibleChannelNames = React.useMemo( + () => + new Map( + (channelsQuery.data ?? []) + .filter((channel) => isChannelOpenable(channel)) + .map((channel) => [channel.id, channel.name]), + ), + [channelsQuery.data], + ); + const status = deriveAgentCardStatus({ + hasError, + isWorking: working.working, + status: agent?.status ?? null, + }); + + const activeChannel = + working.channels.find((channel) => + visibleChannelNames.has(channel.channelId), + ) ?? working.channels[0]; + const activity = + agent && status === "working" && activeChannel ? ( + + ) : agent && lastCompleted ? ( + + ) : null; + + return { activity, status }; +} + +function AgentCardStatusBadges({ + needsRestart, + status, +}: { + needsRestart: boolean; + status: AgentCardStatus; +}) { + const statusPresentation = { + working: { label: "Bezig", variant: "default" as const }, + available: { label: "Beschikbaar", variant: "success" as const }, + error: { label: "Fout", variant: "destructive" as const }, + off: { label: "Uit", variant: "secondary" as const }, + }[status]; + + return ( +
+ + {statusPresentation.label} + + {needsRestart ? ( + + + Restart required + + ) : null} +
+ ); +} + +function AgentCardCurrentActivity({ + agentPubkey, + channel, + channelName, + onOpen, +}: { + agentPubkey: string; + channel: AgentWorkingChannel; + channelName: string | undefined; + onOpen: (pubkey: string, options?: { channelId?: string | null }) => boolean; +}) { + const now = useNow(1000); + return ( + + onOpen(agentPubkey, { + channelId: channel.channelId, + }) + } + /> + ); +} + +function AgentCardLastActivity({ + agentPubkey, + channelId, + channelName, + completedAt, + onOpen, +}: { + agentPubkey: string; + channelId: string | null; + channelName: string | undefined; + completedAt: number; + onOpen: (pubkey: string, options?: { channelId?: string | null }) => boolean; +}) { + return ( + + onOpen(agentPubkey, { + channelId, + }) + } + /> + ); +} + +function AgentCardActivityButton({ + channelId, + label, + testId, + onOpen, +}: { + channelId: string | null; + label: string; + testId: string; + onOpen: () => void; +}) { + return ( + + ); +} + function formatDefaultModelLabel(defaultModel: string) { const model = defaultModel.trim(); return model ? `Default model (${model})` : "Default model"; diff --git a/desktop/tests/e2e/agent-card-activity-screenshots.spec.ts b/desktop/tests/e2e/agent-card-activity-screenshots.spec.ts new file mode 100644 index 0000000000..42f2aed9b0 --- /dev/null +++ b/desktop/tests/e2e/agent-card-activity-screenshots.spec.ts @@ -0,0 +1,105 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/richer-agent-cards"; +const WORKING_AGENT = "aa".repeat(32); +const COMPLETED_AGENT = "bb".repeat(32); +const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const CHANNEL_ENGINEERING = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"; + +async function waitForTurnBridge(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 seedTurn( + page: import("@playwright/test").Page, + input: { + agentPubkey: string; + channelId: string; + turnId: string; + kind?: "turn_started" | "turn_completed"; + }, +) { + await page.evaluate((turn) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (seed: typeof turn) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.(turn); + }, input); +} + +test("agent cards show current and last completed activity", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: WORKING_AGENT, + name: "Onderzoeker", + status: "running", + channelNames: ["engineering"], + }, + { + pubkey: COMPLETED_AGENT, + name: "Redactie", + status: "running", + channelNames: ["general"], + }, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForTurnBridge(page); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("unified-agents-groups")).toBeVisible({ + timeout: 10_000, + }); + + await seedTurn(page, { + agentPubkey: WORKING_AGENT, + channelId: CHANNEL_ENGINEERING, + turnId: "working-turn", + }); + await seedTurn(page, { + agentPubkey: COMPLETED_AGENT, + channelId: CHANNEL_GENERAL, + turnId: "completed-turn", + }); + await seedTurn(page, { + agentPubkey: COMPLETED_AGENT, + channelId: CHANNEL_GENERAL, + turnId: "completed-turn", + kind: "turn_completed", + }); + + const workingCard = page.getByTestId(`managed-agent-${WORKING_AGENT}`); + const completedCard = page.getByTestId(`managed-agent-${COMPLETED_AGENT}`); + + await expect(workingCard.getByText("Bezig", { exact: true })).toBeVisible(); + await expect( + workingCard.getByTestId("agent-card-current-activity"), + ).toContainText("Nu: #engineering"); + await expect( + completedCard.getByText("Beschikbaar", { exact: true }), + ).toBeVisible(); + await expect( + completedCard.getByTestId("agent-card-last-activity"), + ).toContainText("Laatst: #general"); + + await waitForAnimations(page); + await workingCard.locator("..").screenshot({ + path: `${SHOTS}/agent-cards-working-last.png`, + }); + + await completedCard.getByTestId("agent-card-last-activity").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +});