mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
Add workflow interaction cancellation and issue cost summaries (#4862)
## Thinking Path > - Paperclip coordinates work through issue-thread interactions, run history, and cost telemetry. > - Operators need workflow prompts to be cancellable and costs to be visible at the issue level. > - The earlier rollup mixed this workflow/cost work with database backups, reliability recovery, thread scaling, and settings polish. > - This pull request isolates the interaction and cost surfaces into a reviewable slice. > - The backend now supports cancelling pending question interactions and summarizing issue-tree costs. > - The UI component layer can render cancelled questions and interleave activity with run ledger rows. ## What Changed - Added `cancelled` as an issue-thread interaction status and result shape for question interactions. - Added the board-only `POST /issues/:id/interactions/:interactionId/cancel` route and service implementation. - Added issue-tree cost summary support in the cost service and `/issues/:id/cost-summary` API route. - Extended shared cost exports and UI API/query keys for issue cost summaries. - Updated `IssueThreadInteractionCard` and `IssueRunLedger` components for cancelled questions, issue cost surfaces, and activity/run interleaving. - Added focused server and component regression coverage. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/costs-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts ui/src/components/IssueRunLedger.test.tsx` - Result: 4 test files passed, 45 tests passed. - UI screenshots not included because this PR updates reusable components and API surfaces without wiring a new page-level layout. ## Risks - Adds a new interaction terminal status; clients that switch exhaustively on interaction status may need to handle `cancelled`. - Issue-tree cost summaries use recursive issue traversal and should be watched on unusually large issue trees. - Page-level issue detail wiring is intentionally left to the board QoL/issue-detail branch to keep this PR narrow. > 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 - OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium reasoning effort. ## 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>
This commit is contained in:
parent
87f19cd9a6
commit
c4269bab59
23 changed files with 810 additions and 53 deletions
|
|
@ -43,6 +43,9 @@ interface IssueThreadInteractionCardProps {
|
|||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function resolveActorLabel(args: {
|
||||
|
|
@ -72,6 +75,8 @@ function statusLabel(status: IssueThreadInteraction["status"]) {
|
|||
return "Rejected";
|
||||
case "answered":
|
||||
return "Answered";
|
||||
case "cancelled":
|
||||
return "Cancelled";
|
||||
case "expired":
|
||||
return "Expired";
|
||||
case "failed":
|
||||
|
|
@ -100,6 +105,7 @@ function statusIcon(status: IssueThreadInteraction["status"]) {
|
|||
case "answered":
|
||||
return CheckCircle2;
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
case "failed":
|
||||
return XCircle;
|
||||
case "expired":
|
||||
|
|
@ -118,6 +124,7 @@ function statusClasses(status: IssueThreadInteraction["status"]) {
|
|||
badge: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100",
|
||||
};
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return {
|
||||
shell: "border-rose-400/70 bg-transparent",
|
||||
badge: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100",
|
||||
|
|
@ -636,12 +643,16 @@ function QuestionOptionButton({
|
|||
function AskUserQuestionsCard({
|
||||
interaction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: {
|
||||
interaction: AskUserQuestionsInteraction;
|
||||
onSubmitInteractionAnswers?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}) {
|
||||
const [draftAnswers, setDraftAnswers] = useState<Record<string, string[]>>(() =>
|
||||
Object.fromEntries(
|
||||
|
|
@ -652,6 +663,7 @@ function AskUserQuestionsCard({
|
|||
),
|
||||
);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftAnswers(
|
||||
|
|
@ -699,6 +711,16 @@ function AskUserQuestionsCard({
|
|||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!onCancelInteraction) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await onCancelInteraction(interaction);
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
|
|
@ -765,26 +787,54 @@ function AskUserQuestionsCard({
|
|||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Submit once after you finish the full form.
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!onSubmitInteractionAnswers || !canSubmit || working}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{working ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.submitLabel ?? "Submit answers"
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{onCancelInteraction ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={working || cancelling}
|
||||
onClick={() => void handleCancel()}
|
||||
>
|
||||
{cancelling ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Cancelling...
|
||||
</>
|
||||
) : (
|
||||
"Cancel question"
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!onSubmitInteractionAnswers || !canSubmit || working || cancelling}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{working ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.submitLabel ?? "Submit answers"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : interaction.status === "cancelled" ? (
|
||||
<div className="rounded-2xl border border-rose-300/60 bg-rose-50/85 p-4 text-sm leading-6 text-rose-950 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<div className="font-semibold">Question cancelled</div>
|
||||
{interaction.result?.cancellationReason ? (
|
||||
<p className="mt-1">{interaction.result.cancellationReason}</p>
|
||||
) : (
|
||||
<p className="mt-1">No answer was recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{questions.map((question) => {
|
||||
|
|
@ -1162,6 +1212,7 @@ export function IssueThreadInteractionCard({
|
|||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: IssueThreadInteractionCardProps) {
|
||||
const StatusIcon = statusIcon(interaction.status);
|
||||
const styles = statusClasses(interaction.status);
|
||||
|
|
@ -1247,6 +1298,7 @@ export function IssueThreadInteractionCard({
|
|||
<AskUserQuestionsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue