fix(ui): make failed attempt notices stand out (#18174)

<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/257/changes/paperclip/257
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task conversations show when an agent could not finish a saved
request.
> - The exhausted-attempt notice currently looks like ordinary muted
status text.
> - People can overlook both the failure and its available Retry action.
> - This pull request gives failed attempts a padded, tinted surface and
a recognizable error icon using existing tokens.
> - The benefit is a clearer failure state while automatic recovery
keeps its quiet presentation.

## Linked Issues or Issue Description

Related: #17386 and #14156 cover recovery behavior. This PR only
improves the exhausted-attempt notice's presentation. Searches found no
duplicate styling PR.

**What happened?**

“Couldn’t finish this attempt. Your message is saved.” appears as muted
text without a background or padding above the task conversation, making
the failure easy to miss.

**Expected behavior**

An exhausted attempt should have a distinct, readable surface with
internal spacing and an obvious Retry action when permitted.

**Steps to reproduce**

1. Open a task whose system-owned interruption is `failed`.
2. Find the notice above the conversation.
3. Compare it with an automatic retry countdown. Previously, both had
the same muted inline treatment.

## What Changed

- Style failed attempts with a rounded destructive-tinted background,
token-based padding, foreground text, and a decorative error icon.
- Center the icon with the message and Retry button, including when the
message wraps.
- Keep the Retry button from shrinking on narrow screens.
- Check the center positions in the existing browser layout test.
- Add a production-component Storybook example beside a saved message.
- Document the exhausted-attempt treatment in the design guidance.

## Verification

- `pnpm check:token-gates` and `node scripts/check-maps.mjs`: pass.
- `pnpm exec vitest run
ui/src/components/ExecutionBlockerNotice.test.tsx`: 8 tests pass. These
cover Retry, countdowns, read-only access, active and resolved states,
admission waits, and admission errors.
- Chromium layout: 4 cases pass at 320px and 1280px in light and dark
themes. The icon, text, and Retry button have the same vertical center.
The previous code failed this check by 8–10px.
- Updated desktop and mobile screenshots are saved with the task.
- `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter
@paperclipai/ui build`: pass after the workspace SDK is built.
- Full local typecheck and build need Cargo. Cargo is not installed in
this sandbox. The full local Vitest run did not complete in the prior
session. CI must verify these checks.
- The updated head `4ba8de9da3e5c0c084cb5f752f371eccff31376a` passes all
test jobs, typecheck, build, and all eight E2E shards. The final CI
summary is queued.
- Greptile completed its review of this head with a score of 5/5. It
found no actionable issues. There are no review threads.
- The separate Paperclip Review check is queued. Its review task stopped
before it could publish the result. This check must complete before
merge.

## Risks

Low risk: this is a presentation-only change. The failed state takes
more vertical space. Recovery, authorization, retry timing, and API
contracts retain their existing behavior. Colors, spacing, radius, and
icon sizing use existing design tokens.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, code
execution, and browser inspection. The exact runtime model ID and
context-window size were not exposed in this session.

## 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
- [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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-02 09:25:24 -06:00
1 parent 774ddc7271
commit 27db8354e4
5 files changed
+68 -3

No files matched your search

+3
View File
@@ -116,6 +116,9 @@ an answer fallback only after the run ends and its final transcript fetch
completes without a provider reply. Preserve the reply through persistence.
Task chat shows execution errors and waits only while they remain relevant.
Exhausted attempts use a padded, rounded notice with a destructive-tinted background,
an error icon, and foreground text. The surrounding layout supplies separation from
the conversation. Automatic retry countdowns keep their quieter inline styling.
Completing or cancelling a task hides its old execution notices. A newer attempt by
the same agent or an explicit successor supersedes earlier run notices; an
unresolved execution hold remains visible. Historical turns keep their responses,
+4
View File
@@ -28,6 +28,10 @@ Agent access: [companion](../agent-access-map/recovery.md).
- `source-action`: `recovery/source-action` — source-scoped recovery actions remain attached to the task;
independent repair work may have its own issue and return owner.
- `exhaustion`: `recovery/exhaustion` — an exhausted infrastructure attempt offers ordinary Retry.
The failed-attempt notice has padding, a tinted background and an error icon;
its message and Retry remain readable on narrow screens in both themes. Verify
with [the browser layout checks](../../tests/e2e/execution-recovery/failed-attempt-notice.spec.ts)
against an owned Storybook server using `RECOVERY_STORYBOOK_URL`.
Internal repair stays system-owned; genuine user decisions retain their
specific action.
- `workspace`: `recovery/workspace` — workspace divergence/export failures show the applicable repair,
@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
const base = process.env.RECOVERY_STORYBOOK_URL;
test.describe("failed-attempt notice", () => {
test.skip(!base, "Set RECOVERY_STORYBOOK_URL to an owned Storybook server.");
for (const theme of ["light", "dark"]) {
for (const width of [320, 1280]) {
test(`readable failure and Retry at ${width}px in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width, height: 720 });
await page.goto(`${base}/iframe.html?id=task-chat-recovery-notice--failed-in-conversation&viewMode=story&globals=theme:${theme}`);
const notice = page.getByRole("status", { name: "Task status" });
const icon = notice.locator('svg[aria-hidden="true"]');
const message = notice.getByText("Couldn’t finish this attempt. Your message is saved.");
const retry = notice.getByRole("button", { name: "Retry", exact: true });
await expect(icon).toBeVisible();
await expect(message).toBeVisible();
await expect(retry).toBeVisible();
const surface = await notice.evaluate(element => {
const style = getComputedStyle(element);
return {
background: style.backgroundColor,
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft].map(parseFloat),
};
});
expect(surface.background).not.toBe("rgba(0, 0, 0, 0)");
expect(surface.padding.every(value => value > 0)).toBe(true);
const iconBounds = (await icon.boundingBox())!;
const textBounds = (await message.boundingBox())!;
const retryBounds = (await retry.boundingBox())!;
const iconCenter = iconBounds.y + iconBounds.height / 2;
expect(iconCenter).toBeCloseTo(textBounds.y + textBounds.height / 2, 0);
expect(iconCenter).toBeCloseTo(retryBounds.y + retryBounds.height / 2, 0);
expect(textBounds.x + textBounds.width).toBeLessThanOrEqual(retryBounds.x);
expect(retryBounds.x + retryBounds.width).toBeLessThanOrEqual(width);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
});
}
}
});
+9 -3
View File
@@ -1,4 +1,5 @@
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { AlertCircle } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useSecondTick } from "../hooks/useSecondTick";
@@ -27,11 +28,16 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
!["failed", "retry_scheduled"].includes(presentation.state)) return null;
const seconds = Math.max(0, Math.ceil((Date.parse(presentation.nextRetryAt ?? "") - Date.now()) / 1000));
return (
<div role="status" aria-label="Task status" className={cn("flex items-center justify-between gap-2 text-sm text-muted-foreground", className)}>
<span>{presentation.state === "retry_scheduled"
<div role="status" aria-label="Task status" className={cn(
"flex items-center justify-between gap-3 text-sm",
presentation.state === "failed" ? "rounded-lg bg-destructive/10 p-4 text-foreground" : "text-muted-foreground",
className,
)}>
{presentation.state === "failed" && <AlertCircle className="size-5 shrink-0 text-destructive" aria-hidden="true" />}
<span className="min-w-0 flex-1">{presentation.state === "retry_scheduled"
? `Trying again in ${seconds > 60 ? `${Math.ceil(seconds / 60)}m` : `${seconds}s`}…`
: "Couldn’t finish this attempt. Your message is saved."}</span>
{canRetry && presentation.canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
{canRetry && presentation.canRetry && <Button variant="outline" size="sm" className="shrink-0" onClick={onRetry}>Retry</Button>}
</div>
);
}
@@ -30,6 +30,14 @@ const ordinaryRetry: NonNullable<Story["play"]> = async ({ canvasElement }) => {
await expect(canvas.queryByRole("checkbox")).not.toBeInTheDocument();
};
export const Stopped: Story = { play: ordinaryRetry };
export const FailedInConversation: Story = {
render: (args) => <>
<TaskChatBubble item={{ id: "saved-request", kind: "message", author: "human", authorName: "Alex",
text: "Summarize the project's progress and list the next steps." }} />
<ExecutionBlockerNotice {...args} />
</>,
play: ordinaryRetry,
};
export const FileRecovery: Story = { play: ordinaryRetry };
export const RecoveringSavedWork: Story = {
args: { presentation: { ...failed, state: "working" } },