paperclip/ui/src/components/SummarySlotCard.tsx

508 lines
20 KiB
TypeScript

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<LiveGenerationStatus | null>(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<string | null>(null);
const [configureOpen, setConfigureOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(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 (
<section className={cn("space-y-4", className)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Sparkles className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<h2 className="text-sm font-semibold">{title}</h2>
{isGenerating ? <Badge variant="secondary">Generating</Badge> : null}
{displayingHistoricalRevision ? <Badge variant="outline">Historical revision</Badge> : null}
{latestDocument && !displayingHistoricalRevision ? <Badge variant="outline">Latest revision</Badge> : null}
</div>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{displayingHistoricalRevision ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setSelectedRevisionId(null)}
>
Latest
</Button>
) : null}
{latestDocument && !generationFailed ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={startGeneration}
disabled={!selector || generateMutation.isPending || Boolean(isGenerating)}
>
{generateMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Refresh
</Button>
) : null}
</div>
</div>
{needsSetup ? (
<>
<div className="flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
{needsSetup.status === "pending_approval" ? (
<Clock3 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">
{needsSetup.status === "pending_approval"
? "Summarizer setup is pending approval"
: "Set up the Summarizer"}
</p>
<p className="text-muted-foreground">
Summaries are generated by Paperclip's built-in Summarizer agent. Configure its adapter and model
before requesting this summary.
</p>
</div>
</div>
{needsSetup.status === "pending_approval" ? null : (
<Button type="button" size="sm" onClick={() => setConfigureOpen(true)}>
Set up Summarizer
</Button>
)}
</div>
{companyId ? (
<ConfigureBuiltInAgentModal
companyId={companyId}
state={needsSetup}
open={configureOpen}
onOpenChange={setConfigureOpen}
onConfigured={() => setActionError(null)}
/>
) : null}
</>
) : null}
{!needsSetup && summarizerState?.status === "paused" && summarizerState.agent ? (
<InlineBanner
tone="warning"
title="Summarizer is paused"
actions={
<Button
type="button"
size="sm"
onClick={() => summarizerState.agent && resumeSummarizer.mutate(summarizerState.agent.id)}
disabled={resumeSummarizer.isPending}
>
{resumeSummarizer.isPending ? "Resuming..." : "Resume agent"}
</Button>
}
>
Existing summaries remain readable, but new summaries will not be generated until the agent resumes.
</InlineBanner>
) : null}
{actionError ? (
<InlineBanner tone="warning" title="Summary request failed">
{actionError}
</InlineBanner>
) : null}
{slotQuery.isError ? (
<InlineBanner
tone="warning"
title="Summary could not be loaded"
actions={
<Button type="button" size="sm" variant="outline" onClick={() => void slotQuery.refetch()}>
Retry
</Button>
}
>
{slotQuery.error instanceof Error ? slotQuery.error.message : "Try loading the summary again."}
</InlineBanner>
) : null}
{!slotQuery.isError && generationFailed ? (
<InlineBanner
tone="danger"
title="Summary generation failed"
actions={
<Button
type="button"
size="sm"
onClick={startGeneration}
disabled={!selector || generateMutation.isPending}
>
{generateMutation.isPending ? "Retrying..." : "Retry"}
</Button>
}
>
{slotQuery.data?.slot?.failureReason ?? "The generation task ended before writing a summary."}
</InlineBanner>
) : null}
{!slotQuery.isError && isGenerating && generatingIssue ? (
<div className="flex items-start gap-3 text-sm">
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-foreground">Generating summary</p>
{generationStatusLine ? (
<p
className="animate-pulse truncate text-muted-foreground"
aria-live="polite"
data-testid="summary-generation-status-line"
title={generationStatusLine}
>
{generationStatusLine}
</p>
) : null}
{draftStream.draft ? (
<div
className="mt-2 rounded-md border border-border bg-muted/20 p-3"
data-testid="summary-generation-draft"
aria-live="polite"
>
<MarkdownBody className="text-sm leading-7 text-foreground">
{draftStream.draft}
</MarkdownBody>
{!draftStream.draftClosed ? (
<span
className="mt-1 inline-block h-4 w-px animate-pulse bg-foreground align-text-bottom"
aria-hidden="true"
data-testid="summary-generation-caret"
/>
) : null}
</div>
) : null}
<p className="text-muted-foreground">
Summarizer is working in{" "}
<Link className="underline" to={`/issues/${generatingIssue.identifier ?? generatingIssue.id}`}>
{issueLabel(generatingIssue)}
</Link>
.
</p>
</div>
</div>
) : null}
{!slotQuery.isError && !latestDocument && !isGenerating && !generationFailed && canGenerateFirstSummary ? (
<div className="flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">No summary yet</p>
<p className="text-muted-foreground">Generate a concise status snapshot for this surface.</p>
</div>
<Button
type="button"
size="sm"
onClick={startGeneration}
disabled={!selector || generateMutation.isPending}
>
{generateMutation.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
{generateMutation.isPending ? "Generating..." : "Generate summary"}
</Button>
</div>
) : null}
{latestDocument ? (
<div className="space-y-4">
<MarkdownBody className="text-sm leading-7 text-foreground">
{displayedBody}
</MarkdownBody>
<div className="flex flex-col gap-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span title={formatDateTime(historicalRevision?.createdAt ?? latestRevision?.createdAt ?? latestDocument.updatedAt)}>
Updated {relativeTime(historicalRevision?.createdAt ?? latestRevision?.createdAt ?? latestDocument.updatedAt)}
</span>
{revisions.length > 1 ? (
<Select
value={revisionSelectValue}
onValueChange={(value) => {
setSelectedRevisionId(value === LATEST_REVISION_SELECT_VALUE ? null : value);
}}
>
<SelectTrigger
size="sm"
className="h-auto border-0 bg-transparent p-0 text-xs shadow-none hover:text-foreground focus-visible:ring-0"
aria-label="Select summary revision"
title={historicalRevision ? revisionOptionLabel(historicalRevision) : latestSelectLabel}
>
<SelectValue>
<History className="size-3.5" aria-hidden="true" />
<span>{revisions.length} revisions</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value={LATEST_REVISION_SELECT_VALUE} className="text-xs">
{latestSelectLabel}
</SelectItem>
{historicalRevisionOptions.length > 0 ? <SelectSeparator /> : null}
{historicalRevisionOptions.map((revision) => (
<SelectItem
key={revision.id}
value={revision.id}
className="text-xs"
title={formatDateTime(revision.createdAt)}
>
{revisionOptionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
</div>
) : null}
</section>
);
}