mirror of
https://github.com/block/buzz.git
synced 2026-11-15 05:41:14 +00:00
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:
@@ -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",
|
||||
|
||||
22
desktop/playwright.design-kit.config.ts
Normal file
22
desktop/playwright.design-kit.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
831
desktop/src/features/agents/activity/AgentActivityPanel.tsx
Normal file
831
desktop/src/features/agents/activity/AgentActivityPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
desktop/src/features/agents/activity/ThreadActivityPanel.tsx
Normal file
191
desktop/src/features/agents/activity/ThreadActivityPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
desktop/src/features/agents/activity/ThreadActivityPill.tsx
Normal file
124
desktop/src/features/agents/activity/ThreadActivityPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
desktop/src/features/agents/activity/activityModel.ts
Normal file
64
desktop/src/features/agents/activity/activityModel.ts
Normal 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];
|
||||
}
|
||||
368
desktop/src/features/agents/activity/activityScene.test.mjs
Normal file
368
desktop/src/features/agents/activity/activityScene.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
600
desktop/src/features/agents/activity/activityScene.ts
Normal file
600
desktop/src/features/agents/activity/activityScene.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
270
desktop/src/features/agents/activity/activityThreads.test.mjs
Normal file
270
desktop/src/features/agents/activity/activityThreads.test.mjs
Normal 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],
|
||||
);
|
||||
});
|
||||
});
|
||||
168
desktop/src/features/agents/activity/activityThreads.ts
Normal file
168
desktop/src/features/agents/activity/activityThreads.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
158
desktop/src/features/agents/activity/activityTime.test.mjs
Normal file
158
desktop/src/features/agents/activity/activityTime.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
158
desktop/src/features/agents/activity/activityTime.ts
Normal file
158
desktop/src/features/agents/activity/activityTime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
224
desktop/src/features/agents/activity/activityTransport.test.mjs
Normal file
224
desktop/src/features/agents/activity/activityTransport.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
178
desktop/src/features/agents/activity/activityTransport.ts
Normal file
178
desktop/src/features/agents/activity/activityTransport.ts
Normal 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";
|
||||
}
|
||||
196
desktop/src/features/agents/activity/activityTurns.test.mjs
Normal file
196
desktop/src/features/agents/activity/activityTurns.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
144
desktop/src/features/agents/activity/activityTurns.ts
Normal file
144
desktop/src/features/agents/activity/activityTurns.ts
Normal 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 };
|
||||
}
|
||||
117
desktop/src/features/agents/activity/activityUtils.ts
Normal file
117
desktop/src/features/agents/activity/activityUtils.ts
Normal 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;
|
||||
}
|
||||
550
desktop/src/features/agents/activity/deriveActivity.test.mjs
Normal file
550
desktop/src/features/agents/activity/deriveActivity.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
669
desktop/src/features/agents/activity/deriveActivity.ts
Normal file
669
desktop/src/features/agents/activity/deriveActivity.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
374
desktop/src/features/agents/activity/renderScene.test.mjs
Normal file
374
desktop/src/features/agents/activity/renderScene.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
821
desktop/src/features/agents/activity/renderScene.ts
Normal file
821
desktop/src/features/agents/activity/renderScene.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
272
desktop/src/features/agents/activity/renderSceneLayout.ts
Normal file
272
desktop/src/features/agents/activity/renderSceneLayout.ts
Normal 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);
|
||||
}
|
||||
119
desktop/src/features/agents/activity/threadActivityContext.tsx
Normal file
119
desktop/src/features/agents/activity/threadActivityContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
desktop/src/features/agents/activity/useActivityScene.ts
Normal file
91
desktop/src/features/agents/activity/useActivityScene.ts
Normal 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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
221
desktop/src/features/agents/activity/useMultiAgentActivity.ts
Normal file
221
desktop/src/features/agents/activity/useMultiAgentActivity.ts
Normal 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]);
|
||||
}
|
||||
125
desktop/src/features/agents/activity/useThreadScope.ts
Normal file
125
desktop/src/features/agents/activity/useThreadScope.ts
Normal 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 };
|
||||
}
|
||||
119
desktop/src/features/agents/getObservedAgentPubkeys.test.mjs
Normal file
119
desktop/src/features/agents/getObservedAgentPubkeys.test.mjs
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
488
desktop/tests/e2e/agent-activity-panel-screenshots.spec.ts
Normal file
488
desktop/tests/e2e/agent-activity-panel-screenshots.spec.ts
Normal 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` });
|
||||
});
|
||||
});
|
||||
594
desktop/tests/e2e/design-kit-assets.spec.ts
Normal file
594
desktop/tests/e2e/design-kit-assets.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user