mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
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:
1 parent
774ddc7271
commit
27db8354e4
5 files changed
+68
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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" } },
|
||||
|
||||
Reference in new issue
Block a user