2026-02-23 19:47:34 -06:00
|
|
|
import { useMemo, useState } from "react";
|
2026-03-02 16:44:03 -06:00
|
|
|
import { Link } from "@/lib/router";
|
2026-02-23 19:47:34 -06:00
|
|
|
import {
|
|
|
|
|
DndContext,
|
|
|
|
|
DragOverlay,
|
|
|
|
|
PointerSensor,
|
|
|
|
|
useSensor,
|
|
|
|
|
useSensors,
|
|
|
|
|
type DragStartEvent,
|
|
|
|
|
type DragEndEvent,
|
|
|
|
|
type DragOverEvent,
|
|
|
|
|
} from "@dnd-kit/core";
|
|
|
|
|
import { useDroppable } from "@dnd-kit/core";
|
|
|
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
|
|
|
import {
|
|
|
|
|
SortableContext,
|
|
|
|
|
useSortable,
|
|
|
|
|
verticalListSortingStrategy,
|
|
|
|
|
} from "@dnd-kit/sortable";
|
|
|
|
|
import { StatusIcon } from "./StatusIcon";
|
|
|
|
|
import { PriorityIcon } from "./PriorityIcon";
|
|
|
|
|
import { Identity } from "./Identity";
|
2026-03-03 08:45:26 -06:00
|
|
|
import type { Issue } from "@paperclipai/shared";
|
Add recovery handoff system notices (#5289)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent runs can end productively while the source issue still lacks a
durable final disposition.
> - That leaves the control plane unsure whether to resume, escalate, or
close the work.
> - Issue comments also need a presentation contract so system-authored
recovery notices can render as first-class thread messages without
overloading normal comments.
> - This pull request adds successful-run handoff recovery, comment
presentation metadata, and system notice rendering.
> - The benefit is stricter task liveness with clearer operator-facing
recovery state.
## What Changed
- Added successful-run handoff decisions, wake payloads, escalation
behavior, and recovery tests.
- Added issue comment presentation metadata with migration
`0078_white_darwin.sql` and shared/server/company portability support.
- Rendered recovery/system notices in issue chat with dedicated UI
components, fixtures, tests, and storybook/lab coverage.
- Included the current recovery model-profile hint patch so automatic
recovery follow-ups use the cheap profile.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/services/recovery/successful-run-handoff.test.ts
ui/src/components/SystemNotice.test.tsx
ui/src/lib/system-notice-comment.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx`
## Risks
- Migration-bearing PR: merge this before any other branch that might
later add a migration.
- The branch touches both recovery services and issue-thread rendering,
so review should pay attention to recovery wake idempotency and comment
metadata compatibility.
## Model Used
- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-06 06:05:58 -05:00
|
|
|
import { AlertTriangle } from "lucide-react";
|
|
|
|
|
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
2026-02-23 19:47:34 -06:00
|
|
|
|
|
|
|
|
const boardStatuses = [
|
|
|
|
|
"backlog",
|
|
|
|
|
"todo",
|
|
|
|
|
"in_progress",
|
|
|
|
|
"in_review",
|
|
|
|
|
"blocked",
|
|
|
|
|
"done",
|
|
|
|
|
"cancelled",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function statusLabel(status: string): string {
|
|
|
|
|
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Agent {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface KanbanBoardProps {
|
|
|
|
|
issues: Issue[];
|
|
|
|
|
agents?: Agent[];
|
|
|
|
|
liveIssueIds?: Set<string>;
|
|
|
|
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Droppable Column ── */
|
|
|
|
|
|
|
|
|
|
function KanbanColumn({
|
|
|
|
|
status,
|
|
|
|
|
issues,
|
|
|
|
|
agents,
|
|
|
|
|
liveIssueIds,
|
|
|
|
|
}: {
|
|
|
|
|
status: string;
|
|
|
|
|
issues: Issue[];
|
|
|
|
|
agents?: Agent[];
|
|
|
|
|
liveIssueIds?: Set<string>;
|
|
|
|
|
}) {
|
|
|
|
|
const { setNodeRef, isOver } = useDroppable({ id: status });
|
|
|
|
|
|
2026-04-03 23:18:38 +09:00
|
|
|
const isEmpty = issues.length === 0;
|
|
|
|
|
|
2026-02-23 19:47:34 -06:00
|
|
|
return (
|
2026-04-03 23:37:12 +09:00
|
|
|
<div className={`flex flex-col shrink-0 transition-[width,min-width] ${isEmpty && !isOver ? "min-w-[48px] w-[48px]" : "min-w-[260px] w-[260px]"}`}>
|
2026-04-03 23:18:38 +09:00
|
|
|
<div className={`flex items-center gap-2 px-2 py-2 mb-1 ${isEmpty && !isOver ? "justify-center" : ""}`}>
|
2026-02-23 19:47:34 -06:00
|
|
|
<StatusIcon status={status} />
|
2026-04-03 23:18:38 +09:00
|
|
|
{(!isEmpty || isOver) && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
{statusLabel(status)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground/60 ml-auto tabular-nums">
|
|
|
|
|
{issues.length}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-02-23 19:47:34 -06:00
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
className={`flex-1 min-h-[120px] rounded-md p-1 space-y-1 transition-colors ${
|
|
|
|
|
isOver ? "bg-accent/40" : "bg-muted/20"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={issues.map((i) => i.id)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
{issues.map((issue) => (
|
|
|
|
|
<KanbanCard
|
|
|
|
|
key={issue.id}
|
|
|
|
|
issue={issue}
|
|
|
|
|
agents={agents}
|
|
|
|
|
isLive={liveIssueIds?.has(issue.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Draggable Card ── */
|
|
|
|
|
|
|
|
|
|
function KanbanCard({
|
|
|
|
|
issue,
|
|
|
|
|
agents,
|
|
|
|
|
isLive,
|
|
|
|
|
isOverlay,
|
|
|
|
|
}: {
|
|
|
|
|
issue: Issue;
|
|
|
|
|
agents?: Agent[];
|
|
|
|
|
isLive?: boolean;
|
|
|
|
|
isOverlay?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const {
|
|
|
|
|
attributes,
|
|
|
|
|
listeners,
|
|
|
|
|
setNodeRef,
|
|
|
|
|
transform,
|
|
|
|
|
transition,
|
|
|
|
|
isDragging,
|
|
|
|
|
} = useSortable({ id: issue.id, data: { issue } });
|
|
|
|
|
|
|
|
|
|
const style = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const agentName = (id: string | null) => {
|
|
|
|
|
if (!id || !agents) return null;
|
|
|
|
|
return agents.find((a) => a.id === id)?.name ?? null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
{...attributes}
|
|
|
|
|
{...listeners}
|
|
|
|
|
className={`rounded-md border bg-card p-2.5 cursor-grab active:cursor-grabbing transition-shadow ${
|
|
|
|
|
isDragging && !isOverlay ? "opacity-30" : ""
|
|
|
|
|
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"}`}
|
|
|
|
|
>
|
|
|
|
|
<Link
|
|
|
|
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
2026-04-10 22:26:21 -05:00
|
|
|
disableIssueQuicklook
|
2026-02-23 19:47:34 -06:00
|
|
|
className="block no-underline text-inherit"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
// Prevent navigation during drag
|
|
|
|
|
if (isDragging) e.preventDefault();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start gap-1.5 mb-1.5">
|
|
|
|
|
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
|
|
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
|
|
|
</span>
|
Add recovery handoff system notices (#5289)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent runs can end productively while the source issue still lacks a
durable final disposition.
> - That leaves the control plane unsure whether to resume, escalate, or
close the work.
> - Issue comments also need a presentation contract so system-authored
recovery notices can render as first-class thread messages without
overloading normal comments.
> - This pull request adds successful-run handoff recovery, comment
presentation metadata, and system notice rendering.
> - The benefit is stricter task liveness with clearer operator-facing
recovery state.
## What Changed
- Added successful-run handoff decisions, wake payloads, escalation
behavior, and recovery tests.
- Added issue comment presentation metadata with migration
`0078_white_darwin.sql` and shared/server/company portability support.
- Rendered recovery/system notices in issue chat with dedicated UI
components, fixtures, tests, and storybook/lab coverage.
- Included the current recovery model-profile hint patch so automatic
recovery follow-ups use the cheap profile.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/services/recovery/successful-run-handoff.test.ts
ui/src/components/SystemNotice.test.tsx
ui/src/lib/system-notice-comment.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx`
## Risks
- Migration-bearing PR: merge this before any other branch that might
later add a migration.
- The branch touches both recovery services and issue-thread rendering,
so review should pay attention to recovery wake idempotency and comment
metadata compatibility.
## Model Used
- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-06 06:05:58 -05:00
|
|
|
{isSuccessfulRunHandoffRequired(issue) ? (
|
|
|
|
|
<span
|
|
|
|
|
className="inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
|
|
|
|
|
title="This issue needs a next step"
|
|
|
|
|
aria-label="Needs next step"
|
|
|
|
|
>
|
|
|
|
|
<AlertTriangle className="h-3 w-3" />
|
|
|
|
|
Next step
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
2026-02-23 19:47:34 -06:00
|
|
|
{isLive && (
|
|
|
|
|
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
|
ui: apply interface polish from design article review
- Add global font smoothing (antialiased) to body
- Add tabular-nums to all numeric displays: MetricCard values, Costs page,
AgentDetail token/cost grids and tables, IssueDetail cost summary,
Companies page budget display
- Replace markdown image hard border with subtle inset box-shadow overlay
- Replace all animate-ping status dots with calmer animate-pulse across
AgentDetail, IssueDetail, Agents, sidebar, kanban, issues list, and
active agents panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:20:24 -05:00
|
|
|
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
2026-02-23 19:47:34 -06:00
|
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm leading-snug line-clamp-2 mb-2">{issue.title}</p>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<PriorityIcon priority={issue.priority} />
|
|
|
|
|
{issue.assigneeAgentId && (() => {
|
|
|
|
|
const name = agentName(issue.assigneeAgentId);
|
|
|
|
|
return name ? (
|
|
|
|
|
<Identity name={name} size="xs" />
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
|
|
|
{issue.assigneeAgentId.slice(0, 8)}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Main Board ── */
|
|
|
|
|
|
|
|
|
|
export function KanbanBoard({
|
|
|
|
|
issues,
|
|
|
|
|
agents,
|
|
|
|
|
liveIssueIds,
|
|
|
|
|
onUpdateIssue,
|
|
|
|
|
}: KanbanBoardProps) {
|
|
|
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const columnIssues = useMemo(() => {
|
|
|
|
|
const grouped: Record<string, Issue[]> = {};
|
|
|
|
|
for (const status of boardStatuses) {
|
|
|
|
|
grouped[status] = [];
|
|
|
|
|
}
|
|
|
|
|
for (const issue of issues) {
|
|
|
|
|
if (grouped[issue.status]) {
|
|
|
|
|
grouped[issue.status].push(issue);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return grouped;
|
|
|
|
|
}, [issues]);
|
|
|
|
|
|
|
|
|
|
const activeIssue = useMemo(
|
|
|
|
|
() => (activeId ? issues.find((i) => i.id === activeId) : null),
|
|
|
|
|
[activeId, issues]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
function handleDragStart(event: DragStartEvent) {
|
|
|
|
|
setActiveId(event.active.id as string);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
|
|
|
setActiveId(null);
|
|
|
|
|
const { active, over } = event;
|
|
|
|
|
if (!over) return;
|
|
|
|
|
|
|
|
|
|
const issueId = active.id as string;
|
|
|
|
|
const issue = issues.find((i) => i.id === issueId);
|
|
|
|
|
if (!issue) return;
|
|
|
|
|
|
|
|
|
|
// Determine target status: the "over" could be a column id (status string)
|
|
|
|
|
// or another card's id. Find which column the "over" belongs to.
|
|
|
|
|
let targetStatus: string | null = null;
|
|
|
|
|
|
|
|
|
|
if (boardStatuses.includes(over.id as string)) {
|
|
|
|
|
targetStatus = over.id as string;
|
|
|
|
|
} else {
|
|
|
|
|
// It's a card - find which column it's in
|
|
|
|
|
const targetIssue = issues.find((i) => i.id === over.id);
|
|
|
|
|
if (targetIssue) {
|
|
|
|
|
targetStatus = targetIssue.status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (targetStatus && targetStatus !== issue.status) {
|
|
|
|
|
onUpdateIssue(issueId, { status: targetStatus });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDragOver(_event: DragOverEvent) {
|
|
|
|
|
// Could be used for visual feedback; keeping simple for now
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
onDragStart={handleDragStart}
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex gap-3 overflow-x-auto pb-4 -mx-2 px-2">
|
|
|
|
|
{boardStatuses.map((status) => (
|
|
|
|
|
<KanbanColumn
|
|
|
|
|
key={status}
|
|
|
|
|
status={status}
|
|
|
|
|
issues={columnIssues[status] ?? []}
|
|
|
|
|
agents={agents}
|
|
|
|
|
liveIssueIds={liveIssueIds}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<DragOverlay>
|
|
|
|
|
{activeIssue ? (
|
|
|
|
|
<KanbanCard issue={activeIssue} agents={agents} isOverlay />
|
|
|
|
|
) : null}
|
|
|
|
|
</DragOverlay>
|
|
|
|
|
</DndContext>
|
|
|
|
|
);
|
|
|
|
|
}
|