Skip to content

fix(messages): plain copy for failed uploads and a blocked Send - #3097

Open
matt2e wants to merge 4 commits into
errors/user-copy-messagesfrom
errors/upload-tile
Open

matt2e wants to merge 4 commits into
errors/user-copy-messagesfrom
errors/upload-tile

Conversation

@matt2e

@matt2e matt2e commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Summary

A failed attachment upload showed the browser's own text in the tile ("Failed to fetch", "signal timed out"), and a blocked Send gave no reason. Upload failures now map to plain copy through the shared helper. Send explains why it is blocked, and the failed tile's spacing is tidied.

Stacked on #3096: this PR targets errors/user-copy-messages (PR #3096), which adds the shared userCopy/failureKind helper used here. Review and merge #3096 first.

What changed

Upload failure copy (attachment-draft.ts, ComposerAttachments.tsx)

Case Before After
Lost connection (fetch TypeError) Failed to fetch Upload didn’t finish. Check your connection, then retry.
Five-minute upload timeout (TimeoutError) signal timed out Upload took too long. Retry, or remove this file.
Anything else the Error's own message Upload failed. Retry or remove this file.
Tile fallback with no message Upload failed. Retry or remove it to send. Upload failed. Retry or remove this file. (one shared fallback)
UploadError its mapped message unchanged

Blocked Send (MessageComposer.tsx)

  • Before: Send was disabled, with no tooltip.
  • After: while a failed upload blocks it, Send stays focusable (aria-disabled="true") with the tooltip Retry or remove the failed file to send. Clicking Send or pressing Enter still sends, publishes and uploads nothing.

Failed tile layout (ComposerAttachments.module.css)

  • The buttons keep base's order: Retry, then Remove.
  • The error and the button row each span the full tile width from its inner edge. The icon gap stays the resting tile's 14px, so the file name doesn't shift on failure.
  • The error sits 12px below the file row, and the buttons 12px below the error, 8px apart.
  • The buttons never wrap. The tile's min-content width keeps room for both, so a strip too narrow for them scrolls sideways instead of stacking them.

Before / After

failed upload tile at 1280px, base vs now

Final tile at 1280px: Failed to fetch becomes Upload didn’t finish. Check your connection, then retry. The error spans the tile and sits 12px from the file row and from the buttons. Retry still comes before Remove, as on base.

hovering Send while the failed file blocks it

Hovering Send while the failed file blocks it: the tooltip explains why Send does nothing. Older capture (round 3): the tile there shows Remove before Retry and the earlier spacing. The shipped tile is Retry, then Remove, as in the first image.

an upload that hits its timeout

An upload that hits its timeout: the DOMException's signal timed out becomes Upload took too long. Retry, or remove this file. Older capture (round 1): the copy is current, but the tile spacing predates the final layout.

Checks

  • pnpm typecheck: clean on this branch (on top of fix(errors): plain copy for failed history reads and connections #3096).
  • pnpm lint: exit 0, with 30 infos that predate this change. biome format is clean on the changed files.
  • Tests were not run for this PR (the workstation was under load). The changed unit tests and tests/browser/composer-attachments.spec.mjs are included but not run on this snapshot. On the source branch, a separate browser capture at 1280px measured the final tile: 12px file row to error, 12px error to buttons, and Retry and Remove sharing a top.

Known gaps

  • An attachment strip narrower than about 182px scrolls sideways rather than stacking Retry and Remove.

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review November 9, 2026 15:39
@matt2e
matt2e requested a review from a team as a code owner November 9, 2026 15:39

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 One preparation-error recovery regression found.

matt2e and others added 2 commits November 13, 2026 17:29
…d Send

A failed attachment upload showed the Error's own message, such as
"Failed to fetch" or "signal timed out". attachment-draft.ts now maps
it by type with userCopy's failureKind:
- UploadError keeps its message, which is already written for people
- a lost connection: "Upload didn’t finish. Check your connection, then
  retry."
- the five-minute timeout: "Upload took too long. Retry, or remove this
  file."
- anything else: "Upload failed. Retry or remove this file.", now the
  one fallback shared with ComposerAttachments (which said "Upload
  failed. Retry or remove it to send.")

While a failed upload blocks it, Send stays focusable (aria-disabled)
with the tooltip "Retry or remove the failed file to send.", and Send
and Enter still send nothing.

The failed tile keeps Retry, then Remove, as on base. The error and the
actions each take a full row from the tile's inner edge; the error sits
12px below the file row and the actions 12px below the error, 8px
apart. The actions never wrap: the tile's min-content width keeps room
for both, so a strip too narrow for them scrolls instead.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: mtoohey <[email protected]>
Choosing an image over 25 million pixels showed "Upload failed. Retry
or remove this file." instead of why. prepareAttachment threw plain
Errors for its own refusals, and the tile's mapper words any unknown
error generically.

prepare-attachment.ts now throws a PreparationError for the three
refusals it writes for people: the pixel limit, no canvas context, and
an image format the browser cannot prepare. The draft shows that
message as it does an UploadError's. Every other failure, including an
image the browser cannot decode, a canvas that yields no blob, and the
WebP worker's own text, keeps the generic copy.

A draft-level test runs the real preparation with a stubbed bitmap and
canvas context. On the previous commit it fails for the pixel limit and
the missing context; it also pins a decode error to the generic text.

Signed-off-by: mtoohey <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@matt2e
matt2e force-pushed the errors/upload-tile branch from c2fd25e to b267250 Compare November 13, 2026 06:29
@matt2e
matt2e force-pushed the errors/user-copy-messages branch from 7ea5005 to 1877022 Compare November 13, 2026 06:29

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord automated source review via Wes's account.

Reviewed b267250a27f44ab04940c6bcbcdd6b17ae899ea4 against target/merge-base 1877022492b436d299418ba7ec8e73b065d885f9 (stacked on #3096).

One new P2 is reported inline: known upload recovery instructions are lost in detached Huddle chat because the existing bridge reconstructs errors without their type.

The existing preparation-error P2 is partly repaired. The pixel-limit, missing-canvas and unsupported-format cases now preserve their specific messages in the ordinary composer. However, animated PNG/WebP with an ICC profile or oriented EXIF still reach the plain errors in image-metadata.ts:59–67 through prepare-attachment.ts:40–51. The new mapper replaces these deterministic refusal reasons with generic retry copy, although retrying the unchanged file fails before upload. Extend the typed preparation-error treatment to those known refusals and add a draft-level animated-image regression case. This is residual evidence for the existing finding, not a duplicate inline report.

Source review only; no tests, builds or app execution. The three linked screenshots were inspected, but they do not establish exact-head runtime behavior. Browser layout, keyboard interactions and native cross-window behavior remain unverified.

matt2e added a commit that referenced this pull request Nov 14, 2026
…fore the Play click (#3446)

## Why

`composer-attachments.spec.mjs`, "local previews stay interactive during
eager upload and removal collapses the composer", hung once in CI: [11
Nov, Flake check, WebKit
7/9](https://github.com/block/buzz-app/actions/runs/43114573078/job/130840308592),
passed on re-run. The "Play video" click stayed in "performing click
action" past its 10 s action timeout, the test ran to its 120 s timeout,
and the error context holds no page snapshot.

This PR is a mitigation. The root cause is not pinned down.

## What the trace shows

The test pauses the review viewer's autoplaying 2 s clip with `loop =
true; pause()` and then clicks "Play video". The failed attempt's trace
(artifact 13898342784) shows:

- the viewer opening at 12.07 s and the clip at 1.250 s in the dialog
screenshot at 12.52 s;
- a blank stage with the clip reset to 0 at 13.17 s under the same blob
source;
- the clip playing at 1.417 s at 14.08 s, the `loop = true; pause()`
evaluate at 14.11 s (paused at 1.583 s), and the Play click at 14.13 s;
- a last screencast frame at 14.40 s with the clip back on its first
frame. No frame followed in the remaining 110 s and the click never
returned.

The clip was mid-way and never reached its end after `loop` was set, so
a loop seek did not occur. The page froze when the click resumed the
paused pipeline. The exact WebKit mechanism is unknown.

## Change

Set `playbackRate = 0` instead of `loop`, as `messages.spec.mjs` does
since #3058. A zero-rate play turns `paused` false without advancing the
clock, so the Play click leaves the clip on its paused frame and the
clip can neither end nor loop while the eager-upload assertions run. The
Pause button, `paused`, same-element and same-source assertions are
unchanged.

The second commit corrects the code comment, which first attributed the
stall to the loop seek. It now says the cause is not pinned down and
that rate 0 removes both the end and the loop seek.

No timeouts or assertions were relaxed. No browser cases were added or
removed.

## Evidence

Local runs on Linux, WebKit, `-g "local previews stay interactive"`.

| Scope | Unchanged test | With rate 0 |
|---|---|---|
| Idle host, `--workers=1 --repeat-each=10` | 10/10 passed, 12.5 to 13.0
s | 10/10 passed, 13.0 to 17.2 s |
| Pinned to 4 CPUs shared with 5 busy loops, `--repeat-each=6` | 6/6
passed, 24.9 to 31.2 s | 6/6 passed, 26.5 to 31.9 s |
| Whole file, `--workers=2` | | WebKit 10/10, Chromium 10/10 |

A scratch copy that waited for `ended` before taking over passed 3/3
with rate 0 and 3/3 with `loop`, so the ended path needs no extra seek.
After the comment correction, `user-status.spec.mjs` and this spec
passed 11/11 together on WebKit.

## Not reproduced, not run

- The stall was not reproduced locally, with or without the change. The
contended runs matched the 27 to 29 s of the job's passing repeats and
still passed.
- Because the mechanism is unknown, this change removes two candidate
triggers but is not shown to remove the hang. The test needs watching
after it lands.
- No human has tested this yet, so `buzz-review-completed` is not set.

## Left alone

- The earlier reset of the same video element at about 13 s in the
failing run.
- This spec's cold per-test dev server.
- Open PR #3097 edits other parts of `composer-attachments.spec.mjs`. A
trial merge against this change is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
matt2e and others added 2 commits November 14, 2026 12:31
…'s tile

In detached Huddle chat, a failed upload always showed "Upload failed.
Retry or remove this file.". Preparation and upload run in the main
window, and the composer bridge passed only the error's message, which
the client rebuilt as a plain Error. The attachment draft then saw no
UploadError or PreparationError, so it lost messages such as the ffmpeg
install instruction and the 25-million-pixel limit. A lost connection
and the upload timeout lost their own copy the same way.

The owner now adds the failure's identity to a failed upload's result:
the UploadError code, a PreparationError, or failureKind's connection or
timeout. The client restores a typed error from it, so the draft words
the tile as it does in the main window. Only the upload request carries
it; other requests reject as before. An unrecognised failure still
crosses as text only and stays generic on the tile.

composer.test.ts sends each failure through the owner, the client and
the attachment draft, then reads the rendered tile. The ffmpeg refusal,
both connection forms, the timeout and the pixel limit fail without the
fix. An unrecognised Error and a native string stay generic.

Signed-off-by: Matt Toohey <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
An animated PNG or WebP with a color profile, or one rotated by its
metadata, showed "Upload failed. Retry or remove this file.". So did an
image file whose structure cannot be read. image-metadata.ts refused
them with plain Errors, which the tile's mapper words generically, yet
retrying the same file fails the same way before any upload.

Those refusals are now PreparationErrors, so the tile shows their own
message. Each is reworded for a person, since it is shown as is:
- "This animated image has a color profile, which can’t be kept. Export
  it without one, or choose another image."
- "This animated image is rotated by its metadata, which can’t be kept.
  Export it upright, or choose another image."
- "This image file can’t be read. Choose another image."

PreparationError moves to image-metadata.ts, the lowest module that
throws one, and prepare-attachment.ts re-exports it. The Huddle composer
bridge already carries a PreparationError, so the detached composer's
tile shows the same messages.

Failures with no known cause keep the generic copy: an image the
browser cannot decode, a canvas that yields no blob, the WebP encoder's
errors, and encoder output that jpeg-metadata.ts rejects.

attachment-draft.test.tsx runs the draft's real preparation on an
animated PNG and WebP of each kind and on a GIF cut short.
composer.test.ts sends an animated PNG through the bridge. All six
cases fail without the fix.

Signed-off-by: Matt Toohey <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants