import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { CompanySearchIssueSummary, StatusCardUpdate, SummarySlotIssueRef } from "@paperclipai/shared"; import { AlertTriangle, ChevronDown, ExternalLink, History, Loader2, RefreshCw, Wand2 } from "lucide-react"; import { statusCardsApi, type StatusCardDryRun } from "@/api/statusCards"; import { MarkdownBody } from "@/components/MarkdownBody"; import { useSummaryDraftStream } from "@/components/useSummaryDraftStream"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { IssueStatusBadge } from "@/components/StatusBadge"; import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { InlineBanner } from "@/components/InlineBanner"; import { cn, formatDateTime, relativeTime } from "@/lib/utils"; import { queryKeys } from "@/lib/queryKeys"; import { deriveStatusCardLifecycle, describeRefreshPolicy, STATUS_CARD_LIFECYCLE_PRESENTATION, } from "@/lib/status-card-state"; import { StatusCardSettingsForm, defaultSettingsValue, type StatusCardSettingsValue, } from "./StatusCardSettingsForm"; import { SummarizerAgentSelect } from "./SummarizerAgentSelect"; import { formatCents, formatTokens, formatTokenSplit, rollupUpdatesToday, updateKindLabel, } from "./format"; import type { StatusCardView } from "./types"; export function StatusCardDetailDrawer({ card, companyId, open, onOpenChange, initialTab = "summary", }: { card: StatusCardView | null; companyId: string | null | undefined; open: boolean; onOpenChange: (open: boolean) => void; initialTab?: string; }) { const queryClient = useQueryClient(); const [tab, setTab] = useState("summary"); const [settings, setSettings] = useState(defaultSettingsValue()); // Rename + interest ("query") are edited in Settings alongside the policy. const [title, setTitle] = useState(""); const [interest, setInterest] = useState(""); // "" → the built-in Summarizer; otherwise the id of the override agent. const [summarizerAgentId, setSummarizerAgentId] = useState(""); const [actionError, setActionError] = useState(null); // A short confirmation after a build/refresh is queued (the state dot + badge // also update, but a card that finishes fast can look like "nothing happened"). const [actionNote, setActionNote] = useState(null); // null → show the latest summary; otherwise a historical update id. const [selectedRevisionId, setSelectedRevisionId] = useState(null); useEffect(() => { if (card) { setSettings({ refreshPolicy: card.refreshPolicy }); setTitle(card.title ?? ""); setInterest(card.interestPrompt); setSummarizerAgentId(card.agentId ?? ""); setActionError(null); setActionNote(null); setSelectedRevisionId(null); } }, [card]); // Open to the requested tab (e.g. "Query debug" on the tile deep-links to // Settings) whenever the drawer (re)opens. useEffect(() => { if (open) setTab(initialTab); }, [open, initialTab]); const updatesQuery = useQuery({ queryKey: card ? queryKeys.statusCards.updates(card.id) : ["status-cards", "detail", "none", "updates"], queryFn: () => statusCardsApi.updates(card!.id), enabled: Boolean(card && open), }); const summaryRevisionsQuery = useQuery({ queryKey: card ? queryKeys.statusCards.summaryRevisions(card.id) : ["status-cards", "detail", "none", "summary-revisions"], queryFn: () => statusCardsApi.summaryRevisions(card!.id), enabled: Boolean(card && open && card.documentId), }); const dryRunQuery = useQuery({ queryKey: card ? queryKeys.statusCards.dryRun(card.id) : ["status-cards", "detail", "none", "dry-run"], queryFn: () => statusCardsApi.dryRun(card!.id), enabled: Boolean(card && open && tab === "watched" && (card.queries.length > 0 || (card.mentionedIssueIds?.length ?? 0) > 0)), }); const lifecycle = card ? deriveStatusCardLifecycle(card) : "fresh"; const generatingIssue = useMemo( () => card && lifecycle === "updating" && card.generatingIssueId ? { id: card.generatingIssueId, identifier: null, title: card.title ?? "Status update", status: "in_progress" } : null, [card, lifecycle], ); const draftStream = useSummaryDraftStream(companyId, generatingIssue); const invalidateCard = async () => { if (!card) return; await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.list(card.companyId, false) }), queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.detail(card.id) }), ]); }; const refreshMutation = useMutation({ mutationFn: () => statusCardsApi.refresh(card!.id), onMutate: () => { setActionError(null); setActionNote(null); }, onSuccess: async () => { await invalidateCard(); setActionNote("Refresh queued — the Summarizer is updating this card."); }, onError: (err) => setActionError(err instanceof Error ? err.message : "Could not refresh the card."), }); const recompileMutation = useMutation({ mutationFn: () => statusCardsApi.recompile(card!.id), onMutate: () => { setActionError(null); setActionNote(null); }, onSuccess: async () => { await invalidateCard(); setActionNote("Run queued — the Summarizer is updating this card."); }, onError: (err) => setActionError(err instanceof Error ? err.message : "Could not run the card."), }); const saveSettingsMutation = useMutation({ mutationFn: () => { const trimmedTitle = title.trim(); const trimmedInterest = interest.trim(); const interestChanged = trimmedInterest.length > 0 && trimmedInterest !== card!.interestPrompt.trim(); return statusCardsApi.patch(card!.id, { // An explicit name pins the title so a recompile won't overwrite it; // clearing it hands naming back to the compiler. title: trimmedTitle || null, titlePinned: trimmedTitle.length > 0, // Editing the card prompt triggers a server-side recompile. ...(interestChanged ? { interestPrompt: trimmedInterest } : {}), agentId: summarizerAgentId || null, refreshPolicy: settings.refreshPolicy, }); }, onMutate: () => setActionError(null), onSuccess: async () => { if (!card) return; await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.list(card.companyId, false) }), queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.detail(card.id) }), ]); }, onError: (err) => setActionError(err instanceof Error ? err.message : "Could not save settings."), }); if (!card) return null; const updates = updatesQuery.data ?? []; const latestUpdate = updates[0] ?? null; const todayRollup = rollupUpdatesToday(updates); // Each successful summary-producing update is a summary revision (reuses the // SummarySlotCard revision-history pattern). The finishedAt check excludes // updates whose generation is still in flight — their document revision does // not exist yet. const summaryRevisions = updates.filter( (update) => update.status === "ok" && update.finishedAt && (update.kind === "full" || update.kind === "incremental"), ); const selectedRevision = selectedRevisionId ? summaryRevisions.find((update) => update.id === selectedRevisionId) ?? null : null; // Updates (newest-first, completed content updates only) correspond 1:1 with // the card's summary-document revisions (newest-first): writeSummary creates // both in one transaction. Positional matching recovers the full summary // body for a historical pick; the change-summary fallback covers any gap. const documentRevisions = summaryRevisionsQuery.data ?? []; const selectedRevisionBody = selectedRevision ? documentRevisions[summaryRevisions.indexOf(selectedRevision)]?.body ?? null : null; const latestRevisionNumber = summaryRevisions.length; const revisionNumberOf = (update: StatusCardUpdate) => latestRevisionNumber - summaryRevisions.indexOf(update); const displayedChanges = selectedRevision ? selectedRevision.changes : latestUpdate?.changes ?? []; const presentation = STATUS_CARD_LIFECYCLE_PRESENTATION[lifecycle]; const hasSummary = Boolean(card.summaryBody && card.summaryBody.trim().length > 0); // Setup is genuinely in flight only while a generation task exists; a null id // on a compiling card means the first run stalled and needs a manual re-kick. const setupRunning = lifecycle === "compiling" && Boolean(card.generatingIssueId); return (

{card.lastGeneratedAt ? `Updated ${relativeTime(card.lastGeneratedAt)}` : "No summary yet"} ·{" "} {describeRefreshPolicy(card.refreshPolicy)}

Summary Settings Watched issues History {actionError ? (
{actionError}
) : actionNote ? (
{actionNote}
) : null}
{/* Revision picker lives on the right, unpilled. A single-revision card shows a plain label; multi-revision cards get a dropdown capped at the 30 most recent revisions. */} {(hasSummary || summaryRevisions.length > 0) && lifecycle !== "compiling" ? (
{summaryRevisions.length > 1 ? ( ) : latestRevisionNumber > 0 ? ( Revision {latestRevisionNumber} · latest ) : null}
) : null} {lifecycle === "updating" && draftStream.draft && !selectedRevision ? ( {draftStream.draft} ) : selectedRevision ? (

Revision {revisionNumberOf(selectedRevision)} · {updateKindLabel(selectedRevision.kind)} ·{" "} {relativeTime(selectedRevision.startedAt)}

{selectedRevisionBody ? ( {selectedRevisionBody} ) : selectedRevision.changeSummary ? ( <> {selectedRevision.changeSummary}

The full summary text for this revision is unavailable — showing its change summary. The integrated changes below are the live ledger for this revision.

) : (

No change summary was recorded for this revision.

)}
) : hasSummary ? ( {card.summaryBody!} ) : lifecycle === "compiling" ? (

{setupRunning ? ( ) : ( )} {setupRunning ? "Setting up — the first summary is generated automatically once this finishes." : "Setup didn’t finish. Run it now to try again."}

{setupRunning && card.generatingIssueId ? ( View setup task ) : null}
) : (

No summary yet — the first one is generated automatically once this card finishes setting up.

)} {displayedChanges.length > 0 ? (

{selectedRevision ? "Integrated in this revision" : "Integrated in this update"} ( {displayedChanges.length} {displayedChanges.length === 1 ? "change" : "changes"})

{displayedChanges.map((change) => ( ))}
) : null}
{/* History and cost live together: the today rollup up top, then every recorded update (each update is one summary revision). */} {updatesQuery.isLoading ? (
Loading history…
) : updates.length === 0 ? (

No updates recorded yet.

) : ( <>
Today: {todayRollup.updateCount}{" "} {todayRollup.updateCount === 1 ? "update" : "updates"} ·{" "} {formatTokens(todayRollup.totalTokens)} · {formatCents(todayRollup.totalCostCents)} {card.refreshPolicy.dailyTokenCap ? ` · daily cap ${formatTokens(card.refreshPolicy.dailyTokenCap)}` : ""}
{updates.map((update) => (
{updateKindLabel(update.kind)} {update.status === "ok" ? update.trigger : update.status} {relativeTime(update.startedAt)}

{formatTokenSplit(update.inputTokens, update.outputTokens)} · {formatCents(update.costCents)} {update.model ? ` · ${update.model}` : ""} {update.changes.length > 0 ? ` · ${update.changes.length} changes` : ""}

{update.error ?

{update.error}

: null}
))}
)}
{card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0 ? (
This card is still setting up — the issues it watches appear here once it's ready.
) : dryRunQuery.isLoading ? (
Matching issues…
) : dryRunQuery.isError ? ( {dryRunQuery.error instanceof Error ? dryRunQuery.error.message : "Try again."} ) : ( )}

Card name

setTitle(event.target.value)} placeholder="Auto-named from the query" className="text-sm" aria-label="Card name" />

What this card watches & reports