Files
PaperClipAI/ui/src/components/ExecutionBlockerNotice.test.tsx
T
DottaandPaperclip 40bb466d78 fix(ui): align chat connection and recovery cards (#15771)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task pages show external chat connections and recovery notices.
> - These cards use different widths, colors, and spacing.
> - The difference makes related task information harder to scan.
> - This pull request gives these cards one shared surface.
> - The benefit is a consistent task page for chat and email users.

## Linked Issues or Issue Description

**What happened?**

The external chat banner and recovery notice have different backgrounds,
padding, and widths. The first banner needs more space above it.

**Expected behavior**

Connection, recovery, workspace repair, and task email cards use the
same surface. The chat cards have the same width.

**Steps to reproduce**

Open a task with an external Slack conversation and a recovery notice.
Compare the two cards. Open the Tasks / Context notices story to compare
the updated cards with an AgentMail email card.

## What Changed

- Add a shared token-based surface for task context cards.
- Put chat recovery notices in the same width container as the
connection banner.
- Add space above the connection banner.
- Apply the surface to task email cards and workspace export repair.
- Add a Storybook view with per-card width, color, padding, and corner
checks.
- Give the workspace recovery card its own React key to prevent
duplicate connection banners.

## Verification

- Passed `pnpm check:token-gates`.
- Passed 57 tests for ExecutionBlockerNotice, WorkspaceExportRecovery,
and the external chat banner.
- Passed UI typecheck and UI production build.
- Captured desktop and mobile browser screenshots from the real
components with fixture data. The three cards have equal widths,
backgrounds, padding, and corners.
- Full local typecheck needs Cargo, which is absent in the task
environment. Full tests and build could not complete within the
environment memory limit. Remote typecheck, build, all test shards, and
security checks pass on commit e225a8e47. The Canary Dry Run also
passes. Storybook visual/deploy jobs are skipped by workflow policy.

## Risks

- Low risk. Recovery notices move outside the chat panel. Their actions
and API calls stay the same.
- Verify narrow screens and both task layout modes.

## Model Used

- OpenAI GPT-6 through Codex. Tool use and code execution. The runtime
does not expose an exact model variant or context window size.

## 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
- [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-10 10:38:26 -05:00

168 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { act, type AnchorHTMLAttributes } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
import { agentsApi } from "../api/agents";
import { activityApi } from "../api/activity";
vi.mock("../lib/router", () => ({
Link: ({ to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => <a href={to} {...props} />,
}));
vi.mock("../api/agents", () => ({ agentsApi: { retryFailedRun: vi.fn() } }));
vi.mock("../api/activity", () => ({ activityApi: { runsForIssue: vi.fn() } }));
describe("stopped task recovery notice", () => {
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
const onRetried = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
container = document.createElement("div"); document.body.append(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
vi.mocked(activityApi.runsForIssue).mockResolvedValue([{ runId: "failed-run", agentId: "agent", status: "failed" }] as never);
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause: "legacy_execution_requires_reconciliation",
nextAction: "Automatic recovery stopped. Recorded work is preserved; actions with unverified outcomes will not be repeated.",
}} />
</QueryClientProvider>));
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
it("keeps the recovery guidance and an inspection link alongside Retry", () => {
const notice = container.querySelector('[role="status"][aria-label="Task recovery"]')!;
expect(notice.textContent).toContain("Recorded work is preserved");
expect(notice.textContent).toContain("Retry");
expect(notice.classList.contains("task-context-notice")).toBe(true);
expect(notice.querySelector("a")?.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
});
it.each([false, true])("explains cancelled runs and saved input; continue eligibility %s", async canContinue => {
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent",
cause: "legacy_execution_requires_reconciliation", runStatus: "cancelled",
runError: "Provider cancelled execution", savedMessageCount: 2, canContinue,
nextAction: canContinue ? "Verify provider shutdown before continuing."
: "Inspect the run before sending a new message to request continuation.",
}} />
</QueryClientProvider>));
expect(container.textContent).toContain("Provider cancelled execution");
expect(container.textContent).toContain("2 saved messages are waiting");
expect(container.querySelector("a")?.getAttribute("href")).toContain("cancelled-run");
expect(container.querySelector("button")?.textContent ?? null).toBe(canContinue ? "Continue" : null);
if (!canContinue) expect(container.textContent).toContain("sending a new message to request continuation");
if (canContinue) {
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
expect(agentsApi.retryFailedRun).toHaveBeenCalledWith("agent", "cancelled-run", "company");
}
});
it("keeps removed-chat guidance without suggesting a message to an unavailable destination", async () => {
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
recoveryActionId: "recovery", runId: "cancelled-run", agentId: "agent",
cause: "legacy_execution_requires_reconciliation", runStatus: "cancelled", canContinue: false,
nextAction: "This chat connection was removed. Inspect the stopped run and create a new task to continue the work.",
}} />
</QueryClientProvider>));
expect(container.textContent).toContain("create a new task");
expect(container.textContent).not.toContain("sending a new message");
expect(container.querySelector("button")).toBeNull();
expect(container.querySelector("a")?.textContent).toBe("Inspect run");
});
it("keeps the required next action for other reconciliation causes", async () => {
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause: "action_outcome_unknown",
nextAction: "Verify the external action outcome before continuing.",
}} />
</QueryClientProvider>));
expect(container.textContent).toContain("Verify the external action outcome before continuing.");
expect(container.textContent).not.toContain("Automatic recovery of this task stopped.");
});
it.each(["native_continuation_requires_reconciliation", "native_session_cleanup_quarantined"])("links to the source run instead of offering a retry rejected by %s", async (cause) => {
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent",
cause,
nextAction: "Inspect the original failure and reconcile the previous execution before continuing.",
}} />
</QueryClientProvider>));
expect(container.textContent).toContain("Recovery needed.");
expect(container.textContent).not.toContain("Retry");
const link = container.querySelector("a")!;
expect(link.textContent).toBe("Inspect run");
expect(link.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
expect(agentsApi.retryFailedRun).not.toHaveBeenCalled();
});
it("keeps workspace repair guidance and inspection without offering the legacy Retry fallback", async () => {
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
recoveryActionId: "repair", runId: "failed-run", agentId: "agent", cause: "legacy_execution_requires_reconciliation",
workspaceRepairRequired: true, canRetry: false, canContinue: false,
nextAction: "The original sandbox is retained. Recover the missing files and record workspace repair evidence.",
}} />
</QueryClientProvider>));
expect(container.textContent).toContain("Recover the missing files");
expect(container.querySelector("button")).toBeNull();
expect(container.querySelector("a")?.textContent).toBe("Inspect run");
expect(agentsApi.retryFailedRun).not.toHaveBeenCalled();
});
it("retries the exact failed run and refreshes the task", async () => {
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
expect(agentsApi.retryFailedRun).toHaveBeenCalledWith("agent", "failed-run", "company");
expect(onRetried).toHaveBeenCalledOnce();
});
it.each([
"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.",
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.",
])("shows the model rejection and repair action without expanding run logs: %s", async (runError) => {
vi.mocked(activityApi.runsForIssue).mockResolvedValue([{ runId: "model-run", agentId: "agent",
status: "failed", errorCode: "native_provider_model_rejected" }] as never);
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="model-task" onRetried={onRetried} blocker={{
recoveryActionId: "recovery", runId: "model-run", agentId: "agent",
cause: "native_continuation_requires_reconciliation", canRetry: true,
runError,
nextAction: "Inspect the original failure before continuing.",
}} />
</QueryClientProvider>));
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
expect(container.textContent).toContain("Model unavailable.");
expect(container.textContent).toContain(runError);
expect(container.textContent).toContain("clear the task's model override, then retry");
expect(container.textContent).toContain("Inspect the original failure before continuing.");
expect(container.querySelector("button")?.textContent).toBe("Retry");
});
it.each(["native_continuation_requires_reconciliation", "uncertain_external_action"])("offers Retry for a server-admitted native failure: %s", async cause => {
await act(async () => root.render(<QueryClientProvider client={client}>
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause,
canRetry: true,
nextAction: "Automatic recovery stopped. Try again or send a new message to continue.",
}} />
</QueryClientProvider>));
const button = container.querySelector<HTMLButtonElement>("button");
expect(button?.textContent).toBe("Retry");
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
await act(async () => button!.click());
expect(agentsApi.retryFailedRun).toHaveBeenCalledWith("agent", "failed-run", "company");
});
it("shows a failed Retry in the same container and allows another attempt", async () => {
vi.mocked(agentsApi.retryFailedRun).mockRejectedValue(new Error("Environment cleanup is still running."));
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Environment cleanup is still running.");
expect(container.querySelector<HTMLButtonElement>("button")!.disabled).toBe(false);
expect(onRetried).not.toHaveBeenCalled();
});
});