diff --git a/DESIGN.md b/DESIGN.md index 63e8b37c49f..068a7e4530b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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, diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md index a5b8431f6cf..b97ac0d9a08 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -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, diff --git a/tests/e2e/execution-recovery/failed-attempt-notice.spec.ts b/tests/e2e/execution-recovery/failed-attempt-notice.spec.ts new file mode 100644 index 00000000000..fcb8588483e --- /dev/null +++ b/tests/e2e/execution-recovery/failed-attempt-notice.spec.ts @@ -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); + }); + } + } +}); diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 17bd169750c..da88e57fe2e 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -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 ( -