mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(ui): remove stale plan approvals from the composer (#19528)
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - A task can ask a person to approve a saved plan. > - The server refuses approval after a newer plan replaces that plan. > - The document and request queries can finish at different times. > - The composer can then keep controls for a plan that is already out of date. > - This change removes those controls when a newer saved revision is known and refreshes the request state. > - People can review the current plan without an obsolete approval above the composer. ## Linked Issues or Issue Description **What happened?** An old plan approval can stay above the task composer after the plan changes. Selecting Approve returns a superseded error. The failed action does not refresh the request list. **Expected behavior** Remove the old approval and its pending count as soon as the UI knows that the plan has changed. Keep a newer approval visible if its document query still has an older revision. Preserve accepted decisions and unrelated requests. **Steps to reproduce** 1. Create a confirmation for a saved plan revision. 2. Save a newer plan revision while the old request remains in the browser cache. 3. Read the composer before the request query finishes. 4. Try an old approval after the server has expired it. **Paperclip version or commit** Source checkout based on `5d25117ffa`. **Deployment mode** Source checkout. The regressions use the production React components and query cache in jsdom. Related work: Refs #14907 (replaced request receipts) and Refs #15025 (server replacement for requests from board users). This change fixes the interval between document and request reads, plus the refresh after a failed resolution. It does not change those server or history rules. ## What Changed - Remove pending approvals for older saved plan revisions from the task display and composer. - Compare revision numbers, or document and request times for targets that have only a revision ID. Keep newer requests when the document cache is behind. - Refresh request queries after document activity and settled approval errors. - Use the same plan freshness check for recovery retry. An old hidden approval no longer blocks Retry agent. - Test the recovery controls and the guards for missing plans, accepted decisions, questions, other tasks, other companies, and older document reads. - Add regressions for both workspace presentations, task and conversation modes, revision formats, unrelated requests, and accepted history. - Document the display behavior in the development guide. ## Verification - TDD: five assertions failed before the main fix. Old controls stayed in the composer, document activity did not refresh requests, and superseded accept or reject responses did not read the new state. - The previous session recorded 815 passing focused UI tests after the recovery retry fix at `d95cf3235ed83dc1f6c7f51372dff408814eb15c`. UI typecheck, the production UI build, and token gates also passed. - The main fix at `5147fd29a03413596d5b2445ae3b480352cbff1a` passed all CI gates. This includes typecheck, build, general tests, and eight E2E shards. [CI run](https://github.com/paperclipai/paperclip/actions/runs/42266101662). - The prior affected local run passed 7,656 UI tests, with one skipped, and 38 shared tests. Its CLI phase had two file-mode failures under the agent's `0077` umask and a child-server timeout during first-use builds. All three CLI files passed after a new check with `umask 022` and warm build dependencies: 25 tests passed. No CLI source changed. The initial full command did not pass or store a reusable pass. - On this continuation, the local test command could not start because the restored workspace has no installed Vitest dependency. No test verdict came from this attempt. - Final [CI](https://github.com/paperclipai/paperclip/actions/runs/42302544154) passes all applicable required gates for `d95cf3235ed83dc1f6c7f51372dff408814eb15c`, including typecheck, build, general tests, and eight E2E shards. Greptile and Paperclip reviews both report 5/5 at that exact revision, with no unresolved findings. The separate advisory review workflow remains queued. - No live browser acceptance or deployment is claimed. ## Risks - The UI must distinguish an older approval from an older document cache. Tests cover both query orders and preserve newer approvals. - This is a display and cache refresh change. Stored decisions, server authorization, approval gates, and database schemas are unchanged. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The exact deployment model ID and context window size are not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with available 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 described the issue in-PR following the bug template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My remote branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass for the final revision (scope and initial environment failures are stated 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 <[email protected]>
This commit is contained in:
1 parent
c66b4a55ef
commit
fdda02a26e
9 files changed
+174
-14
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<HTMLButtonElement>('[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(<CoordinatorPresentation.Provider value={coordinator}>
|
||||
<TaskChatThread {...props} comments={[]} interactions={interactions} onAcceptInteraction={vi.fn()} />
|
||||
</CoordinatorPresentation.Provider>);
|
||||
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(<TaskChatThread {...props} comments={[]} interactions={[planReviewInteraction()]} />);
|
||||
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(<TaskChatThread {...props} comments={[]} interactions={[review]} />);
|
||||
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(<TaskChatThread {...props} comments={[]} interactions={[review, { ...planReviewInteraction("accepted"), id: "accepted-plan" }]} />);
|
||||
expect(takeover()).not.toBeNull();
|
||||
expect(container.querySelector('#interaction-accepted-plan')).not.toBeNull();
|
||||
});
|
||||
const dismiss = async () => {
|
||||
await act(async () => takeover()!.querySelector<HTMLButtonElement>('[aria-label^="Dismiss "]')!.click());
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<RequestConfirmationInteraction> = {},
|
||||
@@ -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(
|
||||
|
||||
@@ -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<IssueDocument, "id" | "companyId" | "issueId" | "latestRevisionId" | "latestRevisionNumber" | "updatedAt"> | 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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>(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}>
|
||||
<DispositionRecoveryProvider value={{
|
||||
issue,
|
||||
agentMap,
|
||||
hasPendingInteraction: interactions.some((interaction) => interaction.status === "pending" && !isDeferredConversationInteraction(interaction)),
|
||||
hasPendingInteraction,
|
||||
unavailableReason: boardAccess && !canResolveBoardRecoveryAction
|
||||
? "You don’t have permission to retry this recovery action."
|
||||
: treeControlStateError
|
||||
|
||||
Reference in new issue
Block a user