Files
PaperClipAI/ui/src/pages/StatusCards/index.tsx
DottaandClaude Fable 5 3a16b91217 feat(status-cards): single-message setup drives query and update prompt (#10202)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies and their ongoing work.
> - Status cards turn a standing question into recurring,
agent-generated summaries on the board.
> - The existing setup split intent across a watch prompt and separate
update instructions, which made creation and later behavior harder to
understand.
> - A status card should have one durable source of truth for both
deciding what to watch and telling the summarizer what each update must
contain.
> - This pull request makes the card prompt that source of truth,
simplifies creation to one step, and lets operators choose the running
agent immediately.
> - The benefit is a smaller mental model, fewer configuration modes,
and consistent update instructions throughout the card lifecycle.

## Linked Issues or Issue Description

Status cards currently require operators to express the same intent in
two places: the watch prompt and optional update instructions with
append/replace/none modes. This feature simplifies the experimental
status-card workflow so a single prompt defines both the watch query and
every generated update. The create flow must also support selecting the
responsible agent without a second setup step.

Related prior status-card work: #10101.

## What Changed

- Use the status card's single prompt to compile the watch query and
directly instruct every summary update.
- Add migration `0190_status_card_single_prompt` to remove
`status_cards.instructions_mode` and `status_cards.instructions`.
- Add `agentId` to `createStatusCardSchema`, validate company
membership, and default new cards to the built-in Summarizer.
- Replace the two-step create flow with one prompt-and-agent dialog and
extract a shared `SummarizerAgentSelect` for create/settings surfaces.
- Remove the extra-instructions settings section, reset incremental
history when the prompt changes, and rename the board page to "Status".
- Update the bundled `status-card-query` skill and board-operator
documentation, then regenerate the skills catalog manifest.

## Verification

- Server status-card suites: 29/29 passing.
- UI `StatusCards` suites: 22/22 passing.
- Skills catalog suite: 20/20 passing.
- `tsc -b` passes for server, UI, shared, and database packages.
- `pnpm check:migrations` passes.
- Light and dark mode screenshots cover the new create dialog and
settings tab.

## Risks

- Migration `0190` intentionally drops existing separate instruction
text. Existing card prompts remain and become the update instructions
under the new model; status cards are experimental and feature-flagged.
- Prompt edits now reset the incremental summary chain and trigger a
full rebuild, which is intentional because the prompt is also the update
contract.
- Agent selection is company-scoped; invalid agent ids return a
validation error rather than creating a misrouted card.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Implementation: Anthropic Claude via the `claude_local` adapter, agent
label "Claude Fable 5"; extended reasoning, tool use, and code
execution. The exact provider model id and context-window value were not
retained in the task metadata.
- PR preparation: OpenAI GPT-5.4 through Codex CLI, with reasoning,
repository inspection, GitHub CLI, and Paperclip API tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:06:14 -05:00

215 lines
8.8 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Loader2, Plus } from "lucide-react";
import { statusCardsApi } from "@/api/statusCards";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useNavigate, useParams } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/EmptyState";
import { InlineBanner } from "@/components/InlineBanner";
import { formatCents, formatTokens } from "./format";
import { StatusCardTile } from "./StatusCardTile";
import { ArchivedStatusCardRow } from "./ArchivedStatusCardRow";
import { CreateStatusCardDialog } from "./CreateStatusCardDialog";
import { StatusCardDetailDrawer } from "./StatusCardDetailDrawer";
import type { StatusCardView } from "./types";
export function StatusCards() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { cardId } = useParams<{ cardId?: string }>();
const [showArchived, setShowArchived] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
// Which tab the detail drawer opens to (the tile's "Query debug"/"Edit"
// actions deep-link into Settings).
const [detailTab, setDetailTab] = useState("summary");
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Status" }]);
}, [setBreadcrumbs]);
const activeQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.statusCards.list(selectedCompanyId, false) : ["status-cards", "none", "active"],
queryFn: () => statusCardsApi.list(selectedCompanyId!, false),
enabled: Boolean(selectedCompanyId),
});
const archivedQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.statusCards.list(selectedCompanyId, true) : ["status-cards", "none", "archived"],
queryFn: () => statusCardsApi.list(selectedCompanyId!, true),
enabled: Boolean(selectedCompanyId),
});
const activeCards = (activeQuery.data ?? []) as StatusCardView[];
const archivedCards = (archivedQuery.data ?? []) as StatusCardView[];
// Deep-linked detail: prefer a card already in a loaded list, fall back to a
// by-id fetch for direct navigation.
const cardInLists = useMemo(
() => [...activeCards, ...archivedCards].find((card) => card.id === cardId) ?? null,
[activeCards, archivedCards, cardId],
);
const detailFallbackQuery = useQuery({
queryKey: cardId ? queryKeys.statusCards.detail(cardId) : ["status-cards", "detail", "none"],
queryFn: () => statusCardsApi.get(cardId!),
enabled: Boolean(cardId) && !cardInLists,
});
const detailCard = (cardInLists ?? detailFallbackQuery.data ?? null) as StatusCardView | null;
const invalidateLists = () =>
selectedCompanyId
? Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.list(selectedCompanyId, false) }),
queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.list(selectedCompanyId, true) }),
])
: Promise.resolve();
const refreshMutation = useMutation({
mutationFn: (id: string) => statusCardsApi.refresh(id),
onMutate: () => setActionError(null),
onSuccess: () => invalidateLists(),
onError: (err) => setActionError(err instanceof Error ? err.message : "Could not refresh the card."),
});
const recompileMutation = useMutation({
mutationFn: (id: string) => statusCardsApi.recompile(id),
onMutate: () => setActionError(null),
onSuccess: () => invalidateLists(),
onError: (err) => setActionError(err instanceof Error ? err.message : "Could not run the card."),
});
const archiveMutation = useMutation({
mutationFn: (id: string) => statusCardsApi.patch(id, { archived: true }),
onMutate: () => setActionError(null),
onSuccess: () => invalidateLists(),
onError: (err) => setActionError(err instanceof Error ? err.message : "Could not archive the card."),
});
const restoreMutation = useMutation({
mutationFn: (id: string) => statusCardsApi.patch(id, { archived: false }),
onMutate: () => setActionError(null),
onSuccess: () => invalidateLists(),
onError: (err) => setActionError(err instanceof Error ? err.message : "Could not restore the card."),
});
const openDetail = (id: string, tab: string = "summary") => {
setDetailTab(tab);
navigate(`/status/${id}`);
};
const closeDetail = () => navigate("/status");
const todayTotals = activeCards.reduce(
(acc, card) => ({
tokens: acc.tokens + (card.todayTokens ?? 0),
cents: acc.cents + (card.todayCostCents ?? 0),
}),
{ tokens: 0, cents: 0 },
);
const showCostMeter = todayTotals.tokens > 0 || todayTotals.cents > 0;
return (
<div className="mx-auto max-w-6xl space-y-5">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">Status</h1>
<Badge variant="secondary" className="gap-1">
<FlaskConical className="h-3 w-3" />
Experimental
</Badge>
</div>
<div className="flex items-center gap-4">
{showCostMeter ? (
<span className="text-xs text-muted-foreground">
Today: {formatTokens(todayTotals.tokens)} · ~{formatCents(todayTotals.cents)}
</span>
) : null}
<Button onClick={() => setCreateOpen(true)} disabled={!selectedCompanyId}>
<Plus className="h-4 w-4" />
New card
</Button>
</div>
</div>
{actionError ? <InlineBanner tone="warning" title="Heads up">{actionError}</InlineBanner> : null}
{activeQuery.isLoading ? (
<div className="flex items-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading cards…
</div>
) : activeQuery.isError ? (
<InlineBanner tone="danger" title="Could not load status cards">
{activeQuery.error instanceof Error ? activeQuery.error.message : "Try again."}
</InlineBanner>
) : activeCards.length === 0 ? (
<EmptyState
icon={FlaskConical}
title="No status cards yet"
message="Create a card to keep a living summary of the issues you care about."
action={selectedCompanyId ? "New card" : undefined}
onAction={() => setCreateOpen(true)}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{activeCards.map((card) => (
<StatusCardTile
key={card.id}
card={card}
companyId={selectedCompanyId}
onOpen={() => openDetail(card.id)}
onRefresh={() => refreshMutation.mutate(card.id)}
onRecompile={() => recompileMutation.mutate(card.id)}
onEditInterest={() => openDetail(card.id, "settings")}
onOpenDebug={() => openDetail(card.id, "settings")}
onArchive={() => archiveMutation.mutate(card.id)}
refreshPending={refreshMutation.isPending && refreshMutation.variables === card.id}
recompilePending={recompileMutation.isPending && recompileMutation.variables === card.id}
/>
))}
</div>
)}
{archivedCards.length > 0 ? (
<div className="space-y-3 pt-2">
<button
type="button"
onClick={() => setShowArchived((prev) => !prev)}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{showArchived ? "Hide archived" : `Show archived (${archivedCards.length})`}
</button>
{showArchived
? archivedCards.map((card) => (
<ArchivedStatusCardRow
key={card.id}
card={card}
onView={() => openDetail(card.id)}
onRestore={() => restoreMutation.mutate(card.id)}
restorePending={restoreMutation.isPending && restoreMutation.variables === card.id}
/>
))
: null}
</div>
) : null}
{selectedCompanyId ? (
<CreateStatusCardDialog companyId={selectedCompanyId} open={createOpen} onOpenChange={setCreateOpen} />
) : null}
<StatusCardDetailDrawer
card={detailCard}
companyId={selectedCompanyId}
open={Boolean(cardId)}
onOpenChange={(open) => (open ? undefined : closeDetail())}
initialTab={detailTab}
/>
</div>
);
}
export default StatusCards;