mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
## 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>
461 lines
18 KiB
TypeScript
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)
|
|
);
|
|
}
|