mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
## 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>
215 lines
8.8 KiB
TypeScript
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;
|