Files
PaperClipAI/ui/src/components/IssueScheduledRetryCard.test.tsx
T
DottaandClaude Opus 5.5 f5de197536 fix(ui): keep a busy-workspace wait as a quiet status, not a notice (#19507)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The task page tells the user what an agent is doing and what needs
their attention
> - When another task uses the same workspace, the agent waits and
Paperclip starts the work automatically
> - A tester reported that the "Waiting for workspace" notice is not
helpful, because the user cannot do anything about it
> - On `master`, the task page shows this wait four times: a pinned top
banner, a conversation banner, the composer strip and the Workspace
property row
> - This pull request removes the two banners and keeps the two quiet
status surfaces
> - The benefit is that a transient system wait no longer looks like
something that needs the user

## Linked Issues or Issue Description

Refs #19369

**What happened?**
During new-user onboarding tests on Paperclip Cloud staging (Claude
subscription path), the tester saw a "Waiting for workspace" notice and
reported: "this isn't helpful and the user can't do anything about it."

**Steps to reproduce**
1. Start work on a task while another task uses the same workspace.
2. Open the waiting task.
3. See the pinned "Waiting for workspace" banner under the title and a
second "Waiting for workspace" banner in the conversation.

**Expected behavior**
A busy workspace is a transient system wait with no user action. It
shows only as a quiet status on the task. It does not show as a banner
or notice.

**Investigation**
- No server inbox, Needs-you item or activity entry is created for a
workspace wait. The live-updates toast already skips `workspace_busy`
(`LiveUpdatesProvider.tsx`), and the chat thread already hides the
cancelled admission attempt.
- The remaining notices are UI banners: `IssueMonitorBanner` (pinned top
banner) and `IssueScheduledRetryCard` (conversation banner).
- #19369 stopped showing resource waits as failed attempts, but it kept
these banners.

## What Changed

- `IssueMonitorBanner`: render nothing for a workspace wait. The
composer strip still shows "Waiting for workspace" and "You can keep
sending instructions while the agent waits."
- `IssueScheduledRetryCard`: render nothing for `workspace_busy`. The
Workspace property row still shows "Waiting for workspace".
- `DESIGN.md`: add the rule that a busy-workspace wait is a quiet status
only, never a pinned banner, conversation notice or toast.
- Tests: replace the old "shows the banner" assertions with N-14
regression tests.

## Verification

Red on `master` (`819420a60a`), test change only:

```
cd ui && npx vitest run src/components/IssueMonitorBanner.test.tsx src/components/IssueScheduledRetryCard.test.tsx
     × does not raise a pinned banner for a workspace wait the user cannot act on (N-14) 7ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  src/components/IssueMonitorBanner.test.tsx > IssueMonitorBanner / IssueMonitorComposerStrip rendering > does not raise a pinned banner for a workspace wait the user cannot act on (N-14)
AssertionError: expected 'Waiting for workspaceAnother task is …' to be '' // Object.is equality
 FAIL  src/components/IssueScheduledRetryCard.test.tsx > IssueScheduledRetryCard > raises no conversation notice for workspace contention the user cannot act on (N-14)
AssertionError: expected 'Waiting for workspaceAnother task is …' to be '' // Object.is equality
      Tests  2 failed | 32 passed (34)
```

Green after the fix, same command:

```
 Test Files  2 passed (2)
      Tests  34 passed (34)
```

Neighbouring suites:

```
cd ui && npx vitest run src/components/TaskChatThread.test.tsx src/pages/IssueDetail src/components/issue-properties src/lib/issue-monitor
 Test Files  7 passed (7)
      Tests  687 passed | 1 skipped (688)
```

- `pnpm check:token-gates`: all gates clean.
- `tsc --noEmit -p ui`: no errors in the changed files. Local errors
come only from unbuilt workspace packages (`@paperclipai/plugin-sdk/ui`,
Storybook feature map). CI typecheck covers the full build.

## Risks

- Low risk. UI-only change. Two banners no longer render for
`workspace_busy`. Other scheduled-retry reasons (failures, pool
exhaustion, continuations) are unchanged.
- A long workspace wait now has no prominent notice. No actionable next
step exists in the current data (the API does not name the task that
holds the workspace). A persist-past-threshold escalation with a link to
that task can follow when the data exists.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edit, browser for read-only staging evidence).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:30:10 -06:00

267 lines
9.6 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } 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 type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
import { IssueScheduledRetryCard } from "./IssueScheduledRetryCard";
import { ToastProvider } from "../context/ToastContext";
const retryNowMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
retryScheduledRetryNow: retryNowMock,
},
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
const baseRetry: IssueScheduledRetry = {
runId: "run-00000000",
status: "scheduled_retry",
agentId: "agent-1",
agentName: "ClaudeCoder",
retryOfRunId: "run-prev-1234567",
scheduledRetryAt: "2026-04-18T20:15:00.000Z",
scheduledRetryAttempt: 4,
scheduledRetryReason: "transient_failure",
retryExhaustedReason: null,
error: "Upstream provider rate limited",
errorCode: "rate_limited",
};
function buildRetryResponse(outcome: IssueRetryNowOutcome) {
return {
outcome,
message:
outcome === "promoted"
? "Promoted scheduled retry"
: outcome === "already_promoted"
? "Scheduled retry already promoted"
: outcome === "no_scheduled_retry"
? "No scheduled retry"
: "Promotion suppressed by gate",
scheduledRetry:
outcome === "promoted" || outcome === "already_promoted"
? { ...baseRetry, status: "queued" as const }
: null,
};
}
async function waitForUi(assertion: () => void) {
await vi.waitFor(async () => {
await act(async () => {
await Promise.resolve();
});
assertion();
});
}
async function waitForRetryButtonText(expected: string) {
for (let i = 0; i < 20; i += 1) {
if ((getRetryNowButton()?.textContent ?? "").includes(expected)) return;
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
expect(getRetryNowButton()!.textContent ?? "").toContain(expected);
}
function renderWithProviders(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>{ui}</ToastProvider>
</QueryClientProvider>,
);
});
}
beforeEach(() => {
dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
retryNowMock.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
dateNowSpy?.mockRestore();
});
function getCard() {
return container.querySelector('[data-testid="issue-scheduled-retry-card"]');
}
function getRetryNowButton() {
return container.querySelector<HTMLButtonElement>(
'[data-testid="issue-scheduled-retry-card-retry-now"]',
);
}
describe("IssueScheduledRetryCard", () => {
it("raises no conversation notice for workspace contention the user cannot act on (N-14)", () => {
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={{ ...baseRetry, scheduledRetryReason: "workspace_busy" }} />);
expect(container.textContent).toBe("");
expect(getRetryNowButton()).toBeNull();
});
it("surfaces model capacity and the automatic retry without a generic failure", () => {
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={{ ...baseRetry, scheduledRetryAttempt: 1, scheduledRetryReason: "native_provider_overloaded",
error: "Selected model is at capacity. Please try a different model.", errorCode: "native_provider_overloaded" }} />);
expect(container.textContent).toContain("Model at capacity");
expect(container.textContent).toContain("Selected model is at capacity");
expect(container.textContent).toContain("Paperclip will retry automatically");
expect(container.textContent).not.toContain("Native session failed");
});
it("renders nothing when there is no scheduled retry", () => {
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={null} />);
expect(getCard()).toBeNull();
});
it("renders nothing when status is not scheduled_retry", () => {
renderWithProviders(
<IssueScheduledRetryCard
issueId="issue-1"
scheduledRetry={{ ...baseRetry, status: "queued" }}
/>,
);
expect(getCard()).toBeNull();
});
it("shows attempt count, reason, absolute and relative timestamps", () => {
renderWithProviders(
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
);
const card = getCard();
expect(card).not.toBeNull();
const text = card!.textContent ?? "";
expect(text).toContain("Retry scheduled");
expect(text).toContain("Attempt 4");
expect(text).toContain("Transient failure");
expect(text).toContain("Automatic retry in 15m");
expect(text).toContain("run-prev");
});
it("uses continuation copy for max-turn continuations", () => {
renderWithProviders(
<IssueScheduledRetryCard
issueId="issue-1"
scheduledRetry={{ ...baseRetry, scheduledRetryReason: "max_turns_continuation" }}
/>,
);
const text = getCard()?.textContent ?? "";
expect(text).toContain("Continuation scheduled");
expect(text).toContain("Automatic continuation");
expect(text).toContain("Pulls continuation forward immediately");
});
it("uses 'due now' label when scheduledRetryAt is at the current time", () => {
renderWithProviders(
<IssueScheduledRetryCard
issueId="issue-1"
scheduledRetry={{ ...baseRetry, scheduledRetryAt: "2026-04-18T20:00:10.000Z" }}
/>,
);
const text = getCard()?.textContent ?? "";
expect(text).toContain("Automatic retry due now");
});
it("invokes retry-now and shows promoted state on success", async () => {
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
renderWithProviders(
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
);
const button = getRetryNowButton();
expect(button).not.toBeNull();
act(() => {
button!.click();
});
await waitForRetryButtonText("Promoted");
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
const finalButton = getRetryNowButton();
expect(finalButton!.textContent ?? "").toContain("Promoted");
expect(finalButton!.disabled).toBe(true);
});
it("shows already promoted state when backend reports duplicate click", async () => {
retryNowMock.mockResolvedValue(buildRetryResponse("already_promoted"));
renderWithProviders(
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
);
act(() => {
getRetryNowButton()!.click();
});
await waitForRetryButtonText("Already promoted");
expect(getRetryNowButton()!.textContent ?? "").toContain("Already promoted");
expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull();
});
it("shows cleanup waiting without claiming success or disabling Retry now", async () => {
const message = "Waiting for execution cleanup. Paperclip will retry automatically once cleanup finishes.";
retryNowMock.mockResolvedValue({ outcome: "waiting", message, scheduledRetry: baseRetry });
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />);
act(() => { getRetryNowButton()!.click(); });
await waitForUi(() => {
expect(getCard()?.textContent).toContain(message);
expect(getCard()?.textContent).not.toContain("run starting");
expect(getRetryNowButton()!.disabled).toBe(false);
expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull();
});
});
it("renders an inline error band on backend failure", async () => {
retryNowMock.mockRejectedValue(new Error("Server error"));
renderWithProviders(
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
);
act(() => {
getRetryNowButton()!.click();
});
await waitForUi(() => {
const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]');
expect(band).not.toBeNull();
expect((band?.textContent ?? "")).toContain("Server error");
expect(getRetryNowButton()!.disabled).toBe(false);
});
});
it("surfaces gate-suppressed outcome via the inline error band", async () => {
retryNowMock.mockResolvedValue(buildRetryResponse("gate_suppressed"));
renderWithProviders(
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
);
act(() => {
getRetryNowButton()!.click();
});
await waitForUi(() => {
const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]');
expect(band).not.toBeNull();
expect((band?.textContent ?? "")).toContain("Promotion suppressed");
expect(getRetryNowButton()!.disabled).toBe(false);
});
});
});