diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index aad2580dad..203af06164 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ "**/observer-feed-screenshots.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", + "**/agent-activity-panel-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", "**/voice-settings.spec.ts", diff --git a/desktop/playwright.design-kit.config.ts b/desktop/playwright.design-kit.config.ts new file mode 100644 index 0000000000..4c2d328e2a --- /dev/null +++ b/desktop/playwright.design-kit.config.ts @@ -0,0 +1,22 @@ +// Config for generating design-kit assets (screenshots + video) — not a +// test project. Untracked scratch tooling; see design-kit-assets.spec.ts. +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + testMatch: ["**/design-kit-assets.spec.ts"], + timeout: 60_000, + workers: 1, + reporter: [["list"]], + use: { + baseURL: "http://127.0.0.1:4173", + video: { mode: "on", size: { width: 1440, height: 900 } }, + ...devices["Desktop Chrome"], + }, + webServer: { + command: "python3 -m http.server 4173 -d dist", + cwd: ".", + reuseExistingServer: true, + url: "http://127.0.0.1:4173", + }, +}); diff --git a/desktop/src/features/agents/activity/AgentActivityPanel.tsx b/desktop/src/features/agents/activity/AgentActivityPanel.tsx new file mode 100644 index 0000000000..3f55c2cfab --- /dev/null +++ b/desktop/src/features/agents/activity/AgentActivityPanel.tsx @@ -0,0 +1,831 @@ +// Ported/adapted from Berd's agent-activity-panel (branch +// zmarley/agent-activity-panel). Canvas activity panel with DVR transport: +// live file-activity map of an agent turn/session with scrub, speeds, +// keyboard control, and intent-first hover tooltips. + +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import type { ActivityScene, ActivitySceneEvent } from "./activityScene"; +import { + ACTIVITY_SCRUB_STEP_MS, + type ActivityTransportState, + activityTransportBadge, + createActivityTransport, + cycleActivitySpeed, + jumpActivityTransport, + jumpToLiveActivityTransport, + restartActivityTransport, + scrubActivityTransport, + tickActivityTransport, + toggleActivityPlayback, +} from "./activityTransport"; +import type { deriveActivityTurns } from "./activityTurns"; +import { inferActivityRoots } from "./deriveActivity"; +import { type ActivityHoverView, drawFrame } from "./renderScene"; +import { + type ActivityEventHitTarget, + type ActivityRiderHitTarget, + canvasXToTime, + DEFAULT_ACTIVITY_THEME, + type FrameHitTargets, + formatActivityDuration, + getActivityPanelLayout, + preferredActivityContentHeight, +} from "./renderSceneLayout"; +import { + type ActivityScope, + turnScope, + useActivityScene, +} from "./useActivityScene"; + +const EVENT_VERBS: Record = { + r: "read", + w: "edited", + c: "created", + x: "write collision", + sp: "spawned", + d: "done", + s: "ran", +}; + +interface HoverState { + view: ActivityHoverView; + clientX: number; + clientY: number; + target: ActivityEventHitTarget | null; + rider: ActivityRiderHitTarget | null; + rowPath: string | null; + gapLabel: string | null; +} + +/** Auto viewport cap before the canvas starts scrolling. */ +const DEFAULT_VIEWPORT_MAX_HEIGHT = 380; +const MIN_VIEWPORT_HEIGHT = 140; +const MAX_VIEWPORT_HEIGHT = 900; + +function clampViewportHeight(height: number): number { + return Math.min(Math.max(height, MIN_VIEWPORT_HEIGHT), MAX_VIEWPORT_HEIGHT); +} + +export interface AgentActivityPanelProps { + frames: readonly ObserverEvent[]; + agentPubkey: string; + agentName?: string; + agentIndex?: number; + /** Canonical "agent is working" signal from the host surface. */ + isLive: boolean; + className?: string; + /** True when older archived history exists beyond the loaded window. */ + hasOlderHistory?: boolean; + /** Pages older archived frames into the window (store-backed). */ + onLoadOlder?: () => Promise; +} + +export function AgentActivityPanel({ + frames, + agentPubkey, + agentName, + agentIndex, + isLive, + className, + hasOlderHistory, + onLoadOlder, +}: AgentActivityPanelProps) { + const [scope, setScope] = React.useState("session"); + + const roots = React.useMemo(() => inferActivityRoots(frames), [frames]); + + const { scene, turns } = useActivityScene(frames, { + agentId: agentPubkey, + ...(agentName !== undefined ? { agentName } : {}), + ...(agentIndex !== undefined ? { agentIndex } : {}), + roots, + scope, + }); + + // Scoping to a turn that later leaves the retention window resets cleanly. + React.useEffect(() => { + if (scope === "session") return; + const turnId = scope.slice("turn:".length); + if (!turns.some((turn) => turn.id === turnId)) setScope("session"); + }, [scope, turns]); + + const scopeIsLive = React.useMemo(() => { + if (!isLive) return false; + if (scope === "session") return true; + const turnId = scope.slice("turn:".length); + const turn = turns.find((candidate) => candidate.id === turnId); + return turn !== undefined && turn.endT === undefined; + }, [isLive, scope, turns]); + + if (!scene || scene.events.length === 0) { + return ( +
+ No file activity in this window yet. Activity appears when the agent + reads or edits files during a turn. +
+ ); + } + + return ( + + ); +} + +export function ActivityPanelBody({ + scene, + scopeIsLive, + scope, + setScope, + turns, + className, + pickerSlot, + onRiderClick, + hasOlderHistory, + onLoadOlder, +}: { + scene: ActivityScene; + scopeIsLive: boolean; + scope: ActivityScope; + setScope: (scope: ActivityScope) => void; + turns: ReturnType; + className?: string; + /** Replaces the turn picker when set (e.g. agent chips in channel view). */ + pickerSlot?: React.ReactNode; + /** Makes agent riders clickable (canvas dots on the playhead). */ + onRiderClick?: (agentId: string) => void; + /** Shows a "Load older" control that pages archived history in. */ + hasOlderHistory?: boolean; + onLoadOlder?: () => Promise; +}) { + const [loadingOlder, setLoadingOlder] = React.useState(false); + const loadOlder = React.useCallback(async () => { + if (!onLoadOlder) return; + setLoadingOlder(true); + try { + await onLoadOlder(); + } finally { + setLoadingOlder(false); + } + }, [onLoadOlder]); + const containerRef = React.useRef(null); + const canvasRef = React.useRef(null); + const hitTargetsRef = React.useRef(null); + const hoverRef = React.useRef(null); + const draggingRef = React.useRef(false); + const [hover, setHover] = React.useState(null); + const [size, setSize] = React.useState({ width: 0, height: 0 }); + // User-dragged viewport height; null = auto (fit content up to the cap). + const [userHeight, setUserHeight] = React.useState(null); + const resizeDragRef = React.useRef<{ + startY: number; + startHeight: number; + } | null>(null); + + // Transport lives in a ref for the rAF loop; a state mirror drives the + // control chrome (badge/speed/playing) without re-rendering per frame. + const boundsRef = React.useRef({ + d0: scene.d0, + d1: scene.d1, + isLive: scopeIsLive, + }); + boundsRef.current = { d0: scene.d0, d1: scene.d1, isLive: scopeIsLive }; + + const transportRef = React.useRef( + createActivityTransport(boundsRef.current), + ); + const [transportChrome, setTransportChrome] = React.useState(() => ({ + ...transportRef.current, + })); + + const syncChrome = React.useCallback(() => { + const current = transportRef.current; + setTransportChrome((previous) => + previous.mode === current.mode && + previous.playing === current.playing && + previous.speed === current.speed + ? previous + : { ...current }, + ); + }, []); + + const sceneRef = React.useRef(scene); + sceneRef.current = scene; + + // Size tracking: width follows the container; height is the scene's + // natural content height (rows at comfortable size, growing with the + // tree) so nothing gets crushed — the viewport div scrolls when content + // exceeds it. Height changes with node count, so live turns that reveal + // new files grow the canvas. + const contentHeight = preferredActivityContentHeight(scene.nodes.length); + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const { width } = entry.contentRect; + setSize((previous) => + previous.width === width && previous.height === contentHeight + ? previous + : { width, height: contentHeight }, + ); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [contentHeight]); + + // Draw loop: paused when the document is hidden. + React.useEffect(() => { + let raf = 0; + let lastTick: number | null = null; + let running = true; + + const step = (now: number) => { + if (!running) return; + const dt = lastTick === null ? 0 : now - lastTick; + lastTick = now; + + const next = tickActivityTransport( + transportRef.current, + dt, + boundsRef.current, + ); + if (next !== transportRef.current) { + transportRef.current = next; + syncChrome(); + } + + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + const currentScene = sceneRef.current; + if (canvas && ctx && canvas.width > 0) { + const dpr = window.devicePixelRatio || 1; + hitTargetsRef.current = drawFrame( + ctx, + currentScene, + { + viewT: transportRef.current.viewT, + liveT: boundsRef.current.d1, + width: canvas.width / dpr, + height: canvas.height / dpr, + dpr, + hover: hoverRef.current?.view ?? null, + }, + DEFAULT_ACTIVITY_THEME, + ); + } + + raf = requestAnimationFrame(step); + }; + + const onVisibility = () => { + if (document.hidden) { + cancelAnimationFrame(raf); + lastTick = null; + } else { + raf = requestAnimationFrame(step); + } + }; + + raf = requestAnimationFrame(step); + document.addEventListener("visibilitychange", onVisibility); + return () => { + running = false; + cancelAnimationFrame(raf); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [syncChrome]); + + const applyTransport = React.useCallback( + ( + update: ( + state: ActivityTransportState, + bounds: { d0: number; d1: number; isLive: boolean }, + ) => ActivityTransportState, + ) => { + transportRef.current = update(transportRef.current, boundsRef.current); + syncChrome(); + }, + [syncChrome], + ); + + const scrubToCanvasX = React.useCallback((clientX: number) => { + const canvas = canvasRef.current; + const targets = hitTargetsRef.current; + if (!canvas || !targets) return; + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const nodeCount = sceneRef.current.nodes.length; + const layout = getActivityPanelLayout(rect.width, rect.height, nodeCount); + const displayT = canvasXToTime(sceneRef.current, x, layout); + transportRef.current = scrubActivityTransport( + transportRef.current, + displayT, + boundsRef.current, + ); + }, []); + + const handlePointerMove = React.useCallback( + (event: React.PointerEvent) => { + if (draggingRef.current) { + scrubToCanvasX(event.clientX); + return; + } + const canvas = canvasRef.current; + const targets = hitTargetsRef.current; + if (!canvas || !targets) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Riders take precedence when clickable — they sit on the playhead + // above event markers and are the navigation affordance. + let rider: ActivityRiderHitTarget | null = null; + if (onRiderClick) { + let riderDistance = Number.POSITIVE_INFINITY; + for (const target of targets.riders) { + const distance = Math.hypot(target.x - x, target.y - y); + if (distance <= target.radius && distance < riderDistance) { + rider = target; + riderDistance = distance; + } + } + } + + let nearest: ActivityEventHitTarget | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + if (!rider) { + for (const target of targets.events) { + const dx = target.x - x; + const dy = target.y - y; + const distance = Math.hypot(dx, dy); + if (distance <= target.radius + 3 && distance < nearestDistance) { + nearest = target; + nearestDistance = distance; + } + } + } + + canvas.style.cursor = rider ? "pointer" : "crosshair"; + + let rowPath: string | null = null; + if (!rider && !nearest) { + for (const row of targets.rows) { + if ( + x >= row.x && + x <= row.x + row.width && + y >= row.y && + y <= row.y + row.height + ) { + rowPath = row.node.path; + break; + } + } + } + + let gapLabel: string | null = null; + let gapId: string | undefined; + if (!rider && !nearest && !rowPath) { + for (const gap of targets.gaps) { + if ( + x >= gap.x && + x <= gap.x + gap.width && + y >= gap.y && + y <= gap.y + gap.height + ) { + gapLabel = `${formatActivityDuration(gap.realEnd - gap.realStart)} idle`; + gapId = gap.id; + break; + } + } + } + + if (!rider && !nearest && !rowPath && !gapLabel) { + hoverRef.current = null; + setHover(null); + return; + } + + const next: HoverState = { + view: { + ...(rowPath ? { rowPath } : {}), + ...(nearest ? { eventId: nearest.event.id } : {}), + ...(gapId ? { gapId } : {}), + }, + clientX: event.clientX, + clientY: event.clientY, + target: nearest, + rider, + rowPath, + gapLabel, + }; + hoverRef.current = next; + setHover(next); + }, + [onRiderClick, scrubToCanvasX], + ); + + const handlePointerLeave = React.useCallback(() => { + hoverRef.current = null; + draggingRef.current = false; + setHover(null); + }, []); + + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + const targets = hitTargetsRef.current; + const canvas = canvasRef.current; + if (!canvas || !targets) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Rider click → navigate to that agent instead of scrubbing. + if (onRiderClick) { + for (const target of targets.riders) { + if (Math.hypot(target.x - x, target.y - y) <= target.radius) { + event.preventDefault(); + onRiderClick(target.agentId); + return; + } + } + } + + const { chart } = targets; + if (x < chart.x0 || x > chart.x1 || y < chart.y0 || y > chart.y1) { + return; + } + draggingRef.current = true; + canvas.setPointerCapture(event.pointerId); + scrubToCanvasX(event.clientX); + syncChrome(); + }, + [onRiderClick, scrubToCanvasX, syncChrome], + ); + + const handlePointerUp = React.useCallback( + (event: React.PointerEvent) => { + if (!draggingRef.current) return; + draggingRef.current = false; + canvasRef.current?.releasePointerCapture(event.pointerId); + }, + [], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + const bounds = boundsRef.current; + switch (event.key) { + case " ": + event.preventDefault(); + applyTransport(toggleActivityPlayback); + break; + case "ArrowLeft": + event.preventDefault(); + applyTransport((state) => + jumpActivityTransport(state, -ACTIVITY_SCRUB_STEP_MS, bounds), + ); + break; + case "ArrowRight": + event.preventDefault(); + applyTransport((state) => + jumpActivityTransport(state, ACTIVITY_SCRUB_STEP_MS, bounds), + ); + break; + case "l": + case "L": + event.preventDefault(); + applyTransport(jumpToLiveActivityTransport); + break; + case "r": + case "R": + event.preventDefault(); + applyTransport(restartActivityTransport); + break; + default: + break; + } + }, + [applyTransport], + ); + + const badge = activityTransportBadge(transportChrome, { + d0: scene.d0, + d1: scene.d1, + isLive: scopeIsLive, + }); + + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + + // Current visible viewport height: the user's dragged height, else content + // height up to the auto cap. The separator reports/adjusts this value. + const effectiveViewportHeight = + userHeight ?? + Math.min( + size.height || DEFAULT_VIEWPORT_MAX_HEIGHT, + DEFAULT_VIEWPORT_MAX_HEIGHT, + ); + + return ( +
+
+ + {pickerSlot ?? ( + + )} + {hasOlderHistory && onLoadOlder ? ( + + ) : null} +
+ + + + + {scopeIsLive && transportChrome.mode !== "live" ? ( + + ) : null} + +
+
+ +
+
+ +
+ {hover ? : null} +
+ +
+

+ Space play/pause · ←/→ scrub 2s · L live · R restart +

+ {/* biome-ignore lint/a11y/useSemanticElements: an
can't host the drag/keyboard resize interaction; window-splitter-style separator per WAI-ARIA. */} +
setUserHeight(null)} + onKeyDown={(event) => { + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + const delta = event.key === "ArrowUp" ? -32 : 32; + setUserHeight( + clampViewportHeight(effectiveViewportHeight + delta), + ); + } else if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + event.stopPropagation(); + setUserHeight(null); + } + }} + onPointerDown={(event) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + resizeDragRef.current = { + startY: event.clientY, + startHeight: effectiveViewportHeight, + }; + }} + onPointerMove={(event) => { + const drag = resizeDragRef.current; + if (!drag) return; + const next = drag.startHeight + (event.clientY - drag.startY); + setUserHeight(clampViewportHeight(next)); + }} + onPointerUp={(event) => { + resizeDragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }} + /> +
+
+ ); +} + +function TransportBadge({ badge }: { badge: "live" | "replay" | "idle" }) { + if (badge === "live") { + return Live; + } + if (badge === "idle") { + // "Idle", not "Ended": the scope can wake again on the next turn. + return Idle; + } + return Replay; +} + +function TurnScopePicker({ + scope, + setScope, + turns, +}: { + scope: ActivityScope; + setScope: (scope: ActivityScope) => void; + turns: ReturnType; +}) { + if (turns.length === 0) return null; + return ( + + ); +} + +function describeEvent(event: ActivitySceneEvent): string { + const verb = EVENT_VERBS[event.kind] ?? event.kind; + if (event.path) return `${verb} ${event.path}`; + return event.label ? `${verb} · ${event.label}` : verb; +} + +function ActivityTooltip({ hover }: { hover: HoverState }) { + const containerRef = React.useRef(null); + const [offset, setOffset] = React.useState({ left: 0, top: 0 }); + + React.useLayoutEffect(() => { + const element = containerRef.current; + if (!element) return; + const parent = element.offsetParent as HTMLElement | null; + if (!parent) return; + const parentRect = parent.getBoundingClientRect(); + const width = element.offsetWidth; + let left = hover.clientX - parentRect.left + 14; + const top = hover.clientY - parentRect.top + 14; + if (hover.clientX + 14 + width > window.innerWidth - 8) { + left = hover.clientX - parentRect.left - width - 14; + } + setOffset({ left, top }); + }, [hover]); + + const target = hover.target; + const intent = target?.event.intent ?? target?.clusterFirst?.intent; + + return ( +
+ {intent ? ( +

+ {intent} +

+ ) : null} + {hover.rider ? ( +

{hover.rider.agentName} — view activity

+ ) : null} + {!hover.rider && target ? ( + target.clusterCount && target.clusterCount > 1 ? ( +
+

ran {target.clusterCount}×

+ {target.clusterLabels ? ( +

+ {[...new Set(target.clusterLabels)].slice(0, 4).join(", ")} +

+ ) : null} +
+ ) : ( +

{describeEvent(target.event)}

+ ) + ) : null} + {!hover.rider && !target && hover.rowPath ? ( +

{hover.rowPath}

+ ) : null} + {!hover.rider && !target && hover.gapLabel ? ( +

{hover.gapLabel}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/activity/ThreadActivityPanel.tsx b/desktop/src/features/agents/activity/ThreadActivityPanel.tsx new file mode 100644 index 0000000000..1de132159a --- /dev/null +++ b/desktop/src/features/agents/activity/ThreadActivityPanel.tsx @@ -0,0 +1,191 @@ +// Thread-scoped activity map: every agent's file activity in ONE thread, +// rendered as a combined scene with per-agent colored threads and riders; +// riders and agent chips click through to that agent's session panel. +// +// Locked to the thread whose pill opened it — the pill IS the thread +// selection, so there is no picker here. Other threads' maps are reached by +// scrolling to their pills. (An unscoped channel-wide view died with the +// channel strip; resurrect deliberately if ever missed, not by default.) + +import * as React from "react"; + +import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { useChannelWorkingAgentPubkeys } from "../agentWorkingSignal"; +import { ActivityPanelBody } from "./AgentActivityPanel"; +import { colorForAgent } from "./activityModel"; +import type { ThreadGroup } from "./activityThreads"; +import type { deriveActivityTurns } from "./activityTurns"; +import type { ActivityScope } from "./useActivityScene"; +import { + type MultiAgentActivityInput, + useMultiAgentActivityScene, +} from "./useMultiAgentActivity"; + +export interface ThreadActivityPanelProps { + channelId: string; + /** Managed agents visible on this surface (pubkey + display name). */ + agents: readonly MultiAgentActivityInput[]; + /** The resolved thread group this map is locked to. */ + threadGroup: ThreadGroup; + className?: string; +} + +const EMPTY_TURNS: ReturnType = []; + +export function ThreadActivityPanel({ + channelId, + agents, + threadGroup, + className, +}: ThreadActivityPanelProps) { + const { onOpenAgentSession } = useAgentSession(); + const [agentFilter, setAgentFilter] = React.useState(null); + + const { scene, agents: sceneAgents } = useMultiAgentActivityScene( + agentFilter + ? agents.filter((agent) => agent.pubkey === agentFilter) + : agents, + channelId, + threadGroup.turnIdsByAgent, + ); + + // Reset a filter pointing at an agent that no longer has activity. + React.useEffect(() => { + if (!agentFilter) return; + if (!agents.some((agent) => agent.pubkey === agentFilter)) { + setAgentFilter(null); + } + }, [agentFilter, agents]); + + const workingPubkeys = useChannelWorkingAgentPubkeys(channelId); + const anyWorking = workingPubkeys.length > 0; + + const openAgent = React.useCallback( + (agentId: string) => { + onOpenAgentSession?.(agentId, channelId); + }, + [channelId, onOpenAgentSession], + ); + + // Scope state exists to satisfy the shared body; the thread view swaps + // the turn picker for agent chips, so scope stays "session". + const [scope, setScope] = React.useState("session"); + + if (!scene || scene.events.length === 0) { + return ( +
+ No agent file activity in this thread yet. +
+ ); + } + + const chips = ( +
+ setAgentFilter(null)} + /> + {sceneAgentsOrInputs(agents, sceneAgents).map((agent) => ( + + setAgentFilter((current) => + current === agent.pubkey ? null : agent.pubkey, + ) + } + onDoubleClick={() => openAgent(agent.pubkey)} + /> + ))} +
+ ); + + return ( + + ); +} + +interface ChipAgent { + pubkey: string; + name: string; + index: number; +} + +/** + * Chip list source: the input agent set, labeled with scene names when the + * derivation produced them (scene agents carry resolved display names). + */ +function sceneAgentsOrInputs( + inputs: readonly MultiAgentActivityInput[], + sceneAgents: readonly { id: string; name: string }[], +): ChipAgent[] { + const nameById = new Map(sceneAgents.map((agent) => [agent.id, agent.name])); + return inputs.map((input, index) => ({ + pubkey: input.pubkey, + name: + nameById.get(input.pubkey) ?? input.name ?? truncatePubkey(input.pubkey), + index, + })); +} + +function AgentChip({ + active, + color, + label, + working, + onClick, + onDoubleClick, +}: { + active: boolean; + color: string | null; + label: string; + working?: boolean; + onClick: () => void; + onDoubleClick?: () => void; +}) { + return ( + + ); +} diff --git a/desktop/src/features/agents/activity/ThreadActivityPill.tsx b/desktop/src/features/agents/activity/ThreadActivityPill.tsx new file mode 100644 index 0000000000..24f5eaf4de --- /dev/null +++ b/desktop/src/features/agents/activity/ThreadActivityPill.tsx @@ -0,0 +1,124 @@ +// Per-thread activity pill: renders on a thread's summary row when agents +// did file work in that thread, and opens the activity map in a popover +// locked to the thread. The pill IS the thread selection — no picker in +// the popover; other threads are reached by scrolling to their pills. A +// long-lived channel's map entry points stay distributed across its +// threads instead of funneling through one dense channel-wide view. +// +// Reads ChannelThreadActivityProvider context; renders nothing on surfaces +// without the provider (or when the thread has no resolved activity), so +// mounting it inside MessageThreadSummaryRow is free for non-agent threads. + +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import type { ThreadGroup } from "./activityThreads"; +import { ThreadActivityPanel } from "./ThreadActivityPanel"; +import { + type ThreadActivityAgent, + useChannelThreadActivity, +} from "./threadActivityContext"; + +export function ThreadActivityPill({ threadRootId }: { threadRootId: string }) { + const { groupsByRootId, agents, channelId, workingPubkeys } = + useChannelThreadActivity(); + const group = groupsByRootId.get(threadRootId); + if (!group || !channelId) return null; + return ( + + ); +} + +function agentDisplayLabel( + group: ThreadGroup, + agents: readonly ThreadActivityAgent[], +): string { + const memberPubkeys = [...group.turnIdsByAgent.keys()]; + if (memberPubkeys.length === 1) { + const pubkey = memberPubkeys[0].toLowerCase(); + const agent = agents.find( + (candidate) => candidate.pubkey.toLowerCase() === pubkey, + ); + if (agent) return agent.name; + } + return `${memberPubkeys.length} agents`; +} + +function ThreadActivityPillBody({ + agents, + channelId, + group, + workingPubkeys, +}: { + agents: readonly ThreadActivityAgent[]; + channelId: string; + group: ThreadGroup; + workingPubkeys: readonly string[]; +}) { + const [open, setOpen] = React.useState(false); + + // Live only when an agent has an OPEN turn in THIS thread and the channel + // working signal agrees — an open turn alone can be a crashed agent, and + // a working agent may be working in a different thread. + const working = React.useMemo(() => { + if (workingPubkeys.length === 0 || group.agentsWithOpenTurn.size === 0) { + return false; + } + const workingSet = new Set( + workingPubkeys.map((pubkey) => pubkey.toLowerCase()), + ); + return [...group.agentsWithOpenTurn].some((pubkey) => + workingSet.has(pubkey.toLowerCase()), + ); + }, [workingPubkeys, group]); + + return ( + + + + + + + + + ); +} diff --git a/desktop/src/features/agents/activity/activityModel.ts b/desktop/src/features/agents/activity/activityModel.ts new file mode 100644 index 0000000000..7652136c47 --- /dev/null +++ b/desktop/src/features/agents/activity/activityModel.ts @@ -0,0 +1,64 @@ +// Core data model for the agent activity visualization. +// +// Ported from Berd's agent-activity-panel feature (branch +// zmarley/agent-activity-panel) and adapted for Buzz: source references +// point at observer-frame identifiers (seq / toolCallId / turnId) instead +// of Berd chat-message indices. + +/** + * Activity event kinds: r=read, w=write, c=create, s=status (pathless), + * sp=spawn, d=done, x=contention. Derivation emits only r|w|c|s; sp/d come + * from agent lifecycle and x is synthesized by the scene compiler. + */ +export type ActivityEventKind = "r" | "w" | "c" | "s" | "sp" | "d" | "x"; + +export interface ActivityEvent { + agentId: string; + /** Epoch milliseconds. */ + t: number; + kind: ActivityEventKind; + path?: string; + label?: string; + /** Agent narration immediately preceding this tool call, if any. */ + intent?: string; + /** Observer-frame sequence number of the originating acp_read frame. */ + sourceSeq?: number; + sourceToolCallId?: string; + sourceTurnId?: string; +} + +export interface ActivityAgent { + id: string; + name: string; + color: string; + spawnT: number; + doneT?: number; +} + +/** + * Distinguishable-on-dark agent palette. Index-stable so a channel's agents + * keep their colors across recompiles. + */ +const AGENT_COLOR_PALETTE: readonly string[] = [ + "#7bd88f", // green — Berd's original single-agent color + "#6fb7ff", + "#f5a97f", + "#c6a0f6", + "#eed49f", + "#8bd5ca", + "#f28fad", + "#a6da95", +]; + +/** Stable palette pick: by explicit index when known, else FNV-1a of the id. */ +export function colorForAgent(agentId: string, index?: number): string { + if (index !== undefined && index >= 0) { + return AGENT_COLOR_PALETTE[index % AGENT_COLOR_PALETTE.length]; + } + let hash = 0x811c9dc5; + for (let i = 0; i < agentId.length; i++) { + hash ^= agentId.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return AGENT_COLOR_PALETTE[(hash >>> 0) % AGENT_COLOR_PALETTE.length]; +} diff --git a/desktop/src/features/agents/activity/activityScene.test.mjs b/desktop/src/features/agents/activity/activityScene.test.mjs new file mode 100644 index 0000000000..e40088d412 --- /dev/null +++ b/desktop/src/features/agents/activity/activityScene.test.mjs @@ -0,0 +1,368 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { compileScene, nodeHeat } from "./activityScene.ts"; +import { getActivityPanelLayout } from "./renderSceneLayout.ts"; + +const T0 = Date.UTC(2026, 7, 10, 12, 0, 0); + +function ev(t, kind, path, agentId = "agent") { + return { agentId, t: T0 + t, kind, ...(path ? { path } : {}) }; +} + +function agent(id = "agent", spawnOffset = 0) { + return { + id, + name: id, + color: id === "agent" ? "#7bd88f" : "#c792ea", + spawnT: T0 + spawnOffset, + }; +} + +describe("compileScene", () => { + it("expands ancestors and sorts nodes in explorer order", () => { + const scene = compileScene( + [ + ev(1, "r", "src/zeta.ts"), + ev(2, "r", "src/components/Button.tsx"), + ev(3, "r", "README.md"), + ev(4, "r", "docs/guide.md"), + ], + [agent()], + ); + + assert.deepEqual( + scene.nodes.map((node) => `${node.isDir ? "d" : "f"}:${node.path}`), + [ + "d:docs", + "f:docs/guide.md", + "d:src", + "d:src/components", + "f:src/components/Button.tsx", + "f:src/zeta.ts", + "f:README.md", + ], + ); + }); + + it("collapses single-child directory chains into one displayed row", () => { + const path = "src/features/agent-activity-panel/ui/__tests__/x.test.ts"; + const scene = compileScene([ev(1, "r", path)], [agent()]); + + assert.deepEqual( + scene.nodes.map((node) => ({ + path: node.path, + name: node.name, + depth: node.depth, + isDir: node.isDir, + })), + [ + { + path: "src/features/agent-activity-panel/ui/__tests__", + name: "src/features/agent-activity-panel/ui/__tests__", + depth: 0, + isDir: true, + }, + { path, name: "x.test.ts", depth: 1, isDir: false }, + ], + ); + assert.equal(scene.events[0].node, scene.byPath.get(path)); + }); + + it("does not collapse through branching directories", () => { + const scene = compileScene( + [ev(1, "r", "src/features/a.ts"), ev(2, "r", "src/utils/b.ts")], + [agent()], + ); + + assert.deepEqual( + scene.nodes.map((node) => ({ + path: node.path, + name: node.name, + depth: node.depth, + isDir: node.isDir, + })), + [ + { path: "src", name: "src", depth: 0, isDir: true }, + { path: "src/features", name: "features", depth: 1, isDir: true }, + { path: "src/features/a.ts", name: "a.ts", depth: 2, isDir: false }, + { path: "src/utils", name: "utils", depth: 1, isDir: true }, + { path: "src/utils/b.ts", name: "b.ts", depth: 2, isDir: false }, + ], + ); + }); + + it("keeps event-terminal directory paths as displayed rows", () => { + const scene = compileScene( + [ev(1, "r", "src"), ev(2, "r", "src/features/agent/file.ts")], + [agent()], + ); + + assert.deepEqual( + scene.nodes.map((node) => ({ + path: node.path, + name: node.name, + depth: node.depth, + isDir: node.isDir, + })), + [ + { path: "src", name: "src", depth: 0, isDir: true }, + { + path: "src/features/agent", + name: "features/agent", + depth: 1, + isDir: true, + }, + { + path: "src/features/agent/file.ts", + name: "file.ts", + depth: 2, + isDir: false, + }, + ], + ); + assert.equal(scene.byPath.get("src")?.isDir, true); + assert.equal( + scene.events.find((event) => event.path === "src")?.node, + scene.byPath.get("src"), + ); + }); + + it("assigns reveal times for creates and ancestor directories", () => { + const scene = compileScene( + [ev(1, "r", "README.md"), ev(5, "c", "src/new/file.ts")], + [agent()], + ); + + assert.equal(scene.byPath.get("README.md")?.revealAt, T0); + assert.equal(scene.byPath.get("src/new/file.ts")?.revealAt, T0 + 5); + assert.equal( + scene.nodes.some((node) => node.path === "src"), + false, + ); + assert.equal(scene.byPath.get("src/new")?.revealAt, T0 + 5); + }); + + it("synthesizes contention for two agents writing the same path within four seconds", () => { + const scene = compileScene( + [ + ev(1_000, "w", "src/store.ts", "agent"), + ev(4_500, "w", "src/store.ts", "delegate"), + ], + [agent("agent"), agent("delegate")], + ); + + const events = scene.events.filter( + (event) => event.path === "src/store.ts", + ); + assert.deepEqual( + events.map((event) => event.kind), + ["w", "w", "x"], + ); + assert.equal( + events.every((event) => event.warn), + true, + ); + const contention = events.find((event) => event.kind === "x"); + assert.equal(contention?.t, T0 + 4_700); + assert.equal( + contention?.label, + "write collision: store.ts (agent ↔ delegate)", + ); + }); + + it("synthesizes contention exactly at the four-second boundary", () => { + const scene = compileScene( + [ + ev(1_000, "w", "src/store.ts", "agent"), + ev(5_000, "w", "src/store.ts", "delegate"), + ], + [agent("agent"), agent("delegate")], + ); + + assert.equal( + scene.events.some((event) => event.kind === "x"), + true, + ); + }); + + it("does not synthesize contention outside the four-second window", () => { + const scene = compileScene( + [ + ev(1_000, "w", "src/store.ts", "agent"), + ev(5_500, "w", "src/store.ts", "delegate"), + ], + [agent("agent"), agent("delegate")], + ); + + assert.equal( + scene.events.some((event) => event.kind === "x"), + false, + ); + assert.equal( + scene.events.some((event) => event.warn), + false, + ); + }); + + it("computes binary-search heat prefixes like a naive scan", () => { + let seed = 42; + function random() { + seed = (seed * 1_664_525 + 1_013_904_223) % 2 ** 32; + return seed / 2 ** 32; + } + + const events = Array.from({ length: 120 }, (_, index) => ({ + agentId: "agent", + t: T0 + Math.floor(random() * 60_000), + kind: random() > 0.55 ? "w" : "r", + path: + index % 3 === 0 + ? "src/a.ts" + : index % 3 === 1 + ? "src/b.ts" + : "README.md", + })); + const scene = compileScene(events, [agent()]); + const node = scene.byPath.get("src/a.ts"); + assert.notEqual(node, undefined); + + if (!node) return; + for (let t = T0; t <= T0 + 60_000; t += 2_317) { + const displayT = scene.timeMap.toDisplay(t); + const fast = scene.nodeHeat(node, displayT); + const slowTouches = node.touches.filter( + (touch) => touch.displayT <= displayT, + ); + const slow = slowTouches.reduce( + (acc, touch) => { + const dt = Math.max(0, displayT - touch.displayT); + if (touch.kind === "r") acc.hr += Math.exp(-dt / 6_000); + else if ( + touch.kind === "w" || + touch.kind === "c" || + touch.kind === "x" + ) { + acc.hw += Math.exp(-dt / 7_000); + } + if (touch.warn) acc.warn += Math.exp(-dt / 5_000); + return acc; + }, + { hr: 0, hw: 0, warn: 0 }, + ); + assert.equal(fast.cnt, slowTouches.length); + assert.ok( + Math.abs(fast.hr - slow.hr) < 10 ** -10, + `expected ${fast.hr} to be close to ${slow.hr}`, + ); + assert.ok( + Math.abs(fast.hw - slow.hw) < 10 ** -10, + `expected ${fast.hw} to be close to ${slow.hw}`, + ); + assert.ok( + Math.abs(fast.warn - slow.warn) < 10 ** -10, + `expected ${fast.warn} to be close to ${slow.warn}`, + ); + } + + const finalHeat = nodeHeat(node, scene.timeMap.toDisplay(T0 + 60_000)); + assert.equal(finalHeat.cnt, node.touches.length); + assert.equal(finalHeat.last, node.touches.at(-1)); + }); + + it("compresses idle gaps into display time and precomputes display timestamps", () => { + const scene = compileScene( + [ + ev(0, "r", "README.md"), + ev(10_000, "w", "README.md"), + ev(2 * 60 * 60_000 + 10_000, "r", "src/App.tsx"), + ev(2 * 60 * 60_000 + 20_000, "w", "src/App.tsx"), + ], + [agent()], + ); + + assert.equal(scene.t0, T0); + assert.equal(scene.durationMs, 2 * 60 * 60_000 + 20_000); + assert.equal(scene.d1, 22_500); + assert.equal(scene.timeMap.gaps.length, 1); + assert.deepEqual( + scene.events.map((event) => event.displayT), + [0, 10_000, 12_500, 22_500], + ); + + for (const event of scene.events) { + assert.equal(event.displayT, scene.timeMap.toDisplay(event.t)); + } + + for (const node of scene.nodes) { + for (const touch of node.touches) { + assert.equal(touch.displayT, scene.timeMap.toDisplay(touch.t)); + assert.equal(touch.event.displayT, touch.displayT); + } + } + + assert.equal( + scene.agents[0].points.every((point) => Number.isFinite(point.displayT)), + true, + ); + assert.deepEqual( + scene.agents[0].pointTimes, + scene.agents[0].points.map((point) => point.displayT), + ); + }); + + it("carries scene-level agent indices and primary agent id", () => { + const scene = compileScene( + [ev(1_000, "r", "README.md"), ev(2_000, "w", "src/App.tsx")], + [agent("agent")], + ); + + assert.equal(scene.primaryAgentId, "agent"); + assert.equal(scene.agentsById.get("agent"), scene.agents[0]); + assert.deepEqual( + scene.events.map((event) => event.id), + ["event-0", "event-1"], + ); + // Timeline times are display-space: scene start (real t=1_000) anchors to 0. + assert.deepEqual(scene.agents[0].timeline.times, [0, 1_000]); + assert.deepEqual(scene.agents[0].timeline.readPrefixCounts, [0, 1, 1]); + assert.deepEqual(scene.agents[0].timeline.writePrefixCounts, [0, 0, 1]); + }); + + it("uses one layout object for axis strip and tree/chart geometry", () => { + const wide = getActivityPanelLayout(640); + assert.equal(wide.treeWidth, 252); + assert.equal(wide.chartX0, 252); + assert.equal(wide.chartTop, 24); + assert.equal(wide.axisHeight, 24); + + const narrow = getActivityPanelLayout(180); + assert.equal(narrow.treeWidth, 140); + assert.equal(narrow.chartX0, 140); + assert.equal(narrow.chartX1, 164); + assert.equal(narrow.chartTop, 24); + }); + + it("chooses overflow layout height when rows cannot fit", () => { + const layout = getActivityPanelLayout(640, 100, 10); + + assert.equal(layout.rowH, 13); + assert.equal(layout.contentHeight, 24 + 10 * 13 + 12); + assert.ok(layout.contentHeight > 100); + }); + + it("adds hold points before row changes so threads bend only near events", () => { + const scene = compileScene( + [ev(1_000, "r", "README.md"), ev(5_000, "r", "src/App.tsx")], + [agent()], + ); + const points = scene.agents[0].points; + const hold = points.find((point) => point.hold); + + assert.notEqual(hold, undefined); + // Hold points are display-space: real t=5_000 maps to 4_000 (start anchors to 0). + assert.equal(hold?.displayT, 4_000 - scene.constants.transitionMs); + assert.equal(hold?.node.path, "README.md"); + assert.equal(points.at(-1)?.node.path, "src/App.tsx"); + }); +}); diff --git a/desktop/src/features/agents/activity/activityScene.ts b/desktop/src/features/agents/activity/activityScene.ts new file mode 100644 index 0000000000..4a23c7910a --- /dev/null +++ b/desktop/src/features/agents/activity/activityScene.ts @@ -0,0 +1,600 @@ +// Ported from Berd's agent-activity-panel (branch zmarley/agent-activity-panel). +// Compiles raw activity events + agents into a render-ready scene: file-tree +// rows, per-node heat, agent threads, contention synthesis, and display-time +// mapping. + +import type { + ActivityAgent, + ActivityEvent, + ActivityEventKind, +} from "./activityModel"; +import { buildTimeMap, type TimeMap } from "./activityTime"; +import { basename, binarySearchPrefix, hexToRgb } from "./activityUtils"; + +export interface ActivityTouch { + t: number; + displayT: number; + kind: ActivityEventKind; + agentId: string; + warn: boolean; + event: ActivitySceneEvent; +} + +export interface ActivityNodeHeat { + hr: number; + hw: number; + warn: number; + cnt: number; + last: ActivityTouch | null; +} + +export interface ActivitySceneNode { + name: string; + path: string; + depth: number; + isDir: boolean; + row: number; + revealAt: number; + displayRevealAt: number; + touches: ActivityTouch[]; + prefixTimes: number[]; +} + +export interface ActivityThreadPoint { + t: number; + displayT: number; + node: ActivitySceneNode; + event: ActivityEvent | null; + hold: boolean; +} + +export interface ActivityAgentTimeline { + events: ActivitySceneEvent[]; + times: number[]; + readPrefixCounts: number[]; + writePrefixCounts: number[]; +} + +export interface ActivitySceneAgent extends ActivityAgent { + doneT: number; + displaySpawnT: number; + displayDoneT: number; + points: ActivityThreadPoint[]; + pointTimes: number[]; + rgb: Rgb; + timeline: ActivityAgentTimeline; +} + +export interface ActivitySceneEvent extends ActivityEvent { + id: string; + displayT: number; + node?: ActivitySceneNode; + warn: boolean; + source?: "contention"; +} + +export interface ActivityScene { + events: ActivitySceneEvent[]; + agents: ActivitySceneAgent[]; + agentsById: Map; + primaryAgentId: string; + nodes: ActivitySceneNode[]; + byPath: Map; + t0: number; + t1: number; + d0: number; + d1: number; + durationMs: number; + timeMap: TimeMap; + constants: { + contentionWindowMs: number; + transitionMs: number; + }; + nodeHeat: (node: ActivitySceneNode, t: number) => ActivityNodeHeat; +} + +export type Rgb = readonly [number, number, number]; + +const CONTENTION_WINDOW_MS = 4_000; +const THREAD_TRANSITION_MS = 900; + +const READ_DECAY_MS = 6_000; +const WRITE_DECAY_MS = 7_000; +const WARN_DECAY_MS = 5_000; +const DEFAULT_THREAD_PATH = "README.md"; + +interface SourceTreeNode { + segment: string; + path: string; + isDir: boolean; + eventTerminal: boolean; + children: Map; +} + +interface DisplayNodeInput { + name: string; + path: string; + depth: number; + isDir: boolean; +} + +function createSourceTreeNode( + segment: string, + path: string, + isDir: boolean, +): SourceTreeNode { + return { + segment, + path, + isDir, + eventTerminal: false, + children: new Map(), + }; +} + +function pathSegments(path: string): string[] { + const segments = path.split("/").filter(Boolean); + return segments.length > 0 ? segments : [path]; +} + +function sortedSourceChildren(node: SourceTreeNode): SourceTreeNode[] { + return Array.from(node.children.values()).sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.segment.localeCompare(b.segment, undefined, { + sensitivity: "base", + }); + }); +} + +function addPathToSourceTree(root: SourceTreeNode, path: string) { + const segments = pathSegments(path); + let current = root; + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + const childPath = segments.slice(0, index + 1).join("/"); + const isLeaf = index === segments.length - 1; + let child = current.children.get(segment); + + if (!child) { + child = createSourceTreeNode(segment, childPath, !isLeaf); + current.children.set(segment, child); + } + + if (!isLeaf) child.isDir = true; + if (isLeaf) child.eventTerminal = true; + current = child; + } +} + +function collectDisplayNodeInputs( + node: SourceTreeNode, + displayDepth: number, + inputs: DisplayNodeInput[], +) { + if (!node.isDir) { + inputs.push({ + name: basename(node.path), + path: node.path, + depth: displayDepth, + isDir: false, + }); + return; + } + + const chain = [node]; + let deepest = node; + while (!deepest.eventTerminal) { + const children = sortedSourceChildren(deepest); + if (children.length !== 1 || !children[0].isDir) break; + deepest = children[0]; + chain.push(deepest); + } + + inputs.push({ + name: chain.map((chainNode) => chainNode.segment).join("/"), + path: deepest.path, + depth: displayDepth, + isDir: true, + }); + + for (const child of sortedSourceChildren(deepest)) { + collectDisplayNodeInputs(child, displayDepth + 1, inputs); + } +} + +function buildNodes(events: readonly ActivityEvent[]): ActivitySceneNode[] { + const paths = new Set( + events.flatMap((event) => (event.path ? [event.path] : [])), + ); + const root = createSourceTreeNode("", "", true); + + for (const path of paths) { + addPathToSourceTree(root, path); + } + + const nodeInputs: DisplayNodeInput[] = []; + for (const child of sortedSourceChildren(root)) { + collectDisplayNodeInputs(child, 0, nodeInputs); + } + + return nodeInputs.map((node, row) => ({ + ...node, + row, + revealAt: 0, + displayRevealAt: 0, + touches: [], + prefixTimes: [], + })); +} + +type PendingActivitySceneEvent = Omit; + +function agentName( + agentsById: ReadonlyMap, + agentId: string, +): string { + return agentsById.get(agentId)?.name ?? agentId; +} + +function createContentionEvent( + first: PendingActivitySceneEvent, + second: PendingActivitySceneEvent, + agentsById: ReadonlyMap, +): PendingActivitySceneEvent | undefined { + if (!first.path || !second.path || !first.node) return undefined; + + return { + agentId: second.agentId, + t: Math.max(first.t, second.t) + 200, + kind: "x", + path: first.path, + label: `write collision: ${basename(first.path)} (${agentName(agentsById, first.agentId)} ↔ ${agentName(agentsById, second.agentId)})`, + node: first.node, + warn: true, + source: "contention", + }; +} + +function withContention( + events: PendingActivitySceneEvent[], + agentsById: ReadonlyMap, +): PendingActivitySceneEvent[] { + const extra: PendingActivitySceneEvent[] = []; + const writes = events.filter( + (event) => event.path && (event.kind === "w" || event.kind === "c"), + ); + + for (let i = 0; i < writes.length; i += 1) { + for (let j = i + 1; j < writes.length; j += 1) { + const first = writes[i]; + const second = writes[j]; + if (first.path !== second.path) continue; + if (first.agentId === second.agentId) continue; + if (Math.abs(first.t - second.t) > CONTENTION_WINDOW_MS) continue; + + first.warn = true; + second.warn = true; + const contentionEvent = createContentionEvent(first, second, agentsById); + if (contentionEvent) extra.push(contentionEvent); + } + } + + return events.concat(extra).sort((a, b) => a.t - b.t); +} + +function assignTouches( + nodes: readonly ActivitySceneNode[], + events: ActivitySceneEvent[], +) { + for (const node of nodes) { + node.touches = []; + } + + for (const event of events) { + if (!event.node) continue; + event.node.touches.push({ + t: event.t, + displayT: event.displayT, + kind: event.kind, + agentId: event.agentId, + warn: event.warn || event.kind === "x", + event, + }); + } + + for (const node of nodes) { + node.touches.sort((a, b) => a.t - b.t); + node.prefixTimes = node.touches.map((touch) => touch.displayT); + } +} + +function childNodes( + node: ActivitySceneNode, + nodes: readonly ActivitySceneNode[], +): ActivitySceneNode[] { + return nodes.filter( + (candidate) => + candidate.depth === node.depth + 1 && + candidate.path.startsWith(`${node.path}/`), + ); +} + +function assignRevealTimes( + nodes: readonly ActivitySceneNode[], + sceneStart: number, + timeMap: TimeMap, +) { + const displaySceneStart = timeMap.toDisplay(sceneStart); + + for (const node of nodes) { + if (node.isDir) continue; + const first = node.touches[0]; + node.revealAt = first?.kind === "c" ? first.t : sceneStart; + node.displayRevealAt = + first?.kind === "c" ? first.displayT : displaySceneStart; + } + + for (let index = nodes.length - 1; index >= 0; index -= 1) { + const node = nodes[index]; + if (!node.isDir) continue; + + const children = childNodes(node, nodes); + node.revealAt = children.length + ? Math.min(...children.map((child) => child.revealAt)) + : sceneStart; + node.displayRevealAt = children.length + ? Math.min(...children.map((child) => child.displayRevealAt)) + : displaySceneStart; + } +} + +export function nodeHeat( + node: ActivitySceneNode, + displayT: number, +): ActivityNodeHeat { + const end = binarySearchPrefix(node.prefixTimes, displayT); + let hr = 0; + let hw = 0; + let warn = 0; + let last: ActivityTouch | null = null; + + for (let index = 0; index < end; index += 1) { + const touch = node.touches[index]; + last = touch; + const dt = Math.max(0, displayT - touch.displayT); + if (touch.kind === "r") { + hr += Math.exp(-dt / READ_DECAY_MS); + } else if (touch.kind === "w" || touch.kind === "c" || touch.kind === "x") { + hw += Math.exp(-dt / WRITE_DECAY_MS); + } + if (touch.warn) { + warn += Math.exp(-dt / WARN_DECAY_MS); + } + } + + return { hr, hw, warn, cnt: end, last }; +} + +function fallbackThreadNode( + nodes: readonly ActivitySceneNode[], +): ActivitySceneNode | undefined { + return ( + nodes.find((node) => !node.isDir && node.path === DEFAULT_THREAD_PATH) ?? + nodes.find((node) => !node.isDir) ?? + nodes[0] + ); +} + +function nodeForAgentAt( + agentId: string, + t: number, + events: readonly ActivitySceneEvent[], + fallback: ActivitySceneNode, +): ActivitySceneNode { + let current = fallback; + for (const event of events) { + if (event.t > t) break; + if (event.agentId === agentId && event.node) current = event.node; + } + return current; +} + +function buildAgentPoints( + agent: ActivityAgent, + events: readonly ActivitySceneEvent[], + fallback: ActivitySceneNode, + primaryAgentId: string, + timeMap: TimeMap, +): ActivityThreadPoint[] { + const firstAgentWithPath = events.find( + (event) => event.agentId === agent.id && event.node, + ); + let current = + firstAgentWithPath?.node ?? + nodeForAgentAt(primaryAgentId, agent.spawnT, events, fallback); + const basePoints: ActivityThreadPoint[] = [ + { + t: agent.spawnT, + displayT: timeMap.toDisplay(agent.spawnT), + node: current, + event: null, + hold: false, + }, + ]; + + for (const event of events) { + if (event.agentId !== agent.id) continue; + if (event.node) current = event.node; + basePoints.push({ + t: event.t, + displayT: event.displayT, + node: current, + event, + hold: false, + }); + } + + const points: ActivityThreadPoint[] = []; + for (let index = 0; index < basePoints.length; index += 1) { + const point = basePoints[index]; + const next = basePoints[index + 1]; + points.push(point); + if ( + next && + next.node !== point.node && + next.displayT - point.displayT > THREAD_TRANSITION_MS + ) { + const displayT = Math.max( + point.displayT, + next.displayT - THREAD_TRANSITION_MS, + ); + points.push({ + t: timeMap.toReal(displayT), + displayT, + node: point.node, + event: null, + hold: true, + }); + } + } + + return points.sort((a, b) => a.displayT - b.displayT || a.t - b.t); +} + +function buildAgentTimeline( + agentId: string, + events: readonly ActivitySceneEvent[], +): ActivityAgentTimeline { + const timelineEvents = events.filter((event) => event.agentId === agentId); + const times = timelineEvents.map((event) => event.displayT); + const readPrefixCounts = [0]; + const writePrefixCounts = [0]; + + for (const event of timelineEvents) { + const previousReads = readPrefixCounts[readPrefixCounts.length - 1] ?? 0; + const previousWrites = writePrefixCounts[writePrefixCounts.length - 1] ?? 0; + readPrefixCounts.push(previousReads + (event.kind === "r" ? 1 : 0)); + writePrefixCounts.push( + previousWrites + (event.kind === "w" || event.kind === "c" ? 1 : 0), + ); + } + + return { events: timelineEvents, times, readPrefixCounts, writePrefixCounts }; +} + +function buildSceneAgents( + agents: readonly ActivityAgent[], + events: readonly ActivitySceneEvent[], + nodes: readonly ActivitySceneNode[], + primaryAgentId: string, + timeMap: TimeMap, +): ActivitySceneAgent[] { + const fallback = fallbackThreadNode(nodes); + if (!fallback) return []; + + return agents.map((agent) => { + const doneEvent = events.find( + (event) => event.agentId === agent.id && event.kind === "d", + ); + const doneT = agent.doneT ?? doneEvent?.t ?? Number.POSITIVE_INFINITY; + return { + ...agent, + doneT, + displaySpawnT: timeMap.toDisplay(agent.spawnT), + displayDoneT: Number.isFinite(doneT) + ? timeMap.toDisplay(doneT) + : Number.POSITIVE_INFINITY, + points: buildAgentPoints( + agent, + events, + fallback, + primaryAgentId, + timeMap, + ), + pointTimes: [], + rgb: hexToRgb(agent.color), + timeline: buildAgentTimeline(agent.id, events), + }; + }); +} + +export function compileScene( + inputEvents: readonly ActivityEvent[], + inputAgents: readonly ActivityAgent[], +): ActivityScene { + const nodes = buildNodes(inputEvents); + const byPath = new Map(nodes.map((node) => [node.path, node])); + const primaryAgentId = inputAgents[0]?.id ?? inputEvents[0]?.agentId ?? ""; + const inputAgentsById = new Map( + inputAgents.map((agent) => [agent.id, agent]), + ); + const sceneStartCandidates = [ + ...inputEvents.map((event) => event.t), + ...inputAgents.map((agent) => agent.spawnT), + ]; + const sceneStart = sceneStartCandidates.length + ? Math.min(...sceneStartCandidates) + : 0; + + const events = inputEvents + .map((event) => ({ + ...event, + ...(event.path ? { node: byPath.get(event.path) } : {}), + warn: event.kind === "x", + })) + .sort((a, b) => a.t - b.t); + + const pendingContendedEvents = withContention(events, inputAgentsById); + const timeMap = buildTimeMap(pendingContendedEvents.map((event) => event.t)); + const contendedEvents: ActivitySceneEvent[] = pendingContendedEvents.map( + (event, index) => ({ + ...event, + id: `event-${index}`, + displayT: timeMap.toDisplay(event.t), + }), + ); + assignTouches(nodes, contendedEvents); + assignRevealTimes(nodes, sceneStart, timeMap); + + const agents = buildSceneAgents( + inputAgents, + contendedEvents, + nodes, + primaryAgentId, + timeMap, + ).map((agent) => ({ + ...agent, + pointTimes: agent.points.map((point) => point.displayT), + })); + const agentsById = new Map(agents.map((agent) => [agent.id, agent])); + const maxEventT = contendedEvents.at(-1)?.t ?? sceneStart; + const maxDoneT = Math.max( + sceneStart, + ...inputAgents.flatMap((agent) => + agent.doneT !== undefined ? [agent.doneT] : [], + ), + ); + const t0 = sceneStart; + const t1 = Math.max(maxEventT, maxDoneT, t0); + + return { + events: contendedEvents, + agents, + agentsById, + primaryAgentId, + nodes, + byPath, + t0, + t1, + d0: 0, + d1: timeMap.displayDuration, + durationMs: Math.max(1, t1 - t0), + timeMap, + constants: { + contentionWindowMs: CONTENTION_WINDOW_MS, + transitionMs: THREAD_TRANSITION_MS, + }, + nodeHeat, + }; +} diff --git a/desktop/src/features/agents/activity/activityThreads.test.mjs b/desktop/src/features/agents/activity/activityThreads.test.mjs new file mode 100644 index 0000000000..44b9c5908a --- /dev/null +++ b/desktop/src/features/agents/activity/activityThreads.test.mjs @@ -0,0 +1,270 @@ +/** + * Tests for activityThreads — turn → thread-root resolution and grouping. + * + * A channel turn resolves through the LAST of its triggeringEventIds: a + * trigger with no thread tags IS the root; a reply resolves through its root + * marker. Heartbeat turns, triggerless turns, and failed fetches land in the + * unthreaded bucket. Groups order most-recently-active first and carry a + * per-agent turn-id allowlist for scene scoping. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + buildThreadGroups, + filterFramesByTurnIds, + resolveTurnThread, +} from "./activityThreads.ts"; + +const T0 = Date.parse("2026-08-18T12:00:00.000Z"); + +function turn(id, overrides = {}) { + return { + id, + startT: T0, + triggeringEventIds: [], + ...overrides, + }; +} + +/** Minimal RelayEvent for the injected fetcher. */ +function relayEvent(id, { content = "hello", tags = [], createdAt } = {}) { + return { + id, + pubkey: "f".repeat(64), + created_at: createdAt ?? Math.floor(T0 / 1000), + kind: 9, + tags, + content, + sig: "sig", + }; +} + +function fetcherFor(events) { + const byId = new Map(events.map((event) => [event.id, event])); + return async (eventId) => byId.get(eventId) ?? null; +} + +const ROOT_ID = "a".repeat(64); +const REPLY_ID = "b".repeat(64); + +describe("resolveTurnThread", () => { + it("treats a tagless trigger as the thread root itself", async () => { + const fetchEvent = fetcherFor([ + relayEvent(ROOT_ID, { content: "Fix the login flow" }), + ]); + const resolution = await resolveTurnThread( + turn("t1", { source: "channel", triggeringEventIds: [ROOT_ID] }), + fetchEvent, + ); + assert.equal(resolution.rootId, ROOT_ID); + }); + + it("resolves a direct reply (single reply tag) to its root", async () => { + const fetchEvent = fetcherFor([ + relayEvent(REPLY_ID, { + content: "a reply", + // Direct reply to root: single "reply" marker, no "root" marker. + tags: [["e", ROOT_ID, "", "reply"]], + }), + ]); + const resolution = await resolveTurnThread( + turn("t1", { source: "channel", triggeringEventIds: [REPLY_ID] }), + fetchEvent, + ); + assert.equal(resolution.rootId, ROOT_ID); + }); + + it("uses the LAST triggering event when a batch drained several", async () => { + const otherId = "c".repeat(64); + const fetchEvent = fetcherFor([ + relayEvent(otherId, { content: "first drained" }), + relayEvent(ROOT_ID, { content: "last drained" }), + ]); + const resolution = await resolveTurnThread( + turn("t1", { + source: "channel", + triggeringEventIds: [otherId, ROOT_ID], + }), + fetchEvent, + ); + assert.equal(resolution.rootId, ROOT_ID); + }); + + it("resolves a nested reply via its root marker without fetching the root", async () => { + const parentId = "d".repeat(64); + let fetches = 0; + const fetchEvent = async (eventId) => { + fetches += 1; + return eventId === REPLY_ID + ? relayEvent(REPLY_ID, { + content: "a nested reply", + // Nested reply: root marker + reply marker to its parent. + tags: [ + ["e", ROOT_ID, "", "root"], + ["e", parentId, "", "reply"], + ], + }) + : null; + }; + const resolution = await resolveTurnThread( + turn("t1", { source: "channel", triggeringEventIds: [REPLY_ID] }), + fetchEvent, + ); + assert.equal(resolution.rootId, ROOT_ID); + // Root id comes from the trigger's own tags — one fetch, no root fetch. + assert.equal(fetches, 1); + }); + + it("returns unthreaded for heartbeat turns without fetching", async () => { + let fetches = 0; + const resolution = await resolveTurnThread( + turn("t1", { + source: "heartbeat", + triggeringEventIds: [ROOT_ID], + }), + async () => { + fetches += 1; + return null; + }, + ); + assert.equal(resolution.rootId, null); + assert.equal(fetches, 0); + }); + + it("returns unthreaded for triggerless turns and failed fetches", async () => { + const noTrigger = await resolveTurnThread( + turn("t1", { source: "channel" }), + async () => relayEvent(ROOT_ID), + ); + assert.equal(noTrigger.rootId, null); + + const fetchMiss = await resolveTurnThread( + turn("t2", { source: "channel", triggeringEventIds: [ROOT_ID] }), + async () => null, + ); + assert.equal(fetchMiss.rootId, null); + }); +}); + +describe("buildThreadGroups", () => { + const resolutionA = { rootId: ROOT_ID }; + const resolutionB = { rootId: REPLY_ID }; + const unthreadedResolution = { rootId: null }; + + it("groups turns across agents and orders threads by recency", () => { + const turnsByAgent = new Map([ + [ + "agent1", + [ + turn("t1", { startT: T0, endT: T0 + 1000 }), + turn("t2", { startT: T0 + 5000, endT: T0 + 6000 }), + ], + ], + ["agent2", [turn("t3", { startT: T0 + 2000, endT: T0 + 3000 })]], + ]); + const resolutions = new Map([ + ["t1", resolutionA], + ["t2", resolutionB], + ["t3", resolutionA], + ]); + + const groups = buildThreadGroups(turnsByAgent, resolutions); + + assert.equal(groups.threads.length, 2); + // Thread B's turn ended latest → first. + assert.equal(groups.threads[0].rootId, REPLY_ID); + assert.equal(groups.threads[1].rootId, ROOT_ID); + assert.equal(groups.threads[1].turnCount, 2); + assert.deepEqual( + [...groups.threads[1].turnIdsByAgent.get("agent1")], + ["t1"], + ); + assert.deepEqual( + [...groups.threads[1].turnIdsByAgent.get("agent2")], + ["t3"], + ); + assert.equal(groups.unthreaded, null); + }); + + it("collects unthreaded turns separately and skips unresolved turns", () => { + const turnsByAgent = new Map([ + [ + "agent1", + [ + turn("t1", { startT: T0, endT: T0 + 1000 }), + turn("pending", { startT: T0 + 2000 }), + ], + ], + ]); + const resolutions = new Map([["t1", unthreadedResolution]]); + + const groups = buildThreadGroups(turnsByAgent, resolutions); + + assert.equal(groups.threads.length, 0); + assert.equal(groups.unthreaded?.turnCount, 1); + // "pending" has no resolution yet → in neither bucket. + assert.deepEqual( + [...groups.unthreaded.turnIdsByAgent.get("agent1")], + ["t1"], + ); + }); + + it("tracks agents with open turns per group", () => { + const turnsByAgent = new Map([ + [ + "agent1", + [ + turn("done", { startT: T0, endT: T0 + 1000 }), + turn("open", { startT: T0 + 2000 }), // no endT: still running + ], + ], + ["agent2", [turn("also-done", { startT: T0, endT: T0 + 500 })]], + ]); + const resolutions = new Map([ + ["done", resolutionA], + ["open", resolutionA], + ["also-done", resolutionA], + ]); + + const groups = buildThreadGroups(turnsByAgent, resolutions); + assert.deepEqual([...groups.threads[0].agentsWithOpenTurn], ["agent1"]); + }); + + it("uses an open turn's startT for recency", () => { + const turnsByAgent = new Map([ + [ + "agent1", + [ + turn("closed", { startT: T0, endT: T0 + 1000 }), + turn("live", { startT: T0 + 9000 }), // endT undefined: live + ], + ], + ]); + const resolutions = new Map([ + ["closed", resolutionA], + ["live", resolutionB], + ]); + + const groups = buildThreadGroups(turnsByAgent, resolutions); + assert.equal(groups.threads[0].rootId, REPLY_ID); + assert.equal(groups.threads[0].lastT, T0 + 9000); + }); +}); + +describe("filterFramesByTurnIds", () => { + it("keeps only frames whose turnId is allowed; drops turnless frames", () => { + const frames = [ + { seq: 1, turnId: "t1", kind: "turn_started" }, + { seq: 2, turnId: null, kind: "connection" }, + { seq: 3, turnId: "t2", kind: "acp_read" }, + { seq: 4, turnId: "t1", kind: "turn_completed" }, + ]; + const filtered = filterFramesByTurnIds(frames, new Set(["t1"])); + assert.deepEqual( + filtered.map((frame) => frame.seq), + [1, 4], + ); + }); +}); diff --git a/desktop/src/features/agents/activity/activityThreads.ts b/desktop/src/features/agents/activity/activityThreads.ts new file mode 100644 index 0000000000..b77aab76d1 --- /dev/null +++ b/desktop/src/features/agents/activity/activityThreads.ts @@ -0,0 +1,168 @@ +// Thread scoping for the activity map. +// +// Observer frames carry no thread identity, but every channel-triggered +// turn_started frame carries the Nostr event ids of the messages that were +// drained into the turn (`triggeringEventIds`). Resolving the LAST of those +// ids (matching buzz-acp's "thread context follows the last reply" rule) to +// its thread root lets each thread's pill scope the map to that thread's +// turns instead of rendering the channel's entire history as one stream. +// +// Pure module: the event fetcher is injected so node tests never touch the +// Tauri bridge. The React wiring lives in useThreadScope.ts. + +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { RelayEvent } from "@/shared/api/types"; +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import type { ActivityTurn } from "./activityTurns"; + +/** Resolves an event id to the event, or null when unavailable/deleted. */ +export type FetchEvent = (eventId: string) => Promise; + +export interface TurnThreadResolution { + /** Thread root event id; null for heartbeat/unresolvable turns. */ + rootId: string | null; +} + +const UNTHREADED: TurnThreadResolution = { rootId: null }; + +/** + * Resolve one turn to its thread root. + * + * Heartbeat turns and turns with no triggering events are unthreaded. A + * triggering event that is itself a thread root (no thread e-tags) roots the + * thread at that event; a reply resolves through its root marker. Fetch + * failures degrade to unthreaded rather than erroring the map. + * + * One fetch per turn: the root id comes from the trigger's own tags, and + * thread identity in the UI is positional (the pill sits on its thread's + * summary row), so the root message body is never needed here. + */ +export async function resolveTurnThread( + turn: ActivityTurn, + fetchEvent: FetchEvent, +): Promise { + if (turn.source === "heartbeat" || turn.triggeringEventIds.length === 0) { + return UNTHREADED; + } + const triggerId = turn.triggeringEventIds.at(-1); + if (!triggerId) return UNTHREADED; + + const trigger = await fetchEvent(triggerId); + if (!trigger) return UNTHREADED; + + return { rootId: getThreadReference(trigger.tags).rootId ?? trigger.id }; +} + +/** + * Resolve a batch of turns in parallel. The fetcher is expected to cache and + * dedupe by event id (see useThreadScope), so re-resolution on live appends + * costs one map rebuild, not repeated I/O. + */ +export async function resolveTurnThreads( + turns: readonly ActivityTurn[], + fetchEvent: FetchEvent, +): Promise> { + const entries = await Promise.all( + turns.map( + async (turn) => + [turn.id, await resolveTurnThread(turn, fetchEvent)] as const, + ), + ); + return new Map(entries); +} + +export interface ThreadGroup { + /** Thread root event id; null for the unthreaded (heartbeat) bucket. */ + rootId: string | null; + /** Epoch ms of the group's most recent turn activity, for ordering. */ + lastT: number; + turnCount: number; + /** Member turn ids per agent pubkey — the scene-scoping allowlist. */ + turnIdsByAgent: Map>; + /** + * Agents whose newest turn in this group is still open (no turn_completed). + * Intersect with the channel working signal for a live indicator: an open + * turn alone can be a crashed agent, and a working agent may be working in + * a different thread. + */ + agentsWithOpenTurn: Set; +} + +export interface ThreadGroups { + /** Resolved threads, most recently active first. */ + threads: ThreadGroup[]; + /** Heartbeat/unresolvable turns, or null when every turn resolved. */ + unthreaded: ThreadGroup | null; +} + +function turnLastT(turn: ActivityTurn): number { + return turn.endT ?? turn.startT; +} + +/** + * Group each agent's turns by resolved thread root. + * + * Turns missing from `resolutions` (still resolving) are excluded from every + * group — they participate only in the unscoped "All" view until resolution + * lands, which avoids flickering a live turn through the wrong bucket. + */ +export function buildThreadGroups( + turnsByAgent: ReadonlyMap, + resolutions: ReadonlyMap, +): ThreadGroups { + const groups = new Map(); + + for (const [agentId, turns] of turnsByAgent) { + for (const turn of turns) { + const resolution = resolutions.get(turn.id); + if (!resolution) continue; + + let group = groups.get(resolution.rootId); + if (!group) { + group = { + rootId: resolution.rootId, + lastT: 0, + turnCount: 0, + turnIdsByAgent: new Map(), + agentsWithOpenTurn: new Set(), + }; + groups.set(resolution.rootId, group); + } + + group.lastT = Math.max(group.lastT, turnLastT(turn)); + group.turnCount += 1; + if (turn.endT === undefined) group.agentsWithOpenTurn.add(agentId); + + let agentTurns = group.turnIdsByAgent.get(agentId); + if (!agentTurns) { + agentTurns = new Set(); + group.turnIdsByAgent.set(agentId, agentTurns); + } + agentTurns.add(turn.id); + } + } + + const unthreaded = groups.get(null) ?? null; + groups.delete(null); + + const threads = [...groups.values()].sort( + (a, b) => + b.lastT - a.lastT || (a.rootId ?? "").localeCompare(b.rootId ?? ""), + ); + + return { threads, unthreaded }; +} + +/** + * Frames belonging to the allowed turns. Frames with no turnId (connection + * and pre-turn session frames) carry no activity and drop out of a scoped + * view; the unscoped view never calls this. + */ +export function filterFramesByTurnIds( + frames: readonly ObserverEvent[], + allowedTurnIds: ReadonlySet, +): ObserverEvent[] { + return frames.filter( + (frame) => frame.turnId !== null && allowedTurnIds.has(frame.turnId), + ); +} diff --git a/desktop/src/features/agents/activity/activityTime.test.mjs b/desktop/src/features/agents/activity/activityTime.test.mjs new file mode 100644 index 0000000000..b305da2568 --- /dev/null +++ b/desktop/src/features/agents/activity/activityTime.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { buildTimeMap } from "./activityTime.ts"; + +function expectClose(actual, expected) { + assert.ok( + Math.abs(actual - expected) < 10 ** -8, + `expected ${actual} to be close to ${expected}`, + ); +} + +describe("buildTimeMap", () => { + it("is identity-like when no gaps exceed the threshold", () => { + const times = [1_000, 6_000, 16_000, 26_000]; + const map = buildTimeMap(times); + + assert.deepEqual(map.gaps, []); + assert.equal(map.displayDuration, 25_000); + assert.equal(map.toDisplay(1_000), 0); + assert.equal(map.toDisplay(11_000), 10_000); + assert.equal(map.toReal(10_000), 11_000); + }); + + it("compresses a single gap to the configured display duration", () => { + const map = buildTimeMap([1_000, 11_000, 81_000, 91_000], { + gapThresholdMs: 30_000, + gapDisplayMs: 2_500, + }); + + assert.deepEqual(map.gaps, [ + { + realStart: 11_000, + realEnd: 81_000, + displayStart: 10_000, + displayEnd: 12_500, + }, + ]); + assert.equal(map.displayDuration, 22_500); + assert.equal(map.toDisplay(81_000), 12_500); + assert.equal(map.toReal(12_500), 81_000); + expectClose(map.toDisplay(46_000), 11_250); + expectClose(map.toReal(11_250), 46_000); + }); + + it("compresses multiple gaps", () => { + const map = buildTimeMap([0, 10_000, 70_000, 80_000, 200_000], { + gapThresholdMs: 30_000, + gapDisplayMs: 2_500, + }); + + assert.deepEqual(map.gaps, [ + { + realStart: 10_000, + realEnd: 70_000, + displayStart: 10_000, + displayEnd: 12_500, + }, + { + realStart: 80_000, + realEnd: 200_000, + displayStart: 22_500, + displayEnd: 25_000, + }, + ]); + assert.equal(map.displayDuration, 25_000); + assert.equal(map.toDisplay(75_000), 17_500); + assert.equal(map.toReal(23_750), 140_000); + }); + + it("does not compress an interval exactly at the threshold", () => { + const map = buildTimeMap([0, 30_000, 60_001], { + gapThresholdMs: 30_000, + gapDisplayMs: 2_500, + }); + + assert.deepEqual(map.gaps, [ + { + realStart: 30_000, + realEnd: 60_001, + displayStart: 30_000, + displayEnd: 32_500, + }, + ]); + assert.equal(map.toDisplay(30_000), 30_000); + assert.equal(map.displayDuration, 32_500); + }); + + it("round-trips event times and segment boundaries exactly", () => { + const times = [5_000, 15_000, 90_000, 95_000, 180_000]; + const map = buildTimeMap(times); + const boundaries = [ + ...times, + ...map.gaps.flatMap((gap) => [gap.realStart, gap.realEnd]), + ]; + + for (const t of boundaries) { + assert.equal(map.toReal(map.toDisplay(t)), t); + } + for (const d of map.gaps.flatMap((gap) => [ + gap.displayStart, + gap.displayEnd, + ])) { + assert.equal(map.toDisplay(map.toReal(d)), d); + } + }); + + it("is monotonic on random sorted input", () => { + let seed = 9; + function random() { + seed = (seed * 1_664_525 + 1_013_904_223) % 2 ** 32; + return seed / 2 ** 32; + } + + let t = 0; + const times = Array.from({ length: 200 }, () => { + t += Math.floor(random() * 90_000); + return t; + }); + const map = buildTimeMap(times); + + let previousDisplay = Number.NEGATIVE_INFINITY; + for (let index = 0; index < 500; index += 1) { + const real = + times[0] + ((times.at(-1) ?? times[0]) - times[0]) * (index / 499); + const display = map.toDisplay(real); + assert.ok( + display >= previousDisplay, + `expected ${display} >= ${previousDisplay}`, + ); + previousDisplay = display; + } + + let previousReal = Number.NEGATIVE_INFINITY; + for (let index = 0; index < 500; index += 1) { + const display = map.displayDuration * (index / 499); + const real = map.toReal(display); + assert.ok(real >= previousReal, `expected ${real} >= ${previousReal}`); + previousReal = real; + } + }); + + it("handles degenerate zero- and one-event inputs", () => { + const empty = buildTimeMap([]); + assert.ok(empty.displayDuration >= 1); + assert.deepEqual(empty.gaps, []); + assert.equal(empty.toDisplay(123), 0); + assert.equal(empty.toReal(123), 0); + + const single = buildTimeMap([42_000]); + assert.ok(single.displayDuration >= 1); + assert.deepEqual(single.gaps, []); + assert.equal(single.toDisplay(42_000), 0); + assert.equal(single.toDisplay(100_000), 0); + assert.equal(single.toReal(0), 42_000); + assert.equal(single.toReal(100), 42_000); + }); +}); diff --git a/desktop/src/features/agents/activity/activityTime.ts b/desktop/src/features/agents/activity/activityTime.ts new file mode 100644 index 0000000000..c586735374 --- /dev/null +++ b/desktop/src/features/agents/activity/activityTime.ts @@ -0,0 +1,158 @@ +// Ported from Berd's agent-activity-panel (branch zmarley/agent-activity-panel). +// Builds a monotonic real-time ↔ display-time map that compresses long idle +// gaps between events into short fixed-width display intervals. + +export interface TimeGap { + realStart: number; + realEnd: number; + displayStart: number; + displayEnd: number; +} + +export interface TimeMap { + toDisplay(t: number): number; + toReal(d: number): number; + displayDuration: number; + gaps: TimeGap[]; +} + +interface NormalizedTimeMapOptions { + gapThresholdMs: number; + gapDisplayMs: number; +} + +const DEFAULT_GAP_THRESHOLD_MS = 30_000; +const DEFAULT_GAP_DISPLAY_MS = 2_500; + +function finiteOrDefault(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function normalizeOptions(options?: { + gapThresholdMs?: number; + gapDisplayMs?: number; +}): NormalizedTimeMapOptions { + return { + gapThresholdMs: Math.max( + 0, + finiteOrDefault(options?.gapThresholdMs, DEFAULT_GAP_THRESHOLD_MS), + ), + gapDisplayMs: Math.max( + 0, + finiteOrDefault(options?.gapDisplayMs, DEFAULT_GAP_DISPLAY_MS), + ), + }; +} + +function upperBound(values: readonly number[], target: number): number { + let lo = 0; + let hi = values.length; + + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (values[mid] <= target) lo = mid + 1; + else hi = mid; + } + + return lo; +} + +function interpolate( + value: number, + inStart: number, + inEnd: number, + outStart: number, + outEnd: number, +): number { + const inputDelta = inEnd - inStart; + if (inputDelta <= 0) return outStart; + return outStart + ((value - inStart) / inputDelta) * (outEnd - outStart); +} + +export function buildTimeMap( + sortedEventTimes: number[], + options?: { gapThresholdMs?: number; gapDisplayMs?: number }, +): TimeMap { + const { gapThresholdMs, gapDisplayMs } = normalizeOptions(options); + const realTimes = sortedEventTimes.filter(Number.isFinite); + + if (realTimes.length === 0) { + return { + toDisplay: () => 0, + toReal: () => 0, + displayDuration: 1, + gaps: [], + }; + } + + if (realTimes.length === 1) { + const realTime = realTimes[0]; + return { + toDisplay: () => 0, + toReal: () => realTime, + displayDuration: 1, + gaps: [], + }; + } + + const displayTimes = new Array(realTimes.length); + const gaps: TimeGap[] = []; + displayTimes[0] = 0; + + for (let index = 1; index < realTimes.length; index += 1) { + const realStart = realTimes[index - 1]; + const realEnd = realTimes[index]; + const realDelta = Math.max(0, realEnd - realStart); + const displayStart = displayTimes[index - 1]; + const isGap = realDelta > gapThresholdMs; + const displayDelta = isGap ? gapDisplayMs : realDelta; + const displayEnd = displayStart + displayDelta; + + displayTimes[index] = displayEnd; + if (isGap) { + gaps.push({ realStart, realEnd, displayStart, displayEnd }); + } + } + + const lastRealIndex = realTimes.length - 1; + const lastDisplay = displayTimes[lastRealIndex]; + const displayDuration = Math.max(1, lastDisplay); + + return { + toDisplay(t: number) { + if (!Number.isFinite(t)) return t < 0 ? 0 : lastDisplay; + if (t <= realTimes[0]) return 0; + if (t >= realTimes[lastRealIndex]) return lastDisplay; + + const leftIndex = Math.max(0, upperBound(realTimes, t) - 1); + const rightIndex = Math.min(lastRealIndex, leftIndex + 1); + return interpolate( + t, + realTimes[leftIndex], + realTimes[rightIndex], + displayTimes[leftIndex], + displayTimes[rightIndex], + ); + }, + + toReal(d: number) { + if (!Number.isFinite(d)) + return d < 0 ? realTimes[0] : realTimes[lastRealIndex]; + if (d <= 0) return realTimes[0]; + if (d >= lastDisplay) return realTimes[lastRealIndex]; + + const leftIndex = Math.max(0, upperBound(displayTimes, d) - 1); + const rightIndex = Math.min(lastRealIndex, leftIndex + 1); + return interpolate( + d, + displayTimes[leftIndex], + displayTimes[rightIndex], + realTimes[leftIndex], + realTimes[rightIndex], + ); + }, + + displayDuration, + gaps, + }; +} diff --git a/desktop/src/features/agents/activity/activityTransport.test.mjs b/desktop/src/features/agents/activity/activityTransport.test.mjs new file mode 100644 index 0000000000..f8ee880c9a --- /dev/null +++ b/desktop/src/features/agents/activity/activityTransport.test.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + activityTransportBadge, + createActivityTransport, + cycleActivitySpeed, + jumpActivityTransport, + jumpToLiveActivityTransport, + LIVE_REJOIN_THRESHOLD_MS, + restartActivityTransport, + scrubActivityTransport, + tickActivityTransport, + toggleActivityPlayback, +} from "./activityTransport.ts"; + +const LIVE_BOUNDS = { d0: 0, d1: 60_000, isLive: true }; +const ENDED_BOUNDS = { d0: 0, d1: 60_000, isLive: false }; + +describe("createActivityTransport", () => { + it("starts live sessions in live mode at the edge", () => { + const state = createActivityTransport(LIVE_BOUNDS); + assert.equal(state.mode, "live"); + assert.equal(state.playing, true); + assert.equal(state.viewT, 60_000); + }); + + it("starts idle scopes parked at the end", () => { + const state = createActivityTransport(ENDED_BOUNDS); + assert.equal(state.mode, "replay"); + assert.equal(state.playing, false); + assert.equal(state.viewT, 60_000); + }); +}); + +describe("tickActivityTransport", () => { + it("pins the playhead to the growing live edge", () => { + const state = createActivityTransport(LIVE_BOUNDS); + const grown = { ...LIVE_BOUNDS, d1: 61_000 }; + const next = tickActivityTransport(state, 16, grown); + assert.equal(next.viewT, 61_000); + assert.equal(next.mode, "live"); + }); + + it("parks at the end when a live session ends", () => { + const state = createActivityTransport(LIVE_BOUNDS); + const next = tickActivityTransport(state, 16, ENDED_BOUNDS); + assert.equal(next.mode, "replay"); + assert.equal(next.playing, false); + assert.equal(next.viewT, 60_000); + }); + + it("advances replay by dt times speed", () => { + let state = scrubActivityTransport( + createActivityTransport(ENDED_BOUNDS), + 10_000, + ENDED_BOUNDS, + ); + state = { ...state, playing: true, speed: 4 }; + const next = tickActivityTransport(state, 100, ENDED_BOUNDS); + assert.equal(next.viewT, 10_400); + }); + + it("rejoins live when replay catches the edge", () => { + const nearEdge = LIVE_BOUNDS.d1 - LIVE_REJOIN_THRESHOLD_MS + 1; + let state = scrubActivityTransport( + createActivityTransport(LIVE_BOUNDS), + nearEdge - 10, + LIVE_BOUNDS, + ); + state = { ...state, playing: true }; + const next = tickActivityTransport(state, 20, LIVE_BOUNDS); + assert.equal(next.mode, "live"); + assert.equal(next.viewT, LIVE_BOUNDS.d1); + }); + + it("stops at the end of an idle scope without rejoining", () => { + let state = scrubActivityTransport( + createActivityTransport(ENDED_BOUNDS), + 59_990, + ENDED_BOUNDS, + ); + state = { ...state, playing: true }; + const next = tickActivityTransport(state, 100, ENDED_BOUNDS); + assert.equal(next.mode, "replay"); + assert.equal(next.playing, false); + assert.equal(next.viewT, 60_000); + }); + + it("does not move while paused", () => { + const state = { + mode: "replay", + playing: false, + speed: 1, + viewT: 30_000, + }; + const next = tickActivityTransport(state, 100, ENDED_BOUNDS); + assert.equal(next.viewT, 30_000); + }); +}); + +describe("scrub and jump", () => { + it("scrubbing drops live mode into replay and clamps to bounds", () => { + const state = createActivityTransport(LIVE_BOUNDS); + const scrubbed = scrubActivityTransport(state, -5_000, LIVE_BOUNDS); + assert.equal(scrubbed.mode, "replay"); + assert.equal(scrubbed.viewT, 0); + const over = scrubActivityTransport(state, 99_000, LIVE_BOUNDS); + assert.equal(over.viewT, 60_000); + }); + + it("jumping back from live starts replay behind the edge", () => { + const state = createActivityTransport(LIVE_BOUNDS); + const next = jumpActivityTransport(state, -2_000, LIVE_BOUNDS); + assert.equal(next.mode, "replay"); + assert.equal(next.viewT, 58_000); + }); + + it("jumping forward past the edge rejoins live", () => { + let state = scrubActivityTransport( + createActivityTransport(LIVE_BOUNDS), + 59_500, + LIVE_BOUNDS, + ); + state = jumpActivityTransport(state, 2_000, LIVE_BOUNDS); + assert.equal(state.mode, "live"); + assert.equal(state.viewT, 60_000); + }); + + it("jumping forward past the end of an idle scope parks at the end", () => { + let state = scrubActivityTransport( + createActivityTransport(ENDED_BOUNDS), + 59_500, + ENDED_BOUNDS, + ); + state = jumpActivityTransport(state, 2_000, ENDED_BOUNDS); + assert.equal(state.mode, "replay"); + assert.equal(state.viewT, 60_000); + }); +}); + +describe("toggleActivityPlayback", () => { + it("pausing live freezes the playhead in replay", () => { + const state = createActivityTransport(LIVE_BOUNDS); + const next = toggleActivityPlayback(state, LIVE_BOUNDS); + assert.equal(next.mode, "replay"); + assert.equal(next.playing, false); + assert.equal(next.viewT, 60_000); + }); + + it("resuming at the end of an idle scope restarts from the top", () => { + const state = createActivityTransport(ENDED_BOUNDS); + const next = toggleActivityPlayback(state, ENDED_BOUNDS); + assert.equal(next.playing, true); + assert.equal(next.viewT, 0); + }); + + it("plain pause/resume mid-replay keeps the playhead", () => { + let state = scrubActivityTransport( + createActivityTransport(ENDED_BOUNDS), + 20_000, + ENDED_BOUNDS, + ); + state = { ...state, playing: true }; + const paused = toggleActivityPlayback(state, ENDED_BOUNDS); + assert.equal(paused.playing, false); + assert.equal(paused.viewT, 20_000); + const resumed = toggleActivityPlayback(paused, ENDED_BOUNDS); + assert.equal(resumed.playing, true); + assert.equal(resumed.viewT, 20_000); + }); +}); + +describe("live jump, restart, speed, badge", () => { + it("jumpToLive returns to the edge only while live", () => { + let state = scrubActivityTransport( + createActivityTransport(LIVE_BOUNDS), + 10_000, + LIVE_BOUNDS, + ); + state = jumpToLiveActivityTransport(state, LIVE_BOUNDS); + assert.equal(state.mode, "live"); + + let ended = scrubActivityTransport( + createActivityTransport(ENDED_BOUNDS), + 10_000, + ENDED_BOUNDS, + ); + ended = jumpToLiveActivityTransport(ended, ENDED_BOUNDS); + assert.equal(ended.mode, "replay"); + assert.equal(ended.viewT, 10_000); + }); + + it("restart plays from the beginning", () => { + const state = createActivityTransport(ENDED_BOUNDS); + const next = restartActivityTransport(state, ENDED_BOUNDS); + assert.equal(next.playing, true); + assert.equal(next.viewT, 0); + assert.equal(next.mode, "replay"); + }); + + it("cycles speeds 1 -> 2 -> 4 -> 8 -> 1", () => { + let state = createActivityTransport(ENDED_BOUNDS); + const seen = []; + for (let index = 0; index < 4; index += 1) { + state = cycleActivitySpeed(state); + seen.push(state.speed); + } + assert.deepEqual(seen, [2, 4, 8, 1]); + }); + + it("reports live, replay, and idle badges", () => { + const live = createActivityTransport(LIVE_BOUNDS); + assert.equal(activityTransportBadge(live, LIVE_BOUNDS), "live"); + + const replay = scrubActivityTransport(live, 10_000, LIVE_BOUNDS); + assert.equal(activityTransportBadge(replay, LIVE_BOUNDS), "replay"); + + // Parked at the end of an inactive scope: idle (it can wake again), + // never "ended" — the scope's future is not the transport's to claim. + const idle = createActivityTransport(ENDED_BOUNDS); + assert.equal(activityTransportBadge(idle, ENDED_BOUNDS), "idle"); + }); +}); diff --git a/desktop/src/features/agents/activity/activityTransport.ts b/desktop/src/features/agents/activity/activityTransport.ts new file mode 100644 index 0000000000..053a580398 --- /dev/null +++ b/desktop/src/features/agents/activity/activityTransport.ts @@ -0,0 +1,178 @@ +// Ported/adapted from Berd's agent-activity-panel (branch +// zmarley/agent-activity-panel). DVR-style transport state for the activity +// panel: LIVE tracks the scene's growing edge, REPLAY scrubs display time at +// selectable speeds, and playback rejoins LIVE when it catches up. + +export type ActivitySpeed = 1 | 2 | 4 | 8; +export type ActivityTransportMode = "live" | "replay"; + +export interface ActivityTransportState { + mode: ActivityTransportMode; + playing: boolean; + speed: ActivitySpeed; + /** Current playhead in display time (post gap-compression). */ + viewT: number; +} + +export interface ActivityTransportBounds { + /** Scene display start. */ + d0: number; + /** Scene display end — the live edge while the agent is working. */ + d1: number; + /** Whether the underlying stream is still growing. */ + isLive: boolean; +} + +const ACTIVITY_SPEEDS: readonly ActivitySpeed[] = [1, 2, 4, 8]; +export const LIVE_REJOIN_THRESHOLD_MS = 150; +export const ACTIVITY_SCRUB_STEP_MS = 2_000; + +function clampView(viewT: number, bounds: ActivityTransportBounds): number { + return Math.min(Math.max(viewT, bounds.d0), bounds.d1); +} + +export function createActivityTransport( + bounds: ActivityTransportBounds, +): ActivityTransportState { + if (bounds.isLive) { + return { mode: "live", playing: true, speed: 1, viewT: bounds.d1 }; + } + return { mode: "replay", playing: false, speed: 1, viewT: bounds.d1 }; +} + +/** + * Advance the transport by one animation frame. In live mode the playhead + * pins to the growing display end; in replay it advances by `dtMs * speed` + * and either rejoins live at the edge or parks at the end of an idle + * scope. + */ +export function tickActivityTransport( + state: ActivityTransportState, + dtMs: number, + bounds: ActivityTransportBounds, +): ActivityTransportState { + if (state.mode === "live") { + if (!bounds.isLive) { + // Scope went idle while we were watching live: park at the end. + return { ...state, mode: "replay", playing: false, viewT: bounds.d1 }; + } + return state.viewT === bounds.d1 ? state : { ...state, viewT: bounds.d1 }; + } + + if (!state.playing) { + const clamped = clampView(state.viewT, bounds); + return clamped === state.viewT ? state : { ...state, viewT: clamped }; + } + + const advanced = state.viewT + Math.max(0, dtMs) * state.speed; + + if (advanced >= bounds.d1 - LIVE_REJOIN_THRESHOLD_MS && bounds.isLive) { + return { ...state, mode: "live", playing: true, viewT: bounds.d1 }; + } + + if (advanced >= bounds.d1) { + return { ...state, playing: false, viewT: bounds.d1 }; + } + + return { ...state, viewT: clampView(advanced, bounds) }; +} + +/** Scrub to an absolute display time; always drops into replay mode. */ +export function scrubActivityTransport( + state: ActivityTransportState, + viewT: number, + bounds: ActivityTransportBounds, +): ActivityTransportState { + return { + ...state, + mode: "replay", + viewT: clampView(viewT, bounds), + }; +} + +/** Relative scrub (e.g. ±2s keyboard jumps). */ +export function jumpActivityTransport( + state: ActivityTransportState, + deltaMs: number, + bounds: ActivityTransportBounds, +): ActivityTransportState { + const from = state.mode === "live" ? bounds.d1 : state.viewT; + const target = from + deltaMs; + + if (deltaMs > 0 && target >= bounds.d1) { + if (bounds.isLive) { + return { ...state, mode: "live", playing: true, viewT: bounds.d1 }; + } + return { ...state, mode: "replay", viewT: bounds.d1 }; + } + + return scrubActivityTransport(state, target, bounds); +} + +/** + * Toggle play/pause. Pausing live drops into replay at the frozen playhead; + * resuming at the end of an idle scope restarts from the beginning. + */ +export function toggleActivityPlayback( + state: ActivityTransportState, + bounds: ActivityTransportBounds, +): ActivityTransportState { + if (state.mode === "live") { + return { ...state, mode: "replay", playing: false, viewT: bounds.d1 }; + } + + if (state.playing) { + return { ...state, playing: false }; + } + + if (!bounds.isLive && state.viewT >= bounds.d1) { + return { ...state, playing: true, viewT: bounds.d0 }; + } + + return { ...state, playing: true }; +} + +/** Jump back to the live edge (no-op while the scope is idle). */ +export function jumpToLiveActivityTransport( + state: ActivityTransportState, + bounds: ActivityTransportBounds, +): ActivityTransportState { + if (!bounds.isLive) return state; + return { ...state, mode: "live", playing: true, viewT: bounds.d1 }; +} + +/** Restart playback from the beginning of the scene. */ +export function restartActivityTransport( + state: ActivityTransportState, + bounds: ActivityTransportBounds, +): ActivityTransportState { + return { ...state, mode: "replay", playing: true, viewT: bounds.d0 }; +} + +/** Cycle 1× → 2× → 4× → 8× → 1×. */ +export function cycleActivitySpeed( + state: ActivityTransportState, +): ActivityTransportState { + const index = ACTIVITY_SPEEDS.indexOf(state.speed); + const next = ACTIVITY_SPEEDS[(index + 1) % ACTIVITY_SPEEDS.length]; + return { ...state, speed: next }; +} + +export type ActivityTransportBadge = "live" | "replay" | "idle"; + +/** + * Which status badge the transport should show. + * + * "idle", not "ended": parked at the end of a currently-inactive scope + * describes the PRESENT (nothing running, you are caught up), while "ended" + * would claim the future — any new turn can spike the thread awake, at + * which point the badge flips back to live. + */ +export function activityTransportBadge( + state: ActivityTransportState, + bounds: ActivityTransportBounds, +): ActivityTransportBadge { + if (state.mode === "live") return "live"; + if (!bounds.isLive && state.viewT >= bounds.d1) return "idle"; + return "replay"; +} diff --git a/desktop/src/features/agents/activity/activityTurns.test.mjs b/desktop/src/features/agents/activity/activityTurns.test.mjs new file mode 100644 index 0000000000..632386f2dd --- /dev/null +++ b/desktop/src/features/agents/activity/activityTurns.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + deriveActivityTurns, + eventsForTurn, + summarizeTurnActivity, +} from "./activityTurns.ts"; + +const BASE_T = Date.parse("2026-08-17T12:00:00.000Z"); + +function ts(offsetMs = 0) { + return new Date(BASE_T + offsetMs).toISOString(); +} + +function frame(overrides = {}) { + return { + seq: 1, + timestamp: ts(0), + kind: "acp_read", + agentIndex: 0, + channelId: "chan-1", + sessionId: null, + turnId: "turn-1", + payload: null, + ...overrides, + }; +} + +function turnStarted(seq, offsetMs, turnId, payload = {}) { + return frame({ + seq, + timestamp: ts(offsetMs), + kind: "turn_started", + turnId, + payload: { source: "channel", triggeringEventIds: [], ...payload }, + }); +} + +function turnCompleted(seq, offsetMs, turnId) { + return frame({ + seq, + timestamp: ts(offsetMs), + kind: "turn_completed", + turnId, + payload: {}, + }); +} + +function activityEvent(overrides = {}) { + return { + agentId: "agent-1", + t: BASE_T, + kind: "r", + path: "src/a.ts", + ...overrides, + }; +} + +describe("deriveActivityTurns", () => { + it("bounds a turn by turn_started and turn_completed", () => { + const turns = deriveActivityTurns([ + turnStarted(1, 0, "turn-1", { + triggeringEventIds: ["evt-a", "evt-b"], + }), + frame({ seq: 2, timestamp: ts(1_000), turnId: "turn-1" }), + turnCompleted(3, 5_000, "turn-1"), + ]); + + assert.equal(turns.length, 1); + assert.equal(turns[0].id, "turn-1"); + assert.equal(turns[0].startT, BASE_T); + assert.equal(turns[0].endT, BASE_T + 5_000); + assert.equal(turns[0].source, "channel"); + assert.deepEqual(turns[0].triggeringEventIds, ["evt-a", "evt-b"]); + }); + + it("keeps the newest turn open when it has no turn_completed", () => { + const turns = deriveActivityTurns([ + turnStarted(1, 0, "turn-1"), + turnCompleted(2, 2_000, "turn-1"), + turnStarted(3, 10_000, "turn-2"), + frame({ seq: 4, timestamp: ts(11_000), turnId: "turn-2" }), + ]); + + assert.equal(turns.length, 2); + assert.equal(turns[0].endT, BASE_T + 2_000); + assert.equal(turns[1].endT, undefined); + }); + + it("closes abandoned non-newest turns at their last seen frame", () => { + const turns = deriveActivityTurns([ + turnStarted(1, 0, "turn-1"), + frame({ seq: 2, timestamp: ts(3_000), turnId: "turn-1" }), + turnStarted(3, 10_000, "turn-2"), + ]); + + assert.equal(turns[0].endT, BASE_T + 3_000); + assert.equal(turns[1].endT, undefined); + }); + + it("adopts a turn from mid-stream frames when turn_started was missed", () => { + const turns = deriveActivityTurns([ + frame({ seq: 5, timestamp: ts(4_000), turnId: "turn-x" }), + turnCompleted(6, 6_000, "turn-x"), + ]); + + assert.equal(turns.length, 1); + assert.equal(turns[0].startT, BASE_T + 4_000); + assert.equal(turns[0].endT, BASE_T + 6_000); + assert.deepEqual(turns[0].triggeringEventIds, []); + }); + + it("records the session id from session_resolved", () => { + const turns = deriveActivityTurns([ + turnStarted(1, 0, "turn-1"), + frame({ + seq: 2, + timestamp: ts(500), + kind: "session_resolved", + turnId: "turn-1", + payload: { sessionId: "sess-42", isNewSession: true }, + }), + turnCompleted(3, 1_000, "turn-1"), + ]); + + assert.equal(turns[0].sessionId, "sess-42"); + }); + + it("marks heartbeat turns", () => { + const turns = deriveActivityTurns([ + turnStarted(1, 0, "turn-hb", { + source: "heartbeat", + triggeringEventIds: [], + }), + turnCompleted(2, 1_000, "turn-hb"), + ]); + + assert.equal(turns[0].source, "heartbeat"); + }); + + it("ignores frames without a turnId", () => { + const turns = deriveActivityTurns([ + frame({ seq: 1, turnId: null, kind: "agent_initialized" }), + ]); + + assert.equal(turns.length, 0); + }); +}); + +describe("eventsForTurn", () => { + it("filters events by sourceTurnId", () => { + const events = [ + activityEvent({ sourceTurnId: "turn-1", path: "a.ts" }), + activityEvent({ sourceTurnId: "turn-2", path: "b.ts" }), + activityEvent({ sourceTurnId: "turn-1", path: "c.ts" }), + activityEvent({ path: "d.ts" }), + ]; + + const filtered = eventsForTurn(events, "turn-1"); + assert.deepEqual( + filtered.map((event) => event.path), + ["a.ts", "c.ts"], + ); + }); +}); + +describe("summarizeTurnActivity", () => { + it("counts distinct files, reads, and edits", () => { + const summary = summarizeTurnActivity([ + activityEvent({ kind: "r", path: "a.ts" }), + activityEvent({ kind: "w", path: "a.ts" }), + activityEvent({ kind: "c", path: "b.ts" }), + activityEvent({ kind: "r", path: "c.ts" }), + ]); + + assert.deepEqual(summary, { fileCount: 3, readCount: 2, editCount: 2 }); + }); + + it("excludes pathless status events", () => { + const summary = summarizeTurnActivity([ + activityEvent({ kind: "s", path: undefined }), + activityEvent({ kind: "r", path: "a.ts" }), + ]); + + assert.deepEqual(summary, { fileCount: 1, readCount: 1, editCount: 0 }); + }); + + it("returns zeros for no events", () => { + assert.deepEqual(summarizeTurnActivity([]), { + fileCount: 0, + readCount: 0, + editCount: 0, + }); + }); +}); diff --git a/desktop/src/features/agents/activity/activityTurns.ts b/desktop/src/features/agents/activity/activityTurns.ts new file mode 100644 index 0000000000..039fc934ba --- /dev/null +++ b/desktop/src/features/agents/activity/activityTurns.ts @@ -0,0 +1,144 @@ +// Ported/adapted from Berd's agent-activity-panel (branch +// zmarley/agent-activity-panel). Berd segmented turns from chat messages; +// in Buzz turns are first-class in the observer stream, bounded by +// turn_started / turn_completed frames and correlated back to the channel +// messages that triggered them. + +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import type { ActivityEvent } from "./activityModel"; + +export type ActivityTurnSource = "channel" | "heartbeat"; + +export interface ActivityTurn { + /** The observer turnId. */ + id: string; + /** Epoch ms of the turn_started frame (or first frame seen for the turn). */ + startT: number; + /** Epoch ms of turn_completed; undefined while the turn looks live. */ + endT?: number; + sessionId?: string; + source?: ActivityTurnSource; + /** Hex Nostr event ids of the channel events drained into this turn. */ + triggeringEventIds: string[]; +} + +export interface TurnActivitySummary { + /** Distinct touched paths. */ + fileCount: number; + readCount: number; + editCount: number; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function frameTime(frame: ObserverEvent): number { + const parsed = Date.parse(frame.timestamp); + return Number.isFinite(parsed) ? parsed : 0; +} + +function turnSource(value: unknown): ActivityTurnSource | undefined { + return value === "channel" || value === "heartbeat" ? value : undefined; +} + +function triggeringEventIds(payload: Record): string[] { + const ids = payload.triggeringEventIds; + if (!Array.isArray(ids)) return []; + return ids.filter((id): id is string => typeof id === "string"); +} + +/** + * Derive turn bounds from an agent's observer frames. + * + * A turn opens at its turn_started frame (or the first frame carrying its + * turnId, for streams that joined mid-turn) and closes at turn_completed. + * A turn with no turn_completed is closed at its last seen frame — unless it + * is the newest turn in the stream, which stays open (endT undefined) so the + * caller can treat it as live. + */ +export function deriveActivityTurns( + frames: readonly ObserverEvent[], +): ActivityTurn[] { + const turnsById = new Map(); + const order: ActivityTurn[] = []; + const lastSeenT = new Map(); + const completed = new Set(); + + for (const frame of frames) { + const turnId = frame.turnId; + if (!turnId) continue; + + const t = frameTime(frame); + let turn = turnsById.get(turnId); + if (!turn) { + turn = { id: turnId, startT: t, triggeringEventIds: [] }; + turnsById.set(turnId, turn); + order.push(turn); + } + + lastSeenT.set(turnId, t); + if (frame.sessionId && turn.sessionId === undefined) { + turn.sessionId = frame.sessionId; + } + + if (frame.kind === "turn_started") { + const payload = asRecord(frame.payload); + turn.startT = t; + turn.source = turnSource(payload.source) ?? turn.source; + const ids = triggeringEventIds(payload); + if (ids.length > 0) turn.triggeringEventIds = ids; + } else if (frame.kind === "session_resolved") { + const payload = asRecord(frame.payload); + const sessionId = payload.sessionId; + if (typeof sessionId === "string") turn.sessionId = sessionId; + } else if (frame.kind === "turn_completed") { + turn.endT = t; + completed.add(turnId); + } + } + + // Close abandoned turns at their last frame; keep only the newest turn + // open so a crashed/incomplete historical turn doesn't render as live. + for (let index = 0; index < order.length; index += 1) { + const turn = order[index]; + const isNewest = index === order.length - 1; + if (turn.endT === undefined && !completed.has(turn.id) && !isNewest) { + turn.endT = lastSeenT.get(turn.id) ?? turn.startT; + } + } + + return order; +} + +/** Events belonging to one turn (matched by sourceTurnId). */ +export function eventsForTurn( + events: readonly ActivityEvent[], + turnId: string, +): ActivityEvent[] { + return events.filter((event) => event.sourceTurnId === turnId); +} + +/** + * Chip-style summary counts. Matches Berd's semantics: distinct paths for + * the file count, writes+creates count as edits, reads as reads; pathless + * status events contribute nothing. + */ +export function summarizeTurnActivity( + events: readonly ActivityEvent[], +): TurnActivitySummary { + const paths = new Set(); + let readCount = 0; + let editCount = 0; + + for (const event of events) { + if (!event.path) continue; + paths.add(event.path); + if (event.kind === "r") readCount += 1; + if (event.kind === "w" || event.kind === "c") editCount += 1; + } + + return { fileCount: paths.size, readCount, editCount }; +} diff --git a/desktop/src/features/agents/activity/activityUtils.ts b/desktop/src/features/agents/activity/activityUtils.ts new file mode 100644 index 0000000000..d29563b1c8 --- /dev/null +++ b/desktop/src/features/agents/activity/activityUtils.ts @@ -0,0 +1,117 @@ +// Ported from Berd's agent-activity-panel (branch zmarley/agent-activity-panel). +// Small math/color/string helpers shared by the activity scene compiler and +// canvas renderer. + +import type { Rgb } from "./activityScene"; + +export function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +export function lerp(a: number, b: number, t: number) { + return a + (b - a) * t; +} + +export function smooth(value: number) { + const t = clamp(value, 0, 1); + return t * t * (3 - 2 * t); +} + +export function basename(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts.at(-1) ?? path; +} + +interface ActivityEventHashInput { + kind: string; + path?: string; + intent?: string; +} + +export function hashActivityEvents( + events: readonly ActivityEventHashInput[], +): number { + let hash = 0x811c9dc5; + + for (const event of events) { + for (let index = 0; index < event.kind.length; index += 1) { + hash ^= event.kind.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + + hash ^= 124; + hash = Math.imul(hash, 0x01000193); + + const path = event.path ?? ""; + for (let index = 0; index < path.length; index += 1) { + hash ^= path.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + + hash ^= 31; + hash = Math.imul(hash, 0x01000193); + + // Narration can be patched onto an existing tool call after the fact; + // fold it in so cached scenes don't serve stale intent. + const intent = event.intent ?? ""; + for (let index = 0; index < intent.length; index += 1) { + hash ^= intent.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + + hash ^= 17; + hash = Math.imul(hash, 0x01000193); + } + + return hash >>> 0; +} + +export function hexToRgb(color: string): Rgb { + const trimmed = color.trim(); + const hex = /^#?([0-9a-f]{6})$/i.exec(trimmed); + if (hex) { + const value = Number.parseInt(hex[1], 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; + } + + const shortHex = /^#?([0-9a-f]{3})$/i.exec(trimmed); + if (shortHex) { + const expanded = shortHex[1] + .split("") + .map((digit) => `${digit}${digit}`) + .join(""); + const value = Number.parseInt(expanded, 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; + } + + return [255, 255, 255]; +} + +export function rgba(color: Rgb, alpha: number) { + return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`; +} + +export function mixRgb(a: Rgb, b: Rgb, amount: number): Rgb { + const t = clamp(amount, 0, 1); + return [ + Math.round(lerp(a[0], b[0], t)), + Math.round(lerp(a[1], b[1], t)), + Math.round(lerp(a[2], b[2], t)), + ]; +} + +export function binarySearchPrefix( + times: readonly number[], + t: number, +): number { + let lo = 0; + let hi = times.length; + + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (times[mid] <= t) lo = mid + 1; + else hi = mid; + } + + return lo; +} diff --git a/desktop/src/features/agents/activity/deriveActivity.test.mjs b/desktop/src/features/agents/activity/deriveActivity.test.mjs new file mode 100644 index 0000000000..6b860ce127 --- /dev/null +++ b/desktop/src/features/agents/activity/deriveActivity.test.mjs @@ -0,0 +1,550 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + deriveActivityFromObserverFrames, + inferActivityRoots, + normalizeActivityPath, +} from "./deriveActivity.ts"; + +const BASE_T = Date.parse("2026-08-17T12:00:00.000Z"); +const OPTS = { agentId: "agent-pubkey-1", agentName: "Fizz", agentIndex: 0 }; + +function ts(offsetMs = 0) { + return new Date(BASE_T + offsetMs).toISOString(); +} + +function frame(overrides = {}) { + return { + seq: 1, + timestamp: ts(0), + kind: "acp_read", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: null, + ...overrides, + }; +} + +function sessionUpdateFrame(seq, offsetMs, update, frameOverrides = {}) { + return frame({ + seq, + timestamp: ts(offsetMs), + payload: { + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "sess-1", update }, + }, + ...frameOverrides, + }); +} + +function toolCallFrame(seq, offsetMs, update = {}, frameOverrides = {}) { + return sessionUpdateFrame( + seq, + offsetMs, + { + sessionUpdate: "tool_call", + toolCallId: update.toolCallId ?? `tool-${seq}`, + status: "pending", + ...update, + }, + frameOverrides, + ); +} + +function toolCallUpdateFrame(seq, offsetMs, update = {}, frameOverrides = {}) { + return sessionUpdateFrame( + seq, + offsetMs, + { + sessionUpdate: "tool_call_update", + status: "completed", + ...update, + }, + frameOverrides, + ); +} + +function narrationFrame(seq, offsetMs, text, frameOverrides = {}) { + return sessionUpdateFrame( + seq, + offsetMs, + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + frameOverrides, + ); +} + +describe("deriveActivityFromObserverFrames", () => { + it("fans out an edit tool call into one event per location", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + toolCallId: "call-1", + title: "apply_patch", + kind: "edit", + locations: [{ path: "/repo/src/a.ts" }, { path: "/repo/src/b.ts" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.equal(events.length, 2); + // First-ever touches of create-eligible edits promote to "c". + assert.deepEqual( + events.map((event) => event.kind), + ["c", "c"], + ); + assert.deepEqual( + events.map((event) => event.path), + ["src/a.ts", "src/b.ts"], + ); + assert.equal(events[0].sourceToolCallId, "call-1"); + assert.equal(events[0].sourceSeq, 1); + assert.equal(events[0].sourceTurnId, "turn-1"); + assert.equal(events[0].t, BASE_T); + }); + + it("suppresses create promotion when the path was read first", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + toolCallId: "read-1", + kind: "read", + locations: [{ path: "/repo/src/a.ts" }], + }), + toolCallFrame(2, 1_000, { + toolCallId: "edit-1", + kind: "edit", + locations: [{ path: "/repo/src/a.ts" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.deepEqual( + events.map((event) => event.kind), + ["r", "w"], + ); + }); + + it("keeps delete and move as plain writes", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + kind: "delete", + locations: [{ path: "/repo/old.txt" }], + }), + toolCallFrame(2, 1_000, { + kind: "move", + locations: [{ path: "/repo/new.txt" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.deepEqual( + events.map((event) => event.kind), + ["w", "w"], + ); + }); + + it("falls back to path arguments when locations are missing", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + kind: "read", + rawInput: { path: "src/main.rs" }, + }), + ], + { ...OPTS, roots: [] }, + ); + + assert.equal(events.length, 1); + assert.equal(events[0].kind, "r"); + assert.equal(events[0].path, "src/main.rs"); + }); + + it("treats execute locations as read touches and pathless execute as status", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + kind: "execute", + title: "developer__shell", + locations: [{ path: "/repo/src/x.ts" }], + }), + toolCallFrame(2, 1_000, { + kind: "execute", + title: "developer__shell", + rawInput: { command: "ls -la" }, + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.deepEqual( + events.map((event) => event.kind), + ["r", "s"], + ); + assert.equal(events[0].path, "src/x.ts"); + assert.equal(events[1].path, undefined); + }); + + it("sends think and fetch tool calls to status events", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { kind: "think", title: "reasoning" }), + toolCallFrame(2, 1_000, { kind: "fetch", title: "http_get" }), + ], + OPTS, + ); + + assert.deepEqual( + events.map((event) => event.kind), + ["s", "s"], + ); + }); + + it('maps flat tool names when the adapter classifies everything as "other"', () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + kind: "other", + title: "write", + rawInput: { path: "notes.md", content: "hi" }, + }), + toolCallFrame(2, 1_000, { + kind: "other", + title: "edit", + rawInput: { path: "todo.md", before: "a", after: "b" }, + }), + toolCallFrame(3, 2_000, { + kind: "other", + title: "tree", + rawInput: { path: "src" }, + }), + ], + OPTS, + ); + + // write is create-eligible (first touch → c); edit never is; tree reads. + assert.deepEqual( + events.map((event) => [event.kind, event.path]), + [ + ["c", "notes.md"], + ["w", "todo.md"], + ["r", "src"], + ], + ); + }); + + it("maps shell with locations to reads and without to status", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + title: "developer__shell", + locations: [{ path: "/repo/Cargo.toml" }], + }), + toolCallFrame(2, 1_000, { + title: "developer__shell", + rawInput: { command: "cargo test", cwd: "/repo" }, + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.deepEqual( + events.map((event) => event.kind), + ["r", "s"], + ); + // shell never parses paths out of its arguments (cwd would be noise). + assert.equal(events[1].path, undefined); + }); + + it("maps text_editor commands through the developer command table", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + title: "developer__text_editor", + rawInput: { command: "write", path: "/repo/a.ts" }, + }), + toolCallFrame(2, 1_000, { + title: "developer__text_editor", + rawInput: { command: "view", path: "/repo/b.ts" }, + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.deepEqual( + events.map((event) => event.kind), + ["c", "r"], + ); + }); + + it("renders labels by translating the extension prefix to dot form", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + title: "developer__shell", + rawInput: { command: "ls" }, + }), + toolCallFrame(2, 1_000, { title: "bare_tool" }), + toolCallFrame(3, 2_000, { toolCallId: "call-3" }), + ], + OPTS, + ); + + assert.equal(events[0].label, "developer.shell"); + assert.equal(events[1].label, "bare_tool"); + // No title at all falls back to the tool call id. + assert.equal(events[2].label, "call-3"); + }); + + it("emits a single status event for unknown tools without paths", () => { + const { events } = deriveActivityFromObserverFrames( + [toolCallFrame(1, 0, { title: "mystery_tool", rawInput: { q: "x" } })], + OPTS, + ); + + assert.equal(events.length, 1); + assert.equal(events[0].kind, "s"); + assert.equal(events[0].label, "mystery_tool"); + }); + + it("merges locations arriving only on the completion update", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(10, 0, { + toolCallId: "call-x", + title: "apply_patch", + kind: "edit", + }), + toolCallUpdateFrame(20, 5_000, { + toolCallId: "call-x", + locations: [{ path: "/repo/src/store.rs" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.equal(events.length, 1); + assert.equal(events[0].kind, "c"); + assert.equal(events[0].path, "src/store.rs"); + // Timestamp and seq come from the first frame that mentioned the call. + assert.equal(events[0].t, BASE_T); + assert.equal(events[0].sourceSeq, 10); + }); + + it("dedupes repeated paths within one tool call", () => { + const { events } = deriveActivityFromObserverFrames( + [ + toolCallFrame(1, 0, { + kind: "read", + locations: [{ path: "/repo/a.ts" }, { path: "/repo/a.ts" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.equal(events.length, 1); + }); + + it("attaches preceding narration as intent", () => { + const { events } = deriveActivityFromObserverFrames( + [ + narrationFrame(1, 0, "Let me fix"), + narrationFrame(2, 100, "the store."), + toolCallFrame(3, 1_000, { + kind: "edit", + locations: [{ path: "/repo/store.rs" }], + }), + toolCallFrame(4, 2_000, { + kind: "edit", + locations: [{ path: "/repo/other.rs" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.equal(events[0].intent, "Let me fix the store."); + // Narration is consumed by the first tool call after it. + assert.equal(events[1].intent, undefined); + }); + + it("does not bleed narration across turns", () => { + const { events } = deriveActivityFromObserverFrames( + [ + narrationFrame(1, 0, "Planning the fix.", { turnId: "turn-1" }), + toolCallFrame( + 2, + 1_000, + { kind: "edit", locations: [{ path: "/repo/a.ts" }] }, + { turnId: "turn-2" }, + ), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.equal(events.length, 1); + assert.equal(events[0].intent, undefined); + }); + + it("compacts long narration to trailing sentences under the cap", () => { + const longSentence = `This is a very long opening sentence ${"x".repeat(220)}.`; + const { events } = deriveActivityFromObserverFrames( + [ + narrationFrame(1, 0, `${longSentence} Now patch the store.`), + toolCallFrame(2, 1_000, { + kind: "edit", + locations: [{ path: "/repo/store.rs" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.equal(events[0].intent, "Now patch the store."); + }); + + it("hard-cuts a single oversized sentence with a leading ellipsis", () => { + const oversized = `word ${"y".repeat(300)}`; + const { events } = deriveActivityFromObserverFrames( + [ + narrationFrame(1, 0, oversized), + toolCallFrame(2, 1_000, { + kind: "edit", + locations: [{ path: "/repo/store.rs" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.ok(events[0].intent.startsWith("…")); + assert.ok(events[0].intent.length <= 240); + }); + + it("returns a single agent stamped from the first frame", () => { + const { agents } = deriveActivityFromObserverFrames( + [ + frame({ seq: 1, kind: "turn_started", timestamp: ts(0) }), + toolCallFrame(2, 5_000, { + kind: "read", + locations: [{ path: "/repo/a.ts" }], + }), + ], + { ...OPTS, roots: ["/repo"] }, + ); + + assert.equal(agents.length, 1); + assert.equal(agents[0].id, "agent-pubkey-1"); + assert.equal(agents[0].name, "Fizz"); + assert.equal(agents[0].spawnT, BASE_T); + assert.equal(agents[0].doneT, undefined); + assert.ok(agents[0].color.length > 0); + }); + + it("ignores non-acp_read frames and unrelated session updates", () => { + const { events } = deriveActivityFromObserverFrames( + [ + frame({ seq: 1, kind: "turn_started" }), + frame({ + seq: 2, + kind: "acp_write", + payload: { method: "session/update" }, + }), + sessionUpdateFrame(3, 100, { sessionUpdate: "plan", entries: [] }), + frame({ seq: 4, kind: "turn_completed" }), + ], + OPTS, + ); + + assert.equal(events.length, 0); + }); +}); + +describe("inferActivityRoots", () => { + it("returns the common directory prefix of touched absolute paths", () => { + const roots = inferActivityRoots([ + toolCallFrame(1, 0, { + kind: "read", + locations: [{ path: "/Users/z/repo/src/a.ts" }], + }), + toolCallFrame(2, 1_000, { + kind: "edit", + locations: [{ path: "/Users/z/repo/lib/b.ts" }], + }), + ]); + + assert.deepEqual(roots, ["/Users/z/repo"]); + }); + + it("reads path arguments when locations are absent", () => { + const roots = inferActivityRoots([ + toolCallFrame(1, 0, { + title: "write", + rawInput: { path: "/Users/z/repo/notes.md" }, + }), + ]); + + assert.deepEqual(roots, ["/Users/z/repo"]); + }); + + it("falls back to no roots when the common prefix is too shallow", () => { + const roots = inferActivityRoots([ + toolCallFrame(1, 0, { + kind: "read", + locations: [{ path: "/Users/z/repo/a.ts" }], + }), + toolCallFrame(2, 1_000, { + kind: "read", + locations: [{ path: "/etc/nginx/nginx.conf" }], + }), + ]); + + assert.deepEqual(roots, []); + }); + + it("ignores relative paths and non-tool frames", () => { + const roots = inferActivityRoots([ + frame({ seq: 1, kind: "turn_started" }), + toolCallFrame(2, 0, { + kind: "read", + rawInput: { path: "src/a.ts" }, + }), + ]); + + assert.deepEqual(roots, []); + }); +}); + +describe("normalizeActivityPath", () => { + it("relativizes against the longest matching root", () => { + const roots = ["/repo", "/repo/packages/app"]; + assert.equal( + normalizeActivityPath("/repo/packages/app/src/x.ts", roots), + "src/x.ts", + ); + assert.equal(normalizeActivityPath("/repo/README.md", roots), "README.md"); + }); + + it("abbreviates paths outside all roots", () => { + assert.equal( + normalizeActivityPath("/etc/nginx/conf.d/site.conf", ["/repo"]), + "~/…/nginx/conf.d/site.conf", + ); + }); + + it("maps a root itself to its basename", () => { + assert.equal(normalizeActivityPath("/repo", ["/repo"]), "repo"); + }); + + it("passes relative paths through and strips leading ./", () => { + assert.equal(normalizeActivityPath("./src/a.ts", []), "src/a.ts"); + assert.equal(normalizeActivityPath("src/a.ts", []), "src/a.ts"); + }); +}); diff --git a/desktop/src/features/agents/activity/deriveActivity.ts b/desktop/src/features/agents/activity/deriveActivity.ts new file mode 100644 index 0000000000..10e7160b15 --- /dev/null +++ b/desktop/src/features/agents/activity/deriveActivity.ts @@ -0,0 +1,669 @@ +// Ported/adapted from Berd's agent-activity-panel (branch +// zmarley/agent-activity-panel). Derives activity events from decrypted +// NIP-AO observer frames: acp_read session/update tool calls are merged by +// toolCallId, then mapped to read/write/create/status events using Berd's +// battle-tested path and tool-kind heuristics. + +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import { + type ActivityAgent, + type ActivityEvent, + colorForAgent, +} from "./activityModel"; +import { basename } from "./activityUtils"; + +export interface DeriveActivityOptions { + agentId: string; + agentName?: string; + agentIndex?: number; + roots?: readonly string[]; +} + +interface CandidateEvent extends ActivityEvent { + createEligible?: boolean; +} + +/** Merged view of one tool call across its tool_call/tool_call_update frames. */ +interface ToolCallRecord { + toolCallId: string; + /** Epoch ms of the first frame that mentioned this tool call. */ + t: number; + sourceSeq: number; + sourceTurnId?: string; + intent?: string; + /** First-known-wins: the initial tool_call names the tool. */ + title?: string; + /** First-known-wins ACP ToolKind string (read/edit/execute/other/…). */ + acpKind?: string; + /** Last-known-wins: goose populates locations on completion updates. */ + locations: string[]; + /** Last-known-wins tool arguments. */ + rawInput?: Record; +} + +// Mirrored from Berd (which mirrored chat/lib/toolCallPresentation.ts) so +// activity extraction agrees with tool-card summaries about the common +// argument names that carry paths. +const PATH_ARGUMENT_KEYS = [ + "path", + "file", + "filePath", + "filepath", + "targetPath", + "directory", + "dir", + "cwd", + "folder", +] as const; + +const SEARCH_PATH_ARGUMENT_KEYS = [ + "path", + "directory", + "dir", + "cwd", + "folder", +] as const; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function cleanPathValue(path: string): string | undefined { + const normalized = path.trim(); + if (normalized.length === 0) return undefined; + return stripTrailingSlash(normalized); +} + +function stripTrailingSlash(path: string): string { + if (path === "/") return path; + return path.replace(/\/+$/, ""); +} + +function isAbsolutePath(path: string): boolean { + return path.startsWith("/"); +} + +function isSameOrDescendant(path: string, root: string): boolean { + return path === root || path.startsWith(`${root}/`); +} + +function abbreviateOutOfRootPath(path: string): string { + if (path.startsWith("~/")) return path; + + const segments = path.split("/").filter(Boolean); + if (segments.length === 0) return "~/…"; + + return `~/…/${segments.slice(-Math.min(3, segments.length)).join("/")}`; +} + +export function normalizeActivityPath( + rawPath: string, + roots: readonly string[], +): string | undefined { + const path = cleanPathValue(rawPath); + if (!path) return undefined; + + if (!isAbsolutePath(path)) { + return path.replace(/^\.\/+/, ""); + } + + const normalizedRoots = roots + .map(cleanPathValue) + .filter((root): root is string => Boolean(root)) + .sort((a, b) => b.length - a.length); + + const matchingRoot = normalizedRoots.find((root) => + isSameOrDescendant(path, root), + ); + + if (!matchingRoot) { + return abbreviateOutOfRootPath(path); + } + + const relative = path.slice(matchingRoot.length).replace(/^\/+/, ""); + return relative.length > 0 ? relative : basename(matchingRoot); +} + +function collectStringValues(value: unknown): string[] { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? [trimmed] : []; + } + + if (Array.isArray(value)) { + return value.flatMap(collectStringValues); + } + + return []; +} + +function pathArguments( + args: Record, + keys: readonly string[], +): string[] { + return keys.flatMap((key) => collectStringValues(args[key])); +} + +function uniqueNormalizedPaths( + rawPaths: readonly string[], + roots: readonly string[], +): string[] { + const seen = new Set(); + const out: string[] = []; + + for (const rawPath of rawPaths) { + const path = normalizeActivityPath(rawPath, roots); + if (!path || seen.has(path)) continue; + seen.add(path); + out.push(path); + } + + return out; +} + +/** + * Berd rendered labels as "extension.tool". Over raw ACP we only have the + * title (goose and buzz-agent set it to the tool name, e.g. + * "developer__shell"), so translate the first "__" into the same dot form. + */ +function labelForTool(record: ToolCallRecord): string { + const raw = record.title ?? record.toolCallId; + const separator = raw.indexOf("__"); + if (separator > 0) { + return `${raw.slice(0, separator)}.${raw.slice(separator + 2)}`; + } + return raw; +} + +function resolvedToolName(record: ToolCallRecord): string { + const raw = record.title ?? ""; + const separator = raw.indexOf("__"); + return separator > 0 ? raw.slice(separator + 2) : raw; +} + +function toolHasExtension( + record: ToolCallRecord, + extensionName: string, +): boolean { + return (record.title ?? "").startsWith(`${extensionName}__`); +} + +function candidateForPath( + record: ToolCallRecord, + agentId: string, + kind: "r" | "w", + path: string, + createEligible = false, +): CandidateEvent { + return { + agentId, + t: record.t, + kind, + path, + label: labelForTool(record), + ...(record.intent ? { intent: record.intent } : {}), + sourceSeq: record.sourceSeq, + sourceToolCallId: record.toolCallId, + ...(record.sourceTurnId ? { sourceTurnId: record.sourceTurnId } : {}), + createEligible, + }; +} + +function statusCandidate( + record: ToolCallRecord, + agentId: string, +): CandidateEvent { + return { + agentId, + t: record.t, + kind: "s", + label: labelForTool(record), + ...(record.intent ? { intent: record.intent } : {}), + sourceSeq: record.sourceSeq, + sourceToolCallId: record.toolCallId, + ...(record.sourceTurnId ? { sourceTurnId: record.sourceTurnId } : {}), + }; +} + +function pushPathCandidates( + candidates: CandidateEvent[], + record: ToolCallRecord, + agentId: string, + paths: readonly string[], + roots: readonly string[], + kind: "r" | "w", + createEligible = false, +) { + const normalizedPaths = uniqueNormalizedPaths(paths, roots); + + if (normalizedPaths.length === 0) { + candidates.push(statusCandidate(record, agentId)); + return; + } + + for (const path of normalizedPaths) { + candidates.push( + candidateForPath(record, agentId, kind, path, createEligible), + ); + } +} + +function textEditorCommandKind(command: unknown): "r" | "w" { + if (typeof command !== "string") return "r"; + + switch (command) { + case "write": + case "create": + case "str_replace": + case "insert": + case "edit": + case "undo_edit": + return "w"; + default: + return "r"; + } +} + +/** + * Berd's flat-name mapping for tool calls without a usable ACP kind. In Berd + * this path handled replayed messages that lost toolKind; in Buzz it also + * handles adapters that classify everything as "other" (buzz-agent does) — + * the tool name in the title is then the best signal we have. + */ +function flatNameCandidates( + record: ToolCallRecord, + agentId: string, + roots: readonly string[], +): CandidateEvent[] { + const candidates: CandidateEvent[] = []; + const toolName = resolvedToolName(record); + const args = record.rawInput ?? {}; + const paths = + record.locations.length > 0 + ? record.locations + : pathArguments(args, PATH_ARGUMENT_KEYS); + + if (toolName === "text_editor" && toolHasExtension(record, "developer")) { + const kind = textEditorCommandKind(args.command); + pushPathCandidates( + candidates, + record, + agentId, + paths, + roots, + kind, + kind === "w", + ); + return candidates; + } + + // Flat developer-extension tool names in current goose builds: + // write { path, content }, edit { path, before, after }, tree { path, depth }. + // Not gated on extension metadata since titles may omit the prefix. + if (toolName === "write") { + pushPathCandidates(candidates, record, agentId, paths, roots, "w", true); + return candidates; + } + + if (toolName === "edit") { + // Edits require existing content, so they are never create-eligible. + pushPathCandidates(candidates, record, agentId, paths, roots, "w", false); + return candidates; + } + + if (toolName === "tree") { + pushPathCandidates(candidates, record, agentId, paths, roots, "r"); + return candidates; + } + + if (toolName === "shell") { + if (record.locations.length > 0) { + pushPathCandidates( + candidates, + record, + agentId, + record.locations, + roots, + "r", + ); + } else { + candidates.push(statusCandidate(record, agentId)); + } + return candidates; + } + + if (paths.length > 0) { + pushPathCandidates(candidates, record, agentId, paths, roots, "r"); + } else { + candidates.push(statusCandidate(record, agentId)); + } + return candidates; +} + +function toolCandidates( + record: ToolCallRecord, + agentId: string, + roots: readonly string[], +): CandidateEvent[] { + const candidates: CandidateEvent[] = []; + const argPaths = (keys: readonly string[]) => + record.locations.length > 0 + ? record.locations + : pathArguments(record.rawInput ?? {}, keys); + + switch (record.acpKind) { + case "read": + pushPathCandidates( + candidates, + record, + agentId, + argPaths(PATH_ARGUMENT_KEYS), + roots, + "r", + ); + return candidates; + + case "edit": + pushPathCandidates( + candidates, + record, + agentId, + argPaths(PATH_ARGUMENT_KEYS), + roots, + "w", + true, + ); + return candidates; + + case "delete": + case "move": + pushPathCandidates( + candidates, + record, + agentId, + argPaths(PATH_ARGUMENT_KEYS), + roots, + "w", + ); + return candidates; + + case "search": + pushPathCandidates( + candidates, + record, + agentId, + argPaths(SEARCH_PATH_ARGUMENT_KEYS), + roots, + "r", + ); + return candidates; + + case "execute": + // ACP execute locations currently expose touched paths, not access + // kind. Treat each location as a read-like touch (Berd behavior). + pushPathCandidates( + candidates, + record, + agentId, + record.locations, + roots, + "r", + ); + return candidates; + + case "fetch": + case "think": + case "switch_mode": + candidates.push(statusCandidate(record, agentId)); + return candidates; + + // "other" and missing kinds fall through to the flat-name mapping. + // Deviation from Berd (which sent "other" straight to status): adapters + // like buzz-agent classify every tool as "other" while still naming it + // in the title, so the name is the better signal here. Tools without + // path arguments still end up as status events, matching Berd. + case "other": + case undefined: + return flatNameCandidates(record, agentId, roots); + + default: + candidates.push(statusCandidate(record, agentId)); + return candidates; + } +} + +const INTENT_MAX_LENGTH = 240; + +/** + * Compact long narration by keeping its trailing sentences — the text + * closest to the tool call is the most intent-like ("… Now let me patch the + * store:"). Complete sentences render without an ellipsis; the hard cut + * (leading …) only fires when a single sentence exceeds the cap. + */ +function compactIntent(text: string): string { + if (text.length <= INTENT_MAX_LENGTH) return text; + + const sentences = text.split(/(?<=[.!?…])\s+/); + let kept = ""; + for (let index = sentences.length - 1; index >= 0; index -= 1) { + const candidate = kept ? `${sentences[index]} ${kept}` : sentences[index]; + if (candidate.length > INTENT_MAX_LENGTH) break; + kept = candidate; + } + if (kept) return kept; + + return `…${text.slice(1 - INTENT_MAX_LENGTH).trimStart()}`; +} + +/** Extract plain text from an ACP content block (or array of them). */ +function textFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content.map(textFromContent).filter(Boolean).join(" "); + } + const record = asRecord(content); + const text = asString(record.text); + return text ?? ""; +} + +function frameTime(frame: ObserverEvent): number { + const parsed = Date.parse(frame.timestamp); + return Number.isFinite(parsed) ? parsed : 0; +} + +function locationPathsFromUpdate(update: Record): string[] { + const locations = update.locations; + if (!Array.isArray(locations)) return []; + return locations.flatMap((location) => { + const path = asString(asRecord(location).path); + return path ? [path] : []; + }); +} + +function parentDir(path: string): string { + const index = path.lastIndexOf("/"); + return index <= 0 ? "/" : path.slice(0, index); +} + +function segmentDepth(path: string): number { + return path.split("/").filter(Boolean).length; +} + +/** + * Infer workspace roots from the absolute paths in a frame window. Buzz + * observer frames carry no workspace metadata (Berd read roots from its + * workspace model), so we use the longest common directory prefix of every + * absolute path the agent touched. Mixed streams (repo + /etc) collapse the + * prefix below the depth floor and we fall back to no roots, which renders + * abbreviated paths — Berd's out-of-root behavior. + */ +export function inferActivityRoots(frames: readonly ObserverEvent[]): string[] { + const dirs: string[] = []; + + for (const frame of frames) { + if (frame.kind !== "acp_read") continue; + const payload = asRecord(frame.payload); + if (asString(payload.method) !== "session/update") continue; + const update = asRecord(asRecord(payload.params).update); + const updateType = asString(update.sessionUpdate); + if (updateType !== "tool_call" && updateType !== "tool_call_update") { + continue; + } + + const rawPaths = [ + ...locationPathsFromUpdate(update), + ...pathArguments(asRecord(update.rawInput), PATH_ARGUMENT_KEYS), + ]; + for (const rawPath of rawPaths) { + const path = cleanPathValue(rawPath); + if (path && isAbsolutePath(path)) dirs.push(parentDir(path)); + } + } + + if (dirs.length === 0) return []; + + let prefix = dirs[0]; + for (const dir of dirs) { + while (prefix !== "/" && !isSameOrDescendant(dir, prefix)) { + prefix = parentDir(prefix); + } + if (prefix === "/") break; + } + + return segmentDepth(prefix) >= 3 ? [prefix] : []; +} + +/** + * Derive activity events for one agent from its decrypted observer frames. + * Frames are expected in seq order (the observer store keeps them sorted and + * batch-unwrapped). Multi-agent scenes compose by concatenating results. + */ +export function deriveActivityFromObserverFrames( + frames: readonly ObserverEvent[], + opts: DeriveActivityOptions, +): { events: ActivityEvent[]; agents: ActivityAgent[] } { + const roots = opts.roots ?? []; + const records = new Map(); + const recordOrder: ToolCallRecord[] = []; + + let firstFrameT: number | undefined; + let currentTurnKey: string | null | undefined; + let narration = ""; + + const resetNarrationOnTurnChange = (frame: ObserverEvent) => { + const turnKey = frame.turnId ?? null; + if (turnKey !== currentTurnKey) { + currentTurnKey = turnKey; + narration = ""; + } + }; + + for (const frame of frames) { + if (firstFrameT === undefined) firstFrameT = frameTime(frame); + resetNarrationOnTurnChange(frame); + + if (frame.kind !== "acp_read") continue; + + const payload = asRecord(frame.payload); + if (asString(payload.method) !== "session/update") continue; + + const params = asRecord(payload.params); + const update = asRecord(params.update); + const updateType = asString(update.sessionUpdate); + + if (updateType === "agent_message_chunk") { + const text = textFromContent(update.content); + if (text) narration = narration ? `${narration} ${text}` : text; + continue; + } + + if (updateType !== "tool_call" && updateType !== "tool_call_update") { + continue; + } + + const toolCallId = asString(update.toolCallId); + if (!toolCallId) continue; + + const sessionKey = + asString(params.sessionId) ?? frame.sessionId ?? "unknown-session"; + const recordKey = `${sessionKey}:${toolCallId}`; + + let record = records.get(recordKey); + if (!record) { + record = { + toolCallId, + t: frameTime(frame), + sourceSeq: frame.seq, + ...(frame.turnId ? { sourceTurnId: frame.turnId } : {}), + locations: [], + }; + if (updateType === "tool_call") { + const compactedNarration = narration.replace(/\s+/g, " ").trim(); + if (compactedNarration) { + record.intent = compactIntent(compactedNarration); + } + narration = ""; + } + records.set(recordKey, record); + recordOrder.push(record); + } + + const title = asString(update.title); + if (title && record.title === undefined) record.title = title; + + const kind = asString(update.kind); + if (kind && record.acpKind === undefined) record.acpKind = kind; + + const locations = locationPathsFromUpdate(update); + if (locations.length > 0) record.locations = locations; + + if ( + typeof update.rawInput === "object" && + update.rawInput !== null && + !Array.isArray(update.rawInput) + ) { + record.rawInput = update.rawInput as Record; + } + } + + const candidates = recordOrder.flatMap((record) => + toolCandidates(record, opts.agentId, roots), + ); + + const sortedCandidates = candidates.sort( + (a, b) => a.t - b.t || (a.sourceSeq ?? 0) - (b.sourceSeq ?? 0), + ); + const seenPaths = new Set(); + const events: ActivityEvent[] = []; + + for (const candidate of sortedCandidates) { + const { createEligible, ...event } = candidate; + if (event.path) { + const firstTouch = !seenPaths.has(event.path); + if (createEligible && firstTouch) { + event.kind = "c"; + } + seenPaths.add(event.path); + } + events.push(event); + } + + const spawnT = firstFrameT ?? events[0]?.t ?? 0; + return { + events, + agents: [ + { + id: opts.agentId, + name: opts.agentName ?? opts.agentId.slice(0, 8), + color: colorForAgent(opts.agentId, opts.agentIndex), + spawnT, + }, + ], + }; +} diff --git a/desktop/src/features/agents/activity/renderScene.test.mjs b/desktop/src/features/agents/activity/renderScene.test.mjs new file mode 100644 index 0000000000..d52cc03652 --- /dev/null +++ b/desktop/src/features/agents/activity/renderScene.test.mjs @@ -0,0 +1,374 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { compileScene } from "./activityScene.ts"; +import { drawFrame } from "./renderScene.ts"; +import { + chooseActivityGridStep, + formatActivityDuration, + getActivityPanelLayout, + timeToCanvasX, +} from "./renderSceneLayout.ts"; + +function gridTickCount(durationMs, chartWidthPx) { + return ( + Math.floor(durationMs / chooseActivityGridStep(durationMs, chartWidthPx)) + + 1 + ); +} + +function sceneWithDisplayRange(d0, d1) { + return { d0, d1 }; +} + +const T0 = Date.UTC(2026, 7, 10, 12, 0, 0); + +class RecordingCanvasContext { + operations = []; + fillStyle = ""; + strokeStyle = ""; + lineWidth = 1; + font = ""; + textAlign = "left"; + textBaseline = "alphabetic"; + globalAlpha = 1; + lineCap = "butt"; + lineJoin = "miter"; + + record(name, args = []) { + this.operations.push({ name, args }); + } + + setTransform(...args) { + this.record("setTransform", args); + } + + clearRect(...args) { + this.record("clearRect", args); + } + + fillRect(...args) { + this.record("fillRect", args); + } + + fillText(...args) { + this.record("fillText", args); + } + + beginPath() { + this.record("beginPath"); + } + + moveTo(...args) { + this.record("moveTo", args); + } + + lineTo(...args) { + this.record("lineTo", args); + } + + closePath() { + this.record("closePath"); + } + + stroke() { + this.record("stroke"); + } + + fill() { + this.record("fill"); + } + + arc(...args) { + this.record("arc", args); + } + + save() { + this.record("save"); + } + + restore() { + this.record("restore"); + } + + translate(...args) { + this.record("translate", args); + } + + rotate(...args) { + this.record("rotate", args); + } + + quadraticCurveTo(...args) { + this.record("quadraticCurveTo", args); + } + + bezierCurveTo(...args) { + this.record("bezierCurveTo", args); + } + + setLineDash(...args) { + this.record("setLineDash", args); + } + + createLinearGradient() { + this.record("createLinearGradient"); + return { addColorStop: () => {} }; + } + + createRadialGradient() { + this.record("createRadialGradient"); + return { addColorStop: () => {} }; + } +} + +function idleGapScene() { + const events = [ + { agentId: "agent", t: T0, kind: "s" }, + { agentId: "agent", t: T0 + 60_000, kind: "s" }, + { agentId: "agent", t: T0 + 120_000, kind: "s" }, + ]; + const agents = [{ id: "agent", name: "Goose", color: "#7bd88f", spawnT: T0 }]; + return compileScene(events, agents); +} + +describe("activity render time helpers", () => { + it("formats durations without minute overflow", () => { + assert.equal(formatActivityDuration(59_999), "00:59"); + assert.equal(formatActivityDuration(80_000), "01:20"); + assert.equal(formatActivityDuration(82_072_000), "22:47:52"); + }); + + it("chooses grid steps by rounding up from duration and chart width", () => { + assert.equal(chooseActivityGridStep(80_000, 800), 10_000); + assert.equal(chooseActivityGridStep(22_500, 400), 5_000); + assert.equal(chooseActivityGridStep(3 * 60 * 60_000, 1_000), 15 * 60_000); + assert.equal(chooseActivityGridStep(3 * 60 * 60_000, 200), 2 * 60 * 60_000); + }); + + it("reduces grid tick density on narrow charts", () => { + const eighteenMinutes = 18 * 60_000; + + assert.ok(gridTickCount(eighteenMinutes, 200) <= 3); + assert.ok(gridTickCount(eighteenMinutes, 400) <= 6); + assert.ok(gridTickCount(eighteenMinutes, 800) <= 12); + }); + + it("maps display-space times to chart edges using d0/d1", () => { + const scene = sceneWithDisplayRange(5_000, 15_000); + const layout = getActivityPanelLayout(640, 320, 2); + + assert.equal(timeToCanvasX(scene, 5_000, layout), layout.chartX0); + assert.equal(timeToCanvasX(scene, 15_000, layout), layout.chartX1); + assert.equal(timeToCanvasX(scene, 20_000, layout), layout.chartX1); + }); +}); + +describe("activity gap rendering", () => { + it("uses axis-strip hit targets for idle gaps", () => { + const scene = idleGapScene(); + const ctx = new RecordingCanvasContext(); + const targets = drawFrame(ctx, scene, { + viewT: scene.d1, + liveT: scene.d1, + width: 640, + height: 160, + dpr: 1, + }); + const layout = getActivityPanelLayout(640, 160, scene.nodes.length); + + assert.equal(targets.gaps.length, scene.timeMap.gaps.length); + assert.equal( + targets.gaps.every((target) => target.y === 0), + true, + ); + assert.equal( + targets.gaps.every((target) => target.height === layout.axisHeight), + true, + ); + assert.equal( + targets.gaps.every( + (target) => target.y + target.height <= layout.axisHeight, + ), + true, + ); + assert.equal( + targets.gaps.every((target) => target.width >= 8), + true, + ); + }); + + it("coalesces tight runs of status events into one counted marker", () => { + const events = [ + // A file touch so the scene has nodes and the agent gets a thread. + { agentId: "agent", t: T0, kind: "r", path: "README.md" }, + { agentId: "agent", t: T0 + 20, kind: "s", label: "developer.shell" }, + { agentId: "agent", t: T0 + 40, kind: "s", label: "developer.shell" }, + { agentId: "agent", t: T0 + 80, kind: "s", label: "developer.shell" }, + // Far-away status event lands well outside the cluster window. + { agentId: "agent", t: T0 + 60_000, kind: "s", label: "developer.shell" }, + ]; + const agents = [ + { id: "agent", name: "Goose", color: "#7bd88f", spawnT: T0 }, + ]; + const scene = compileScene(events, agents); + const ctx = new RecordingCanvasContext(); + + const targets = drawFrame(ctx, scene, { + viewT: scene.d1, + liveT: scene.d1, + width: 640, + height: 160, + dpr: 1, + }); + + const statusTargets = targets.events.filter( + (target) => target.event.kind === "s", + ); + assert.equal(statusTargets.length, 2); + + const cluster = statusTargets.find((target) => target.clusterCount === 3); + assert.notEqual(cluster, undefined); + assert.deepEqual(cluster?.clusterLabels, ["developer.shell"]); + + const countLabels = ctx.operations.filter( + (op) => op.name === "fillText" && op.args[0] === "×3", + ); + assert.equal(countLabels.length, 1); + }); + + it("keeps distant status events as separate markers", () => { + const events = [ + // A file touch so the scene has nodes and the agent gets a thread. + { agentId: "agent", t: T0, kind: "r", path: "README.md" }, + { agentId: "agent", t: T0 + 100, kind: "s", label: "developer.shell" }, + { agentId: "agent", t: T0 + 10_000, kind: "s", label: "developer.shell" }, + ]; + const agents = [ + { id: "agent", name: "Goose", color: "#7bd88f", spawnT: T0 }, + ]; + const scene = compileScene(events, agents); + const ctx = new RecordingCanvasContext(); + + const targets = drawFrame(ctx, scene, { + viewT: scene.d1, + liveT: scene.d1, + width: 640, + height: 160, + dpr: 1, + }); + + const statusTargets = targets.events.filter( + (target) => target.event.kind === "s", + ); + assert.equal(statusTargets.length, 2); + assert.equal( + statusTargets.every((target) => !target.clusterCount), + true, + ); + }); + + it("draws one axis tick/bracket stroke per idle gap", () => { + const scene = idleGapScene(); + const ctx = new RecordingCanvasContext(); + + drawFrame(ctx, scene, { + viewT: scene.d1, + liveT: scene.d1, + width: 640, + height: 160, + dpr: 1, + }); + + assert.equal( + ctx.operations.filter((op) => op.name === "stroke").length, + scene.timeMap.gaps.length, + ); + }); +}); + +describe("rider hit targets", () => { + it("emits one rider target at the playhead for a single live agent", () => { + const events = [ + { agentId: "agent", t: T0, kind: "r", path: "README.md" }, + { agentId: "agent", t: T0 + 10_000, kind: "r", path: "README.md" }, + ]; + const agents = [ + { id: "agent", name: "Goose", color: "#7bd88f", spawnT: T0 }, + ]; + const scene = compileScene(events, agents); + const ctx = new RecordingCanvasContext(); + + const targets = drawFrame(ctx, scene, { + viewT: scene.d1, + liveT: scene.d1, + width: 640, + height: 160, + dpr: 1, + }); + const layout = getActivityPanelLayout(640, 160, scene.nodes.length); + + assert.equal(targets.riders.length, 1); + const rider = targets.riders[0]; + assert.equal(rider.kind, "rider"); + assert.equal(rider.id, "rider:agent"); + assert.equal(rider.agentId, "agent"); + assert.equal(rider.agentName, "Goose"); + assert.equal(rider.x, timeToCanvasX(scene, scene.d1, layout)); + assert.ok(rider.radius >= 6); + }); + + it("emits one rider target per live agent", () => { + const events = [ + { agentId: "goose", t: T0, kind: "r", path: "README.md" }, + { agentId: "scout", t: T0 + 10_000, kind: "w", path: "src/main.ts" }, + ]; + const agents = [ + { id: "goose", name: "Goose", color: "#7bd88f", spawnT: T0 }, + { id: "scout", name: "Scout", color: "#6fb7ff", spawnT: T0 + 10_000 }, + ]; + const scene = compileScene(events, agents); + const ctx = new RecordingCanvasContext(); + + const targets = drawFrame(ctx, scene, { + viewT: scene.d1, + liveT: scene.d1, + width: 640, + height: 160, + dpr: 1, + }); + + assert.equal(targets.riders.length, 2); + assert.deepEqual(targets.riders.map((target) => target.agentId).sort(), [ + "goose", + "scout", + ]); + }); + + it("excludes riders for agents that have not spawned at viewT", () => { + const events = [ + { agentId: "goose", t: T0, kind: "r", path: "README.md" }, + { agentId: "scout", t: T0 + 20_000, kind: "w", path: "src/main.ts" }, + ]; + const agents = [ + { id: "goose", name: "Goose", color: "#7bd88f", spawnT: T0 }, + { id: "scout", name: "Scout", color: "#6fb7ff", spawnT: T0 + 20_000 }, + ]; + const scene = compileScene(events, agents); + const scout = scene.agentsById.get("scout"); + // Strictly after goose's spawn (display 0) and before scout's spawn. + const viewT = scout.displaySpawnT / 2; + const ctx = new RecordingCanvasContext(); + + const targets = drawFrame(ctx, scene, { + viewT, + liveT: scene.d1, + width: 640, + height: 160, + dpr: 1, + }); + + assert.equal(targets.riders.length, 1); + assert.equal(targets.riders[0].agentId, "goose"); + }); +}); diff --git a/desktop/src/features/agents/activity/renderScene.ts b/desktop/src/features/agents/activity/renderScene.ts new file mode 100644 index 0000000000..b98b5ceb89 --- /dev/null +++ b/desktop/src/features/agents/activity/renderScene.ts @@ -0,0 +1,821 @@ +// Ported from Berd's agent-activity-panel (branch zmarley/agent-activity-panel). +// Canvas 2D drawing passes for the activity scene; layout/theme live in renderSceneLayout.ts. + +import type { + ActivityScene, + ActivitySceneAgent, + ActivitySceneEvent, + Rgb, +} from "./activityScene"; +import { + binarySearchPrefix, + clamp, + hexToRgb, + lerp, + mixRgb, + rgba, + smooth, +} from "./activityUtils"; +import type { + ActivityEventHitTarget, + ActivityGapHitTarget, + ActivityPanelLayout, + ActivityRiderHitTarget, + ActivityRowHitTarget, + ActivityTheme, + FrameHitTargets, +} from "./renderSceneLayout"; +import { + CHART_RIGHT, + chooseActivityGridStep, + DEFAULT_ACTIVITY_THEME, + formatActivityDuration, + getActivityPanelLayout, + timeToCanvasX, +} from "./renderSceneLayout"; + +export interface ActivityHoverView { + rowPath?: string; + eventId?: string; + gapId?: string; +} + +export interface ActivityFrameView { + viewT: number; + liveT: number; + width: number; + height: number; + dpr: number; + hover?: ActivityHoverView | null; +} + +interface FrameLayout { + ys: number[]; + hs: number[]; + rowH: number; + gridBottom: number; +} + +const RECENT_SEGMENT_MS = 6_000; +const READ_FADE_MS = 6_000; +const WRITE_FADE_MS = 8_000; +const GRID_LABEL_MIN_SPACING_PX = 40; + +function computeRows( + scene: ActivityScene, + viewT: number, + mapLayout: ActivityPanelLayout, +): FrameLayout { + const rowH = mapLayout.rowH; + const ys: number[] = []; + const hs: number[] = []; + let y = mapLayout.chartTop; + + for (const node of scene.nodes) { + const born = smooth((viewT - node.displayRevealAt) / 450); + const h = rowH * born; + ys.push(y); + hs.push(h); + y += h; + } + + return { + ys, + hs, + rowH, + gridBottom: y, + }; +} + +function rowMidY(layout: FrameLayout, row: number) { + return layout.ys[row] + layout.hs[row] / 2; +} + +function threadY( + agent: ActivitySceneAgent, + viewT: number, + layout: FrameLayout, +): number | null { + const points = agent.points; + if (points.length === 0 || viewT < points[0].displayT) return null; + + const index = Math.max(0, binarySearchPrefix(agent.pointTimes, viewT) - 1); + + const current = points[index]; + const next = points[index + 1]; + const y0 = rowMidY(layout, current.node.row); + if (!next) return y0; + + const progress = smooth( + (viewT - current.displayT) / Math.max(1, next.displayT - current.displayT), + ); + return lerp(y0, rowMidY(layout, next.node.row), progress); +} + +function drawBackground( + ctx: CanvasRenderingContext2D, + view: ActivityFrameView, + theme: ActivityTheme, + mapLayout: ActivityPanelLayout, +) { + ctx.fillStyle = theme.background; + ctx.fillRect(0, 0, view.width, view.height); + ctx.fillStyle = theme.panel; + ctx.fillRect( + 0, + mapLayout.chartTop, + mapLayout.treeWidth, + view.height - mapLayout.chartTop, + ); +} + +function drawRows( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + theme: ActivityTheme, + layout: FrameLayout, + readRgb: Rgb, + writeRgb: Rgb, + warnRgb: Rgb, + coldRgb: Rgb, +): ActivityRowHitTarget[] { + const rows: ActivityRowHitTarget[] = []; + + for (const node of scene.nodes) { + const y = layout.ys[node.row]; + const height = layout.hs[node.row]; + if (height <= 0.5) continue; + + const midY = y + height / 2; + const born = height / layout.rowH; + const heat = scene.nodeHeat(node, view.viewT); + const hovered = view.hover?.rowPath === node.path; + + if (hovered) { + ctx.fillStyle = theme.rowHover; + ctx.fillRect(0, y, view.width, height); + } else if (node.isDir) { + ctx.fillStyle = theme.directoryRow; + ctx.fillRect(0, y, view.width, height); + } + + ctx.fillStyle = theme.separator; + ctx.fillRect(0, y + height, view.width, 1); + + for (let depth = 1; depth <= node.depth; depth += 1) { + ctx.fillStyle = theme.indentGuide; + ctx.fillRect(10 + depth * 14, y, 1, height); + } + + rows.push({ + id: node.path, + kind: "row", + x: 0, + y, + width: view.width, + height, + node, + }); + + if (born <= 0.5) continue; + + const alpha = (born - 0.5) * 2; + const indent = 16 + node.depth * 14; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + if (node.isDir) { + ctx.font = `600 11px ${theme.fontMono}`; + ctx.fillStyle = theme.directoryText; + ctx.globalAlpha = 0.9 * alpha; + ctx.fillText(`▾ ${node.name}/`, indent, midY); + ctx.globalAlpha = 1; + continue; + } + + const totalHeat = heat.hr + heat.hw; + let dotColor = coldRgb; + let dotAlpha = 0.35; + if (totalHeat > 0.03) { + dotColor = mixRgb(readRgb, writeRgb, heat.hw / totalHeat); + dotAlpha = clamp(0.5 + totalHeat * 0.3, 0.5, 1); + } + if (heat.warn > 0.05) { + dotColor = mixRgb(dotColor, warnRgb, Math.min(1, heat.warn)); + } + + ctx.fillStyle = rgba(dotColor, dotAlpha * alpha); + ctx.beginPath(); + ctx.arc(indent + 3, midY, 2.5 + Math.min(2, totalHeat), 0, Math.PI * 2); + ctx.fill(); + + if (totalHeat > 0.4) { + ctx.fillStyle = rgba(dotColor, 0.14 * alpha); + ctx.beginPath(); + ctx.arc(indent + 3, midY, 7 + totalHeat * 3, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.font = `11px ${theme.fontMono}`; + const baseText = hexToRgb(theme.mutedText); + let nameColor = + totalHeat > 0.03 + ? mixRgb(baseText, hexToRgb(theme.text), Math.min(1, totalHeat * 0.7)) + : baseText; + if (heat.warn > 0.1) { + nameColor = mixRgb(nameColor, warnRgb, Math.min(1, heat.warn * 0.8)); + } + ctx.fillStyle = rgba(nameColor, (totalHeat > 0.03 ? 1 : 0.62) * alpha); + ctx.fillText(node.name, indent + 12, midY); + } + + return rows; +} + +function drawTimeGrid( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + theme: ActivityTheme, + layout: FrameLayout, + mapLayout: ActivityPanelLayout, +) { + const step = chooseActivityGridStep( + scene.d1, + mapLayout.chartX1 - mapLayout.chartX0, + ); + let lastLabelX = Number.NEGATIVE_INFINITY; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.font = `9px ${theme.fontMono}`; + + for (let displayT = 0; displayT <= scene.d1 + 0.5; displayT += step) { + const x = timeToCanvasX(scene, displayT, mapLayout); + ctx.fillStyle = theme.grid; + ctx.fillRect( + x, + mapLayout.chartTop, + 1, + layout.gridBottom - mapLayout.chartTop, + ); + if (x - lastLabelX < GRID_LABEL_MIN_SPACING_PX) continue; + + ctx.fillStyle = theme.gridText; + ctx.fillText(formatActivityDuration(displayT), x + 3, 14); + lastLabelX = x; + } +} + +function drawGapMarkers( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + theme: ActivityTheme, + mapLayout: ActivityPanelLayout, +): ActivityGapHitTarget[] { + const targets: ActivityGapHitTarget[] = []; + const axisTop = 0; + const axisBottom = mapLayout.axisHeight; + const tickTop = 4; + const tickBottom = Math.max(tickTop, axisBottom - 5); + + for (let index = 0; index < scene.timeMap.gaps.length; index += 1) { + const gap = scene.timeMap.gaps[index]; + const x0 = timeToCanvasX(scene, gap.displayStart, mapLayout); + const x1 = timeToCanvasX(scene, gap.displayEnd, mapLayout); + const width = Math.max(1, x1 - x0); + const hovered = view.hover?.gapId === `gap-${index}`; + + ctx.strokeStyle = hovered ? theme.gapText : theme.gapBand; + ctx.lineWidth = hovered ? 1.4 : 1; + ctx.beginPath(); + ctx.moveTo(x0, tickTop); + ctx.lineTo(x0, tickBottom); + ctx.moveTo(x1, tickTop); + ctx.lineTo(x1, tickBottom); + if (width >= 4) { + ctx.moveTo(x0, tickBottom); + ctx.lineTo(x1, tickBottom); + } + ctx.stroke(); + + if (width >= 12) { + ctx.fillStyle = theme.gapText; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `11px ${theme.fontMono}`; + ctx.fillText("⋯", x0 + width / 2, axisBottom / 2); + } + + const hitWidth = Math.max(8, width); + targets.push({ + id: `gap-${index}`, + kind: "gap", + x: x0 + width / 2 - hitWidth / 2, + y: axisTop, + width: hitWidth, + height: mapLayout.axisHeight, + realStart: gap.realStart, + realEnd: gap.realEnd, + displayStart: gap.displayStart, + displayEnd: gap.displayEnd, + }); + } + + return targets; +} + +function drawHeatTrails( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + layout: FrameLayout, + mapLayout: ActivityPanelLayout, + readRgb: Rgb, + writeRgb: Rgb, + warnRgb: Rgb, +) { + for (const event of scene.events) { + if (event.displayT > view.viewT) break; + if (!event.node) continue; + const y = layout.ys[event.node.row]; + const height = layout.hs[event.node.row]; + if (height <= 0.5) continue; + + const warn = event.warn || event.kind === "x"; + const color = event.kind === "r" ? readRgb : warn ? warnRgb : writeRgb; + const fadeMs = event.kind === "r" ? READ_FADE_MS : WRITE_FADE_MS; + const x0 = timeToCanvasX(scene, event.displayT, mapLayout); + const xFade = timeToCanvasX(scene, event.displayT + fadeMs, mapLayout); + const x1 = Math.min(xFade, timeToCanvasX(scene, view.viewT, mapLayout)); + if (x1 <= x0) continue; + + const gradient = ctx.createLinearGradient(x0, 0, xFade, 0); + gradient.addColorStop(0, rgba(color, event.kind === "r" ? 0.16 : 0.26)); + gradient.addColorStop(1, rgba(color, 0)); + ctx.fillStyle = gradient; + ctx.fillRect(x0, y + 1, x1 - x0, Math.max(0, height - 2)); + } +} + +function drawAgentPath( + ctx: CanvasRenderingContext2D, + points: Array<{ x: number; y: number }>, +) { + if (points.length === 0) return; + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let index = 1; index < points.length; index += 1) { + const p0 = points[index - 1]; + const p1 = points[index]; + const dx = (p1.x - p0.x) / 3; + ctx.bezierCurveTo(p0.x + dx, p0.y, p1.x - dx, p1.y, p1.x, p1.y); + } +} + +function drawThreads( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + layout: FrameLayout, + mapLayout: ActivityPanelLayout, +) { + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + for (const agent of scene.agents) { + if (view.viewT < agent.displaySpawnT) continue; + + const points = agent.points + .filter((point) => point.displayT <= view.viewT) + .map((point) => ({ + x: timeToCanvasX(scene, point.displayT, mapLayout), + y: rowMidY(layout, point.node.row), + t: point.displayT, + })); + if (points.length === 0) continue; + + const nowY = threadY(agent, view.viewT, layout); + const nowX = timeToCanvasX(scene, view.viewT, mapLayout); + if (nowY === null) continue; + + const fade = + view.viewT > agent.displayDoneT + ? Math.max(0.25, 1 - (view.viewT - agent.displayDoneT) / 2_000) + : 1; + + drawAgentPath(ctx, points); + const last = points.at(-1); + if (last && nowX > last.x) { + const dx = (nowX - last.x) / 3; + ctx.bezierCurveTo(last.x + dx, last.y, nowX - dx, nowY, nowX, nowY); + } + ctx.strokeStyle = rgba(agent.rgb, 0.42 * fade); + ctx.lineWidth = 1.4; + ctx.stroke(); + + const recent = points.filter( + (point) => view.viewT - point.t < RECENT_SEGMENT_MS, + ); + const segment = + recent.length > 0 ? recent : [{ x: nowX - 1, y: nowY, t: view.viewT }]; + drawAgentPath(ctx, segment); + const segmentLast = segment.at(-1); + if (segmentLast && nowX > segmentLast.x) { + const dx = (nowX - segmentLast.x) / 3; + ctx.bezierCurveTo( + segmentLast.x + dx, + segmentLast.y, + nowX - dx, + nowY, + nowX, + nowY, + ); + } + ctx.strokeStyle = rgba(agent.rgb, 0.85 * fade); + ctx.lineWidth = 2; + ctx.stroke(); + } +} + +function drawEventMarkers( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + theme: ActivityTheme, + layout: FrameLayout, + mapLayout: ActivityPanelLayout, + readRgb: Rgb, + writeRgb: Rgb, + warnRgb: Rgb, +): ActivityEventHitTarget[] { + const targets: ActivityEventHitTarget[] = []; + + // Coalesce runs of pathless status diamonds that land within a few pixels + // of each other into one marker with a count, so shell-heavy sessions don't + // dissolve into diamond confetti. + const CLUSTER_PX = 14; + let statusRun: { + agentId: string; + xStart: number; + xEnd: number; + y: number; + first: ActivitySceneEvent; + last: ActivitySceneEvent; + count: number; + labels: string[]; + } | null = null; + + const flushStatusRun = () => { + if (!statusRun) return; + const run = statusRun; + statusRun = null; + const agent = scene.agentsById.get(run.agentId); + if (!agent) return; + const x = run.count === 1 ? run.xStart : (run.xStart + run.xEnd) / 2; + const hovered = view.hover?.eventId === run.last.id; + const size = 2.8; + ctx.save(); + ctx.translate(x, run.y); + ctx.rotate(Math.PI / 4); + ctx.fillStyle = rgba(agent.rgb, hovered ? 1 : 0.9); + ctx.fillRect(-size, -size, size * 2, size * 2); + ctx.restore(); + if (run.count > 1) { + ctx.fillStyle = rgba(agent.rgb, hovered ? 1 : 0.85); + ctx.font = `9px ${theme.fontMono}`; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText(`×${run.count}`, x + 6, run.y - 6); + } + targets.push({ + id: run.last.id, + kind: "event", + x, + y: run.y, + radius: Math.max(8, (run.xEnd - run.xStart) / 2 + 6), + event: run.last, + ...(run.count > 1 + ? { + clusterCount: run.count, + clusterFirst: run.first, + clusterLabels: run.labels, + } + : {}), + }); + }; + + for (const event of scene.events) { + if (event.displayT > view.viewT) break; + const agent = scene.agentsById.get(event.agentId); + if (!agent) continue; + + let y: number | null; + if (event.node) { + if (layout.hs[event.node.row] <= 0.5) continue; + y = rowMidY(layout, event.node.row); + } else { + y = threadY(agent, event.displayT, layout); + } + if (y === null) continue; + + const x = timeToCanvasX(scene, event.displayT, mapLayout); + const age = view.viewT - event.displayT; + const warn = event.warn || event.kind === "x"; + const id = event.id; + const hovered = view.hover?.eventId === id; + + if (event.kind === "s") { + if ( + statusRun && + statusRun.agentId === event.agentId && + Math.abs(statusRun.y - y) < 0.5 && + x - statusRun.xEnd <= CLUSTER_PX + ) { + statusRun.xEnd = x; + statusRun.last = event; + statusRun.count += 1; + if (event.label && statusRun.labels.at(-1) !== event.label) { + statusRun.labels.push(event.label); + } + continue; + } + flushStatusRun(); + statusRun = { + agentId: event.agentId, + xStart: x, + xEnd: x, + y, + first: event, + last: event, + count: 1, + labels: event.label ? [event.label] : [], + }; + continue; + } + + flushStatusRun(); + + if (event.kind === "sp" || event.kind === "d") { + const size = event.kind === "sp" ? 3.4 : 2.8; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(Math.PI / 4); + ctx.fillStyle = rgba(agent.rgb, hovered ? 1 : 0.9); + ctx.fillRect(-size, -size, size * 2, size * 2); + ctx.restore(); + targets.push({ id, kind: "event", x, y, radius: 8, event }); + continue; + } + + const color = event.kind === "r" ? readRgb : warn ? warnRgb : writeRgb; + const radius = event.kind === "r" ? 2.2 : 3; + ctx.fillStyle = rgba(color, event.kind === "r" ? 0.75 : 0.95); + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + + if (event.kind === "c") { + ctx.strokeStyle = rgba(color, 0.9); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(x, y, radius + 2.5, 0, Math.PI * 2); + ctx.stroke(); + } + + ctx.strokeStyle = rgba(agent.rgb, 0.85); + ctx.lineWidth = hovered ? 1.7 : 1; + ctx.beginPath(); + ctx.arc(x, y, radius + 1.2, 0, Math.PI * 2); + ctx.stroke(); + + if (age < 700) { + const pulse = age / 700; + ctx.strokeStyle = rgba(color, (1 - pulse) * 0.9); + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(x, y, radius + pulse * 16, 0, Math.PI * 2); + ctx.stroke(); + } + + if (warn) { + const phase = ((view.viewT / 1_000) * 1.6) % 1; + ctx.strokeStyle = rgba(warnRgb, (1 - phase) * 0.55); + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.arc(x, y, radius + 3 + phase * 10, 0, Math.PI * 2); + ctx.stroke(); + } + + targets.push({ id, kind: "event", x, y, radius: 8, event }); + } + + flushStatusRun(); + + return targets; +} + +function drawVeilAndPlayhead( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + theme: ActivityTheme, + layout: FrameLayout, + mapLayout: ActivityPanelLayout, +) { + const { chartX1 } = mapLayout; + const liveX = timeToCanvasX(scene, view.liveT, mapLayout); + ctx.fillStyle = theme.veil; + ctx.fillRect( + liveX, + mapLayout.chartTop, + chartX1 - liveX + CHART_RIGHT, + layout.gridBottom - mapLayout.chartTop, + ); + ctx.fillStyle = theme.live; + ctx.globalAlpha = 0.7; + ctx.fillRect( + liveX - 0.5, + mapLayout.chartTop, + 1.5, + layout.gridBottom - mapLayout.chartTop, + ); + ctx.globalAlpha = 1; + + const playheadX = timeToCanvasX(scene, view.viewT, mapLayout); + ctx.fillStyle = theme.playhead; + ctx.fillRect( + playheadX - 0.5, + mapLayout.chartTop, + 1, + layout.gridBottom - mapLayout.chartTop, + ); + ctx.beginPath(); + ctx.moveTo(playheadX - 5, mapLayout.chartTop); + ctx.lineTo(playheadX + 5, mapLayout.chartTop); + ctx.lineTo(playheadX, mapLayout.chartTop + 7); + ctx.closePath(); + ctx.fill(); +} + +const RIDER_HIT_SLOP_PX = 6; + +function drawRiders( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + theme: ActivityTheme, + layout: FrameLayout, + mapLayout: ActivityPanelLayout, +): ActivityRiderHitTarget[] { + const playheadX = timeToCanvasX(scene, view.viewT, mapLayout); + const targets: ActivityRiderHitTarget[] = []; + const riders = scene.agents.flatMap((agent) => { + if (view.viewT < agent.displaySpawnT) return []; + const y = threadY(agent, view.viewT, layout); + if (y === null) return []; + let alpha = Math.min(1, (view.viewT - agent.displaySpawnT) / 700); + if (view.viewT > agent.displayDoneT) { + alpha = Math.max(0.25, 1 - (view.viewT - agent.displayDoneT) / 1_500); + } + return [{ agent, y, alpha, labelY: y }]; + }); + + riders.sort((a, b) => a.y - b.y); + for (let index = 1; index < riders.length; index += 1) { + if (riders[index].labelY - riders[index - 1].labelY < 13) { + riders[index].labelY = riders[index - 1].labelY + 13; + } + } + + for (const rider of riders) { + const radius = rider.agent.id === scene.primaryAgentId ? 6 : 5; + targets.push({ + id: `rider:${rider.agent.id}`, + kind: "rider", + agentId: rider.agent.id, + agentName: rider.agent.name, + x: playheadX, + y: rider.y, + radius: radius + RIDER_HIT_SLOP_PX, + }); + const glow = ctx.createRadialGradient( + playheadX, + rider.y, + 0, + playheadX, + rider.y, + radius * 3, + ); + glow.addColorStop(0, rgba(rider.agent.rgb, 0.45 * rider.alpha)); + glow.addColorStop(1, rgba(rider.agent.rgb, 0)); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(playheadX, rider.y, radius * 3, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = rgba(rider.agent.rgb, rider.alpha); + ctx.beginPath(); + ctx.arc(playheadX, rider.y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = theme.riderRing; + ctx.globalAlpha = rider.alpha; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(playheadX, rider.y, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + + ctx.font = `600 10.5px ${theme.fontSans}`; + const flip = playheadX > view.width - 110; + ctx.textAlign = flip ? "right" : "left"; + ctx.textBaseline = "middle"; + ctx.fillStyle = rgba(rider.agent.rgb, Math.min(1, rider.alpha + 0.15)); + ctx.fillText(rider.agent.name, playheadX + (flip ? -10 : 10), rider.labelY); + } + + return targets; +} + +export function drawFrame( + ctx: CanvasRenderingContext2D, + scene: ActivityScene, + view: ActivityFrameView, + theme: ActivityTheme = DEFAULT_ACTIVITY_THEME, +): FrameHitTargets { + const dpr = view.dpr || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, view.width, view.height); + + const mapLayout = getActivityPanelLayout( + view.width, + view.height, + scene.nodes.length, + ); + const layout = computeRows(scene, view.viewT, mapLayout); + const readRgb = hexToRgb(theme.read); + const writeRgb = hexToRgb(theme.write); + const warnRgb = hexToRgb(theme.warn); + const coldRgb = hexToRgb(theme.cold); + const { chartX0, chartX1 } = mapLayout; + + drawBackground(ctx, view, theme, mapLayout); + const rows = drawRows( + ctx, + scene, + view, + theme, + layout, + readRgb, + writeRgb, + warnRgb, + coldRgb, + ); + drawTimeGrid(ctx, scene, theme, layout, mapLayout); + const gaps = drawGapMarkers(ctx, scene, view, theme, mapLayout); + drawHeatTrails( + ctx, + scene, + view, + layout, + mapLayout, + readRgb, + writeRgb, + warnRgb, + ); + drawThreads(ctx, scene, view, layout, mapLayout); + const events = drawEventMarkers( + ctx, + scene, + view, + theme, + layout, + mapLayout, + readRgb, + writeRgb, + warnRgb, + ); + drawVeilAndPlayhead(ctx, scene, view, theme, layout, mapLayout); + const riders = drawRiders(ctx, scene, view, theme, layout, mapLayout); + + ctx.fillStyle = theme.panelEdge; + ctx.fillRect( + mapLayout.treeWidth - 6, + mapLayout.chartTop, + 1, + layout.gridBottom - mapLayout.chartTop, + ); + + return { + events, + rows, + gaps, + riders, + chart: { + x0: chartX0, + x1: chartX1, + y0: mapLayout.chartTop, + y1: layout.gridBottom, + }, + }; +} diff --git a/desktop/src/features/agents/activity/renderSceneLayout.ts b/desktop/src/features/agents/activity/renderSceneLayout.ts new file mode 100644 index 0000000000..62809e54d3 --- /dev/null +++ b/desktop/src/features/agents/activity/renderSceneLayout.ts @@ -0,0 +1,272 @@ +// Ported from Berd's agent-activity-panel (branch zmarley/agent-activity-panel). +// Layout math, grid-step selection, time↔x mapping, duration formatting, the +// default canvas theme, and hit-target types for the activity renderer. + +import type { + ActivityScene, + ActivitySceneEvent, + ActivitySceneNode, +} from "./activityScene"; +import { clamp } from "./activityUtils"; + +export interface ActivityTheme { + background: string; + panel: string; + panelEdge: string; + rowHover: string; + directoryRow: string; + separator: string; + grid: string; + gridText: string; + indentGuide: string; + text: string; + mutedText: string; + directoryText: string; + gapBand: string; + gapText: string; + cold: string; + read: string; + write: string; + warn: string; + live: string; + playhead: string; + veil: string; + riderRing: string; + fontMono: string; + fontSans: string; +} + +export interface ActivityEventHitTarget { + id: string; + kind: "event"; + x: number; + y: number; + radius: number; + event: ActivitySceneEvent; + /** Set when this marker represents a run of coalesced status events. */ + clusterCount?: number; + clusterFirst?: ActivitySceneEvent; + clusterLabels?: string[]; +} + +export interface ActivityRowHitTarget { + id: string; + kind: "row"; + x: number; + y: number; + width: number; + height: number; + node: ActivitySceneNode; +} + +export interface ActivityGapHitTarget { + id: string; + kind: "gap"; + x: number; + y: number; + width: number; + height: number; + realStart: number; + realEnd: number; + displayStart: number; + displayEnd: number; +} + +export interface ActivityRiderHitTarget { + id: string; + kind: "rider"; + agentId: string; + agentName: string; + x: number; + y: number; + radius: number; +} + +export interface FrameHitTargets { + events: ActivityEventHitTarget[]; + rows: ActivityRowHitTarget[]; + gaps: ActivityGapHitTarget[]; + riders: ActivityRiderHitTarget[]; + chart: { + x0: number; + x1: number; + y0: number; + y1: number; + }; +} + +export interface ActivityPanelLayout { + treeWidth: number; + chartX0: number; + chartX1: number; + chartTop: number; + axisHeight: number; + contentHeight: number; + rowH: number; + viewportHeight: number; +} + +const AXIS_STRIP_HEIGHT = 24; +const BOTTOM_PADDING = 12; +const DEFAULT_TREE_WIDTH = 252; +export const CHART_RIGHT = 16; +const MIN_ROW_HEIGHT = 13; +const MAX_ROW_HEIGHT = 24; +const MIN_GRID_TICKS = 2; +const MAX_GRID_TICKS = 12; +const GRID_TICK_STEPS_MS = [ + 5_000, + 10_000, + 30_000, + 60_000, + 5 * 60_000, + 15 * 60_000, + 60 * 60_000, + 2 * 60 * 60_000, + 4 * 60 * 60_000, + 8 * 60 * 60_000, +] as const; + +export const DEFAULT_ACTIVITY_THEME: ActivityTheme = { + background: "#070b13", + panel: "rgba(10,15,26,0.94)", + panelEdge: "rgba(27,36,54,0.9)", + rowHover: "rgba(140,165,210,0.07)", + directoryRow: "rgba(140,165,210,0.035)", + separator: "rgba(255,255,255,0.03)", + grid: "rgba(120,140,175,0.10)", + gridText: "rgba(120,140,175,0.50)", + indentGuide: "rgba(120,140,175,0.12)", + text: "#dfe7f5", + mutedText: "#9aa8c2", + directoryText: "#8fa3c8", + gapBand: "rgba(120,140,175,0.08)", + gapText: "rgba(154,168,194,0.72)", + cold: "#4a5a7d", + read: "#38b6ff", + write: "#ff9e3d", + warn: "#ff5d73", + live: "#7bd88f", + playhead: "rgba(223,231,245,0.85)", + veil: "rgba(3,5,9,0.6)", + riderRing: "rgba(255,255,255,0.65)", + fontMono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", +}; + +export function formatActivityDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1_000)); + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + } + + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +export function chooseActivityGridStep( + displayDurationMs: number, + chartWidthPx: number, +): number { + const safeDuration = Math.max(1, displayDurationMs); + const safeWidth = Number.isFinite(chartWidthPx) + ? Math.max(0, chartWidthPx) + : 0; + const maxTicks = clamp( + Math.floor(safeWidth / 72), + MIN_GRID_TICKS, + MAX_GRID_TICKS, + ); + const targetStep = safeDuration / maxTicks; + return ( + GRID_TICK_STEPS_MS.find((step) => step >= targetStep) ?? + GRID_TICK_STEPS_MS[GRID_TICK_STEPS_MS.length - 1] + ); +} + +const ROW_HEIGHT_BUDGET_PX = 560; + +/** + * Comfortable natural content height for a scene: rows render at full + * MAX_ROW_HEIGHT for small trees and compress toward MIN_ROW_HEIGHT as the + * tree grows past the row budget, after which content height keeps growing + * linearly (callers wrap the canvas in a scrollable viewport). + */ +export function preferredActivityContentHeight(nodeCount: number): number { + const safeCount = Math.max(1, nodeCount); + const rowH = clamp( + Math.round(ROW_HEIGHT_BUDGET_PX / safeCount), + MIN_ROW_HEIGHT, + MAX_ROW_HEIGHT, + ); + return AXIS_STRIP_HEIGHT + safeCount * rowH + BOTTOM_PADDING; +} + +export function getActivityPanelLayout( + width: number, + height = 0, + nodeCount = 0, +): ActivityPanelLayout { + const treeWidth = Math.min( + DEFAULT_TREE_WIDTH, + Math.max(96, width - CHART_RIGHT - 24), + ); + const chartX0 = treeWidth; + const chartX1 = Math.max(chartX0 + 1, width - CHART_RIGHT); + const availableHeight = Math.max( + 1, + height - AXIS_STRIP_HEIGHT - BOTTOM_PADDING, + ); + const minContentHeight = + AXIS_STRIP_HEIGHT + + Math.max(1, nodeCount) * MIN_ROW_HEIGHT + + BOTTOM_PADDING; + const contentHeight = Math.max(height || minContentHeight, minContentHeight); + const contentAvailableHeight = Math.max( + 1, + contentHeight - AXIS_STRIP_HEIGHT - BOTTOM_PADDING, + ); + const rowH = + nodeCount > 0 && nodeCount * MIN_ROW_HEIGHT > availableHeight + ? MIN_ROW_HEIGHT + : clamp( + contentAvailableHeight / Math.max(1, nodeCount), + MIN_ROW_HEIGHT, + MAX_ROW_HEIGHT, + ); + + return { + treeWidth, + chartX0, + chartX1, + chartTop: AXIS_STRIP_HEIGHT, + axisHeight: AXIS_STRIP_HEIGHT, + contentHeight, + rowH, + viewportHeight: height, + }; +} + +export function timeToCanvasX( + scene: ActivityScene, + displayT: number, + layout: ActivityPanelLayout, +): number { + const { chartX0, chartX1 } = layout; + const duration = Math.max(1, scene.d1 - scene.d0); + const ratio = clamp((displayT - scene.d0) / duration, 0, 1); + return chartX0 + ratio * (chartX1 - chartX0); +} + +export function canvasXToTime( + scene: ActivityScene, + x: number, + layout: ActivityPanelLayout, +): number { + const { chartX0, chartX1 } = layout; + const ratio = clamp((x - chartX0) / Math.max(1, chartX1 - chartX0), 0, 1); + return scene.d0 + ratio * Math.max(1, scene.d1 - scene.d0); +} diff --git a/desktop/src/features/agents/activity/threadActivityContext.tsx b/desktop/src/features/agents/activity/threadActivityContext.tsx new file mode 100644 index 0000000000..78dbdcbdea --- /dev/null +++ b/desktop/src/features/agents/activity/threadActivityContext.tsx @@ -0,0 +1,119 @@ +// Channel-level provider for per-thread activity pills. +// +// Computes thread groups ONCE per channel (frames → turns → thread-root +// resolution) and exposes a root-id lookup, so each thread summary row in +// the timeline can render an activity pill without running its own store +// subscription or archive hydration. Pills consume this context directly — +// the timeline rows themselves never subscribe, so observer-frame ticks +// re-render only the pills, not the message list. +// +// Mounted by ChannelPane around the timeline. Surfaces without the provider +// (thread panel, profile) get the empty default and render no pills. + +import * as React from "react"; + +import { useFeatureEnabled } from "@/shared/features"; +import { useChannelWorkingAgentPubkeys } from "../agentWorkingSignal"; +import { useLoadArchivedObserverEvents } from "../ui/useObserverEvents"; +import type { ThreadGroup } from "./activityThreads"; +import { useFramesByAgent } from "./useMultiAgentActivity"; +import { useThreadScope } from "./useThreadScope"; + +export interface ThreadActivityAgent { + pubkey: string; + name: string; +} + +export interface ChannelThreadActivityValue { + /** Resolved thread groups keyed by thread-root event id. */ + groupsByRootId: ReadonlyMap; + /** + * Agents with observer frames in THIS channel (pill labels, map input) — + * pre-filtered so agent chips never offer an agent with nothing to show. + */ + agents: readonly ThreadActivityAgent[]; + channelId: string | null; + /** Channel-level working signal; intersect with a group's open turns. */ + workingPubkeys: readonly string[]; +} + +const EMPTY_VALUE: ChannelThreadActivityValue = { + groupsByRootId: new Map(), + agents: [], + channelId: null, + workingPubkeys: [], +}; + +const ChannelThreadActivityContext = + React.createContext(EMPTY_VALUE); + +export function useChannelThreadActivity(): ChannelThreadActivityValue { + return React.useContext(ChannelThreadActivityContext); +} + +export function ChannelThreadActivityProvider({ + activityAgents, + channelId, + children, +}: { + activityAgents: readonly ThreadActivityAgent[]; + channelId: string | null; + children: React.ReactNode; +}) { + const enabled = useFeatureEnabled("agentActivityPanel"); + if (!enabled || !channelId) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +function ChannelThreadActivityProviderBody({ + activityAgents, + channelId, + children, +}: { + activityAgents: readonly ThreadActivityAgent[]; + channelId: string; + children: React.ReactNode; +}) { + // Hydrate this channel's archived observer frames into the store so thread + // activity pills can render from both live and saved observer windows. Cheap + // when the thread panel already hydrated: pages dedup on (seq, timestamp). + useLoadArchivedObserverEvents(true, channelId); + + const framesByAgent = useFramesByAgent(activityAgents, channelId); + const { groups } = useThreadScope(framesByAgent); + const workingPubkeys = useChannelWorkingAgentPubkeys(channelId); + + const value = React.useMemo(() => { + const groupsByRootId = new Map(); + for (const group of groups.threads) { + if (group.rootId) groupsByRootId.set(group.rootId, group); + } + return { + groupsByRootId, + // framesByAgent only contains agents with frames in this channel; use it + // to keep pills scoped to agents that have channel-local activity. + agents: activityAgents.filter((agent) => framesByAgent.has(agent.pubkey)), + channelId, + workingPubkeys, + }; + }, [groups, activityAgents, framesByAgent, channelId, workingPubkeys]); + + return ( + + {children} + + ); +} diff --git a/desktop/src/features/agents/activity/useActivityScene.ts b/desktop/src/features/agents/activity/useActivityScene.ts new file mode 100644 index 0000000000..2f548ec79f --- /dev/null +++ b/desktop/src/features/agents/activity/useActivityScene.ts @@ -0,0 +1,91 @@ +// Ported/adapted from Berd's agent-activity-panel (branch +// zmarley/agent-activity-panel). Data hook: derives activity events + turns +// from an agent's observer frames and compiles the render scene, with a +// fingerprint cache so unchanged frame windows never recompile (the lazy +// -compile lesson from Berd's review). + +import * as React from "react"; + +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import type { ActivityAgent, ActivityEvent } from "./activityModel"; +import { type ActivityScene, compileScene } from "./activityScene"; +import { + type ActivityTurn, + deriveActivityTurns, + eventsForTurn, +} from "./activityTurns"; +import { hashActivityEvents } from "./activityUtils"; +import { deriveActivityFromObserverFrames } from "./deriveActivity"; + +/** "session" for the whole stream, or a turnId to scope to one turn. */ +export type ActivityScope = "session" | `turn:${string}`; + +export interface ActivitySceneState { + scene: ActivityScene | null; + turns: ActivityTurn[]; + events: ActivityEvent[]; + agents: ActivityAgent[]; +} + +export interface UseActivitySceneOptions { + agentId: string; + agentName?: string; + agentIndex?: number; + roots?: readonly string[]; + scope?: ActivityScope; +} + +export function turnScope(turnId: string): ActivityScope { + return `turn:${turnId}`; +} + +function scopeTurnId(scope: ActivityScope): string | null { + return scope.startsWith("turn:") ? scope.slice("turn:".length) : null; +} + +export function useActivityScene( + frames: readonly ObserverEvent[], + options: UseActivitySceneOptions, +): ActivitySceneState { + const { agentId, agentName, agentIndex, roots, scope = "session" } = options; + + const derived = React.useMemo( + () => + deriveActivityFromObserverFrames(frames, { + agentId, + ...(agentName !== undefined ? { agentName } : {}), + ...(agentIndex !== undefined ? { agentIndex } : {}), + ...(roots !== undefined ? { roots } : {}), + }), + [frames, agentId, agentName, agentIndex, roots], + ); + + const turns = React.useMemo(() => deriveActivityTurns(frames), [frames]); + + const scopedEvents = React.useMemo(() => { + const turnId = scopeTurnId(scope); + return turnId ? eventsForTurn(derived.events, turnId) : derived.events; + }, [derived.events, scope]); + + // Fingerprint cache: recompiling the scene on every observer append is the + // hot path during live turns. The hash covers kind/path/intent (late- + // patched narration invalidates), and length + last timestamp cover pure + // appends. + const cacheRef = React.useRef<{ key: string; scene: ActivityScene } | null>( + null, + ); + + const scene = React.useMemo(() => { + if (scopedEvents.length === 0) return null; + const lastT = scopedEvents[scopedEvents.length - 1]?.t ?? 0; + const key = `${scope}:${scopedEvents.length}:${lastT}:${hashActivityEvents( + scopedEvents, + )}`; + if (cacheRef.current?.key === key) return cacheRef.current.scene; + const compiled = compileScene(scopedEvents, derived.agents); + cacheRef.current = { key, scene: compiled }; + return compiled; + }, [scopedEvents, derived.agents, scope]); + + return { scene, turns, events: scopedEvents, agents: derived.agents }; +} diff --git a/desktop/src/features/agents/activity/useMultiAgentActivity.test.mjs b/desktop/src/features/agents/activity/useMultiAgentActivity.test.mjs new file mode 100644 index 0000000000..811d768b36 --- /dev/null +++ b/desktop/src/features/agents/activity/useMultiAgentActivity.test.mjs @@ -0,0 +1,176 @@ +/** + * Tests for disambiguatePathNamespaces — the multi-agent path-namespace layer. + * + * When agents work out of DIFFERENT workspace roots, each agent's pathful + * events are prefixed with the basename of its first root so `src/main.rs` in + * two repos lands on two distinct activity rows. Agents sharing a root, agents + * with no inferred root, and pathless (status) events must pass through + * untouched. Output arrays are aligned 1:1 with the input derivation order. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { disambiguatePathNamespaces } from "./useMultiAgentActivity.ts"; + +const BASE_T = Date.parse("2026-08-17T12:00:00.000Z"); + +/** Minimal ActivityEvent: extra fields are optional for this pure function. */ +function event(agentId, kind, path, offsetMs = 0) { + return { + agentId, + t: BASE_T + offsetMs, + kind, + ...(path !== undefined ? { path } : {}), + }; +} + +/** Minimal PerAgentDerivation-shaped object. */ +function derivation(agentId, roots, events) { + return { + agentId, + events, + agents: [{ id: agentId, name: agentId, colorIndex: 0 }], + roots, + frameCount: events.length, + lastSeq: events.length, + }; +} + +describe("disambiguatePathNamespaces", () => { + it("test_two_agents_different_roots_both_prefixed_and_aligned", () => { + const a = derivation( + "agent-a", + ["/Users/a/buzz"], + [ + event("agent-a", "r", "src/main.rs"), + event("agent-a", "w", "src/lib.rs", 1), + ], + ); + const b = derivation( + "agent-b", + ["/Users/a/berd"], + [event("agent-b", "r", "src/main.rs")], + ); + + const out = disambiguatePathNamespaces([a, b]); + + assert.equal(out.length, 2, "output aligned with input length"); + // output[0] corresponds to input[0] (agent-a), output[1] to input[1]. + assert.deepEqual( + out[0].map((e) => e.path), + ["buzz/src/main.rs", "buzz/src/lib.rs"], + ); + assert.equal(out[0][0].agentId, "agent-a"); + assert.deepEqual( + out[1].map((e) => e.path), + ["berd/src/main.rs"], + ); + assert.equal(out[1][0].agentId, "agent-b"); + }); + + it("test_two_agents_same_root_no_prefixing", () => { + const a = derivation( + "agent-a", + ["/Users/a/buzz"], + [event("agent-a", "r", "src/main.rs")], + ); + const b = derivation( + "agent-b", + ["/Users/a/buzz"], + [event("agent-b", "w", "src/other.rs")], + ); + + const out = disambiguatePathNamespaces([a, b]); + + assert.equal(out[0][0].path, "src/main.rs"); + assert.equal(out[1][0].path, "src/other.rs"); + // No prefixing means the exact input arrays pass through. + assert.equal(out[0], a.events); + assert.equal(out[1], b.events); + }); + + it("test_one_rooted_one_rootless_single_distinct_root_no_prefixing", () => { + // Only ONE distinct non-empty root exists across derivations, so there is + // nothing to disambiguate — nobody gets prefixed, including the rooted one. + const rooted = derivation( + "agent-a", + ["/Users/a/buzz"], + [event("agent-a", "r", "src/main.rs")], + ); + const rootless = derivation( + "agent-b", + [], + [event("agent-b", "w", "notes.md")], + ); + + const out = disambiguatePathNamespaces([rooted, rootless]); + + assert.equal(out[0][0].path, "src/main.rs", "rooted agent unprefixed"); + assert.equal(out[1][0].path, "notes.md", "rootless agent unprefixed"); + }); + + it("test_two_distinct_roots_plus_rootless_only_rooted_get_prefixed", () => { + const a = derivation( + "agent-a", + ["/Users/a/buzz"], + [event("agent-a", "r", "src/main.rs")], + ); + const b = derivation( + "agent-b", + ["/Users/a/berd"], + [event("agent-b", "w", "src/main.rs")], + ); + const c = derivation("agent-c", [], [event("agent-c", "r", "todo.txt")]); + + const out = disambiguatePathNamespaces([a, b, c]); + + assert.equal(out[0][0].path, "buzz/src/main.rs"); + assert.equal(out[1][0].path, "berd/src/main.rs"); + assert.equal(out[2][0].path, "todo.txt", "rootless agent stays bare"); + }); + + it("test_status_events_without_path_pass_through_unprefixed", () => { + const statusEvent = event("agent-a", "s", undefined); + const a = derivation( + "agent-a", + ["/Users/a/buzz"], + [statusEvent, event("agent-a", "r", "src/main.rs", 1)], + ); + const b = derivation( + "agent-b", + ["/Users/a/berd"], + [event("agent-b", "s", undefined)], + ); + + const out = disambiguatePathNamespaces([a, b]); + + assert.equal( + out[0][0].path, + undefined, + "status event path stays undefined", + ); + // Pathless events are passed through as-is (same object, not cloned). + assert.equal(out[0][0], statusEvent); + assert.equal( + out[0][1].path, + "buzz/src/main.rs", + "sibling pathful event prefixed", + ); + assert.equal(out[1][0].path, undefined); + }); + + it("test_single_agent_never_prefixed", () => { + const a = derivation( + "agent-a", + ["/Users/a/buzz"], + [event("agent-a", "r", "src/main.rs")], + ); + + const out = disambiguatePathNamespaces([a]); + + assert.equal(out.length, 1); + assert.equal(out[0][0].path, "src/main.rs"); + assert.equal(out[0], a.events, "single agent's events pass through as-is"); + }); +}); diff --git a/desktop/src/features/agents/activity/useMultiAgentActivity.ts b/desktop/src/features/agents/activity/useMultiAgentActivity.ts new file mode 100644 index 0000000000..ad52283d32 --- /dev/null +++ b/desktop/src/features/agents/activity/useMultiAgentActivity.ts @@ -0,0 +1,221 @@ +// Multi-agent activity merge: derives per-agent activity events from each +// agent's observer frames, disambiguates path namespaces across differing +// workspace roots, and compiles a single combined scene. This is the +// concatenation layer the single-agent panel was designed for ("callers +// compose multi-agent by concatenating results"). + +import * as React from "react"; + +import { + getAgentObserverSnapshot, + getArchivedChannelEvents, + getObservedAgentPubkeys, + subscribeAgentObserverStore, +} from "../observerRelayStore"; +import { + mergeObserverEventWindows, + scopeByChannel, +} from "../ui/agentSessionPanelLayout"; +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import type { ActivityAgent, ActivityEvent } from "./activityModel"; +import { type ActivityScene, compileScene } from "./activityScene"; +import { filterFramesByTurnIds } from "./activityThreads"; +import { basename } from "./activityUtils"; +import { + deriveActivityFromObserverFrames, + inferActivityRoots, +} from "./deriveActivity"; + +/** + * Per-agent allowlist of turn ids (thread scope). Agents absent from the + * map contribute no frames; a null filter means "no scoping" (all turns). + */ +export type TurnFilter = ReadonlyMap> | null; + +export interface MultiAgentActivityInput { + pubkey: string; + name?: string; +} + +export interface MultiAgentActivityState { + scene: ActivityScene | null; + events: ActivityEvent[]; + agents: ActivityAgent[]; + /** Pubkeys that contributed at least one activity event. */ + activeAgentIds: string[]; +} + +interface PerAgentDerivation { + agentId: string; + events: ActivityEvent[]; + agents: ActivityAgent[]; + roots: readonly string[]; + frameCount: number; + lastSeq: number; +} + +function deriveForAgent( + agent: MultiAgentActivityInput, + index: number, + frames: readonly ObserverEvent[], +): PerAgentDerivation { + const roots = inferActivityRoots(frames); + const derived = deriveActivityFromObserverFrames(frames, { + agentId: agent.pubkey, + ...(agent.name !== undefined ? { agentName: agent.name } : {}), + agentIndex: index, + roots, + }); + return { + agentId: agent.pubkey, + events: derived.events, + agents: derived.agents, + roots, + frameCount: frames.length, + lastSeq: frames.at(-1)?.seq ?? -1, + }; +} + +/** + * Prefix each agent's paths with its workspace-root basename when agents are + * working out of DIFFERENT roots, so `src/main.rs` in two repos lands on two + * distinct rows (`buzz/src/main.rs` vs `berd/src/main.rs`). Agents sharing a + * root (or with no inferred root) keep unprefixed paths and merge naturally. + */ +export function disambiguatePathNamespaces( + derivations: readonly PerAgentDerivation[], +): ActivityEvent[][] { + const rootKeys = new Set( + derivations.map((derivation) => derivation.roots[0] ?? ""), + ); + const distinctRoots = [...rootKeys].filter((root) => root.length > 0); + const needsPrefix = distinctRoots.length > 1; + + return derivations.map((derivation) => { + const root = derivation.roots[0]; + if (!needsPrefix || !root) return derivation.events; + const prefix = basename(root); + if (!prefix) return derivation.events; + return derivation.events.map((event) => + event.path ? { ...event, path: `${prefix}/${event.path}` } : event, + ); + }); +} + +function framesForAgent( + pubkey: string, + channelId: string | null, +): ObserverEvent[] { + const live = getAgentObserverSnapshot(pubkey).events; + const scopedLive = channelId ? scopeByChannel(live, channelId) : live; + if (!channelId) return scopedLive; + const archived = getArchivedChannelEvents(pubkey, channelId); + return mergeObserverEventWindows(scopedLive, archived); +} + +/** + * Combined activity scene across a set of agents, optionally scoped to one + * channel (live + archived windows merged per agent) and to a per-agent set + * of turn ids (thread scope). Per-agent derivations are memo-cached on + * (frameCount, lastSeq, filter identity) so live appends to one agent do + * not re-derive the others; the scene compiles once over the merged stream. + */ +export function useMultiAgentActivityScene( + agents: readonly MultiAgentActivityInput[], + channelId: string | null, + turnFilter: TurnFilter = null, +): MultiAgentActivityState { + // Reactive tick: any store change re-runs the snapshot reads below. + const storeTick = React.useSyncExternalStore( + subscribeAgentObserverStore, + getObservedAgentPubkeys, + ); + + const derivationCacheRef = React.useRef( + new Map(), + ); + + return React.useMemo(() => { + // storeTick is the reactive dependency that re-runs this memo. + void storeTick; + + const cache = derivationCacheRef.current; + const derivations: PerAgentDerivation[] = []; + const liveIds = new Set(); + + agents.forEach((agent, index) => { + const unfiltered = framesForAgent(agent.pubkey, channelId); + let frames = unfiltered; + let filterKey = "all"; + if (turnFilter) { + const allowedTurnIds = turnFilter.get(agent.pubkey); + if (!allowedTurnIds || allowedTurnIds.size === 0) return; + frames = filterFramesByTurnIds(unfiltered, allowedTurnIds); + filterKey = [...allowedTurnIds].sort().join(","); + } + if (frames.length === 0) return; + liveIds.add(agent.pubkey); + + const cacheKey = `${agent.pubkey}:${channelId ?? ""}:${filterKey}`; + const cached = cache.get(cacheKey); + const lastSeq = frames.at(-1)?.seq ?? -1; + if ( + cached && + cached.frameCount === frames.length && + cached.lastSeq === lastSeq + ) { + derivations.push(cached); + return; + } + const derivation = deriveForAgent(agent, index, frames); + cache.set(cacheKey, derivation); + derivations.push(derivation); + }); + + // Drop cache entries for agents no longer in the input set. Entries for + // other thread scopes of a live agent stay (bounded by scopes visited; + // freed on unmount or when the agent leaves). + for (const key of [...cache.keys()]) { + const pubkey = key.slice(0, key.indexOf(":")); + if (!liveIds.has(pubkey)) cache.delete(key); + } + + const eventStreams = disambiguatePathNamespaces(derivations); + const events = eventStreams + .flat() + .sort((a, b) => a.t - b.t || (a.sourceSeq ?? 0) - (b.sourceSeq ?? 0)); + const sceneAgents = derivations.flatMap((derivation) => derivation.agents); + const activeAgentIds = derivations + .filter((derivation) => derivation.events.length > 0) + .map((derivation) => derivation.agentId); + + const scene = events.length > 0 ? compileScene(events, sceneAgents) : null; + + return { scene, events, agents: sceneAgents, activeAgentIds }; + }, [agents, channelId, storeTick, turnFilter]); +} + +/** + * Per-agent merged frame windows (live + archive) for a channel, rebuilt on + * store ticks. Reference-stable input for useThreadScope: agents with no + * frames in this channel are omitted. + */ +export function useFramesByAgent( + agents: readonly MultiAgentActivityInput[], + channelId: string | null, +): ReadonlyMap { + const storeTick = React.useSyncExternalStore( + subscribeAgentObserverStore, + getObservedAgentPubkeys, + ); + + return React.useMemo(() => { + void storeTick; + const result = new Map(); + for (const agent of agents) { + const frames = framesForAgent(agent.pubkey, channelId); + if (frames.length > 0) result.set(agent.pubkey, frames); + } + return result; + }, [agents, channelId, storeTick]); +} diff --git a/desktop/src/features/agents/activity/useThreadScope.ts b/desktop/src/features/agents/activity/useThreadScope.ts new file mode 100644 index 0000000000..dc3bc09611 --- /dev/null +++ b/desktop/src/features/agents/activity/useThreadScope.ts @@ -0,0 +1,125 @@ +// React wiring for thread-scoped activity: derives each agent's turns from +// its observer frames, resolves turn → thread root through a module-level +// event cache, and exposes ordered thread groups for the chip row. +// +// The event cache is module-level (not per-mount) because thread roots are +// immutable once fetched — a channel revisit or a second surface mounting the +// map should never refetch the same roots. Failed lookups cache as null so a +// deleted trigger message doesn't refetch on every recompute. + +import * as React from "react"; + +import { getEventById } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import type { ObserverEvent } from "../ui/agentSessionTypes"; +import { + buildThreadGroups, + resolveTurnThreads, + type ThreadGroups, + type TurnThreadResolution, +} from "./activityThreads"; +import { type ActivityTurn, deriveActivityTurns } from "./activityTurns"; + +const eventCache = new Map>(); + +function fetchEventCached(eventId: string): Promise { + let promise = eventCache.get(eventId); + if (!promise) { + promise = getEventById(eventId).catch(() => null); + eventCache.set(eventId, promise); + } + return promise; +} + +/** Test-only: reset the module cache between specs. */ +export function _testResetThreadEventCache() { + eventCache.clear(); +} + +export interface ThreadScopeState { + groups: ThreadGroups; + /** + * True while any derived turn lacks a resolution — including the first + * render after mount, BEFORE the resolution effect has fired. Derived + * (not effect-set state) so consumers can trust it in their own + * first-commit effects: a panel mounted pinned to a thread must not + * mistake "not resolved yet" for "thread has no activity". + */ + resolving: boolean; +} + +const EMPTY_GROUPS: ThreadGroups = { threads: [], unthreaded: null }; + +/** + * Thread groups across a set of agents' frame windows. + * + * `framesByAgent` must be reference-stable per recompute (the caller builds + * it inside its own memo). Resolution results accumulate in React state; a + * turn appears in a group only after its thread root resolves, so a group + * chip can lag a live turn by one fetch round-trip — acceptable, because the + * default view (latest thread) already contains the new turn once resolved. + */ +export function useThreadScope( + framesByAgent: ReadonlyMap, +): ThreadScopeState { + const [resolutions, setResolutions] = React.useState< + ReadonlyMap + >(new Map()); + + const turnsByAgent = React.useMemo(() => { + const result = new Map(); + for (const [agentId, frames] of framesByAgent) { + const turns = deriveActivityTurns(frames); + if (turns.length > 0) result.set(agentId, turns); + } + return result; + }, [framesByAgent]); + + // Derived, so it is truthful from the very first render (see interface). + const resolving = React.useMemo(() => { + for (const turns of turnsByAgent.values()) { + for (const turn of turns) { + if (!resolutions.has(turn.id)) return true; + } + } + return false; + }, [turnsByAgent, resolutions]); + + // Resolve any turns not yet in the resolutions map. Generation counter + // discards stale async completions after the input set changes. + const generationRef = React.useRef(0); + React.useEffect(() => { + const unresolved: ActivityTurn[] = []; + for (const turns of turnsByAgent.values()) { + for (const turn of turns) { + if (!resolutions.has(turn.id)) unresolved.push(turn); + } + } + if (unresolved.length === 0) return; + + const generation = ++generationRef.current; + let cancelled = false; + + void resolveTurnThreads(unresolved, fetchEventCached).then((resolved) => { + if (cancelled || generation !== generationRef.current) return; + setResolutions((current) => { + const next = new Map(current); + for (const [turnId, resolution] of resolved) { + next.set(turnId, resolution); + } + return next; + }); + }); + + return () => { + cancelled = true; + }; + }, [turnsByAgent, resolutions]); + + const groups = React.useMemo(() => { + if (turnsByAgent.size === 0) return EMPTY_GROUPS; + return buildThreadGroups(turnsByAgent, resolutions); + }, [turnsByAgent, resolutions]); + + return { groups, resolving }; +} diff --git a/desktop/src/features/agents/getObservedAgentPubkeys.test.mjs b/desktop/src/features/agents/getObservedAgentPubkeys.test.mjs new file mode 100644 index 0000000000..9bb60f8fdb --- /dev/null +++ b/desktop/src/features/agents/getObservedAgentPubkeys.test.mjs @@ -0,0 +1,119 @@ +/** + * Tests for getObservedAgentPubkeys — the whole-store "which agents have + * observer data" snapshot. + * + * The function unions agents with live frames and agents with channel-scoped + * archive windows, normalizes + sorts pubkeys, and caches the resulting array + * per store notify-cycle so useSyncExternalStore callers get a stable + * reference between store changes. + * + * Harness mirrors the existing observer-store tests + * (ingestArchivedObserverEvents.test.mjs / observerTranscriptRetention.test.mjs): + * resetAgentObserverStore between tests, live events seeded via + * injectObserverEventsForE2E, archive events seeded via + * ingestArchivedObserverEvents with _testRegisterKnownAgents and an injected + * mock decrypt fn. + */ + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + getObservedAgentPubkeys, + ingestArchivedObserverEvents, + injectObserverEventsForE2E, + resetAgentObserverStore, + _testRegisterKnownAgents, +} from "@/features/agents/observerRelayStore.ts"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +// Chosen so sorted order is deterministic: "aa…" < "bb…" < "cc…". +const AGENT_A = "a".repeat(64); +const AGENT_B = "b".repeat(64); +const AGENT_C = "c".repeat(64); +const SUB_ID = "test-sub-observed"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeObserverEvent(seq, overrides = {}) { + return { + seq, + timestamp: new Date(1_760_000_000_000 + seq * 1000).toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId: null, + sessionId: null, + turnId: null, + payload: null, + ...overrides, + }; +} + +/** Raw relay event that passes the archive-ingest guards for `agentPubkey`. */ +function makeRawArchiveEvent(agentPubkey) { + return { + id: "e".repeat(64), + pubkey: agentPubkey, + created_at: 1000, + kind: 24200, + tags: [ + ["p", AGENT_A], + ["agent", agentPubkey], + ["frame", "telemetry"], + ], + content: "encrypted", + sig: "s".repeat(128), + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("getObservedAgentPubkeys", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("test_empty_store_returns_empty_array", () => { + assert.deepEqual(getObservedAgentPubkeys(), []); + }); + + it("test_live_event_for_one_agent_returns_normalized_pubkey", () => { + // Seed with an uppercase pubkey — the store normalizes to lowercase. + injectObserverEventsForE2E(AGENT_A.toUpperCase(), [makeObserverEvent(1)]); + assert.deepEqual(getObservedAgentPubkeys(), [AGENT_A]); + }); + + it("test_live_events_for_two_agents_returns_sorted_pubkeys", () => { + // Inject B before A to prove the result is sorted, not insertion-ordered. + injectObserverEventsForE2E(AGENT_B, [makeObserverEvent(1)]); + injectObserverEventsForE2E(AGENT_A, [makeObserverEvent(1)]); + assert.deepEqual(getObservedAgentPubkeys(), [AGENT_A, AGENT_B]); + }); + + it("test_archive_only_agent_is_observed", async () => { + // Agent C has NO live frames — only a channel-scoped archive window, + // seeded through the real ingest path with a mock decrypt fn (the + // test-only _decryptFn parameter, same seam ingestArchivedObserverEvents + // tests use — no real decryption involved). + _testRegisterKnownAgents(SUB_ID, [AGENT_C]); + const archivedEvent = makeObserverEvent(1, { channelId: "chan-1" }); + await ingestArchivedObserverEvents([makeRawArchiveEvent(AGENT_C)], () => + Promise.resolve(archivedEvent), + ); + assert.deepEqual(getObservedAgentPubkeys(), [AGENT_C]); + }); + + it("test_consecutive_calls_without_store_change_return_same_reference", () => { + injectObserverEventsForE2E(AGENT_A, [makeObserverEvent(1)]); + const ref1 = getObservedAgentPubkeys(); + const ref2 = getObservedAgentPubkeys(); + assert.equal(ref1, ref2, "cache must return the same array reference"); + + // A store change invalidates the cache — new reference, updated contents. + injectObserverEventsForE2E(AGENT_B, [makeObserverEvent(1)]); + const ref3 = getObservedAgentPubkeys(); + assert.notEqual(ref3, ref1, "store change must produce a fresh snapshot"); + assert.deepEqual(ref3, [AGENT_A, AGENT_B]); + }); +}); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index b945496380..a9e2050c19 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -206,7 +206,12 @@ let startPromise: Promise | null = null; let eventProcessingQueue: Promise = Promise.resolve(); let generation = 0; +// Monotonic notify-cycle counter; cheap cache-invalidation key for derived +// whole-store snapshots (e.g. getObservedAgentPubkeys). +let storeVersion = 0; + function notifyListeners(update?: AgentObserverStoreUpdate) { + storeVersion += 1; for (const listener of listeners) { listener(update); } @@ -760,6 +765,34 @@ export function getAgentObserverSnapshot( return snapshot; } +/** + * Pubkeys of every agent with observer data in the store — live frames, + * channel-scoped archive windows, or both. Cached by store notify-cycle via + * the version counter so useSyncExternalStore callers get a stable snapshot. + */ +export function getObservedAgentPubkeys(): string[] { + if (observedAgentsCacheVersion === storeVersion && observedAgentsCache) { + return observedAgentsCache; + } + const pubkeys = new Set(); + for (const [key, events] of eventsByAgent) { + if (events.length > 0) pubkeys.add(key); + } + for (const [key, events] of archiveEventsByChannel) { + if (events.length === 0) continue; + // archiveChannelKey shape: `${normalizedPubkey}:${channelId}` — hex + // pubkeys never contain ":", so the first separator is unambiguous. + const separator = key.indexOf(":"); + if (separator > 0) pubkeys.add(key.slice(0, separator)); + } + observedAgentsCache = [...pubkeys].sort(); + observedAgentsCacheVersion = storeVersion; + return observedAgentsCache; +} + +let observedAgentsCache: string[] | null = null; +let observedAgentsCacheVersion = -1; + export function getAgentTranscript( agentPubkey?: string | null, // `_enabled` previously gated store reads — now only gates the relay diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 4e52669224..62f46e54aa 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -7,8 +7,10 @@ import { XCircle, } from "lucide-react"; +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -35,8 +37,15 @@ import { shorten } from "./agentSessionUtils"; import { useObserverEvents, useArchivedChannelEvents, + useLoadArchivedObserverEvents, } from "./useObserverEvents"; import { buildTranscriptState } from "./agentSessionTranscript"; +import { AgentActivityPanel } from "../activity/AgentActivityPanel"; + +export type ArchivePagingHandle = { + fetchOlder: () => Promise; + hasOlder: boolean; +}; type ManagedAgentSessionPanelProps = { agent: Pick & { @@ -57,6 +66,12 @@ type ManagedAgentSessionPanelProps = { profiles?: UserProfileLookup; rawEventsOverride?: ObserverEvent[]; transcriptOverride?: TranscriptItem[]; + /** + * External archive pager (e.g. the thread panel's scroll-driven pager). + * When omitted, this panel runs its own paging hook so non-thread surfaces + * (profile tabs) still hydrate archived history. + */ + archivePaging?: ArchivePagingHandle; }; export function ManagedAgentSessionPanel({ @@ -75,8 +90,22 @@ export function ManagedAgentSessionPanel({ profiles, rawEventsOverride, transcriptOverride, + archivePaging, }: ManagedAgentSessionPanelProps) { const hasObserver = agent.status === "running" || agent.status === "deployed"; + + // Hydration parity: surfaces without their own archive pager (profile + // tabs) get one here, so archived history renders on every mount surface. + // Thread-panel mounts pass their scroll-driven pager in via archivePaging + // and this hook stays disabled (enabled=false → no queries fire). + const ownPaging = useLoadArchivedObserverEvents( + archivePaging === undefined && Boolean(channelId), + archivePaging === undefined ? (channelId ?? null) : null, + ); + const effectivePaging: ArchivePagingHandle = archivePaging ?? { + fetchOlder: ownPaging.fetchOlderArchived, + hasOlder: ownPaging.hasOlderArchived, + }; // Always read from the store — archived frames are ingested regardless of // live status and must be renderable for idle agents with channel history. // The `hasObserver` flag still gates the relay subscription (via the @@ -128,6 +157,12 @@ export function ManagedAgentSessionPanel({ [displayEvents], ); + // Preview feature: file activity map derived from the same + // combined observer window the transcript reads. The working signal (scoped + // to this panel's channel) is the canonical "live" flag. + const activityPanelEnabled = useFeatureEnabled("agentActivityPanel"); + const { working: agentWorking } = useAgentWorking(agent.pubkey, channelId); + return (
) : null} + {activityPanelEnabled ? ( + + ) : null} + ) : null}
- : undefined + {/* Per-thread activity pills read this provider from their summary + rows — the channel-top strip this replaces put one entry point + on the whole channel; pills distribute them across threads. */} + + > + : undefined + } + huddleMemberPubkeys={huddleMemberPubkeys} + huddleMemberPubkeysPending={huddleMemberPubkeysPending} + isFetchingOlder={isFetchingOlder} + isFollowingThreadById={isFollowingThreadById} + isMessageUnreadById={isMessageUnreadById} + personaLookup={personaLookup} + profiles={profiles} + ownerProfiles={ownerProfiles} + unfollowThreadById={unfollowThreadById} + emptyDescription={ + activeChannel?.channelType === "forum" + ? "Select a stream or DM to load real message history in this first integration pass." + : "Messages and sub-replies will appear here once the relay has history for this channel." + } + emptyTitle={ + activeChannel + ? activeChannel.channelType === "forum" + ? "Forum channels are next" + : "No messages yet" + : "No channel selected" + } + isError={isTimelineError} + isLoading={isHuddleTranscript ? false : isTimelineLoading} + onRetry={onRetryTimeline} + entranceMessageId={entranceMessageId} + onEntranceMessageComplete={onEntranceMessageComplete} + mainEntries={mainTimelineEntries} + threadSummaries={threadSummaries} + messages={visibleMessages} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + onDelete={onDelete} + onEdit={handleRoutedEdit} + onMarkUnread={onMarkUnread} + onMarkRead={onMarkRead} + onReply={timelineReplyHandler} + onOpenThread={isHuddleTranscript ? undefined : onOpenThread} + channelName={activeChannel?.name} + channelType={activeChannel?.channelType ?? null} + isSendingVideoReviewComment={isSending} + onSendVideoReviewComment={ + activeChannel?.archivedAt + ? undefined + : onSendVideoReviewComment + } + onTargetReached={onTargetReached} + onToggleReaction={onToggleReaction} + {...searchHighlightProps.timeline} + targetMessageId={targetMessageId} + splitThreadPanelOpen={ + useSplitAuxiliaryPane && + !useFocusThreadDrawer && + Boolean(openThreadHeadId) + } + threadUnreadCounts={threadUnreadCounts} + /> + {isNonMemberView ? (
+
{showDepthGuides && depthGuideItems.length > 0 ? (
+ {/* Activity map entry point for threads where agents did file work. + Renders null without the channel provider or when this thread has + no resolved agent activity. Sibling of the summary button (not a + child): nested interactive elements are invalid and the two open + different surfaces. */} +
); } diff --git a/desktop/tests/e2e/agent-activity-panel-screenshots.spec.ts b/desktop/tests/e2e/agent-activity-panel-screenshots.spec.ts new file mode 100644 index 0000000000..a841edce64 --- /dev/null +++ b/desktop/tests/e2e/agent-activity-panel-screenshots.spec.ts @@ -0,0 +1,488 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; + +const SHOTS = "test-results/agent-activity-panel"; + +// A running managed agent whose pubkey maps to the "agents" channel. +// Tyler's pubkey so the mock bridge already knows this identity. +const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; +const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents +const T0 = Date.parse("2025-06-15T12:00:00Z"); + +const MANAGED_AGENTS = [ + { + pubkey: AGENT_PUBKEY, + name: "Observer Agent", + status: "running" as const, + channelNames: ["agents"], + }, +]; + +type SeededObserverEvent = { + seq: number; + timestamp: string; + kind: string; + agentIndex: number | null; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + payload: unknown; +}; + +function frame( + seq: number, + offsetMs: number, + kind: string, + turnId: string | null, + payload: unknown, +): SeededObserverEvent { + return { + seq, + timestamp: new Date(T0 + offsetMs).toISOString(), + kind, + agentIndex: 0, + channelId: CHANNEL_ID, + sessionId: "session-001", + turnId, + payload, + }; +} + +function sessionUpdate(update: Record): unknown { + return { + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "session-001", update }, + }; +} + +function toolCall( + toolCallId: string, + fields: Record, +): unknown { + return sessionUpdate({ + sessionUpdate: "tool_call", + toolCallId, + status: "pending", + ...fields, + }); +} + +function narration(text: string): unknown { + return sessionUpdate({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); +} + +/** + * Two completed turns of realistic file activity in one repo, separated by a + * two-minute idle gap (exercises gap compression). Turn 1 reads and patches + * the store, creates a notes file, and runs a shell command; turn 2 touches + * the README. + */ +function activityFrames(): SeededObserverEvent[] { + const repo = "/Users/tyler/repo"; + let seq = 0; + const next = () => { + seq += 1; + return seq; + }; + + return [ + frame(next(), 0, "turn_started", "turn-001", { + source: "channel", + triggeringEventIds: ["a".repeat(64)], + }), + frame(next(), 500, "session_resolved", "turn-001", { + sessionId: "session-001", + isNewSession: true, + }), + frame( + next(), + 1_000, + "acp_read", + "turn-001", + narration("Let me look at the store implementation first."), + ), + frame( + next(), + 2_000, + "acp_read", + "turn-001", + toolCall("call-1", { + title: "read_file", + kind: "read", + locations: [{ path: `${repo}/src/store.rs` }], + }), + ), + frame( + next(), + 5_000, + "acp_read", + "turn-001", + toolCall("call-2", { + title: "read_file", + kind: "read", + locations: [{ path: `${repo}/src/lib.rs` }], + }), + ), + frame( + next(), + 9_000, + "acp_read", + "turn-001", + narration("Now patching the eviction bug in the store."), + ), + frame( + next(), + 10_000, + "acp_read", + "turn-001", + toolCall("call-3", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${repo}/src/store.rs` }], + }), + ), + frame( + next(), + 14_000, + "acp_read", + "turn-001", + toolCall("call-4", { + title: "write", + kind: "other", + rawInput: { path: `${repo}/docs/notes.md`, content: "notes" }, + }), + ), + frame( + next(), + 17_000, + "acp_read", + "turn-001", + toolCall("call-5", { + title: "developer__shell", + kind: "execute", + rawInput: { command: "cargo test" }, + }), + ), + frame(next(), 21_000, "turn_completed", "turn-001", {}), + + // Two minutes idle — compressed to a short gap band in display time. + frame(next(), 141_000, "turn_started", "turn-002", { + source: "channel", + triggeringEventIds: ["b".repeat(64)], + }), + frame( + next(), + 142_000, + "acp_read", + "turn-002", + narration("Updating the README with the new behavior."), + ), + frame( + next(), + 143_000, + "acp_read", + "turn-002", + toolCall("call-6", { + title: "read_file", + kind: "read", + locations: [{ path: `${repo}/README.md` }], + }), + ), + frame( + next(), + 147_000, + "acp_read", + "turn-002", + toolCall("call-7", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${repo}/README.md` }], + }), + ), + frame(next(), 150_000, "turn_completed", "turn-002", {}), + ]; +} + +async function waitForSeedHook(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + null, + { timeout: 10_000 }, + ); +} + +async function openObserverFeedPanel(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForSeedHook(page); + + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const messageRow = page + .getByTestId("message-row") + .filter({ has: page.getByText("Observer Agent", { exact: false }) }); + await expect(messageRow.first()).toBeVisible({ timeout: 8_000 }); + await messageRow.first().getByRole("button").first().click(); + + const profilePanel = page.getByTestId("user-profile-panel"); + await expect(profilePanel).toBeVisible({ timeout: 10_000 }); + + const activityBtn = page.getByTestId( + `user-profile-view-activity-${AGENT_PUBKEY}`, + ); + await expect(activityBtn).toBeVisible({ timeout: 5_000 }); + await activityBtn.click(); + + const feedPanel = page.getByTestId("agent-session-thread-panel"); + await expect(feedPanel).toBeVisible({ timeout: 10_000 }); + return feedPanel; +} + +async function seedObserverEvents( + page: import("@playwright/test").Page, + events: SeededObserverEvent[], +) { + await page.evaluate( + ({ pubkey, evts }) => { + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey: pubkey, + events: evts, + }); + }, + { pubkey: AGENT_PUBKEY, evts: events }, + ); + // Let React re-render and the rAF loop paint a frame. + await page.waitForTimeout(400); +} + +test.describe("agent activity panel screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + page.on("console", (msg) => { + if (msg.type() === "error") { + console.error("CONSOLE ERROR:", msg.text().slice(0, 500)); + } + }); + + // Enable the preview feature before the app boots. + await page.addInitScript((overridesKey) => { + window.localStorage.setItem( + overridesKey, + JSON.stringify({ agentActivityPanel: true }), + ); + }, FEATURE_OVERRIDES_STORAGE_KEY); + }); + + test("01 — session activity map with gap compression", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + const feedPanel = await openObserverFeedPanel(page); + + const panel = feedPanel.locator( + 'section[aria-label="Agent file activity"]', + ); + + // Before any frames: placeholder copy renders instead of the canvas. + await expect( + feedPanel.getByText("No file activity in this window yet.", { + exact: false, + }), + ).toBeVisible({ timeout: 5_000 }); + + await seedObserverEvents(page, activityFrames()); + + await expect(panel).toBeVisible({ timeout: 5_000 }); + // Both turns completed and the agent is idle → parked at the end. + await expect(panel.getByText("Idle")).toBeVisible({ timeout: 5_000 }); + + // Turn picker offers the whole session plus both turns. + const scopePicker = panel.getByLabel("Activity scope"); + await expect(scopePicker).toBeVisible(); + const optionLabels = await scopePicker.locator("option").allTextContents(); + expect(optionLabels.some((label) => label.includes("Whole session"))).toBe( + true, + ); + expect(optionLabels.some((label) => label.includes("Turn 1"))).toBe(true); + expect(optionLabels.some((label) => label.includes("Turn 2"))).toBe(true); + + await panel.screenshot({ path: `${SHOTS}/01-session-activity-map.png` }); + }); + + /** + * Seed two threads whose root ids match the seeded turns' + * triggeringEventIds (turn→thread resolution finds real roots with + * labels), each with an agent reply so the timeline renders a thread + * summary row — the surface the activity pill mounts on. + */ + async function seedThreadedChannel(page: import("@playwright/test").Page) { + await page.evaluate((agentPubkey) => { + const emit = ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + createdAt?: number; + id?: string; + parentEventId?: string; + pubkey?: string; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable"); + const nowS = Math.floor(Date.now() / 1000); + emit({ + channelName: "agents", + content: "Fix the eviction bug in the store", + createdAt: nowS - 600, + id: "a".repeat(64), + }); + emit({ + channelName: "agents", + content: "On it — patching the store now.", + createdAt: nowS - 590, + parentEventId: "a".repeat(64), + pubkey: agentPubkey, + }); + emit({ + channelName: "agents", + content: "Update the README for the new behavior", + createdAt: nowS - 300, + id: "b".repeat(64), + }); + emit({ + channelName: "agents", + content: "Updating the README.", + createdAt: nowS - 290, + parentEventId: "b".repeat(64), + pubkey: agentPubkey, + }); + }, AGENT_PUBKEY); + } + + test("03 — thread activity pill opens the map scoped to its thread", async ({ + page, + }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForSeedHook(page); + + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + await seedThreadedChannel(page); + + // No pills until an agent has observer activity resolved to the threads. + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(0); + + await seedObserverEvents(page, activityFrames()); + + // One pill per thread with agent file activity, on the summary rows. + await expect(pills).toHaveCount(2, { timeout: 5_000 }); + await expect(pills.first()).toContainText("Observer Agent"); + + const summaryRow = page + .getByTestId("message-thread-summary") + .filter({ hasText: "1 reply" }) + .first(); + await expect(summaryRow).toBeVisible(); + + await page.screenshot({ + path: `${SHOTS}/03-thread-activity-pills.png`, + fullPage: false, + }); + + // The pill for the eviction thread (older, first in the timeline) opens + // the map locked to THAT thread: only turn-001's store/lib/notes rows. + // The pill IS the thread selection — no thread picker inside. (File + // rows are canvas-drawn, not DOM text; scope proof is per-pill + // screenshot diffing in spec 04.) + await pills.first().click(); + const popover = page.getByTestId("thread-activity-popover"); + await expect(popover).toBeVisible({ timeout: 5_000 }); + const panel = popover.locator('section[aria-label="Agent file activity"]'); + await expect(panel).toBeVisible({ timeout: 5_000 }); + + await expect(panel.getByTestId("activity-thread-chips")).toHaveCount(0); + + await popover.screenshot({ + path: `${SHOTS}/03-thread-pill-popover.png`, + }); + + // Agent chips still work inside the popover. + await expect( + panel.getByRole("button", { name: "All agents" }), + ).toBeVisible(); + }); + + test("04 — each pill opens the map locked to its own thread", async ({ + page, + }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForSeedHook(page); + + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + await seedThreadedChannel(page); + await seedObserverEvents(page, activityFrames()); + + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(2, { timeout: 5_000 }); + const popover = page.getByTestId("thread-activity-popover"); + const panel = popover.locator('section[aria-label="Agent file activity"]'); + + // Eviction pill (older thread, first in the timeline) → turn-001 scope: + // three file rows (store.rs, lib.rs, notes.md), so the canvas is taller. + await pills.first().click(); + await expect(panel).toBeVisible({ timeout: 5_000 }); + const evictionBox = await panel.boundingBox(); + await panel.screenshot({ path: `${SHOTS}/04-scope-eviction-thread.png` }); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + + // README pill (newest thread) → turn-002 scope: one file row. + await pills.nth(1).click(); + await expect(panel).toBeVisible({ timeout: 5_000 }); + const readmeBox = await panel.boundingBox(); + await panel.screenshot({ path: `${SHOTS}/04-scope-readme-thread.png` }); + + // Different thread → different scope: the one-file map is strictly + // shorter than the three-file map (rows drive canvas height). + if (!evictionBox || !readmeBox) throw new Error("panel box unavailable"); + expect(readmeBox.height).toBeLessThan(evictionBox.height); + }); + + test("02 — turn-scoped replay", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + const feedPanel = await openObserverFeedPanel(page); + await seedObserverEvents(page, activityFrames()); + + const panel = feedPanel.locator( + 'section[aria-label="Agent file activity"]', + ); + await expect(panel).toBeVisible({ timeout: 5_000 }); + + // Scope to turn 2: only the README events remain. + const scopePicker = panel.getByLabel("Activity scope"); + await scopePicker.selectOption("turn:turn-002"); + await page.waitForTimeout(400); + + await expect(panel.getByText("Idle")).toBeVisible({ timeout: 5_000 }); + await panel.screenshot({ path: `${SHOTS}/02-turn-scoped-replay.png` }); + }); +}); diff --git a/desktop/tests/e2e/design-kit-assets.spec.ts b/desktop/tests/e2e/design-kit-assets.spec.ts new file mode 100644 index 0000000000..d5445c6326 --- /dev/null +++ b/desktop/tests/e2e/design-kit-assets.spec.ts @@ -0,0 +1,594 @@ +// Design-kit asset generator — NOT part of any test project on purpose. +// Run manually: +// pnpm build:e2e && pnpm exec playwright test tests/e2e/design-kit-assets.spec.ts --config=playwright.design-kit.config.ts +// Outputs 2x screenshots + a video walkthrough to test-results/design-kit/. +// Untracked scratch tooling for the Agent Activity Map design package. + +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; + +const SHOTS = "test-results/design-kit"; + +const AGENT_A = TEST_IDENTITIES.tyler.pubkey; // "Scout" +const AGENT_B = TEST_IDENTITIES.charlie.pubkey; // "Mason" +const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents + +const THREAD_EVICTION = "a".repeat(64); +const THREAD_README = "b".repeat(64); + +const MANAGED_AGENTS = [ + { + pubkey: AGENT_A, + name: "Scout", + status: "running" as const, + channelNames: ["agents"], + }, + { + pubkey: AGENT_B, + name: "Mason", + status: "running" as const, + channelNames: ["agents"], + }, +]; + +type SeededObserverEvent = { + seq: number; + timestamp: string; + kind: string; + agentIndex: number | null; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + payload: unknown; +}; + +function frameFactory(sessionId: string, t0: number, seqStart = 0) { + let seq = seqStart; + return ( + offsetMs: number, + kind: string, + turnId: string | null, + payload: unknown, + ): SeededObserverEvent => { + seq += 1; + return { + seq, + timestamp: new Date(t0 + offsetMs).toISOString(), + kind, + agentIndex: 0, + channelId: CHANNEL_ID, + sessionId, + turnId, + payload, + }; + }; +} + +function sessionUpdate(sessionId: string, update: Record) { + return { + jsonrpc: "2.0", + method: "session/update", + params: { sessionId, update }, + }; +} + +function toolCall( + sessionId: string, + toolCallId: string, + fields: Record, +) { + return sessionUpdate(sessionId, { + sessionUpdate: "tool_call", + toolCallId, + status: "pending", + ...fields, + }); +} + +function narration(sessionId: string, text: string) { + return sessionUpdate(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); +} + +const REPO = "/Users/demo/checkout"; + +/** Agent A: eviction-thread turn (3 files) + README-thread turn. */ +function scoutFrames(t0: number): SeededObserverEvent[] { + const f = frameFactory("scout-session", t0); + const s = "scout-session"; + return [ + f(0, "turn_started", "scout-t1", { + source: "channel", + triggeringEventIds: [THREAD_EVICTION], + }), + f(800, "acp_read", "scout-t1", narration(s, "Reading the store first.")), + f( + 1_500, + "acp_read", + "scout-t1", + toolCall(s, "sc-1", { + title: "read_file", + kind: "read", + locations: [{ path: `${REPO}/src/store.rs` }], + }), + ), + f( + 4_000, + "acp_read", + "scout-t1", + toolCall(s, "sc-2", { + title: "read_file", + kind: "read", + locations: [{ path: `${REPO}/src/lib.rs` }], + }), + ), + f( + 7_500, + "acp_read", + "scout-t1", + narration(s, "Patching the eviction bug now."), + ), + f( + 8_200, + "acp_read", + "scout-t1", + toolCall(s, "sc-3", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${REPO}/src/store.rs` }], + }), + ), + f( + 12_000, + "acp_read", + "scout-t1", + toolCall(s, "sc-4", { + title: "write", + kind: "other", + rawInput: { path: `${REPO}/docs/notes.md`, content: "notes" }, + }), + ), + f(15_000, "turn_completed", "scout-t1", {}), + + f(120_000, "turn_started", "scout-t2", { + source: "channel", + triggeringEventIds: [THREAD_README], + }), + f( + 121_000, + "acp_read", + "scout-t2", + narration(s, "Updating the README with the new behavior."), + ), + f( + 122_000, + "acp_read", + "scout-t2", + toolCall(s, "sc-5", { + title: "read_file", + kind: "read", + locations: [{ path: `${REPO}/README.md` }], + }), + ), + f( + 126_000, + "acp_read", + "scout-t2", + toolCall(s, "sc-6", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${REPO}/README.md` }], + }), + ), + f(129_000, "turn_completed", "scout-t2", {}), + ]; +} + +/** Agent B: same eviction thread, same repo — near-simultaneous write to + * store.rs (drives the cross-agent contention flare). */ +function masonFrames(t0: number): SeededObserverEvent[] { + const f = frameFactory("mason-session", t0); + const s = "mason-session"; + return [ + f(2_000, "turn_started", "mason-t1", { + source: "channel", + triggeringEventIds: [THREAD_EVICTION], + }), + f( + 2_800, + "acp_read", + "mason-t1", + narration(s, "Adding a regression test for the eviction path."), + ), + f( + 3_500, + "acp_read", + "mason-t1", + toolCall(s, "ms-1", { + title: "read_file", + kind: "read", + locations: [{ path: `${REPO}/tests/eviction.rs` }], + }), + ), + f( + 6_500, + "acp_read", + "mason-t1", + toolCall(s, "ms-2", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${REPO}/tests/eviction.rs` }], + }), + ), + f( + 9_000, + "acp_read", + "mason-t1", + narration(s, "Store API changed — fixing the callsite too."), + ), + f( + 9_600, + "acp_read", + "mason-t1", + toolCall(s, "ms-3", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${REPO}/src/store.rs` }], + }), + ), + f( + 13_000, + "acp_read", + "mason-t1", + toolCall(s, "ms-4", { + title: "developer__shell", + kind: "execute", + rawInput: { command: "cargo test eviction" }, + }), + ), + f(16_000, "turn_completed", "mason-t1", {}), + ]; +} + +/** Live tail for Scout: an OPEN turn (no turn_completed) in the README + * thread with wall-clock-recent timestamps, so the pill dot + LIVE badge + * light up when paired with a seeded active turn. */ +function scoutLiveFrames(nowMs: number): SeededObserverEvent[] { + // seqStart continues after scoutFrames' 13 frames — the store orders by + // (timestamp, seq) and the live turn must sort after the historical ones. + const f = frameFactory("scout-session", nowMs - 20_000, 100); + const s = "scout-session"; + return [ + f(0, "turn_started", "scout-t3", { + source: "channel", + triggeringEventIds: [THREAD_README], + }), + f( + 1_000, + "acp_read", + "scout-t3", + narration(s, "Follow-up: expanding the usage section."), + ), + f( + 2_500, + "acp_read", + "scout-t3", + toolCall(s, "sc-7", { + title: "read_file", + kind: "read", + locations: [{ path: `${REPO}/README.md` }], + }), + ), + f( + 9_000, + "acp_read", + "scout-t3", + toolCall(s, "sc-8", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${REPO}/README.md` }], + }), + ), + f( + 16_000, + "acp_read", + "scout-t3", + toolCall(s, "sc-9", { + title: "apply_patch", + kind: "edit", + locations: [{ path: `${REPO}/docs/usage.md` }], + }), + ), + ]; +} + +async function waitForSeedHook(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + null, + { timeout: 10_000 }, + ); +} + +async function seedObserver( + page: import("@playwright/test").Page, + agentPubkey: string, + events: SeededObserverEvent[], +) { + await page.evaluate( + ({ pubkey, evts }) => { + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey: pubkey, + events: evts, + }); + }, + { pubkey: agentPubkey, evts: events }, + ); + await page.waitForTimeout(400); +} + +async function seedThreads(page: import("@playwright/test").Page) { + await page.evaluate( + ({ agentA, agentB, evictionId, readmeId }) => { + const emit = ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + createdAt?: number; + id?: string; + parentEventId?: string; + pubkey?: string; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable"); + const nowS = Math.floor(Date.now() / 1_000); + emit({ + channelName: "agents", + content: + "Fix the eviction bug in the cache store — repro in #4821, evictions fire twice under load", + createdAt: nowS - 900, + id: evictionId, + }); + emit({ + channelName: "agents", + content: "On it — patching the store and adding a regression test.", + createdAt: nowS - 880, + parentEventId: evictionId, + pubkey: agentA, + }); + emit({ + channelName: "agents", + content: "Taking the test half.", + createdAt: nowS - 870, + parentEventId: evictionId, + pubkey: agentB, + }); + emit({ + channelName: "agents", + content: "Update the README for the new eviction behavior", + createdAt: nowS - 400, + id: readmeId, + }); + emit({ + channelName: "agents", + content: "Updating the docs now.", + createdAt: nowS - 390, + parentEventId: readmeId, + pubkey: agentA, + }); + }, + { + agentA: AGENT_A, + agentB: AGENT_B, + evictionId: THREAD_EVICTION, + readmeId: THREAD_README, + }, + ); +} + +async function seedActiveTurn( + page: import("@playwright/test").Page, + agentPubkey: string, + turnId: string, +) { + await page.evaluate( + ({ pubkey, channelId, turn }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: pubkey, + channelId, + turnId: turn, + }); + }, + { pubkey: agentPubkey, channelId: CHANNEL_ID, turn: turnId }, + ); +} + +async function openChannel(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForSeedHook(page); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); +} + +test.describe("design kit assets", () => { + test.use({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript((overridesKey) => { + window.localStorage.setItem( + overridesKey, + JSON.stringify({ agentActivityPanel: true }), + ); + }, FEATURE_OVERRIDES_STORAGE_KEY); + }); + + test("A — pills in timeline (idle)", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openChannel(page); + await seedThreads(page); + const t0 = Date.now() - 15 * 60_000; + await seedObserver(page, AGENT_A, scoutFrames(t0)); + await seedObserver(page, AGENT_B, masonFrames(t0)); + + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(2, { timeout: 8_000 }); + await expect(pills.first()).toContainText("2 agents"); + await expect(pills.nth(1)).toContainText("Scout"); + + await page.screenshot({ path: `${SHOTS}/A-pills-in-timeline.png` }); + }); + + test("B — popover idle, multi-agent with contention", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openChannel(page); + await seedThreads(page); + const t0 = Date.now() - 15 * 60_000; + await seedObserver(page, AGENT_A, scoutFrames(t0)); + await seedObserver(page, AGENT_B, masonFrames(t0)); + + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(2, { timeout: 8_000 }); + await pills.first().click(); + + const popover = page.getByTestId("thread-activity-popover"); + const panel = popover.locator('section[aria-label="Agent file activity"]'); + await expect(panel).toBeVisible({ timeout: 5_000 }); + await expect(panel.getByText("Idle")).toBeVisible({ timeout: 5_000 }); + await page.waitForTimeout(600); + + await popover.screenshot({ + path: `${SHOTS}/B-popover-idle-multiagent.png`, + }); + await page.screenshot({ path: `${SHOTS}/B-popover-in-context.png` }); + }); + + test("C — agent filter chip active", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openChannel(page); + await seedThreads(page); + const t0 = Date.now() - 15 * 60_000; + await seedObserver(page, AGENT_A, scoutFrames(t0)); + await seedObserver(page, AGENT_B, masonFrames(t0)); + + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(2, { timeout: 8_000 }); + await pills.first().click(); + + const popover = page.getByTestId("thread-activity-popover"); + const panel = popover.locator('section[aria-label="Agent file activity"]'); + await expect(panel).toBeVisible({ timeout: 5_000 }); + + await panel.getByRole("button", { name: /Mason/ }).click(); + await page.waitForTimeout(600); + await popover.screenshot({ path: `${SHOTS}/C-agent-filter-mason.png` }); + }); + + test("D — replay scrubbed mid-turn", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openChannel(page); + await seedThreads(page); + const t0 = Date.now() - 15 * 60_000; + await seedObserver(page, AGENT_A, scoutFrames(t0)); + await seedObserver(page, AGENT_B, masonFrames(t0)); + + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(2, { timeout: 8_000 }); + await pills.first().click(); + + const popover = page.getByTestId("thread-activity-popover"); + const panel = popover.locator('section[aria-label="Agent file activity"]'); + await expect(panel).toBeVisible({ timeout: 5_000 }); + + // Restart playback from the beginning, let the playhead run into the + // turn, then pause mid-flight for the shot. + await panel.getByRole("button", { name: "Restart" }).click(); + await page.waitForTimeout(1_800); + await panel.getByRole("button", { name: "Play" }).click(); + await page.waitForTimeout(300); + await expect(panel.getByText("Replay")).toBeVisible(); + await popover.screenshot({ path: `${SHOTS}/D-replay-mid-scrub.png` }); + }); + + test("E — live: working dot + LIVE badge", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openChannel(page); + await seedThreads(page); + const t0 = Date.now() - 15 * 60_000; + await seedObserver(page, AGENT_A, scoutFrames(t0)); + await seedObserver(page, AGENT_B, masonFrames(t0)); + await seedObserver(page, AGENT_A, scoutLiveFrames(Date.now())); + await seedActiveTurn(page, AGENT_A, "scout-t3"); + + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(2, { timeout: 8_000 }); + // README thread pill (second) should now show the working dot. + await page.waitForTimeout(800); + await page.screenshot({ path: `${SHOTS}/E-pill-working-dot.png` }); + + await pills.nth(1).click(); + const popover = page.getByTestId("thread-activity-popover"); + const panel = popover.locator('section[aria-label="Agent file activity"]'); + await expect(panel).toBeVisible({ timeout: 5_000 }); + await expect(panel.getByText("Live", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + await page.waitForTimeout(800); + await popover.screenshot({ path: `${SHOTS}/E-popover-live.png` }); + }); + + test("F — video walkthrough", async ({ page }) => { + await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); + await openChannel(page); + await seedThreads(page); + const t0 = Date.now() - 15 * 60_000; + await seedObserver(page, AGENT_A, scoutFrames(t0)); + await seedObserver(page, AGENT_B, masonFrames(t0)); + + const pills = page.getByTestId("thread-activity-pill"); + await expect(pills).toHaveCount(2, { timeout: 8_000 }); + await page.waitForTimeout(1_500); + + // Open the multi-agent thread, let the DVR replay run. + await pills.first().click(); + const popover = page.getByTestId("thread-activity-popover"); + const panel = popover.locator('section[aria-label="Agent file activity"]'); + await expect(panel).toBeVisible({ timeout: 5_000 }); + await page.waitForTimeout(1_000); + await panel.getByRole("button", { name: "Restart" }).click(); + await page.waitForTimeout(4_000); + + // Filter to one agent, then back to all. + await panel.getByRole("button", { name: /Mason/ }).click(); + await page.waitForTimeout(1_500); + await panel.getByRole("button", { name: "All agents" }).click(); + await page.waitForTimeout(1_500); + + // Close, open the other thread's pill. + await page.keyboard.press("Escape"); + await page.waitForTimeout(600); + await pills.nth(1).click(); + await expect(panel).toBeVisible({ timeout: 5_000 }); + await page.waitForTimeout(1_000); + await panel.getByRole("button", { name: "Restart" }).click(); + await page.waitForTimeout(3_500); + await page.keyboard.press("Escape"); + await page.waitForTimeout(800); + }); +}); diff --git a/preview-features.json b/preview-features.json index 3b9de66ca8..2b128bcac1 100644 --- a/preview-features.json +++ b/preview-features.json @@ -36,6 +36,12 @@ "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", "platforms": ["desktop"] + }, + { + "id": "agentActivityPanel", + "name": "Agent activity panel", + "description": "File activity map with DVR replay for agent sessions", + "platforms": ["desktop"] } ] }