diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c194c4c202b..fc5d1031e0c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -593,6 +593,13 @@ choices, item verdicts, suggested tasks, tool reviews, runtime questions, and ph the bottom navigation. The normal message composer remains usable below the pending card. +Plan reviews leave the composer and pending count when a newer saved plan +revision is known. Document activity refreshes the interaction list, and a +superseded or stale-target response refreshes it immediately. The UI keeps a +newer review visible if its document query still has an older revision. These +display rules do not resolve requests or change the server's approval checks. +Recovery retry uses the same plan freshness check as the composer. + Use **Chat & Comments → Task Chat Unanswered Questions → Test Drive** to try a regular task question: dismiss it, reload, reopen its compact feed entry, and submit the preserved draft. The composer has no question pending badge. diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 2888e573599..bdaa13c562e 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -3919,6 +3919,56 @@ describe.each([true, false])("Unanswered question history (conversationMode=%s)" const props = { conversationMode, issueId: "issue-1", currentUserId: "user-board", onAdd: async () => {}, onSubmitInteractionAnswers: vi.fn() }; const takeover = () => container.querySelector('[data-testid="task-chat-composer-takeover"]'); const pendingIndicator = () => container.querySelector('[data-testid="task-chat-pending-input-indicator"]'); + it.each([false, true])("removes an old plan approval as soon as the newer document arrives (coordinator=%s)", coordinator => { + const review = planReviewInteraction(); + planState.data = planDocument(); + const show = (interactions = [review]) => render( + + ); + show(); + expect(takeover()?.textContent).toContain("Do you accept this plan?"); + + planState.data = planDocument({ latestRevisionId: "revision-4", latestRevisionNumber: 4, updatedAt: new Date("2026-08-23T10:03:00Z") }); + show(); + expect(takeover()).toBeNull(); + expect(pendingIndicator()).toBeNull(); + expect(container.querySelector('[data-testid="task-chat-pending-request"]')).toBeNull(); + + const replacement = { ...planReviewInteraction("pending", "revision-4"), id: "new-review", createdAt: new Date("2026-08-23T10:04:00Z") }; + show([review, replacement]); + expect(takeover()?.textContent).toContain("Do you accept this plan?"); + expect(takeover()?.textContent).not.toContain("2 pending"); + expect(review.status).toBe("pending"); + }); + + it("keeps a new approval when its document query is still on an older revision", () => { + planState.data = planDocument({ latestRevisionId: "revision-2", latestRevisionNumber: 2 }); + render(); + expect(takeover()?.textContent).toContain("Do you accept this plan?"); + }); + + it.each(["id", "number"] as const)("removes an older plan review that has only a revision %s", reference => { + const review = { + ...planReviewInteraction(), + payload: { + version: 1, + prompt: "Approve this Plan?", + target: { type: "issue_document", key: "plan", ...(reference === "id" ? { revisionId: "revision-3" } : { revisionNumber: 3 }) }, + }, + } as IssueThreadInteraction; + planState.data = planDocument({ latestRevisionId: "revision-4", latestRevisionNumber: 4, updatedAt: new Date("2026-08-23T10:03:00Z") }); + render(); + expect(takeover()).toBeNull(); + expect(pendingIndicator()).toBeNull(); + }); + + it("keeps unrelated approvals and accepted plan history when a plan changes", () => { + planState.data = planDocument({ latestRevisionId: "revision-4", latestRevisionNumber: 4, updatedAt: new Date("2026-08-23T10:03:00Z") }); + const review = { ...planReviewInteraction(), payload: { version: 1, prompt: "Review another task", target: { type: "issue_document", issueId: "other-task", key: "plan", revisionId: "revision-3" } } } as IssueThreadInteraction; + render(); + expect(takeover()).not.toBeNull(); + expect(container.querySelector('#interaction-accepted-plan')).not.toBeNull(); + }); const dismiss = async () => { await act(async () => takeover()!.querySelector('[aria-label^="Dismiss "]')!.click()); }; diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 317fc7f4b9f..d4420da495f 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -97,6 +97,7 @@ import { TaskChatProtocolCard } from "@/components/task-chat/TaskChatProtocolCar import { TaskChatRequestHistoryContext } from "@/components/task-chat/TaskChatComposerTakeoverContext"; import { interactionThreadAnchorMs, + isOutdatedPlanConfirmation, isSuppressedThreadInteraction, } from "@/components/task-chat/interaction-thread-order"; import { orderTaskCreationItems, taskCreationSourceRunId } from "@/components/task-chat/task-creation-order"; @@ -519,7 +520,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { initialHistoryError = false, onRetryInitialHistory, comments, - interactions, + interactions: storedInteractions, documents = [], workProducts = [], attachments = [], @@ -592,6 +593,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { resumeAssigneePending = false, onOpenSkill, } = props; + const { + data: planDocument, + isLoading: planLoading, + isError: planError, + refetch: retryPlan, + } = useIssuePlanDocument(issueId); + const interactions = useMemo(() => storedInteractions?.filter( + interaction => !isOutdatedPlanConfirmation(interaction, planDocument), + ), [storedInteractions, planDocument]); const confirmedStoppedRunIds = useMemo(() => new Set((linkedRuns ?? []).filter((run) => companyId && (run.runtimeMode === "legacy" || run.runtimeMode === "native") && isRunStopConfirmed({ id: run.runId, companyId, status: run.status, runtimeMode: run.runtimeMode, resultJson: run.resultJson ?? null, @@ -1200,13 +1210,6 @@ export function TaskChatThread(props: TaskChatThreadProps) { return map; }, [comments]); - const { - data: planDocument, - isLoading: planLoading, - isError: planError, - refetch: retryPlan, - } = useIssuePlanDocument(issueId); - // A native-runner Plan is part of the turn that wrote its current revision. // Legacy adapters do not expose the semantic write_document boundary needed // for stable embedding, so their Plans remain chronological thread entries. diff --git a/ui/src/components/task-chat/interaction-thread-order.test.ts b/ui/src/components/task-chat/interaction-thread-order.test.ts index 3371ca8413b..4ac5afb5241 100644 --- a/ui/src/components/task-chat/interaction-thread-order.test.ts +++ b/ui/src/components/task-chat/interaction-thread-order.test.ts @@ -5,8 +5,10 @@ import type { } from "@/lib/issue-thread-interactions"; import { interactionThreadAnchorMs, + isOutdatedPlanConfirmation, isSuppressedThreadInteraction, } from "./interaction-thread-order"; +import { pendingAskUserQuestionsInteraction } from "@/fixtures/issueThreadInteractionFixtures"; function confirmation( overrides: Partial = {}, @@ -46,6 +48,35 @@ function result(outcome: RequestConfirmationResult["outcome"]): RequestConfirmat const CREATED_MS = new Date("2026-04-06T12:00:00.000Z").getTime(); const RESOLVED_MS = new Date("2026-04-06T12:05:00.000Z").getTime(); +describe("isOutdatedPlanConfirmation", () => { + const plan = { + id: "document-plan", companyId: "company-1", issueId: "issue-1", + latestRevisionId: "revision-4", latestRevisionNumber: 4, updatedAt: new Date("2026-04-06T12:01:00Z"), + }; + const review = confirmation({ payload: { version: 1, prompt: "Approve?", target: { + type: "issue_document", key: "plan", documentId: plan.id, revisionId: "revision-3", revisionNumber: 3, + } } }); + const idOnlyReview = confirmation({ payload: { version: 1, prompt: "Approve?", target: { + type: "issue_document", key: "plan", revisionId: "revision-3", + } } }); + + it.each([ + { name: "no plan", interaction: review, document: null }, + { name: "accepted decision", interaction: { ...review, status: "accepted" as const }, document: plan }, + { name: "question", interaction: pendingAskUserQuestionsInteraction, document: plan }, + { name: "other issue", interaction: review, document: { ...plan, issueId: "other-issue" } }, + { name: "other company", interaction: review, document: { ...plan, companyId: "other-company" } }, + { name: "matching revision", interaction: review, document: { ...plan, latestRevisionId: "revision-3" } }, + { name: "older document cache", interaction: idOnlyReview, document: { ...plan, updatedAt: new Date("2026-04-06T11:59:00Z") } }, + ])("keeps $name", ({ interaction, document }) => { + expect(isOutdatedPlanConfirmation(interaction, document)).toBe(false); + }); + + it.each([review, idOnlyReview])("hides an older saved revision", interaction => { + expect(isOutdatedPlanConfirmation(interaction, plan)).toBe(true); + }); +}); + describe("isSuppressedThreadInteraction", () => { it("hides withdrawn confirmations", () => { expect( diff --git a/ui/src/components/task-chat/interaction-thread-order.ts b/ui/src/components/task-chat/interaction-thread-order.ts index a81e91314c8..717c7828022 100644 --- a/ui/src/components/task-chat/interaction-thread-order.ts +++ b/ui/src/components/task-chat/interaction-thread-order.ts @@ -1,4 +1,20 @@ import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions"; +import type { IssueDocument } from "@paperclipai/shared"; + +export function isOutdatedPlanConfirmation( + interaction: IssueThreadInteraction, + plan: Pick | null | undefined, +): boolean { + if (!plan || interaction.status !== "pending" || interaction.kind !== "request_confirmation") return false; + const target = interaction.payload.target; + if (target?.type !== "issue_document" || target.key !== "plan") return false; + if (interaction.companyId !== plan.companyId || (target.issueId ?? interaction.issueId) !== plan.issueId) return false; + if (target.revisionId && target.revisionId === plan.latestRevisionId) return false; + if ((!target.documentId || target.documentId === plan.id) && target.revisionNumber != null) { + return target.revisionNumber < plan.latestRevisionNumber; + } + return new Date(plan.updatedAt).getTime() > new Date(interaction.createdAt).getTime(); +} /** * Thread-ordering + visibility rules for issue-thread interaction cards diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index e7d3603e003..9d9806e473d 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -578,6 +578,11 @@ describe("LiveUpdatesProvider issue invalidation", () => { queryKey: queryKeys.issues.documents("issue-1"), refetchType: "inactive", }); + for (const ref of ["issue-1", "PAP-9403"]) { + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.interactions(ref), + }); + } }); it("refreshes all issue document caches when document activity omits a document key", () => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index f14de12239e..c59b592f578 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1589,6 +1589,7 @@ function invalidateActivityQueries( queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) }); } if (action && ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action)) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) }); const documentKey = readString(details?.key); queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref), diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 8782d387842..00e52bede1a 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -106,6 +106,7 @@ const mockIssuesApi = vi.hoisted(() => ({ deleteAttachment: vi.fn(), upsertDocument: vi.fn(), getDocument: vi.fn(), + acceptInteraction: vi.fn(), rejectInteraction: vi.fn(), })); @@ -3900,7 +3901,7 @@ describe("IssueDetail", () => { mockIssuesApi.resolveRecoveryAction.mockReset(); }); - it.each(["success", "failure", "pending question"])("connects the inline disposition retry to current state (%s)", async outcome => { + it.each(["success", "failure", "pending question", "outdated plan", "current plan"])("connects the inline disposition retry to current state (%s)", async outcome => { const fail = outcome === "failure"; const actionId = "recovery-action-inline"; const snapshot: DispositionRecoverySnapshot = { kind: "disposition_repair_escalated", actionId, attemptCount: 2, maxAttempts: 2, reason: "unchanged_source_state_exhausted", assigneeAgentId: "agent-1" }; @@ -3913,13 +3914,28 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(issue); mockIssuesApi.listComments.mockResolvedValue([{ id: "notice-inline", companyId: issue.companyId, issueId: issue.id, authorType: "system", body: "Unrelated prose", createdAt: new Date(), updatedAt: new Date(), metadata: { version: 1, sections: [], recovery: snapshot } }]); if (outcome === "pending question") mockIssuesApi.listInteractions.mockResolvedValue([{ id: "question-1", kind: "ask_user_questions", status: "pending", payload: { version: 1, questions: [] } }]); + if (outcome.endsWith("plan")) { + mockIssuesApi.getDocument.mockResolvedValue({ + id: "plan-document", companyId: issue.companyId, issueId: issue.id, key: "plan", title: "Plan", format: "markdown", body: "# Current plan", + latestRevisionId: "revision-4", latestRevisionNumber: 4, + createdAt: new Date("2026-11-01T10:00:00Z"), updatedAt: new Date("2026-11-01T10:03:00Z"), + createdByAgentId: "agent-1", createdByUserId: null, updatedByAgentId: "agent-1", updatedByUserId: null, + lockedAt: null, lockedByAgentId: null, lockedByUserId: null, + }); + const fixture = (await import("@/fixtures/issueThreadInteractionFixtures")).genericPendingRequestConfirmationInteraction; + const revision = outcome === "outdated plan" ? 3 : 4; + mockIssuesApi.listInteractions.mockResolvedValue([{ + ...fixture, companyId: issue.companyId, issueId: issue.id, createdAt: new Date("2026-11-01T10:01:00Z"), + payload: { version: 1, prompt: "Approve the plan?", target: { type: "issue_document", key: "plan", documentId: "plan-document", revisionId: `revision-${revision}`, revisionNumber: revision } }, + }]); + } if (fail) mockIssuesApi.resolveRecoveryAction.mockRejectedValue(new Error("The task is now paused.")); else mockIssuesApi.resolveRecoveryAction.mockResolvedValue({ issue: { ...issue, status: "todo", activeRecoveryAction: null }, recoveryAction: { ...issue.activeRecoveryAction, status: "resolved" } }); await act(async () => renderIssueView()); await flushReact(); await flushReact(); const retry = Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Retry agent"); expect(retry).toBeDefined(); - if (outcome === "pending question") { + if (outcome === "pending question" || outcome === "current plan") { expect(retry!.disabled).toBe(true); expect(container.textContent).toContain("Respond to the pending question or confirmation before retrying."); await act(async () => retry!.click()); @@ -6316,6 +6332,28 @@ describe("IssueDetail", () => { }); }); + it.each(["accept", "reject"] as const)("refreshes the request after a superseded %s response", async action => { + const pending = (await import("@/fixtures/issueThreadInteractionFixtures")).genericPendingRequestConfirmationInteraction; + const expired = { ...pending, status: "expired", result: { version: 1, outcome: "superseded_by_newer_request" } }; + mockIssuesApi.get.mockResolvedValue(createIssue()); + mockIssuesApi.listInteractions.mockResolvedValue([pending]); + const error = new ApiError("Interaction has been superseded", 409, { details: { code: "interaction_superseded" } }); + mockIssuesApi.acceptInteraction.mockRejectedValue(error); + mockIssuesApi.rejectInteraction.mockRejectedValue(error); + await act(async () => renderIssueView()); + await flushReact(); + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0]; + const reads = mockIssuesApi.listInteractions.mock.calls.length; + mockIssuesApi.listInteractions.mockResolvedValue([expired]); + await act(async () => { + const resolve = action === "accept" ? props.onAcceptInteraction : props.onRejectInteraction; + await expect(resolve(pending)).rejects.toBe(error); + }); + await flushReact(); + expect(mockIssuesApi.listInteractions.mock.calls.length).toBeGreaterThan(reads); + expect(queryClient.getQueryData(queryKeys.issues.interactions("PAP-1"))).toEqual([expired]); + }); + it("reports the selected continuation action after rejecting completion", async () => { const pendingInteraction = { id: "interaction-continue", diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 4306f6fb770..9b685a8099d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -77,7 +77,7 @@ import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { classifySendFailure } from "../lib/pending-send"; import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation"; -import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error"; +import { describeInteractionResolutionFailure, interactionResolutionErrorMessage } from "../lib/interaction-resolution-error"; import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; import { @@ -261,6 +261,7 @@ import { waitForStoppedRuns } from "../lib/wait-for-stopped-runs"; import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects"; import { IssueGalleryContext } from "../context/IssueGalleryContext"; import { useIssuePlanDocument } from "../hooks/useIssuePlanDocument"; +import { isOutdatedPlanConfirmation } from "../components/task-chat/interaction-thread-order"; import { useTaskArtifactArrival } from "../hooks/useTaskArtifactArrival"; import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; @@ -4013,9 +4014,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const shouldDeferPanelUntilPlan = taskChatShellEnabled && (isOnboardingFirstTask || issue?.workMode === "planning"); + const hasPendingPlanConfirmation = interactions.some(interaction => + interaction.status === "pending" && interaction.kind === "request_confirmation" + && interaction.payload.target?.type === "issue_document" && interaction.payload.target.key === "plan"); const { data: deferredPanelPlanDoc } = useIssuePlanDocument( - shouldDeferPanelUntilPlan ? issue?.id : null, + shouldDeferPanelUntilPlan || hasPendingPlanConfirmation ? issue?.id : null, ); + const hasPendingInteraction = interactions.some(interaction => + interaction.status === "pending" && !isDeferredConversationInteraction(interaction) + && !isOutdatedPlanConfirmation(interaction, deferredPanelPlanDoc)); const [panelBeforePlanOverrideIssueId, setPanelBeforePlanOverrideIssueId] = useState(null); const panelBeforePlanOverride = @@ -5268,6 +5275,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS }); }, onError: (err) => { + if (describeInteractionResolutionFailure(err).kind === "settled") invalidateIssueDetail(); pushToast({ title: "Accept failed", body: interactionResolutionErrorMessage(err), @@ -5297,6 +5305,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS }); }, onError: (err) => { + if (describeInteractionResolutionFailure(err).kind === "settled") invalidateIssueDetail(); pushToast({ title: "Reject failed", body: interactionResolutionErrorMessage(err), @@ -8478,11 +8487,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : activePauseHold ? "Resume the task before retrying." : issue.project?.pausedAt ? "Resume the project before retrying." - : interactions.some(i => i.status === "pending" && !isDeferredConversationInteraction(i)) ? "Respond to the pending question or confirmation before retrying." : null}> + : hasPendingInteraction ? "Respond to the pending question or confirmation before retrying." : null}> interaction.status === "pending" && !isDeferredConversationInteraction(interaction)), + hasPendingInteraction, unavailableReason: boardAccess && !canResolveBoardRecoveryAction ? "You don’t have permission to retry this recovery action." : treeControlStateError