mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Stabilize streamlined agent sidebar
Add a 120s linger window for live sidebar agent rows so finished runs do not immediately swap the section to the recent fallback. Scope heartbeat progress invalidation away from hot list queries while preserving direct cache patches and agent detail refreshes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
953b315dfb
commit
621d9e289c
4 files changed
+136
-24
No files matched your search
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent, ResourceMemberships } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
@@ -267,6 +268,7 @@ describe("SidebarAgents", () => {
|
||||
currentRoot.unmount();
|
||||
});
|
||||
}
|
||||
vi.useRealTimers();
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
@@ -302,6 +304,23 @@ describe("SidebarAgents", () => {
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function renderSidebarAgentsWithFakeTimers() {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents streamlined />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderRailSidebarAgents() {
|
||||
mockSidebarState.collapsed = true;
|
||||
const currentRoot = createRoot(container);
|
||||
@@ -645,6 +664,49 @@ describe("SidebarAgents", () => {
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("keeps formerly live agents visible for the streamlined linger window", async () => {
|
||||
vi.useFakeTimers({ now: new Date("2026-01-01T00:00:00Z") });
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
makeAgent({ id: "agent-d", name: "Delta", urlKey: "delta" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-a", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgentsWithFakeTimers();
|
||||
|
||||
let labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
expect(labels[0]).toContain("Alpha");
|
||||
expect(labels[0]).toContain("1 live");
|
||||
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
await act(async () => {
|
||||
queryClient.setQueryData(queryKeys.liveRuns("company-1"), []);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
labels = agentLinkLabels(container);
|
||||
expect(labels).toEqual(["Alpha"]);
|
||||
expect(labels.join(" ")).not.toContain("live");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
});
|
||||
expect(agentLinkLabels(container)).toEqual(["Alpha"]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
});
|
||||
|
||||
it("shows up to 3 recently-active agents plus a See all link when none are running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -63,6 +63,7 @@ import type { Agent } from "@paperclipai/shared";
|
||||
* recently-active agents plus a "See all agents" link (IA Phase 5).
|
||||
*/
|
||||
const RECENT_AGENT_LIMIT = 3;
|
||||
const LIVE_AGENT_LINGER_MS = 120_000;
|
||||
|
||||
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||
{ value: "top", label: "Top" },
|
||||
@@ -298,6 +299,8 @@ function SidebarAgentItem({
|
||||
export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const [, setLiveLingerVersion] = useState(0);
|
||||
const lastSeenLiveAtRef = useRef<Map<string, number>>(new Map());
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
@@ -354,6 +357,13 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
||||
}
|
||||
return counts;
|
||||
}, [liveRuns]);
|
||||
const liveAgentIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const [agentId, count] of liveCountByAgent) {
|
||||
if (count > 0) ids.add(agentId);
|
||||
}
|
||||
return ids;
|
||||
}, [liveCountByAgent]);
|
||||
|
||||
const visibleAgents = useMemo(() => {
|
||||
const filtered = (agents ?? []).filter(
|
||||
@@ -384,15 +394,36 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
||||
() => sortAgents(orderedAgents, sortMode),
|
||||
[orderedAgents, sortMode],
|
||||
);
|
||||
const sortedAgentIdSet = useMemo(
|
||||
() => new Set(sortedAgents.map((agent: Agent) => agent.id)),
|
||||
[sortedAgents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
for (const agentId of liveAgentIds) {
|
||||
lastSeenLiveAtRef.current.set(agentId, now);
|
||||
}
|
||||
for (const agentId of lastSeenLiveAtRef.current.keys()) {
|
||||
if (!sortedAgentIdSet.has(agentId)) {
|
||||
lastSeenLiveAtRef.current.delete(agentId);
|
||||
}
|
||||
}
|
||||
}, [liveAgentIds, sortedAgentIdSet]);
|
||||
|
||||
// IA Phase 5 (streamlined): if any agent has a live run, show only those
|
||||
// active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either
|
||||
// way a "See all agents" link is shown so the full list is always reachable.
|
||||
// active agents. Agents that just stopped running linger briefly so clustered
|
||||
// run boundaries do not make rows pop out and the section does not immediately
|
||||
// swap to the recent fallback during short all-idle gaps. Otherwise fall back
|
||||
// to up to RECENT_AGENT_LIMIT agents. Either way a "See all agents" link is
|
||||
// shown so the full list is always reachable.
|
||||
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
|
||||
const runningAgents = useMemo(
|
||||
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
|
||||
[sortedAgents, liveCountByAgent],
|
||||
);
|
||||
const nowForLiveLinger = Date.now();
|
||||
const runningAgents = sortedAgents.filter((agent: Agent) => {
|
||||
if ((liveCountByAgent.get(agent.id) ?? 0) > 0) return true;
|
||||
const lastSeenLiveAt = lastSeenLiveAtRef.current.get(agent.id);
|
||||
return lastSeenLiveAt !== undefined && nowForLiveLinger - lastSeenLiveAt <= LIVE_AGENT_LINGER_MS;
|
||||
});
|
||||
const hasActiveAgents = runningAgents.length > 0;
|
||||
const displayedAgents = !streamlined
|
||||
? sortedAgents
|
||||
@@ -437,6 +468,30 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
|
||||
};
|
||||
}, [sortModeStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamlined) return;
|
||||
|
||||
const now = Date.now();
|
||||
let nextExpiryAt: number | null = null;
|
||||
for (const agent of sortedAgents) {
|
||||
if ((liveCountByAgent.get(agent.id) ?? 0) > 0) continue;
|
||||
const lastSeenLiveAt = lastSeenLiveAtRef.current.get(agent.id);
|
||||
if (lastSeenLiveAt === undefined) continue;
|
||||
const expiresAt = lastSeenLiveAt + LIVE_AGENT_LINGER_MS;
|
||||
if (expiresAt < now) continue;
|
||||
nextExpiryAt = nextExpiryAt === null ? expiresAt : Math.min(nextExpiryAt, expiresAt);
|
||||
}
|
||||
if (nextExpiryAt === null) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setLiveLingerVersion((version) => version + 1);
|
||||
}, Math.max(0, nextExpiryAt - now + 1));
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [streamlined, sortedAgents, liveCountByAgent]);
|
||||
|
||||
const persistSortMode = useCallback(
|
||||
(value: string) => {
|
||||
const nextSortMode: AgentSidebarSortMode =
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps heartbeat progress invalidation scoped to live run data", () => {
|
||||
it("keeps heartbeat progress invalidation scoped away from hot list queries", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
@@ -117,21 +117,21 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.liveRuns("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.heartbeats("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.agents.list("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.agents.detail("agent-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.liveRuns("company-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.heartbeats("company-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.heartbeats("company-1", "agent-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.agents.list("company-1"),
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.dashboard("company-1"),
|
||||
});
|
||||
|
||||
@@ -817,17 +817,12 @@ function invalidateHeartbeatQueries(
|
||||
|
||||
function invalidateHeartbeatProgressQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
companyId: string,
|
||||
_companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
if (agentId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user