Files
PaperClipAI/ui/src/lib/issue-thread-interactions.ts
T
Devin FoleyandPaperclip 144083fd48 fix(interactions): wait for workspace readiness before enabling approval (#14893)
## Thinking Path

> - Paperclip lets people manage AI agents and review their work.
> - Task confirmations must use the work produced by their source run.
> - The server blocks approval while that run still needs to sync its
workspace.
> - The card currently enables approval before that check can pass, so
an ordinary click produces an error.
> - This PR exposes the existing readiness check and shows “Preparing
approval…” with acceptance disabled.
> - The card refreshes itself and enables approval when the source
workspace settles.

## Linked Issues or Issue Description

**What happened?**

A confirmation appears while its source run is still preparing or
syncing its workspace. Its enabled approval button returns a conflict
asking the user to retry after syncing.

**Steps to reproduce**

1. Run an agent in an isolated workspace.
2. Have it create a confirmation before workspace finalization
completes.
3. Click the approval button while the source workspace is still active.

**Expected behavior**

The card explains that approval is preparing. Acceptance becomes
available automatically when the same server check permits it. Reject
and revise remain available.

Related work: #10770 handles this conflict after a click with retries.
#9520 proposes changing the workspace acceptance barrier. This PR
preserves that barrier and exposes readiness before the click, including
in compact task chat. It preserves the terminal-finalize behavior from
#10099.

## What Changed

- Add an optional, read-only `acceptanceBlocker` to interaction
responses. Readiness uses the existing source-run workspace predicate,
with one check per pending source run.
- Disable acceptance and show a shared preparation notice in classic and
compact confirmation cards, including checkbox and secret-binding
confirmations.
- Refresh preparing cards every two seconds in task detail, attention,
pipelines, and Skill Studio. Restore each surface's previous polling
cadence when preparation clears.
- Preserve live tool reviews, questions, rejection, revision, and the
server acceptance barrier. No automatic acceptance occurs.
- Document the preparation state and cover readiness, terminal sync
outcomes, unrelated runs, historical cards, and automatic refresh.

## Verification

- Focused service, card, query-refresh, and helper tests: 217 passed
across five files.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- `pnpm build-storybook`: passed.
- `pnpm check:token-gates`: passed.
- `pnpm test:run`: incomplete locally. Stopped after about 17 minutes
once it reproduced seven existing environment failures: two Slack tests
and two email tests lack ancestor-directory skill fixtures; three
company-skills tests fail on macOS runtime-cache staging permissions.
These are outside this change. The full CI test matrix passed.
- CI: all 53 checks passed; two optional Storybook jobs were skipped.
The branch has no conflicts with `master`.
- Greptile: 5/5 on commit `8a6f216d8d`, with no review threads.
- Reviewed added lines and new files for credentials, private URLs,
internal task references, user paths, and run artifacts. None found.

## Risks

- No database migration or change to acceptance authorization. Readiness
is advisory; the server still enforces its existing gate at acceptance.
- An open preparing card adds a read every two seconds. This cadence
stops after readiness clears; historical cards add no workspace checks.
- Failed or stale finalization retains the existing server behavior.
This PR does not change recovery policy.

## Model Used

OpenAI GPT-6 through Codex. The exact serving model ID and
context-window size are not exposed in this session. Used reasoning,
repository inspection, tool execution, and automated tests. No
sub-agents.

## 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)
- [x] 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 (217 focused tests; full
local-suite limitations are recorded above)
- [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: Paperclip <noreply@paperclip.ing>
2026-10-02 13:08:25 -07:00

461 lines
18 KiB
TypeScript

export type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
AskUserQuestionsPayload,
AskUserQuestionsQuestion,
AskUserQuestionsQuestionOption,
AskUserQuestionsResult,
ConnectionIntentInteraction,
ConnectionIntentPayload,
ConnectionIntentResult,
IssueThreadInteraction,
IssueThreadInteractionActorFields,
IssueThreadInteractionBase,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionStatus,
RequestCheckboxConfirmationInteraction,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestConfirmationSecretProposalPayload,
RequestConfirmationSecretProposalResult,
RequestConfirmationTarget,
RequestConfirmationToolActionPayload,
RequestConfirmationToolActionResult,
RequestItemVerdictsInteraction,
RequestItemVerdictsItem,
RequestItemVerdictsPayload,
RequestItemVerdictsResult,
RequestItemVerdictsResultItem,
RequestItemVerdictValue,
SubmitIssueThreadInteractionVerdicts,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksPayload,
SuggestTasksResult,
SuggestTasksResultCreatedTask,
} from "@paperclipai/shared";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
ConnectionIntentInteraction,
IssueThreadInteraction,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationTarget,
RequestItemVerdictsInteraction,
RequestItemVerdictsPayload,
RequestItemVerdictsResult,
RequestItemVerdictValue,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksResultCreatedTask,
} from "@paperclipai/shared";
export function isInteractionPreparingApproval(interaction: IssueThreadInteraction): boolean {
return interaction.status === "pending"
&& interaction.acceptanceBlocker === "workspace_sync_pending";
}
/** Poll only while an approval is preparing; retain each surface's idle cadence. */
export function interactionReadinessRefetchInterval(
interactions: IssueThreadInteraction[] | undefined,
fallback: number | false = false,
): number | false {
return interactions?.some(isInteractionPreparingApproval) ? 2_000 : fallback;
}
export interface SuggestedTaskTreeNode {
task: SuggestedTaskDraft;
children: SuggestedTaskTreeNode[];
}
/**
* These takeovers already expose a deliberate non-accept path in their form.
* Showing the composer's generic Skip beside Reject/Revise duplicates that
* escape hatch and makes the action hierarchy ambiguous.
*/
export function interactionReplacesComposerSkip(
interaction: IssueThreadInteraction,
): boolean {
return interaction.kind === "request_confirmation"
|| interaction.kind === "request_checkbox_confirmation"
|| interaction.kind === "suggest_tasks";
}
export function isIssueThreadInteraction(
value: unknown,
): value is IssueThreadInteraction {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<IssueThreadInteraction>;
return typeof candidate.id === "string"
&& typeof candidate.companyId === "string"
&& typeof candidate.issueId === "string"
&& (
candidate.kind === "suggest_tasks"
|| candidate.kind === "ask_user_questions"
|| candidate.kind === "request_confirmation"
|| candidate.kind === "request_checkbox_confirmation"
|| candidate.kind === "request_item_verdicts"
|| candidate.kind === "connection_intent"
);
}
export interface ItemVerdictProgress {
total: number;
decided: number;
approved: number;
rejected: number;
deferred: number;
/** ids in payload order that still have no verdict. */
pendingItemIds: string[];
}
/**
* Derive the `M of N decided` progress for a per-item verdict interaction from
* its payload (the full item roster) and result (verdicts accumulated so far).
* Present-tense verdict values (`approve`/`reject`/`defer`) are what the server
* stores in `result.items[].verdict` (see PAP-13247).
*/
export function getItemVerdictProgress(args: {
payload: RequestItemVerdictsPayload;
result?: RequestItemVerdictsResult | null;
}): ItemVerdictProgress {
const { payload, result } = args;
const resolvedById = new Map<string, RequestItemVerdictValue>(
(result?.items ?? []).map((item) => [item.id, item.verdict] as const),
);
let approved = 0;
let rejected = 0;
let deferred = 0;
const pendingItemIds: string[] = [];
for (const item of payload.items) {
const verdict = resolvedById.get(item.id);
if (verdict === "approve") approved += 1;
else if (verdict === "reject") rejected += 1;
else if (verdict === "defer") deferred += 1;
else pendingItemIds.push(item.id);
}
const decided = approved + rejected + deferred;
return { total: payload.items.length, decided, approved, rejected, deferred, pendingItemIds };
}
export function buildItemVerdictsSummary(
interaction: RequestItemVerdictsInteraction,
): string {
const progress = getItemVerdictProgress({
payload: interaction.payload,
result: interaction.result,
});
if (interaction.status === "answered") {
const parts = [`${progress.decided} decided`];
if (progress.approved > 0) parts.push(`${progress.approved} approved`);
if (progress.rejected > 0) parts.push(`${progress.rejected} rejected`);
if (progress.deferred > 0) parts.push(`${progress.deferred} deferred`);
return parts.join(" · ");
}
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Verdicts expired after comment";
if (outcome === "stale_target") return "Verdicts expired after target changed";
return "Verdicts expired";
}
return `${progress.decided} of ${progress.total} decided`;
}
export function getCheckboxConfirmationSelectedLabels(args: {
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}): string[] {
const { payload, result } = args;
const selectedIds = result?.selectedOptionIds ?? [];
const optionLabelById = new Map(
payload.options.map((option) => [option.id, option.label] as const),
);
return selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
}
export function normalizeRequestConfirmationTargetHref(href: string) {
const value = href.trim();
if (value.startsWith("#")) return value;
if (value.startsWith("/")) return value.startsWith("//") ? null : value;
return /^https?:\/\//i.test(value) ? value : null;
}
export function getRequestConfirmationTargetHref({
issueId,
target,
}: {
issueId: string;
target: RequestConfirmationTarget;
}) {
if (target.href) {
const safeHref = normalizeRequestConfirmationTargetHref(target.href);
if (safeHref) return safeHref;
}
if (target.type === "issue_document") {
const targetIssueId = target.issueId ?? issueId;
return `/issues/${targetIssueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
export function buildIssueThreadInteractionSummary(
interaction: IssueThreadInteraction,
) {
const administrativeOutcome = interaction.result && "outcome" in interaction.result
? interaction.result.outcome
: null;
if (administrativeOutcome === "skipped") return "Skipped interaction";
if (administrativeOutcome === "withdrawn") return "Withdrawn interaction";
if (administrativeOutcome === "issue_closed") return "Expired when issue closed";
if (administrativeOutcome === "addressee_deleted") return "Cancelled when addressee was deleted";
if (interaction.kind === "suggest_tasks") {
const count = interaction.payload.tasks.length;
if (interaction.status === "accepted") {
const createdCount = interaction.result?.createdTasks?.length ?? 0;
const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0;
if (skippedCount > 0) {
return `Accepted ${createdCount} of ${count} tasks`;
}
return createdCount === 1 ? "Accepted 1 task" : `Accepted ${createdCount} tasks`;
}
if (interaction.status === "rejected") {
return count === 1 ? "Rejected 1 task" : `Rejected ${count} tasks`;
}
return count === 1 ? "Suggested 1 task" : `Suggested ${count} tasks`;
}
if (interaction.kind === "request_confirmation") {
if (interaction.status === "accepted") return "Confirmed request";
if (interaction.status === "rejected") {
const rejectLabel = interaction.payload.rejectLabel?.trim();
return rejectLabel ? `Selected “${rejectLabel}”` : "Declined request";
}
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Confirmation expired after comment";
if (outcome === "stale_target") return "Confirmation expired after target changed";
return "Confirmation expired";
}
return "Requested confirmation";
}
if (interaction.kind === "request_checkbox_confirmation") {
const optionCount = interaction.payload.options.length;
if (interaction.status === "accepted") {
const selectedCount = interaction.result?.selectedOptionIds?.length ?? 0;
if (selectedCount === 0) return "Confirmed with no options selected";
return selectedCount === 1
? `Confirmed 1 of ${optionCount} options`
: `Confirmed ${selectedCount} of ${optionCount} options`;
}
if (interaction.status === "rejected") return "Declined selection";
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Selection expired after comment";
if (outcome === "stale_target") return "Selection expired after target changed";
return "Selection expired";
}
return optionCount === 1
? "Requested a selection from 1 option"
: `Requested a selection from ${optionCount} options`;
}
if (interaction.kind === "request_item_verdicts") {
return buildItemVerdictsSummary(interaction);
}
if (interaction.kind === "connection_intent") {
if (interaction.status === "accepted") return `${interaction.payload.serviceName} connected`;
if (interaction.status === "rejected") return `${interaction.payload.serviceName} declined`;
if (interaction.status === "expired") {
return interaction.result?.outcome === "superseded"
? `${interaction.payload.serviceName} request superseded`
: `${interaction.payload.serviceName} request expired`;
}
return `Connect ${interaction.payload.serviceName}`;
}
const count = interaction.payload.questions.length;
if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
}
if (interaction.status === "cancelled") {
return count === 1 ? "Cancelled 1 question" : `Cancelled ${count} questions`;
}
if (interaction.status === "expired") {
if (interaction.result?.expirationReason === "superseded_by_comment") {
return count === 1 ? "Question expired after comment" : "Questions expired after comment";
}
return count === 1 ? "Question expired" : "Questions expired";
}
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
}
/** Readable model input for a durable answer delivered into a successor run. */
export function buildAnsweredQuestionsDeliveryText(
interaction: AskUserQuestionsInteraction,
): string {
const questionSet = interaction.payload.questionSet;
const legacyQuestionById = new Map(
interaction.payload.questions.map((question) => [question.id, question] as const),
);
const canonicalQuestionById = new Map(
(questionSet?.questions ?? []).map((question) => [question.id, question] as const),
);
const lines = (interaction.result?.answers ?? []).map((answer) => {
const canonical = canonicalQuestionById.get(answer.questionId);
const legacy = legacyQuestionById.get(answer.questionId);
const optionLabels = new Map(
(canonical?.options ?? legacy?.options ?? []).map((option) => [option.id, option.label] as const),
);
const values = answer.optionIds.map((optionId) => optionLabels.get(optionId) ?? optionId);
if (answer.otherText?.trim()) values.push(answer.otherText.trim());
const prompt = canonical?.prompt ?? legacy?.prompt ?? answer.questionId;
const label = canonical?.header && canonical.header !== prompt
? `${canonical.header} — ${prompt}`
: prompt;
return `- ${label}: ${values.join(", ") || "No answer"}`;
});
return ["Answered questions", ...(lines.length > 0 ? ["", ...lines] : [])].join("\n");
}
export function buildSuggestedTaskTree(
tasks: readonly SuggestedTaskDraft[],
): SuggestedTaskTreeNode[] {
const nodes = new Map<string, SuggestedTaskTreeNode>();
for (const task of tasks) {
nodes.set(task.clientKey, { task, children: [] });
}
const roots: SuggestedTaskTreeNode[] = [];
for (const task of tasks) {
const node = nodes.get(task.clientKey);
if (!node) continue;
const parentNode = task.parentClientKey ? nodes.get(task.parentClientKey) : null;
if (parentNode) {
parentNode.children.push(node);
continue;
}
roots.push(node);
}
return roots;
}
export function countSuggestedTaskNodes(node: SuggestedTaskTreeNode): number {
return 1 + node.children.reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0);
}
export function collectSuggestedTaskClientKeys(node: SuggestedTaskTreeNode): string[] {
return [
node.task.clientKey,
...node.children.flatMap((child) => collectSuggestedTaskClientKeys(child)),
];
}
export function getQuestionAnswerLabels(args: {
question: AskUserQuestionsQuestion;
answers: readonly AskUserQuestionsAnswer[];
}) {
const { question, answers } = args;
const answer = answers.find((candidate) => candidate.questionId === question.id);
const selectedIds = answer?.optionIds ?? [];
const optionLabelById = new Map(
question.options.map((option) => [option.id, option.label] as const),
);
const labels = selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
const otherText = answer?.otherText?.trim();
if (otherText) labels.push(`Other: ${otherText}`);
return labels;
}
/**
* A single `ask_user_questions` question is degenerate when it offers *no way at
* all* to answer — hiding it therefore strands nothing the user could have
* resolved. Structural only, no semantic guessing about the wording:
*
* - its `prompt` is empty / whitespace-only, OR
* - it presents nothing to respond to: no first-class free-text option (the
* PAP-419 `freeText` flag) AND no selectable fixed option.
*
* A question with even a single fixed option is answerable (the user selects it
* and submits), so it is NOT degenerate and must keep rendering — otherwise a
* hidden-but-pending interaction would strand the assignee waiting on a response
* that can never arrive. Legitimate shapes all pass: yes/no, multi-select,
* free-text, and single-option acknowledgements.
*/
function isDegenerateAskUserQuestion(question: AskUserQuestionsQuestion): boolean {
if (question.prompt.trim().length === 0) return true;
const hasFreeTextOption = question.options.some((option) => option.freeText === true);
if (hasFreeTextOption) return false;
const selectableOptionCount = question.options.filter(
(option) => option.freeText !== true,
).length;
return selectableOptionCount === 0;
}
/**
* Structural render guard for `ask_user_questions` cards. A card is degenerate —
* safe to never draw because it strands nothing the user could resolve — when it
* offers no answerable question: it has zero questions, OR every question is
* degenerate (see {@link isDegenerateAskUserQuestion}: blank prompt, or no
* option and no free-text). A card with any answerable question — including a
* single fixed option — always renders.
*
* UI-only: the interaction is still created and stored server-side (audit
* intact); callers use this purely to decide whether to draw the card. Returns
* false for any other interaction kind — the guard is scoped to
* `ask_user_questions`.
*/
export function isDegenerateAskUserQuestions(
interaction: IssueThreadInteraction,
): boolean {
if (interaction.kind !== "ask_user_questions") return false;
const questions = interaction.payload.questions;
if (questions.length === 0) return true;
return questions.every(isDegenerateAskUserQuestion);
}
/**
* A stale sibling `ask_user_questions` that the server auto-expired when its own
* creator posted a newer one on the same issue (PAP-437). The replacement card
* is already in the thread, so this expired shell adds nothing and is never
* drawn. Gated on `status === "expired"` so a still-pending card is never hidden
* (PAP-424 / 00b136f45: hiding a pending question would strand the assignee).
* Distinct from `superseded_by_comment`, which keeps its stale notice.
*/
export function isSupersededByNewerSiblingInteraction(
interaction: IssueThreadInteraction,
): boolean {
if (interaction.kind !== "ask_user_questions") return false;
if (interaction.status !== "expired") return false;
return interaction.result?.expirationReason === "superseded_by_newer_interaction";
}
/**
* Single enforcement point for whether an interaction card should be suppressed
* from every thread surface. Routing all render sites through this one predicate
* keeps composition backbones and the card in lockstep, so a suppressed card
* never leaves an empty slot in one place while another still draws it.
*/
export function shouldHideInteractionCard(
interaction: IssueThreadInteraction,
): boolean {
return (
isDegenerateAskUserQuestions(interaction)
|| isSupersededByNewerSiblingInteraction(interaction)
);
}