feat(desktop): add per-thread agent activity map

Adds the activity scene compiler and canvas panel, mounts the draft preview behind the agentActivityPanel flag, and exposes the final per-thread activity pill entry point.

Includes screenshot/design-kit coverage and slop cleanup from the phase-0 branch.

Co-authored-by: Zach Marley <zmarley@squareup.com>

Signed-off-by: Zach Marley <zmarley@squareup.com>
This commit is contained in:
zrmarley
2026-09-08 14:54:40 -04:00
parent 218633b8fd
commit 654cb12f44
36 changed files with 8615 additions and 72 deletions

View File

@@ -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",

View File

@@ -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",
},
});

View File

@@ -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<string, string> = {
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<unknown>;
}
export function AgentActivityPanel({
frames,
agentPubkey,
agentName,
agentIndex,
isLive,
className,
hasOlderHistory,
onLoadOlder,
}: AgentActivityPanelProps) {
const [scope, setScope] = React.useState<ActivityScope>("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 (
<div
className={cn(
"rounded-lg border border-border/70 bg-muted/30 px-3 py-4 text-sm text-muted-foreground",
className,
)}
>
No file activity in this window yet. Activity appears when the agent
reads or edits files during a turn.
</div>
);
}
return (
<ActivityPanelBody
key={`${agentPubkey}:${scope}`}
scene={scene}
scopeIsLive={scopeIsLive}
scope={scope}
setScope={setScope}
turns={turns}
className={className}
hasOlderHistory={hasOlderHistory}
onLoadOlder={onLoadOlder}
/>
);
}
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<typeof deriveActivityTurns>;
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<unknown>;
}) {
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<HTMLElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const hitTargetsRef = React.useRef<FrameHitTargets | null>(null);
const hoverRef = React.useRef<HoverState | null>(null);
const draggingRef = React.useRef(false);
const [hover, setHover] = React.useState<HoverState | null>(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<number | null>(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<ActivityTransportState>(
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
if (!draggingRef.current) return;
draggingRef.current = false;
canvasRef.current?.releasePointerCapture(event.pointerId);
},
[],
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
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 (
<section
ref={containerRef}
className={cn(
"flex flex-col gap-2 rounded-lg border border-border/70 bg-background/80 p-2 shadow-xs focus:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
aria-label="Agent file activity"
// biome-ignore lint/a11y/noNoninteractiveTabindex: the section is the DVR keyboard surface (Space/arrows/L/R), same pattern as TerminalSubstrate.
tabIndex={0}
onKeyDown={handleKeyDown}
>
<div className="flex flex-wrap items-center gap-2">
<TransportBadge badge={badge} />
{pickerSlot ?? (
<TurnScopePicker scope={scope} setScope={setScope} turns={turns} />
)}
{hasOlderHistory && onLoadOlder ? (
<Button
disabled={loadingOlder}
size="xs"
type="button"
variant="ghost"
onClick={() => void loadOlder()}
>
{loadingOlder ? "Loading…" : "Load older"}
</Button>
) : null}
<div className="ml-auto flex items-center gap-1">
<Button
size="xs"
type="button"
variant="ghost"
onClick={() =>
applyTransport((state) =>
jumpActivityTransport(
state,
-ACTIVITY_SCRUB_STEP_MS,
boundsRef.current,
),
)
}
>
−2s
</Button>
<Button
size="xs"
type="button"
variant="ghost"
onClick={() => applyTransport(toggleActivityPlayback)}
>
{transportChrome.mode === "replay" && !transportChrome.playing
? "Play"
: "Pause"}
</Button>
<Button
size="xs"
type="button"
variant="ghost"
onClick={() =>
applyTransport((state) =>
jumpActivityTransport(
state,
ACTIVITY_SCRUB_STEP_MS,
boundsRef.current,
),
)
}
>
+2s
</Button>
<Button
size="xs"
type="button"
variant="ghost"
onClick={() => applyTransport(cycleActivitySpeed)}
>
{transportChrome.speed}×
</Button>
{scopeIsLive && transportChrome.mode !== "live" ? (
<Button
size="xs"
type="button"
variant="secondary"
onClick={() => applyTransport(jumpToLiveActivityTransport)}
>
Live
</Button>
) : null}
<Button
size="xs"
type="button"
variant="ghost"
onClick={() => applyTransport(restartActivityTransport)}
>
Restart
</Button>
</div>
</div>
<div className="relative">
<div
className="overflow-y-auto overscroll-contain rounded-md"
style={{ maxHeight: effectiveViewportHeight }}
>
<canvas
ref={canvasRef}
className="w-full cursor-crosshair"
style={{ height: size.height }}
width={Math.max(1, Math.floor(size.width * dpr))}
height={Math.max(1, Math.floor(size.height * dpr))}
aria-label="Agent activity timeline"
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
/>
</div>
{hover ? <ActivityTooltip hover={hover} /> : null}
</div>
<div className="flex items-center gap-2">
<p className="min-w-0 flex-1 truncate text-2xs text-muted-foreground/70">
Space play/pause · ←/→ scrub 2s · L live · R restart
</p>
{/* biome-ignore lint/a11y/useSemanticElements: an <hr> can't host the drag/keyboard resize interaction; window-splitter-style separator per WAI-ARIA. */}
<div
aria-label="Activity panel height"
aria-orientation="horizontal"
aria-valuemax={MAX_VIEWPORT_HEIGHT}
aria-valuemin={MIN_VIEWPORT_HEIGHT}
aria-valuenow={Math.round(effectiveViewportHeight)}
className="h-2 w-16 shrink-0 cursor-row-resize touch-none rounded-full bg-border/70 hover:bg-border focus:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
role="separator"
tabIndex={0}
title="Drag or ↑/↓ to resize · double-click or Backspace to reset"
onDoubleClick={() => 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);
}}
/>
</div>
</section>
);
}
function TransportBadge({ badge }: { badge: "live" | "replay" | "idle" }) {
if (badge === "live") {
return <Badge variant="success">Live</Badge>;
}
if (badge === "idle") {
// "Idle", not "Ended": the scope can wake again on the next turn.
return <Badge variant="secondary">Idle</Badge>;
}
return <Badge variant="info">Replay</Badge>;
}
function TurnScopePicker({
scope,
setScope,
turns,
}: {
scope: ActivityScope;
setScope: (scope: ActivityScope) => void;
turns: ReturnType<typeof deriveActivityTurns>;
}) {
if (turns.length === 0) return null;
return (
<select
className="h-6 rounded-md border border-input/40 bg-background px-1 text-xs text-foreground"
aria-label="Activity scope"
value={scope}
onChange={(event) => {
const value = event.target.value;
setScope(value === "session" ? "session" : turnScope(value.slice(5)));
}}
>
<option value="session">Whole session</option>
{turns.map((turn, index) => (
<option key={turn.id} value={turnScope(turn.id)}>
Turn {index + 1}
{turn.source === "heartbeat" ? " (heartbeat)" : ""}
{turn.endT === undefined ? " — live" : ""}
</option>
))}
</select>
);
}
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<HTMLDivElement | null>(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 (
<div
ref={containerRef}
className="pointer-events-none absolute z-20 max-w-72 rounded-md border border-border/70 bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-md"
style={{ left: offset.left, top: offset.top }}
>
{intent ? (
<p className="mb-1 line-clamp-4 text-muted-foreground italic">
{intent}
</p>
) : null}
{hover.rider ? (
<p className="font-medium">{hover.rider.agentName} — view activity</p>
) : null}
{!hover.rider && target ? (
target.clusterCount && target.clusterCount > 1 ? (
<div>
<p className="font-medium">ran {target.clusterCount}×</p>
{target.clusterLabels ? (
<p className="text-muted-foreground">
{[...new Set(target.clusterLabels)].slice(0, 4).join(", ")}
</p>
) : null}
</div>
) : (
<p className="font-medium">{describeEvent(target.event)}</p>
)
) : null}
{!hover.rider && !target && hover.rowPath ? (
<p className="font-medium">{hover.rowPath}</p>
) : null}
{!hover.rider && !target && hover.gapLabel ? (
<p className="font-medium">{hover.gapLabel}</p>
) : null}
</div>
);
}

View File

@@ -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<typeof deriveActivityTurns> = [];
export function ThreadActivityPanel({
channelId,
agents,
threadGroup,
className,
}: ThreadActivityPanelProps) {
const { onOpenAgentSession } = useAgentSession();
const [agentFilter, setAgentFilter] = React.useState<string | null>(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<ActivityScope>("session");
if (!scene || scene.events.length === 0) {
return (
<div
className={cn(
"rounded-lg border border-border/70 bg-muted/30 px-3 py-4 text-sm text-muted-foreground",
className,
)}
>
No agent file activity in this thread yet.
</div>
);
}
const chips = (
<div className="flex min-w-0 flex-wrap items-center gap-1">
<AgentChip
active={agentFilter === null}
color={null}
label="All agents"
onClick={() => setAgentFilter(null)}
/>
{sceneAgentsOrInputs(agents, sceneAgents).map((agent) => (
<AgentChip
key={agent.pubkey}
active={agentFilter === agent.pubkey}
color={colorForAgent(agent.pubkey, agent.index)}
label={agent.name}
working={workingPubkeys.includes(agent.pubkey)}
onClick={() =>
setAgentFilter((current) =>
current === agent.pubkey ? null : agent.pubkey,
)
}
onDoubleClick={() => openAgent(agent.pubkey)}
/>
))}
</div>
);
return (
<ActivityPanelBody
key={`thread:${channelId}:${threadGroup.rootId ?? "unthreaded"}:${agentFilter ?? "all"}`}
className={className}
scene={scene}
scopeIsLive={anyWorking}
scope={scope}
setScope={setScope}
turns={EMPTY_TURNS}
pickerSlot={chips}
onRiderClick={openAgent}
/>
);
}
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 (
<button
className={cn(
"flex h-6 items-center gap-1.5 rounded-full border px-2 text-xs transition-colors",
active
? "border-ring/60 bg-accent text-accent-foreground"
: "border-border/70 bg-background text-muted-foreground hover:bg-muted/70",
)}
title={onDoubleClick ? `${label} — double-click to open session` : label}
type="button"
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{color ? (
<span
aria-hidden
className={cn("h-2 w-2 rounded-full", working && "animate-pulse")}
style={{ backgroundColor: color }}
/>
) : null}
<span className="max-w-32 truncate">{label}</span>
</button>
);
}

View File

@@ -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 (
<ThreadActivityPillBody
agents={agents}
channelId={channelId}
group={group}
workingPubkeys={workingPubkeys}
/>
);
}
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 (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-expanded={open}
className={cn(
"flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-border/50 bg-background/85 px-2.5 text-2xs text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-muted/70 hover:text-foreground",
open && "text-foreground",
)}
data-testid="thread-activity-pill"
type="button"
>
<span className="font-medium uppercase tracking-[0.14em]">
Activity
</span>
<span className="max-w-32 truncate">
{agentDisplayLabel(group, agents)}
</span>
{working ? (
<span
aria-hidden
className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500"
title="Agents working"
/>
) : null}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(90vw,44rem)] p-1.5"
collisionPadding={12}
data-testid="thread-activity-popover"
side="bottom"
>
<ThreadActivityPanel
agents={agents}
channelId={channelId}
className="border-0 bg-transparent shadow-none"
threadGroup={group}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -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];
}

View File

@@ -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");
});
});

View File

@@ -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<string, ActivitySceneAgent>;
primaryAgentId: string;
nodes: ActivitySceneNode[];
byPath: Map<string, ActivitySceneNode>;
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<string, SourceTreeNode>;
}
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<ActivitySceneEvent, "id" | "displayT">;
function agentName(
agentsById: ReadonlyMap<string, ActivityAgent>,
agentId: string,
): string {
return agentsById.get(agentId)?.name ?? agentId;
}
function createContentionEvent(
first: PendingActivitySceneEvent,
second: PendingActivitySceneEvent,
agentsById: ReadonlyMap<string, ActivityAgent>,
): 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<string, ActivityAgent>,
): 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<PendingActivitySceneEvent>((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,
};
}

View File

@@ -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],
);
});
});

View File

@@ -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<RelayEvent | null>;
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<TurnThreadResolution> {
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<Map<string, TurnThreadResolution>> {
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<string, Set<string>>;
/**
* 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<string>;
}
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<string, readonly ActivityTurn[]>,
resolutions: ReadonlyMap<string, TurnThreadResolution>,
): ThreadGroups {
const groups = new Map<string | null, ThreadGroup>();
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<string>,
): ObserverEvent[] {
return frames.filter(
(frame) => frame.turnId !== null && allowedTurnIds.has(frame.turnId),
);
}

View File

@@ -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);
});
});

View File

@@ -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<number>(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,
};
}

View File

@@ -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");
});
});

View File

@@ -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";
}

View File

@@ -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,
});
});
});

View File

@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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, unknown>): 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<string, ActivityTurn>();
const order: ActivityTurn[] = [];
const lastSeenT = new Map<string, number>();
const completed = new Set<string>();
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<string>();
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 };
}

View File

@@ -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;
}

View File

@@ -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");
});
});

View File

@@ -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<string, unknown>;
}
// 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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<string, unknown>,
keys: readonly string[],
): string[] {
return keys.flatMap((key) => collectStringValues(args[key]));
}
function uniqueNormalizedPaths(
rawPaths: readonly string[],
roots: readonly string[],
): string[] {
const seen = new Set<string>();
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, unknown>): 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<string, ToolCallRecord>();
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<string, unknown>;
}
}
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<string>();
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,
},
],
};
}

View File

@@ -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");
});
});

View File

@@ -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,
},
};
}

View File

@@ -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);
}

View File

@@ -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<string, ThreadGroup>;
/**
* 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<ChannelThreadActivityValue>(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 (
<ChannelThreadActivityContext.Provider value={EMPTY_VALUE}>
{children}
</ChannelThreadActivityContext.Provider>
);
}
return (
<ChannelThreadActivityProviderBody
activityAgents={activityAgents}
channelId={channelId}
>
{children}
</ChannelThreadActivityProviderBody>
);
}
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<ChannelThreadActivityValue>(() => {
const groupsByRootId = new Map<string, ThreadGroup>();
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 (
<ChannelThreadActivityContext.Provider value={value}>
{children}
</ChannelThreadActivityContext.Provider>
);
}

View File

@@ -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 };
}

View File

@@ -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");
});
});

View File

@@ -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<string, ReadonlySet<string>> | 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<string, PerAgentDerivation>(),
);
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<string>();
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<string, readonly ObserverEvent[]> {
const storeTick = React.useSyncExternalStore(
subscribeAgentObserverStore,
getObservedAgentPubkeys,
);
return React.useMemo(() => {
void storeTick;
const result = new Map<string, readonly ObserverEvent[]>();
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]);
}

View File

@@ -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<string, Promise<RelayEvent | null>>();
function fetchEventCached(eventId: string): Promise<RelayEvent | null> {
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<string, readonly ObserverEvent[]>,
): ThreadScopeState {
const [resolutions, setResolutions] = React.useState<
ReadonlyMap<string, TurnThreadResolution>
>(new Map());
const turnsByAgent = React.useMemo(() => {
const result = new Map<string, readonly ActivityTurn[]>();
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 };
}

View File

@@ -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]);
});
});

View File

@@ -206,7 +206,12 @@ let startPromise: Promise<void> | null = null;
let eventProcessingQueue: Promise<void> = 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<string>();
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

View File

@@ -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<unknown>;
hasOlder: boolean;
};
type ManagedAgentSessionPanelProps = {
agent: Pick<ManagedAgent, "pubkey" | "name"> & {
@@ -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 (
<section
className={cn(
@@ -146,6 +181,18 @@ export function ManagedAgentSessionPanel({
/>
) : null}
{activityPanelEnabled ? (
<AgentActivityPanel
agentPubkey={agent.pubkey}
agentName={agent.name}
className="mt-3"
frames={combinedEvents}
hasOlderHistory={effectivePaging.hasOlder}
isLive={agentWorking}
onLoadOlder={effectivePaging.fetchOlder}
/>
) : null}
<SessionBody
agentAvatarUrl={agent.avatarUrl ?? null}
agentName={agent.name}

View File

@@ -517,6 +517,10 @@ export function AgentSessionThreadPanel({
<div ref={contentRef}>
<ManagedAgentSessionPanel
agent={agent}
archivePaging={{
fetchOlder: fetchOlderArchived,
hasOlder: hasOlderArchived,
}}
channelId={sessionChannelId}
className="border-0 bg-transparent px-0 py-2 shadow-none"
emptyDescription={

View File

@@ -26,6 +26,7 @@ import {
import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ChannelThreadActivityProvider } from "@/features/agents/activity/threadActivityContext";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel";
@@ -622,78 +623,90 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
) : null}
<div className="relative isolate flex min-h-0 min-w-0 flex-1 flex-col">
<MessageTimeline
ref={messageTimelineRef}
channelId={activeChannel?.id}
channelIntro={channelIntro}
directMessageIntro={directMessageIntro}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
hideDayDividers={isHuddleTranscript}
alwaysShowMessageIdentity={isHuddleTranscript}
hideAgentAccessBadges={isHuddleTranscript}
pinnedIntro={
isHuddleTranscript ? <HuddleTranscriptIntro /> : 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. */}
<ChannelThreadActivityProvider
activityAgents={activityAgents}
channelId={
isHuddleTranscript ? null : (activeChannel?.id ?? null)
}
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}
/>
>
<MessageTimeline
ref={messageTimelineRef}
channelId={activeChannel?.id}
channelIntro={channelIntro}
directMessageIntro={directMessageIntro}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
hideDayDividers={isHuddleTranscript}
alwaysShowMessageIdentity={isHuddleTranscript}
hideAgentAccessBadges={isHuddleTranscript}
pinnedIntro={
isHuddleTranscript ? <HuddleTranscriptIntro /> : 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}
/>
</ChannelThreadActivityProvider>
{isNonMemberView ? (
<div
data-testid="join-banner"

View File

@@ -15,6 +15,7 @@ import {
THREAD_REPLY_LINE_WIDTH_REM,
THREAD_REPLY_ROW_MARGIN_INLINE_REM,
} from "@/features/messages/lib/threadTreeLayout";
import { ThreadActivityPill } from "@/features/agents/activity/ThreadActivityPill";
import { cn } from "@/shared/lib/cn";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -109,7 +110,7 @@ export function MessageThreadSummaryRow({
);
return (
<div className="relative pb-1 pt-0.5">
<div className="relative flex items-center gap-2 pb-1 pt-0.5">
{showDepthGuides && depthGuideItems.length > 0 ? (
<div
aria-hidden={
@@ -274,6 +275,12 @@ export function MessageThreadSummaryRow({
</div>
</div>
</button>
{/* 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. */}
<ThreadActivityPill threadRootId={message.id} />
</div>
);
}

View File

@@ -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<string, unknown>): unknown {
return {
jsonrpc: "2.0",
method: "session/update",
params: { sessionId: "session-001", update },
};
}
function toolCall(
toolCallId: string,
fields: Record<string, unknown>,
): 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` });
});
});

View File

@@ -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<string, unknown>) {
return {
jsonrpc: "2.0",
method: "session/update",
params: { sessionId, update },
};
}
function toolCall(
sessionId: string,
toolCallId: string,
fields: Record<string, unknown>,
) {
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);
});
});

View File

@@ -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"]
}
]
}