mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +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>
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import type { StatusCardRefreshPolicy } from "@paperclipai/shared";
|
||
import { ChevronDown } from "lucide-react";
|
||
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||
import { cn } from "@/lib/utils";
|
||
import { estimateStatusCardCost } from "./format";
|
||
|
||
export interface StatusCardSettingsValue {
|
||
refreshPolicy: StatusCardRefreshPolicy;
|
||
}
|
||
|
||
export function defaultSettingsValue(): StatusCardSettingsValue {
|
||
return {
|
||
refreshPolicy: {
|
||
mode: "manual",
|
||
triggers: {
|
||
statusTransitions: true,
|
||
membershipChanges: true,
|
||
humanComments: true,
|
||
assigneeChanges: true,
|
||
anyUpdate: false,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
const INTERVAL_OPTIONS = [5, 15, 30, 60];
|
||
const DEBOUNCE_OPTIONS = [30, 60, 120, 300];
|
||
|
||
type TriggerKey = keyof StatusCardRefreshPolicy["triggers"];
|
||
|
||
const TRIGGER_ROWS: { key: TriggerKey; label: string; noisy?: boolean }[] = [
|
||
{ key: "statusTransitions", label: "Became blocked / needs review / done / cancelled" },
|
||
{ key: "membershipChanges", label: "New issue matches the query · issue leaves the query" },
|
||
{ key: "humanComments", label: "Human comments" },
|
||
{ key: "assigneeChanges", label: "Assignee changes" },
|
||
{ key: "anyUpdate", label: "Any update at all (noisy — includes in-progress churn)", noisy: true },
|
||
];
|
||
|
||
function RadioRow({
|
||
selected,
|
||
title,
|
||
badge,
|
||
onSelect,
|
||
children,
|
||
}: {
|
||
selected: boolean;
|
||
title: string;
|
||
badge?: React.ReactNode;
|
||
onSelect: () => void;
|
||
children?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"rounded-md border px-3 py-2.5 transition-colors",
|
||
selected ? "border-primary bg-primary/5 ring-1 ring-primary" : "border-border hover:bg-accent/40",
|
||
)}
|
||
>
|
||
<button type="button" role="radio" aria-checked={selected} onClick={onSelect} className="flex w-full items-center gap-2 text-left">
|
||
<span
|
||
className={cn(
|
||
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
|
||
selected ? "border-primary" : "border-muted-foreground/50",
|
||
)}
|
||
>
|
||
{selected ? <span className="h-2 w-2 rounded-full bg-primary" /> : null}
|
||
</span>
|
||
<span className="text-sm font-medium">{title}</span>
|
||
{badge}
|
||
</button>
|
||
{selected && children ? <div className="mt-2 pl-6">{children}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StatusCardSettingsForm({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: StatusCardSettingsValue;
|
||
onChange: (next: StatusCardSettingsValue) => void;
|
||
}) {
|
||
const { refreshPolicy: policy } = value;
|
||
// Change triggers, active-hours, and the daily token cap only govern
|
||
// *automatic* updates. In Manual mode none of them apply, so the whole
|
||
// "Advanced" group is hidden rather than shown-but-dimmed.
|
||
const autoUpdating = policy.mode !== "manual";
|
||
const costEstimate = estimateStatusCardCost(policy);
|
||
|
||
const setPolicy = (patch: Partial<StatusCardRefreshPolicy>) =>
|
||
onChange({ ...value, refreshPolicy: { ...policy, ...patch } });
|
||
|
||
const setMode = (mode: StatusCardRefreshPolicy["mode"]) => {
|
||
const patch: Partial<StatusCardRefreshPolicy> = { mode };
|
||
if (mode === "interval") patch.intervalMinutes = policy.intervalMinutes ?? 15;
|
||
if (mode === "reactive") {
|
||
patch.debounceSeconds = policy.debounceSeconds ?? 60;
|
||
patch.maxUpdatesPerHour = policy.maxUpdatesPerHour ?? 6;
|
||
}
|
||
setPolicy(patch);
|
||
};
|
||
|
||
const toggleTrigger = (key: TriggerKey) =>
|
||
setPolicy({ triggers: { ...policy.triggers, [key]: !policy.triggers[key] } });
|
||
|
||
const activeHours = policy.activeHours;
|
||
const setActiveHoursEnabled = (enabled: boolean) =>
|
||
setPolicy({
|
||
activeHours: enabled
|
||
? { start: activeHours?.start ?? "08:00", end: activeHours?.end ?? "19:00", timezone: activeHours?.timezone ?? "UTC" }
|
||
: undefined,
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<section className="space-y-2">
|
||
<h3 className="text-sm font-semibold">Auto-update policy</h3>
|
||
<div className="space-y-2">
|
||
<RadioRow
|
||
selected={policy.mode === "manual"}
|
||
title="Manual only — updates when I press refresh"
|
||
badge={
|
||
<span className="ml-auto rounded-full bg-muted px-2 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">
|
||
Default
|
||
</span>
|
||
}
|
||
onSelect={() => setMode("manual")}
|
||
/>
|
||
<RadioRow
|
||
selected={policy.mode === "interval"}
|
||
title="On a schedule, only if something changed"
|
||
onSelect={() => setMode("interval")}
|
||
>
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<span>Check every</span>
|
||
<Select
|
||
value={String(policy.intervalMinutes ?? 15)}
|
||
onValueChange={(next) => setPolicy({ intervalMinutes: Number(next) })}
|
||
>
|
||
<SelectTrigger size="sm" className="w-28" aria-label="Check interval">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{INTERVAL_OPTIONS.map((minutes) => (
|
||
<SelectItem key={minutes} value={String(minutes)}>
|
||
{minutes} min
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</RadioRow>
|
||
<RadioRow
|
||
selected={policy.mode === "reactive"}
|
||
title="As soon as something changes (debounced)"
|
||
onSelect={() => setMode("reactive")}
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||
<span>Wait</span>
|
||
<Select
|
||
value={String(policy.debounceSeconds ?? 60)}
|
||
onValueChange={(next) => setPolicy({ debounceSeconds: Number(next) })}
|
||
>
|
||
<SelectTrigger size="sm" className="w-24" aria-label="Debounce">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{DEBOUNCE_OPTIONS.map((seconds) => (
|
||
<SelectItem key={seconds} value={String(seconds)}>
|
||
{seconds}s
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-xs">after the last change · max</span>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={60}
|
||
value={policy.maxUpdatesPerHour ?? 6}
|
||
onChange={(event) => setPolicy({ maxUpdatesPerHour: Math.max(1, Number(event.target.value) || 1) })}
|
||
className="h-8 w-16 text-sm"
|
||
aria-label="Max updates per hour"
|
||
/>
|
||
<span className="text-xs">updates/hour</span>
|
||
</div>
|
||
</RadioRow>
|
||
</div>
|
||
</section>
|
||
|
||
{/*
|
||
Change triggers, active hours, and the daily token cap only apply to
|
||
automatic updates, so they are hidden entirely in Manual mode and tucked
|
||
under a collapsed "Advanced" disclosure otherwise.
|
||
*/}
|
||
{autoUpdating ? (
|
||
<Collapsible className="rounded-md border border-border">
|
||
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-2 px-3 py-2.5 text-sm font-semibold">
|
||
Advanced
|
||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="space-y-6 border-t border-border px-3 py-3">
|
||
<section className="space-y-2">
|
||
<h3 className="text-sm font-semibold">Count as a change</h3>
|
||
<div className="space-y-2">
|
||
{TRIGGER_ROWS.map((row) => (
|
||
<label key={row.key} className="flex items-start gap-2.5 text-sm">
|
||
<Checkbox
|
||
checked={policy.triggers[row.key]}
|
||
onCheckedChange={() => toggleTrigger(row.key)}
|
||
className="mt-0.5"
|
||
aria-label={row.label}
|
||
/>
|
||
<span className={cn(row.noisy && "text-muted-foreground")}>{row.label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="space-y-3">
|
||
<h3 className="text-sm font-semibold">Guardrails</h3>
|
||
<label className="flex items-start gap-2.5 text-sm">
|
||
<Checkbox checked={Boolean(activeHours)} onCheckedChange={(checked) => setActiveHoursEnabled(Boolean(checked))} className="mt-0.5" aria-label="Limit to active hours" />
|
||
<span>Only auto-update during active hours</span>
|
||
</label>
|
||
{activeHours ? (
|
||
<div className="flex flex-wrap items-center gap-2 pl-6 text-sm">
|
||
<Input
|
||
type="time"
|
||
value={activeHours.start}
|
||
onChange={(event) => setPolicy({ activeHours: { ...activeHours, start: event.target.value } })}
|
||
className="h-8 w-32"
|
||
aria-label="Active hours start"
|
||
/>
|
||
<span className="text-muted-foreground">–</span>
|
||
<Input
|
||
type="time"
|
||
value={activeHours.end}
|
||
onChange={(event) => setPolicy({ activeHours: { ...activeHours, end: event.target.value } })}
|
||
className="h-8 w-32"
|
||
aria-label="Active hours end"
|
||
/>
|
||
<Input
|
||
value={activeHours.timezone}
|
||
onChange={(event) => setPolicy({ activeHours: { ...activeHours, timezone: event.target.value } })}
|
||
className="h-8 w-40"
|
||
placeholder="Timezone"
|
||
aria-label="Active hours timezone"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||
<span className="w-32 shrink-0">Daily token cap</span>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
step={1000}
|
||
value={policy.dailyTokenCap ?? ""}
|
||
onChange={(event) => {
|
||
const parsed = Number(event.target.value);
|
||
setPolicy({ dailyTokenCap: event.target.value === "" || parsed <= 0 ? undefined : parsed });
|
||
}}
|
||
className="h-8 w-36"
|
||
placeholder="no cap"
|
||
aria-label="Daily token cap"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
) : null}
|
||
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className="font-semibold">Estimated cost</span>
|
||
<span className="text-muted-foreground">=</span>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="cursor-default font-medium text-foreground underline decoration-dotted decoration-muted-foreground/50 underline-offset-4">
|
||
{costEstimate.cost}
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-(--sz-18rem) text-left">
|
||
<p>{costEstimate.primary}</p>
|
||
{costEstimate.note ? <p className="mt-1 opacity-80">{costEstimate.note}</p> : null}
|
||
<p className="mt-1 opacity-80">Rough estimate from typical update sizes; actual cost is tracked per update.</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|