diff --git a/.agents/skills/create-issue-interaction-ui/SKILL.md b/.agents/skills/create-issue-interaction-ui/SKILL.md index 9c46dacd46..59e22795ba 100644 --- a/.agents/skills/create-issue-interaction-ui/SKILL.md +++ b/.agents/skills/create-issue-interaction-ui/SKILL.md @@ -6,7 +6,7 @@ description: > checkbox confirmations, ask_user_questions, or suggest_tasks. --- -# Create a new issue-thread interaction UI (developer skill) +# Create a new issue-thread interaction UI (Developer/maintainer skill) This skill walks a Paperclip contributor through introducing a new issue-thread interaction kind from shared contract to issue-detail wiring, helpers, and @@ -14,6 +14,8 @@ docs. It is intentionally a developer/maintainer skill: the audience is a human or coding agent making code changes inside `paperclipai/paperclip`, not the operational agents that run inside a deployed Paperclip company. +Do NOT install this on production Paperclip agents. This guide is for repository contributors changing Paperclip itself. + ## When to use - A new interaction kind is being introduced (compact picker, structured diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 1630a66f12..1cdfdd49a8 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -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( + + + , + ); + }); + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + }); + } + async function renderRailSidebarAgents() { mockSidebarState.collapsed = true; const currentRoot = createRoot(container); @@ -645,6 +664,110 @@ 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("expires staggered lingering agents without unrelated sidebar updates", 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(); + expect(agentLinkLabels(container)[0]).toContain("Alpha"); + + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + await act(async () => { + queryClient.setQueryData(queryKeys.liveRuns("company-1"), []); + }); + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(agentLinkLabels(container)).toEqual(["Alpha"]); + + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + { id: "run-2", agentId: "agent-b", status: "running" }, + ]); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + queryClient.setQueryData(queryKeys.liveRuns("company-1"), [ + { id: "run-2", agentId: "agent-b", status: "running" }, + ]); + }); + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(agentLinkLabels(container).join(" ")).toContain("Bravo"); + + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + await act(async () => { + queryClient.setQueryData(queryKeys.liveRuns("company-1"), []); + }); + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo"]); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_001); + }); + expect(agentLinkLabels(container)).toEqual(["Bravo"]); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + 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) => diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 7e681da39b..da1f9dde4f 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -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>(() => new Set()); + const [liveLingerVersion, setLiveLingerVersion] = useState(0); + const lastSeenLiveAtRef = useRef>(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(); + 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,39 @@ 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 runningAgents = useMemo(() => { + const nowForLiveLinger = Date.now(); + const lastSeenLiveAtByAgent = lastSeenLiveAtRef.current; + return sortedAgents.filter((agent: Agent) => { + if ((liveCountByAgent.get(agent.id) ?? 0) > 0) return true; + const lastSeenLiveAt = lastSeenLiveAtByAgent.get(agent.id); + return lastSeenLiveAt !== undefined && nowForLiveLinger - lastSeenLiveAt <= LIVE_AGENT_LINGER_MS; + }); + }, [liveCountByAgent, liveLingerVersion, sortedAgents]); const hasActiveAgents = runningAgents.length > 0; const displayedAgents = !streamlined ? sortedAgents @@ -437,6 +471,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, liveLingerVersion]); + const persistSortMode = useCallback( (value: string) => { const nextSortMode: AgentSidebarSortMode = diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 422046a52c..9d13bfe50a 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -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"), }); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index cef24b1f13..19eb9ce7f7 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -817,17 +817,12 @@ function invalidateHeartbeatQueries( function invalidateHeartbeatProgressQueries( queryClient: ReturnType, - companyId: string, + _companyId: string, payload: Record, ) { - 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) }); } } diff --git a/ui/src/hooks/useSharedPolling.test.ts b/ui/src/hooks/useSharedPolling.test.ts new file mode 100644 index 0000000000..71fbb8bc4d --- /dev/null +++ b/ui/src/hooks/useSharedPolling.test.ts @@ -0,0 +1,43 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { applySharedPollingResult } from "./useSharedPolling"; + +describe("applySharedPollingResult", () => { + it("drops result messages that are older than local query state", () => { + const queryClient = new QueryClient(); + const queryKey = ["live-runs", "company-1"]; + queryClient.setQueryData(queryKey, [{ id: "run-1", lastEventAt: "newer" }], { updatedAt: 2_000 }); + + const applied = applySharedPollingResult(queryClient, queryKey, { + type: "result", + key: "company:live-runs", + from: "leader", + at: 1_000, + dataUpdatedAt: 1_000, + data: [{ id: "run-1", lastEventAt: "older" }], + }); + + expect(applied).toBe(false); + expect(queryClient.getQueryData(queryKey)).toEqual([{ id: "run-1", lastEventAt: "newer" }]); + expect(queryClient.getQueryState(queryKey)?.dataUpdatedAt).toBe(2_000); + }); + + it("applies newer result messages with the producer dataUpdatedAt", () => { + const queryClient = new QueryClient(); + const queryKey = ["live-runs", "company-1"]; + queryClient.setQueryData(queryKey, [{ id: "run-1", lastEventAt: "older" }], { updatedAt: 1_000 }); + + const applied = applySharedPollingResult(queryClient, queryKey, { + type: "result", + key: "company:live-runs", + from: "leader", + at: 4_000, + dataUpdatedAt: 3_000, + data: [{ id: "run-1", lastEventAt: "newer" }], + }); + + expect(applied).toBe(true); + expect(queryClient.getQueryData(queryKey)).toEqual([{ id: "run-1", lastEventAt: "newer" }]); + expect(queryClient.getQueryState(queryKey)?.dataUpdatedAt).toBe(3_000); + }); +}); diff --git a/ui/src/hooks/useSharedPolling.ts b/ui/src/hooks/useSharedPolling.ts index 1fc2ea674e..094d40c69f 100644 --- a/ui/src/hooks/useSharedPolling.ts +++ b/ui/src/hooks/useSharedPolling.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type QueryKey, useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, type QueryKey, useQueryClient } from "@tanstack/react-query"; import { SharedPollingCoordinator, + type SharedMessage, type SharedPollingSnapshot, } from "../lib/cross-tab-poll"; @@ -26,7 +27,7 @@ export interface SharedPollingQueryState { isLeader: boolean; enabled: boolean; refetchInterval: RefetchInterval; - publish: (data: TData | undefined) => void; + publish: (data: TData | undefined, dataUpdatedAt: number) => void; } type RegistryEntry = { @@ -61,6 +62,20 @@ function resourceKey(companyId: string, key: string, queryKeyHash: string): stri return `${companyId}:${key}:${queryKeyHash}`; } +export function applySharedPollingResult( + queryClient: Pick, + queryKey: QueryKey, + message: SharedMessage, +): boolean { + if (message.type !== "result") return false; + const incomingUpdatedAt = message.dataUpdatedAt ?? message.at; + if (incomingUpdatedAt <= 0) return false; + const localUpdatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0; + if (localUpdatedAt >= incomingUpdatedAt) return false; + queryClient.setQueryData(queryKey, message.data as TData, { updatedAt: incomingUpdatedAt }); + return true; +} + export function useSharedPollingQuery({ companyId, resourceKey: rawResourceKey, @@ -89,8 +104,7 @@ export function useSharedPollingQuery({ const coordinator = acquireCoordinator(activeCompanyId); const unsubscribeState = coordinator.subscribe(setSnapshot); const unsubscribeResource = coordinator.subscribeResource(fullResourceKey, (message) => { - if (message.type !== "result") return; - queryClient.setQueryData(queryKeyRef.current, message.data as TData); + applySharedPollingResult(queryClient, queryKeyRef.current, message); }); coordinator.request(fullResourceKey); @@ -105,10 +119,10 @@ export function useSharedPollingQuery({ const queryEnabled = enabled && (!leaderOnly || snapshot.isLeader); const coordinatedInterval = leaderOnly && !snapshot.isLeader ? false : refetchInterval; - const publish = useCallback((data: TData | undefined) => { + const publish = useCallback((data: TData | undefined, dataUpdatedAt: number) => { if (!activeCompanyId || !fullResourceKey || data === undefined) return; const entry = coordinators.get(activeCompanyId); - entry?.coordinator.publish(fullResourceKey, data); + entry?.coordinator.publish(fullResourceKey, data, dataUpdatedAt); }, [activeCompanyId, fullResourceKey]); return useMemo( @@ -129,6 +143,6 @@ export function usePublishSharedQueryData( ): void { useEffect(() => { if (!shared.isLeader || dataUpdatedAt <= 0) return; - shared.publish(data); + shared.publish(data, dataUpdatedAt); }, [data, dataUpdatedAt, shared]); } diff --git a/ui/src/lib/cross-tab-poll.test.ts b/ui/src/lib/cross-tab-poll.test.ts index d2a831b245..19d5c67c84 100644 --- a/ui/src/lib/cross-tab-poll.test.ts +++ b/ui/src/lib/cross-tab-poll.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { LeaderElection, + SharedPollingCoordinator, createLeaseStore, createStorageRelayShared, type LeaseStore, + type SharedChannel, type SharedMessage, } from "./cross-tab-poll"; @@ -23,6 +25,48 @@ class MemoryLeaseStore implements LeaseStore { } } +class MemorySharedChannel implements SharedChannel { + posts: SharedMessage[] = []; + private handler: ((message: SharedMessage) => void) | null = null; + + post(message: SharedMessage) { + this.posts.push(message); + } + + subscribe(handler: (message: SharedMessage) => void) { + this.handler = handler; + return () => { + this.handler = null; + }; + } + + emit(message: SharedMessage) { + this.handler?.(message); + } + + close() {} +} + +function startLeaderCoordinator(channel: MemorySharedChannel) { + const store = new MemoryLeaseStore(); + const election = new LeaderElection({ now: () => Date.now(), store, random: () => 0 }, { + tabId: "leader", + leaseTtlMs: 10_000, + }); + const coordinator = new SharedPollingCoordinator("company-1", { + tabId: "leader", + channel, + election, + tickMs: 10_000, + publishDebounceMs: 1_000, + now: () => Date.now(), + getVisible: () => true, + }); + coordinator.start(); + expect(coordinator.getSnapshot().isLeader).toBe(true); + return coordinator; +} + describe("LeaderElection", () => { it("elects one visible leader and keeps the second visible tab as follower", () => { let now = 1_000; @@ -157,3 +201,72 @@ describe("createStorageRelayShared", () => { unsubscribe(); }); }); + +describe("SharedPollingCoordinator", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("dedupes same-resource publishes and rate limits trailing result broadcasts", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const channel = new MemorySharedChannel(); + const coordinator = startLeaderCoordinator(channel); + + coordinator.publish("company:live-runs", [{ id: "run-1", lastEventAt: "a" }], 100); + expect(channel.posts.filter((message) => message.type === "result")).toEqual([ + { + type: "result", + key: "company:live-runs", + from: "leader", + at: 1_000, + dataUpdatedAt: 100, + data: [{ id: "run-1", lastEventAt: "a" }], + }, + ]); + + coordinator.publish("company:live-runs", [{ lastEventAt: "a", id: "run-1" }], 100); + coordinator.publish("company:live-runs", [{ id: "run-1", lastEventAt: "a" }], 101); + expect(channel.posts.filter((message) => message.type === "result")).toHaveLength(1); + + vi.setSystemTime(1_200); + coordinator.publish("company:live-runs", [{ id: "run-1", lastEventAt: "b" }], 200); + coordinator.publish("company:live-runs", [{ id: "run-1", lastEventAt: "b" }], 200); + expect(channel.posts.filter((message) => message.type === "result")).toHaveLength(1); + + vi.advanceTimersByTime(799); + expect(channel.posts.filter((message) => message.type === "result")).toHaveLength(1); + + vi.advanceTimersByTime(1); + expect(channel.posts.filter((message) => message.type === "result")).toHaveLength(2); + expect(channel.posts.at(-1)).toEqual({ + type: "result", + key: "company:live-runs", + from: "leader", + at: 2_000, + dataUpdatedAt: 200, + data: [{ id: "run-1", lastEventAt: "b" }], + }); + + coordinator.stop(); + }); + + it("preserves original result timestamps when reposting the latest result for a request", () => { + vi.useFakeTimers(); + vi.setSystemTime(5_000); + const channel = new MemorySharedChannel(); + const coordinator = startLeaderCoordinator(channel); + + coordinator.publish("company:live-runs", [{ id: "run-1" }], 123); + const original = channel.posts[0]; + + vi.setSystemTime(9_000); + channel.emit({ type: "request", key: "company:live-runs", from: "follower", at: 9_000 }); + + expect(channel.posts[1]).toEqual({ ...original, from: "leader" }); + expect(channel.posts[1]?.at).toBe(5_000); + expect(channel.posts[1]?.dataUpdatedAt).toBe(123); + + coordinator.stop(); + }); +}); diff --git a/ui/src/lib/cross-tab-poll.ts b/ui/src/lib/cross-tab-poll.ts index 3569a00f5b..116438d46f 100644 --- a/ui/src/lib/cross-tab-poll.ts +++ b/ui/src/lib/cross-tab-poll.ts @@ -184,6 +184,27 @@ export class LeaderElection { } } +function stableFingerprint(value: unknown): string { + const seen = new WeakSet(); + try { + return JSON.stringify(value, (_key, entry: unknown) => { + if (!entry || typeof entry !== "object") return entry; + if (seen.has(entry)) return "[Circular]"; + seen.add(entry); + if (Array.isArray(entry)) return entry; + const record = entry as Record; + return Object.keys(record) + .sort() + .reduce>((acc, key) => { + acc[key] = record[key]; + return acc; + }, {}); + }); + } catch { + return String(value); + } +} + /** Build a `LeaseStore` backed by a Storage-like object under one key. */ export function createLeaseStore( storage: Pick, @@ -242,6 +263,8 @@ export interface SharedMessage { from: string; /** Epoch ms the payload was produced. */ at: number; + /** React Query `dataUpdatedAt` for result freshness comparisons. */ + dataUpdatedAt?: number; /** Present on `result` messages. */ data?: unknown; } @@ -364,11 +387,13 @@ export interface SharedPollingCoordinatorOptions { election?: LeaderElection; leaseTtlMs?: number; tickMs?: number; + publishDebounceMs?: number; now?: () => number; getVisible?: () => boolean; } const DEFAULT_COORDINATOR_TICK_MS = 1_000; +const DEFAULT_PUBLISH_DEBOUNCE_MS = 1_000; const TAB_ID_STORAGE_KEY = "paperclip:shared-poll:tab-id"; function sanitizeCompanyId(companyId: string): string { @@ -434,10 +459,23 @@ export class SharedPollingCoordinator { private readonly election: LeaderElection; private readonly localOnlyFallback: boolean; private readonly tickMs: number; + private readonly publishDebounceMs: number; + private readonly now: () => number; private readonly getVisible: () => boolean; private readonly listeners = new Set(); private readonly resourceListeners = new Map>(); private readonly latestResults = new Map(); + private readonly lastPublished = new Map(); + private readonly pendingPublishes = new Map; + }>(); private unsubscribeChannel: (() => void) | null = null; private intervalId: ReturnType | null = null; private releaseListeners: Array<() => void> = []; @@ -447,12 +485,14 @@ export class SharedPollingCoordinator { this.tabId = options.tabId ?? getSharedPollingTabId(); this.channel = options.channel ?? createSharedChannel(sharedPollingChannelName(companyId)); this.tickMs = options.tickMs ?? DEFAULT_COORDINATOR_TICK_MS; + this.publishDebounceMs = options.publishDebounceMs ?? DEFAULT_PUBLISH_DEBOUNCE_MS; + this.now = options.now ?? (() => Date.now()); this.getVisible = options.getVisible ?? getBrowserVisible; const leaseStore = createBrowserLeaseStore(companyId); this.localOnlyFallback = !options.election && !leaseStore; this.election = options.election ?? new LeaderElection( { - now: options.now ?? (() => Date.now()), + now: this.now, store: leaseStore ?? { read: () => null, write: () => {}, @@ -485,6 +525,7 @@ export class SharedPollingCoordinator { } this.unsubscribeChannel?.(); this.unsubscribeChannel = null; + this.clearPendingPublishes(); for (const release of this.releaseListeners) release(); this.releaseListeners = []; this.election.release(); @@ -520,23 +561,80 @@ export class SharedPollingCoordinator { type: "request", key, from: this.tabId, - at: Date.now(), + at: this.now(), }); } - publish(key: string, data: unknown): void { + publish(key: string, data: unknown, dataUpdatedAt = this.now()): void { if (!this.snapshot.isLeader) return; + if (dataUpdatedAt <= 0) return; + const fingerprint = stableFingerprint(data); + const last = this.lastPublished.get(key); + if (last) { + if (dataUpdatedAt <= last.dataUpdatedAt) return; + if (fingerprint === last.fingerprint) { + this.cancelPendingPublish(key); + return; + } + } + const pending = this.pendingPublishes.get(key); + if (pending) { + if (dataUpdatedAt < pending.dataUpdatedAt) return; + if (dataUpdatedAt === pending.dataUpdatedAt && fingerprint === pending.fingerprint) return; + this.pendingPublishes.set(key, { + ...pending, + data, + dataUpdatedAt, + fingerprint, + }); + return; + } + + const elapsedSinceLastPublish = last ? this.now() - last.sentAt : Number.POSITIVE_INFINITY; + if (elapsedSinceLastPublish >= this.publishDebounceMs) { + this.postResult(key, data, dataUpdatedAt, fingerprint); + return; + } + + const delay = Math.max(this.publishDebounceMs - elapsedSinceLastPublish, 0); + const timer = setTimeout(() => this.flushPendingPublish(key), delay); + this.pendingPublishes.set(key, { data, dataUpdatedAt, fingerprint, timer }); + } + + private postResult(key: string, data: unknown, dataUpdatedAt: number, fingerprint: string): void { + const sentAt = this.now(); const message: SharedMessage = { type: "result", key, from: this.tabId, - at: Date.now(), + at: sentAt, + dataUpdatedAt, data, }; + this.lastPublished.set(key, { dataUpdatedAt, fingerprint, sentAt }); this.latestResults.set(key, message); this.channel.post(message); } + private flushPendingPublish(key: string): void { + const pending = this.pendingPublishes.get(key); + if (!pending) return; + this.pendingPublishes.delete(key); + this.postResult(key, pending.data, pending.dataUpdatedAt, pending.fingerprint); + } + + private cancelPendingPublish(key: string): void { + const pending = this.pendingPublishes.get(key); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingPublishes.delete(key); + } + + private clearPendingPublishes(): void { + for (const pending of this.pendingPublishes.values()) clearTimeout(pending.timer); + this.pendingPublishes.clear(); + } + private tick(): void { if (this.localOnlyFallback) { this.setSnapshot({ isLeader: this.getVisible() }); @@ -551,7 +649,7 @@ export class SharedPollingCoordinator { if (message.type === "request") { if (!this.snapshot.isLeader) return; const latest = this.latestResults.get(message.key); - if (latest) this.channel.post({ ...latest, from: this.tabId, at: Date.now() }); + if (latest) this.channel.post({ ...latest, from: this.tabId }); return; }