import { useEffect, useMemo, useState } from "react"; import { Link } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { SummarySlotDocument, SummarySlotIssueRef, SummarySlotKey, SummarySlotRevision, SummarySlotScopeKind, } from "@paperclipai/shared"; import { Bot, Clock3, History, Loader2, RefreshCw, Sparkles } from "lucide-react"; import { agentsApi } from "@/api/agents"; import { builtInAgentsApi, type BuiltInAgentState } from "@/api/builtInAgents"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { summarySlotsApi, type SummarySlotSelector } from "@/api/summarySlots"; import { MarkdownBody } from "@/components/MarkdownBody"; import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal"; import { InlineBanner } from "@/components/InlineBanner"; import { useSummaryDraftStream } from "@/components/useSummaryDraftStream"; import { useCompanyLiveEvent } from "@/context/LiveUpdatesProvider"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"; import { queryKeys } from "@/lib/queryKeys"; import { cn, formatDateTime, relativeTime } from "@/lib/utils"; const SUMMARIZER_KEY = "summarizer"; const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); const LATEST_REVISION_SELECT_VALUE = "__latest__"; const MAX_REVISION_OPTIONS = 30; export interface SummarySlotCardProps { companyId: string | null | undefined; scopeKind: SummarySlotScopeKind; scopeId?: string | null; slotKey?: SummarySlotKey; title: string; description?: string; className?: string; } function issueLabel(issue: SummarySlotIssueRef) { return issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title; } function revisionLabel(revision: SummarySlotRevision) { return `Rev ${revision.revisionNumber}`; } function formatRevisionTimestamp(date: Date | string) { return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).format(new Date(date)).replace(",", ""); } function revisionOptionLabel(revision: SummarySlotRevision) { return `${revisionLabel(revision)} - ${formatRevisionTimestamp(revision.createdAt)}`; } function latestRevisionOptionLabel( document: SummarySlotDocument, revision: SummarySlotRevision | null, ) { return `Latest (Rev ${document.latestRevisionNumber}) - ${ formatRevisionTimestamp(revision?.createdAt ?? document.updatedAt) }`; } interface LiveGenerationStatus { message: string | null; currentToolName: string | null; lastAssistantSnippet: string | null; } function readPayloadString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } /** * Pick the single most useful line to show while a summary is generating. * Prefers the server-derived status `message`, then the last streamed * assistant snippet, then the active tool name. Returns null when nothing * has streamed yet so the card falls back to its static generating text. */ export function resolveGenerationStatusLine(status: LiveGenerationStatus | null): string | null { if (!status) return null; if (status.message) return status.message; if (status.lastAssistantSnippet) return status.lastAssistantSnippet; if (status.currentToolName) return `Working with ${status.currentToolName}`; return null; } /** * Subscribe to the shared LiveUpdates socket and track the generation run's * live status derived from `heartbeat.run.progress` events matching the slot's * generating issue. Resets whenever the tracked generation changes, and stays * null (static fallback) when no events arrive. */ function useGenerationStatus(generatingIssueId: string | null): LiveGenerationStatus | null { const [status, setStatus] = useState(null); useEffect(() => { setStatus(null); }, [generatingIssueId]); useCompanyLiveEvent((event) => { if (!generatingIssueId) return; if (event.type !== "heartbeat.run.progress") return; const payload = event.payload ?? {}; if (payload.issueId !== generatingIssueId) return; setStatus({ message: readPayloadString(payload.message), currentToolName: readPayloadString(payload.currentToolName), lastAssistantSnippet: readPayloadString(payload.lastAssistantSnippet), }); }); return generatingIssueId ? status : null; } function setupState(state: BuiltInAgentState | undefined) { if (!state) return null; return state.status === "not_provisioned" || state.status === "needs_setup" || state.status === "pending_approval" ? state : null; } export function SummarySlotCard({ companyId, scopeKind, scopeId = null, slotKey = "header", title, description, className, }: SummarySlotCardProps) { const queryClient = useQueryClient(); const [selectedRevisionId, setSelectedRevisionId] = useState(null); const [configureOpen, setConfigureOpen] = useState(false); const [actionError, setActionError] = useState(null); const selector: SummarySlotSelector | null = companyId ? { companyId, scopeKind, scopeId, slotKey } : null; const experimentalQuery = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); const summariesEnabled = experimentalQuery.data?.enableSummaries === true; const builtInAgentsEnabled = experimentalQuery.data?.enableBuiltInAgents === true; const builtInAgentsQuery = useQuery({ queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__"), queryFn: () => builtInAgentsApi.list(companyId!), enabled: Boolean(companyId && summariesEnabled && builtInAgentsEnabled), retry: false, }); const summarizerState = builtInAgentsQuery.data?.find( (entry) => entry.definition.key === SUMMARIZER_KEY, ); const needsSetup = setupState(summarizerState); const slotQueryKey = selector ? queryKeys.summarySlots.detail(selector.companyId, selector.scopeKind, selector.slotKey, selector.scopeId) : queryKeys.summarySlots.detail("__none__", scopeKind, slotKey, scopeId); const revisionsQueryKey = selector ? queryKeys.summarySlots.revisions(selector.companyId, selector.scopeKind, selector.slotKey, selector.scopeId) : queryKeys.summarySlots.revisions("__none__", scopeKind, slotKey, scopeId); const slotQuery = useQuery({ queryKey: slotQueryKey, queryFn: () => summarySlotsApi.get(selector!), enabled: Boolean(selector && summariesEnabled), retry: false, refetchInterval: (query) => query.state.data?.slot?.status === "generating" ? 3_000 : false, }); const revisionsQuery = useQuery({ queryKey: revisionsQueryKey, queryFn: () => summarySlotsApi.revisions(selector!), enabled: Boolean(selector && summariesEnabled && slotQuery.data?.document), retry: false, }); const generateMutation = useMutation({ mutationFn: () => summarySlotsApi.generate(selector!), onMutate: () => setActionError(null), onSuccess: async () => { setSelectedRevisionId(null); await Promise.all([ queryClient.invalidateQueries({ queryKey: slotQueryKey }), queryClient.invalidateQueries({ queryKey: revisionsQueryKey }), ]); }, onError: (error) => { setActionError(error instanceof Error ? error.message : "Summary generation could not be started."); }, }); const resumeSummarizer = useMutation({ mutationFn: (agentId: string) => agentsApi.resume(agentId, companyId ?? undefined), onSuccess: async () => { if (companyId) { await queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) }); } }, }); const revisions = revisionsQuery.data?.revisions ?? []; const latestDocument = slotQuery.data?.document ?? null; const selectedRevision = useMemo( () => revisions.find((revision) => revision.id === selectedRevisionId) ?? null, [revisions, selectedRevisionId], ); const latestRevision = latestDocument ? revisions.find((revision) => revision.id === latestDocument.latestRevisionId) ?? null : null; const historicalRevision = selectedRevision && selectedRevision.id !== latestDocument?.latestRevisionId ? selectedRevision : null; const displayedBody = historicalRevision?.body ?? latestDocument?.body ?? ""; const displayingHistoricalRevision = Boolean(historicalRevision); const historicalRevisionOptions = (latestDocument ? revisions.filter((revision) => revision.id !== latestDocument.latestRevisionId) : revisions) .toSorted((left, right) => right.revisionNumber - left.revisionNumber) .slice(0, MAX_REVISION_OPTIONS - (latestDocument ? 1 : 0)); const revisionSelectValue = historicalRevision?.id ?? LATEST_REVISION_SELECT_VALUE; const latestSelectLabel = latestDocument ? latestRevisionOptionLabel(latestDocument, latestRevision) : "Latest"; const generatingIssue = slotQuery.data?.generatingIssue ?? null; const liveStatusLine = resolveGenerationStatusLine(useGenerationStatus(generatingIssue?.id ?? null)); const draftStream = useSummaryDraftStream(companyId, generatingIssue); // The token-streamed STATUS line is more responsive than the server-derived // progress snippet; prefer it and fall back to the Phase 1 status line. const generationStatusLine = draftStream.statusLine ?? liveStatusLine; const isGenerating = slotQuery.data?.slot?.status === "generating" && generatingIssue && !TERMINAL_ISSUE_STATUSES.has(generatingIssue.status); const generationFailed = slotQuery.data?.slot?.status === "failed"; const canGenerateFirstSummary = summarizerState?.status === "ready"; if (experimentalQuery.isLoading || !summariesEnabled) return null; const startGeneration = () => { if (!selector || generateMutation.isPending) return; generateMutation.mutate(); }; return (
{description ?

{description}

: null}
{displayingHistoricalRevision ? ( ) : null} {latestDocument && !generationFailed ? ( ) : null}
{needsSetup ? ( <>
{needsSetup.status === "pending_approval" ? ( ) : ( )}

{needsSetup.status === "pending_approval" ? "Summarizer setup is pending approval" : "Set up the Summarizer"}

Summaries are generated by Paperclip's built-in Summarizer agent. Configure its adapter and model before requesting this summary.

{needsSetup.status === "pending_approval" ? null : ( )}
{companyId ? ( setActionError(null)} /> ) : null} ) : null} {!needsSetup && summarizerState?.status === "paused" && summarizerState.agent ? ( summarizerState.agent && resumeSummarizer.mutate(summarizerState.agent.id)} disabled={resumeSummarizer.isPending} > {resumeSummarizer.isPending ? "Resuming..." : "Resume agent"} } > Existing summaries remain readable, but new summaries will not be generated until the agent resumes. ) : null} {actionError ? ( {actionError} ) : null} {slotQuery.isError ? ( void slotQuery.refetch()}> Retry } > {slotQuery.error instanceof Error ? slotQuery.error.message : "Try loading the summary again."} ) : null} {!slotQuery.isError && generationFailed ? ( {generateMutation.isPending ? "Retrying..." : "Retry"} } > {slotQuery.data?.slot?.failureReason ?? "The generation task ended before writing a summary."} ) : null} {!slotQuery.isError && isGenerating && generatingIssue ? (

Generating summary

{generationStatusLine ? (

{generationStatusLine}

) : null} {draftStream.draft ? (
{draftStream.draft} {!draftStream.draftClosed ? (
) : null}

Summarizer is working in{" "} {issueLabel(generatingIssue)} .

) : null} {!slotQuery.isError && !latestDocument && !isGenerating && !generationFailed && canGenerateFirstSummary ? (

No summary yet

Generate a concise status snapshot for this surface.

) : null} {latestDocument ? (
{displayedBody}
Updated {relativeTime(historicalRevision?.createdAt ?? latestRevision?.createdAt ?? latestDocument.updatedAt)} {revisions.length > 1 ? ( ) : null}
) : null}
); }