2026-02-17 12:24:48 -06:00
|
|
|
import { useEffect, type ReactNode } from "react";
|
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import type { LiveEvent } from "@paperclip/shared";
|
|
|
|
|
import { useCompany } from "./CompanyContext";
|
|
|
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
|
|
|
|
|
|
function readString(value: unknown): string | null {
|
|
|
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invalidateHeartbeatQueries(
|
|
|
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
|
|
|
companyId: string,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
2026-02-20 10:32:32 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
2026-02-17 12:24:48 -06:00
|
|
|
|
|
|
|
|
const agentId = readString(payload.agentId);
|
|
|
|
|
if (agentId) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invalidateActivityQueries(
|
|
|
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
|
|
|
companyId: string,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
UI: approval detail page, agent hiring UX, costs breakdown, sidebar badges, and dashboard improvements
Add ApprovalDetail page with comment thread, revision request/resubmit flow,
and ApprovalPayload component for structured payload display. Extend AgentDetail
with permissions management, config revision history, and duplicate action.
Add agent hire dialog with permission-gated access. Rework Costs page with
per-agent breakdown table and period filtering. Add sidebar badge counts for
pending approvals and inbox items. Enhance Dashboard with live metrics and
sparkline trends. Extend Agents list with pending_approval status and bulk
actions. Update IssueDetail with approval linking. Various component improvements
to MetricCard, InlineEditor, CommentThread, and StatusBadge.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:03:08 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
2026-02-17 12:24:48 -06:00
|
|
|
|
|
|
|
|
const entityType = readString(payload.entityType);
|
|
|
|
|
const entityId = readString(payload.entityId);
|
|
|
|
|
|
|
|
|
|
if (entityType === "issue") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
|
|
|
|
if (entityId) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) });
|
UI: Identity component, LiveRunWidget, issue identifiers, and UX improvements
Add Identity component (avatar + name) used across agent/issue displays. Add
LiveRunWidget for real-time streaming of active heartbeat runs on issue detail
pages via WebSocket. Display issue identifiers (PAP-42) instead of UUID
fragments throughout Issues, Inbox, CommandPalette, and detail pages.
Enhance CommentThread with re-open checkbox, Cmd+Enter submit, sorted display,
and run linking. Improve Activity page with richer formatting and filtering.
Update Dashboard with live metrics. Add reports-to agent link in AgentProperties.
Various small fixes: StatusIcon centering, CopyText ref init, agent detail
run-issue cross-links.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:10:07 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(entityId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(entityId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(entityId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(entityId) });
|
2026-02-17 12:24:48 -06:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entityType === "agent") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.org(companyId) });
|
|
|
|
|
if (entityId) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(entityId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, entityId) });
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entityType === "project") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) });
|
|
|
|
|
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(entityId) });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entityType === "goal") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(companyId) });
|
|
|
|
|
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(entityId) });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entityType === "approval") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entityType === "cost_event") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entityType === "company") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleLiveEvent(
|
|
|
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
|
|
|
expectedCompanyId: string,
|
|
|
|
|
event: LiveEvent,
|
|
|
|
|
) {
|
|
|
|
|
if (event.companyId !== expectedCompanyId) return;
|
|
|
|
|
|
|
|
|
|
const payload = event.payload ?? {};
|
|
|
|
|
if (event.type === "heartbeat.run.log") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status" || event.type === "heartbeat.run.event") {
|
|
|
|
|
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.type === "agent.status") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(expectedCompanyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(expectedCompanyId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.org(expectedCompanyId) });
|
|
|
|
|
const agentId = readString(payload.agentId);
|
|
|
|
|
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.type === "activity.logged") {
|
|
|
|
|
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|
|
|
|
const { selectedCompanyId } = useCompany();
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedCompanyId) return;
|
|
|
|
|
|
|
|
|
|
let closed = false;
|
|
|
|
|
let reconnectAttempt = 0;
|
|
|
|
|
let reconnectTimer: number | null = null;
|
|
|
|
|
let socket: WebSocket | null = null;
|
|
|
|
|
|
|
|
|
|
const clearReconnect = () => {
|
|
|
|
|
if (reconnectTimer !== null) {
|
|
|
|
|
window.clearTimeout(reconnectTimer);
|
|
|
|
|
reconnectTimer = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scheduleReconnect = () => {
|
|
|
|
|
if (closed) return;
|
|
|
|
|
reconnectAttempt += 1;
|
|
|
|
|
const delayMs = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttempt - 1, 4));
|
|
|
|
|
reconnectTimer = window.setTimeout(() => {
|
|
|
|
|
reconnectTimer = null;
|
|
|
|
|
connect();
|
|
|
|
|
}, delayMs);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const connect = () => {
|
|
|
|
|
if (closed) return;
|
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
|
|
|
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`;
|
|
|
|
|
socket = new WebSocket(url);
|
|
|
|
|
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
reconnectAttempt = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onmessage = (message) => {
|
|
|
|
|
const raw = typeof message.data === "string" ? message.data : "";
|
|
|
|
|
if (!raw) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw) as LiveEvent;
|
|
|
|
|
handleLiveEvent(queryClient, selectedCompanyId, parsed);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore non-JSON payloads.
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onerror = () => {
|
|
|
|
|
socket?.close();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onclose = () => {
|
|
|
|
|
if (closed) return;
|
|
|
|
|
scheduleReconnect();
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
connect();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
closed = true;
|
|
|
|
|
clearReconnect();
|
|
|
|
|
if (socket) {
|
|
|
|
|
socket.onopen = null;
|
|
|
|
|
socket.onmessage = null;
|
|
|
|
|
socket.onerror = null;
|
|
|
|
|
socket.onclose = null;
|
|
|
|
|
socket.close(1000, "provider_unmount");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [queryClient, selectedCompanyId]);
|
|
|
|
|
|
|
|
|
|
return <>{children}</>;
|
|
|
|
|
}
|