feat(web): add <ErrorBanner> to unify error display across event log, transcript, and header #1192
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1192
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "dev/1168-error-banner"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
<ErrorBanner message retryable onRetry>incomponents/error-banner.tsx: red-tinted border/background matchingStatusPillerror state (--state-failuretoken),AlertCircleicon, user-selectable text, optional "Retry" buttonevent-log.tsx:errorevent rows now render via<ErrorBanner retryable={false}>instead of the plain whitespace-pre-wrap spanplanner/transcript.tsx: the inline stream-error block is replaced with<ErrorBanner retryable={true} onRetry={onRetryStream}>; removes the now-unusedRefreshCwimporttask-detail.tsx: header shows<ErrorBanner retryable={false}>whentask.status === "failure"andtask.erroris setCloses #1168
Test plan
just qapasses (typecheck + lint + format + tests)<dl>Introduces a shared `<ErrorBanner message retryable onRetry>` component (red-tinted border/background matching StatusPill error state, AlertCircle icon, user-selectable text) and adopts it in three places: - event-log.tsx: error event rows now render <ErrorBanner retryable={false}> - planner/transcript.tsx: stream error inline block replaced with <ErrorBanner retryable={true} onRetry={onRetryStream}> - task-detail.tsx: header shows <ErrorBanner retryable={false}> when task.status === "failure" and task.error is set Closes #1168 Co-authored-by: Cursor <cursoragent@cursor.com>Review — PR #1192
feat(web): add <ErrorBanner>CI ✅ green. Two blocking items before merge, plus one verification note.
✅ What's good
role="alert",aria-hiddenon the icon,select-text break-wordsfor copyability.data-testidattributes on both the banner and the retry button.--state-failure/bg-state-failure/10) matchingStatusPill.transcript.tsx(theRefreshCwimport and the entire inline error block).task.errorguard intask-detail.tsxmeans no empty banner can appear in the header.🔴 Blocking —
retryabletype contract is unenforcedapps/web/src/components/error-banner.tsxThe render condition is
{retryable && onRetry && ...}, soretryable={true}+ noonRetrysilently renders no button. TypeScript won't catch a caller who passesretryable={true}and forgetsonRetry. This matters intranscript.tsxwhereonRetryStreamis optional on theTranscriptcomponent — if future callers omit it the retry button disappears with no error.Two acceptable fixes:
Option A — discriminated union (preferred):
Now the compiler rejects
retryable={true}withoutonRetry.Option B — drop
retryable, infer fromonRetry:retryablebecomes redundant — presence ofonRetryis the signal. Callers that currently passretryable={false}just drop the prop.Either approach eliminates the silent-no-button footgun.
🔴 Blocking — empty banner in event log
apps/web/src/components/event-log.tsxIf
ev.summaryisnull/undefinedthe component renders an icon with no text — visually broken and meaningless. Thetask-detail.tsxchange correctly guards with&& task.error; apply the same pattern here:Or keep the ternary structure but add a non-empty guard:
⚠️ Verify — test-id rename
The inline stream-error block previously exposed two test IDs:
data-testid="stream-error"(outer<div>)data-testid="stream-error-retry"(retry<Button>)These are replaced by
error-banner/error-banner-retry. If any Playwright / Vitest browser tests reference the old IDs they'll silently start failing. Confirm no existing tests referencestream-errororstream-error-retrybefore merging.Summary: Fix the discriminated-union type contract on
ErrorBannerPropsand guard the empty-summary path inevent-log.tsx. Once those two are addressed and the test-id search comes back clean, this is good to merge.ee18b9bfd3ff46401462APPROVED ✅
Clean, well-structured extraction. The component is correct and consistent with existing design tokens. Three minor notes below, none blocking.
What this does
Introduces
<ErrorBanner message retryable onRetry>incomponents/error-banner.tsxand wires it into three callsites, replacing inconsistent inline error markup:event-log.tsxtext-errorspan<ErrorBanner retryable={false}>planner/transcript.tsx<Button><ErrorBanner retryable={true} onRetry={...}>task-detail.tsxdt/ddpair inside<dl><ErrorBanner retryable={false}>below<dl>Strengths
role="alert"andaria-hidden="true"on the icon — correct ARIA usage.data-testid="error-banner"/"error-banner-retry"make the component easily assertable.select-text break-words min-w-0on the span handles long unbreakable strings correctly in a flex context and enables copy-paste.border-state-failure/40(rather than full opacity) gives a visually lighter border that matches the softbg-state-failure/10fill — good consistency.tone="error"on the Retry button keeps it in the red family rather than the neutral secondary style.task-detail.tsx— operators can now read (and copy) the full error message.data-testid="stream-error"on the wrapper<div>intranscript.tsxso existing selectors still work.Minor issues
1.
ev.summary ?? ""renders an empty bannerIf an
errorevent has nosummarythe banner renders as icon-only with no text — visually broken. A simple guard would fix it:Or add a non-empty guard inside the component itself (
if (!message) return null).2.
retryableshould probably be optional (defaultfalse)Every non-retryable callsite must write
retryable={false}explicitly. Since the non-retryable case is the majority, making it optional with a default avoids boilerplate:Not a blocker — just a DX note.
3.
retryable={true}+ missingonRetryis silently no-opThe guard
{retryable && onRetry && ...}means passingretryable={true}withoutonRetryrenders no button with no warning. Intranscript.tsxonRetryStreamis always provided in practice, but a dev-mode invariant or TypeScript discriminated union (e.g.retryable: falsevsretryable: true; onRetry: () => void) would make the contract explicit at the type level. Again, not blocking for this PR.PR #1195 note
PR #1195 is a duplicate of this feature on a different branch (
dev/1168). It has a real regression: it does not remove the old{task.error && <div className="w-full text-error">…</div>}from inside<dl>, so the failed-task header would show two error displays. This PR (#1192) handles the cleanup correctly. #1195 should be closed as superseded.ff46401462a367d3d056a367d3d05644393e2663