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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 11:30:10 -06:00
1 parent 0d03610d2f
commit f5de197536
5 files changed
+22 -15

No files matched your search

+1 -1
View File
@@ -153,7 +153,7 @@ results in place; keep failures actionable inline. Notifications for other work
remain useful. Repeated delivery of the same run outcome must refresh cached state
without repeating its toast, including after reconnecting. A terminal outcome
delivered more than five minutes after the run finished is historical and should
refresh state silently. Expected cancellation is neutral gray, not an error. The composer's Stop action stops the current response and leaves the composer available for a new message. Pause work is a separate explicit task or subtree action. A paused task replaces the composer with an amber takeover. It says “Task is
refresh state silently. Expected cancellation is neutral gray, not an error. A busy workspace is a transient system wait with no user action. Show it only as a quiet status in the composer strip and the Workspace property row, never as a pinned banner, conversation notice or toast. The composer's Stop action stops the current response and leaves the composer available for a new message. Pause work is a separate explicit task or subtree action. A paused task replaces the composer with an amber takeover. It says “Task is
paused.” and “Resume this task to send a message.” with a “Resume task” action.
Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be
dismissed, retains drafts, and hides message inputs until the pause is released.
+12 -2
View File
@@ -206,13 +206,23 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
flushSync(() => root.unmount());
});
it("explains automatic workspace waiting without promising that a reply bypasses the lock", () => {
it("does not raise a pinned banner for a workspace wait the user cannot act on (N-14)", () => {
const issue = {
status: "todo", scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "workspace_busy", scheduledRetryAt: NOW.toISOString() },
} as Issue;
const root = createRoot(container);
flushSync(() => root.render(<IssueMonitorBanner issue={issue} onCheckNow={vi.fn()} />));
expect(container.textContent).toBe("");
flushSync(() => root.unmount());
});
it("keeps the workspace wait as a quiet composer status without promising that a reply bypasses the lock", () => {
const issue = {
status: "todo", scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "workspace_busy", scheduledRetryAt: NOW.toISOString() },
} as Issue;
const root = createRoot(container);
flushSync(() => root.render(<><IssueMonitorBanner issue={issue} onCheckNow={vi.fn()} /><IssueMonitorComposerStrip issue={issue} onCheckNow={vi.fn()} /></>));
expect(container.textContent).toContain("Waiting for workspace");
expect(container.querySelector('[data-testid="issue-monitor-composer-strip"]')?.textContent).toContain("Waiting for workspace");
expect(container.textContent).toContain("You can keep sending instructions while the agent waits.");
expect(container.textContent).not.toContain("wakes the agent now");
expect(container.querySelector("button")).toBeNull();
+4
View File
@@ -278,6 +278,10 @@ export function IssueMonitorBanner({
const copy = useMonitorSurfaceCopy(issue, reviews.length);
if (!copy) return null;
// A busy workspace is a transient system wait the user cannot act on. It stays
// a quiet status (composer strip, properties row), never a pinned notice.
if (copy.workspaceWait) return null;
const canCancel = onCancelMonitor && deriveMonitorState(issue).source === "monitor";
return (
@@ -121,11 +121,9 @@ function getRetryNowButton() {
}
describe("IssueScheduledRetryCard", () => {
it("shows workspace contention as an automatic wait without failure or retry controls", () => {
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).toContain("Waiting for workspace");
expect(container.textContent).toContain("Work starts automatically");
expect(container.textContent).not.toMatch(/failed|Retry|Attempt|Replaces run/);
expect(container.textContent).toBe("");
expect(getRetryNowButton()).toBeNull();
});
it("surfaces model capacity and the automatic retry without a generic failure", () => {
@@ -7,7 +7,6 @@ import { formatRetryReason } from "@/lib/runRetryState";
import type { IssueScheduledRetry } from "@paperclipai/shared";
import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation";
import { Badge } from "@/components/ui/badge";
import { InlineBanner } from "@/components/InlineBanner";
import { describeError } from "@/api/errors";
const MAX_TURN_CONTINUATION = "max_turns_continuation";
@@ -34,13 +33,9 @@ export function IssueScheduledRetryCard({
if (!scheduledRetry || !issueId) return null;
if (scheduledRetry.status !== "scheduled_retry") return null;
if (scheduledRetry.scheduledRetryReason === "workspace_busy") {
return (
<InlineBanner tone="info" icon={Clock} title="Waiting for workspace" className="mb-3">
Another task is using this workspace. Work starts automatically when it is available.
</InlineBanner>
);
}
// Workspace contention is a transient wait with nothing for the user to do.
// The composer strip and properties row carry the quiet status instead.
if (scheduledRetry.scheduledRetryReason === "workspace_busy") return null;
const continuation = isContinuationReason(scheduledRetry.scheduledRetryReason);
const dueAtIso = scheduledRetry.scheduledRetryAt