Files
PaperClipAI/ui/src/hooks/useTaskPluginPanels.ts
T

150 lines
9.7 KiB
TypeScript

import { queryViewKind } from "@/components/QueryView";
import { useEffect, useMemo } from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { api, ApiError } from "@/api/client";
import { heartbeatsApi } from "@/api/heartbeats";
import { useTaskRunLiveUpdates, useTranscriptLiveStream } from "@/context/LiveUpdatesProvider";
import { createLiveQueryRecovery } from "@/lib/live-query-recovery";
import { QueryNotReadyError } from "@/lib/query-client";
import { queryKeys } from "@/lib/queryKeys";
import { useAgentEnvironmentSlots } from "@/plugins/useAgentEnvironmentSlots";
import { usePluginSlots, type ResolvedPluginSlot } from "@/plugins/slots";
export interface TaskPluginPanel {
entityId: string;
runId?: string;
autoOpen: boolean;
slot: ResolvedPluginSlot;
}
const EMPTY_PANELS: TaskPluginPanel[] = [];
export function taskPluginPanelId(panel: TaskPluginPanel) {
return `plugin:${panel.slot.pluginKey}:${panel.slot.id}:${panel.entityId}`;
}
export function taskPluginPanelActivationKey(panel: TaskPluginPanel, accountScope: string, issue: Pick<Issue, "companyId" | "id">) {
return JSON.stringify([accountScope, issue.companyId, issue.id, panel.runId, taskPluginPanelId(panel)]);
}
/** Plugins own their views; activity controls automatic opening. */
export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = true): { panels: TaskPluginPanel[]; isLoading: boolean } {
const queryClient = useQueryClient();
const savedTask = enabled && !!issue && !issue.id.startsWith("chat:");
const { slots: declaredSlots, errorMessage, isLoading } = usePluginSlots({
slotTypes: ["detailTab"], entityType: "agent", taskSidePanel: true,
companyId: issue?.companyId, enabled: savedTask,
});
const declaredPanels = useMemo(() => declaredSlots.filter(slot => slot.taskSidePanel), [declaredSlots]);
const connected = useTaskRunLiveUpdates({
companyId: savedTask && declaredPanels.length > 0 && !errorMessage ? issue?.companyId : null,
issueId: issue?.id, issueQueryRef: issue?.id ?? "pending", assigneeAgentId: issue?.assigneeAgentId,
});
const { data: runs, isError: runsError, isPending: runsPending } = useQuery({
queryKey: queryKeys.issues.liveRuns(issue?.id ?? "pending"),
queryFn: () => heartbeatsApi.liveRunsForIssue(issue!.id),
enabled: savedTask && declaredPanels.length > 0 && !errorMessage,
refetchInterval: connected ? false : 3000,
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
retryDelay: 1000,
});
const run = runs?.find(run => run.status === "running"
&& (!issue?.executionRunId || run.id === issue.executionRunId));
const agentId = (!runsError && run?.agentId) || issue?.assigneeAgentId;
const { slots: panels, isLoading: environmentLoading } = useAgentEnvironmentSlots(
declaredPanels, savedTask && !errorMessage ? agentId : null, issue?.companyId,
);
const slots = useMemo(() => panels.filter(slot => slot.taskSidePanel?.activation), [panels]);
const active = savedTask && slots.length > 0 && !!run && !runsError && !errorMessage;
const declarations = slots.map(slot => [slot.pluginKey, slot.id, slot.pluginVersion,
slot.taskSidePanel!.activation!.namespace, slot.taskSidePanel!.activation!.activityKey]);
const activationScope = JSON.stringify(declarations);
const activationKey = ["task-plugin-panel-activations", issue?.companyId, issue?.id, run?.id, run?.agentId, activationScope];
// Keep unchanged declarations active while a new declaration scope loads.
const retained = new Map<string, { pluginKey: string; slotId: string }>();
for (const [key, panels] of queryClient.getQueriesData<Array<{ pluginKey: string; slotId: string }>>({ queryKey: activationKey.slice(0, 5) })) {
if (typeof key[5] !== "string" || queryClient.getQueryState(key)?.status !== "success") continue;
const unchanged = (JSON.parse(key[5]) as string[][]).filter(previous =>
declarations.some(current => current.every((part, index) => part === previous[index])));
for (const panel of panels ?? []) if (unchanged.some(declaration => declaration[0] === panel.pluginKey && declaration[1] === panel.slotId)) {
retained.set(JSON.stringify([panel.pluginKey, panel.slotId]), panel);
}
}
const { data: recordedActivations = [], isError: activationsError } = useQuery({
queryKey: activationKey,
queryFn: ({ signal }) => heartbeatsApi.taskPanelActivations(run!.id, { signal, cache: "no-store" }),
initialData: () => retained.size ? [...retained.values()] : undefined,
initialDataUpdatedAt: 0,
// Run activations only grow; merge history and live hints when committing cache data.
structuralSharing: (previous, incoming) => {
const merged = new Map(retained);
for (const panel of [...((previous ?? []) as Array<{ pluginKey: string; slotId: string }>), ...(incoming as Array<{ pluginKey: string; slotId: string }>)]) {
merged.set(JSON.stringify([panel.pluginKey, panel.slotId]), panel);
}
return [...merged.values()];
},
staleTime: 0,
enabled: active,
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
retryDelay: 1000,
});
const activations = [...recordedActivations, ...retained.values()];
const stream = useTranscriptLiveStream(active ? [run!.id] : [], { companyId: issue?.companyId, enabled: active }, event => {
if (!active || !run || event.companyId !== issue?.companyId || event.payload.runId !== run.id
|| event.payload.agentId !== run.agentId || event.payload.issueId !== issue?.id
|| event.type !== "heartbeat.run.event" || event.payload.eventType !== "runtime.activity.recorded") return;
const native = event.payload.payload as { prpEvent?: { eventType?: string; sourceKind?: string; payload?: unknown } } | null;
if (native?.prpEvent?.eventType !== "runtime.activity.recorded" || native.prpEvent.sourceKind !== "runner") return;
const activity = native.prpEvent.payload as { namespace?: unknown; activityKey?: unknown } | null;
if (!activity || Object.keys(activity).length !== 2 || typeof activity.namespace !== "string" || typeof activity.activityKey !== "string") return;
const panels = slots.filter(slot => slot.taskSidePanel?.activation?.namespace === activity.namespace
&& slot.taskSidePanel?.activation?.activityKey === activity.activityKey)
.map(slot => ({ pluginKey: slot.pluginKey, slotId: slot.id }));
if (!panels.length) return;
queryClient.setQueryData<Array<{ pluginKey: string; slotId: string }>>(activationKey, previous => {
const missing = panels.filter(panel => !previous?.some(item => item.pluginKey === panel.pluginKey && item.slotId === panel.slotId));
return missing.length ? [...(previous ?? []), ...missing] : previous;
});
for (const panel of panels) void queryClient.invalidateQueries({
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run.id, run.agentId, panel.pluginKey, panel.slotId],
predicate: ({ state }) => state.status === "error" && state.fetchStatus === "idle"
&& !(state.error instanceof ApiError && state.error.status >= 400 && state.error.status < 500),
});
});
useEffect(() => {
if (!active || !stream.healthy) return;
let current = true;
void queryClient.invalidateQueries({ queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId] });
// A pre-subscription history read must settle before the recovery read.
createLiveQueryRecovery(queryClient, () => current, { queryKey: activationKey, exact: true })();
return () => { current = false; };
}, [active, stream.healthy, stream.epoch, queryClient, issue?.companyId, issue?.id, run?.id, run?.agentId, activationScope]);
const availability = useQueries({ queries: slots.map(slot => ({
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId, slot.pluginKey, slot.id, slot.pluginVersion, slot.pluginId, slot.taskSidePanel!.availabilityPath],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const result = await api.get<{ available: boolean }>(
`/plugins/${encodeURIComponent(slot.pluginId)}/api${slot.taskSidePanel!.availabilityPath.replace(":entityId", encodeURIComponent(run!.agentId))}?runId=${encodeURIComponent(run!.id)}&issueId=${encodeURIComponent(issue!.id)}`,
{ signal, cache: "no-store" },
);
if (result.available !== true) throw new QueryNotReadyError("Plugin panel is not ready");
return result;
},
enabled: active && !activationsError && activations.some(panel => panel.pluginKey === slot.pluginKey && panel.slotId === slot.id),
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
retryDelay: 1000,
})) });
const available = availability.map((result, index) => !activationsError && queryViewKind(result) !== "error" && result.data?.available === true
&& activations.some(panel => panel.pluginKey === slots[index]?.pluginKey && panel.slotId === slots[index]?.id));
const availableKey = available.join(",");
const manualPanels = useMemo(() => savedTask && !errorMessage && agentId ? panels.map(slot => ({
entityId: agentId,
...(!runsError && run ? { runId: run.id } : {}),
autoOpen: active && available[slots.indexOf(slot)] === true,
slot,
})) : EMPTY_PANELS,
[savedTask, errorMessage, agentId, panels, run?.id, runsError, active, slots, availableKey]);
return { panels: manualPanels, isLoading: savedTask && !errorMessage
&& (isLoading || environmentLoading || (declaredPanels.length > 0 && runsPending)) };
}