Skip to content

fix(web): offer download when video playback fails

MacroscopeApp / Macroscope - UI Consistency failed Aug 30, 2026 in 4m 55s

UI Consistency: 1 issue found

apps/web/src/components/chat/ExpandedImageDialog.tsx — The new "Download video" fallback (shown when <video> fails to decode) relies solely on the anchor download attribute against the environment server's asset URL. This PR makes that URL inline (issueAssetUrl drops download: true for video/*, so no Content-Disposition is sent), and download is ignored cross-origin — a constraint openFileAttachment in ChatView.tsx already documents. Clicking the only escape hatch for an undecodable video (e.g. an HEVC .mp4, still video/mp4) performs a top-level navigation away from the SPA instead of saving the file. Suggested minimum fix posted inline (target="_blank" rel="noreferrer"); the durable fix is to issue a download-scoped asset URL for the fallback.

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.

Files reviewed in scope: apps/web/src/components/ChatView.tsx, apps/web/src/components/ChatView.logic.ts, apps/web/src/components/chat/ChatComposer.tsx, apps/web/src/components/chat/MessagesTimeline.tsx, apps/web/src/components/chat/ExpandedImageDialog.tsx, apps/web/src/components/chat/ExpandedImagePreview.tsx, apps/web/src/components/chat/composerAttachmentFiles.ts, apps/web/src/types.ts. Server (AssetAccess.ts, http.ts) and Electron CSP changes were read as context only.

Items checked and found acceptable:

  • Play tiles are raw <button> image-target affordances consistent with the sibling image tiles in the same grid (not a reconstruction of the Button primitive); removal/retry controls still use Button with icon-xs.
  • Loading state now uses aria-busy/aria-disabled rather than disabled, preserving keyboard focus across the dialog open/close round trip (prior finding addressed).
  • Timeline video tiles caption the filename, matching the previewless-image fallback (prior finding addressed).
  • <video> has aria-label={item.name}, mirroring the <img> alt (prior finding addressed).
  • Composer routing of restored videos is environment-scoped via isPreviewableComposerVideo(file, environmentId); needs-reattach and oversize videos still fall through to the file-row path and the capability block banner.
  • Blob preview URLs created by buildExpandedImagePreview/loadDesktopVideoPreviewUrl are revoked by the expandedImage effect in ChatView; ref-cleanup return from ComposerVideoThumbnail is valid on React 19.2.

🔍 Agent activity — 20 tool calls

💭 I'm checking the current state of the PR to see what's changed since prior review runs, since it looks like several previously flagged issues—like missing aria-labels, onError handlers, and captions—have now been addressed. I want to look at the key files to confirm the fixes are in place.

1. browse_code — apps/web/src/components/chat/ExpandedImageDialog.tsx

2. browse_code — apps/web/src/components/chat/ExpandedImagePreview.tsx

💭 I'm tracing where the object URL created for local video previews gets revoked, checking whether ChatView's effect on expandedImage cleans up blob URLs when the item is a video. Now I'm looking at the download fallback in the expanded image dialog - for blob URLs the anchor download works fine, but for remote video URLs served inline (no Content-Disposition), the download attribute may just play the video instead of saving it, which is a minor edge case since it only triggers on codec failure. I should also diff against the last reviewed commit (4e4179b) to see exactly what's changed since then, focusing on the timeline and composer video tiles.

3. git_tools — 4e4179b..32d5688

💭 Since the branch was force-pushed, I should look at the current head and check the relevant sections of MessagesTimeline and ChatComposer as they stand now.

4. browse_code — apps/web/src/components/chat/MessagesTimeline.tsx

💭 I'm noticing the video tile button in MessagesTimeline lacks a focus-visible ring that sibling file rows have, though the image tile button also relies on default browser outline without an explicit ring, so the inconsistency might not be as clear-cut as I thought. The disabled prop tied to downloadable === false is a persistent state for optimistic uploads, which seems acceptable — the earlier concern about disabling during preview loading was already addressed via aria-disabled. Now I'm moving on to check the video tile in ChatComposer.

5. browse_code — apps/web/src/components/chat/ChatComposer.tsx

💭 I'm noticing image tiles show a "non-persisted draft" warning that video tiles don't, since videos come from composerFiles rather than composerImages. That seems consistent with how other non-image files are handled, which instead show size and reattachment state like "Attach again" or "Remove to send". The new video tile actually drops that reattachment affordance entirely, though. Tracing through isPreviewableComposerVideo, a restored draft video without a valid file or matching upload environment falls back to the "other files" list where reattachment is still shown, so that edge case seems covered — I still need to confirm composerFileNeedsReattach's exact logic to be sure a video with file !== null never needs reattachment, and note that the video tile also skips the file size and upload-status states that image tiles omit too. I'm also checking whether there's an equivalent to nonPersistedComposerImageIdSet for non-image files, verifying focus-visible and aria-disabled styling is consistent with image tiles, and confirming the openingVideoAttachmentId comparison against uploadedAttachmentId handles undefined/null safely. I recall a prior review flagged a caption z-index overlap issue, but now it looks like the composer tiles have dropped the filename caption altogether — only the play icon remains — while MessagesTimeline still keeps its caption, so I want to check how composer image tiles handle this now. I'm noticing that restored/uploaded video tiles without a file object show just a black square with a play icon and no filename, whereas image tiles fall back to displaying the name as text when there's no preview URL. This seems like a real inconsistency worth flagging, since a composer with multiple restored videos would leave the user unable to distinguish between them despite the aria-label having the name internally. But then I remember the history here: I previously flagged a similar issue in MessagesTimeline (dropping the visible filename), which they fixed there but not in the composer. I also flagged a z-index overlap issue where the composer's caption overlapped the progress strip and retry button — and it looks like their fix was to remove the caption from the composer tile entirely, which resolves that overlap. So re-flagging "no name shown" in the composer now would just be circling back to a problem they already intentionally traded off; I shouldn't raise it again, especially given it's a small 64px tile similar to image tiles. Now shifting to a more substantial issue: in ExpandedImageDialog, when a video fails to load it offers a "Download video" link using an anchor with a download attribute set to the item's name. But since the video source is a signed, likely cross-origin asset URL, the download attribute won't actually force a download in that case — combined with the fact that the server now serves video inline instead of as an attachment, this download fallback may silently not work as intended. I should check whether this is a genuine deviation from existing patterns elsewhere in the codebase — like how MessagesTimeline handles download anchors without a target attribute. Let me look at ChatView's file attachment download implementation to compare.

6. browse_code — apps/web/src/components/ChatView.tsx

💭 I'm confirming the finally block still runs on early return, so cleanup happens correctly. Checking that isCurrentRequest properly gates whether finishVideoPreviewRequest clears state when the ref doesn't match. Now I'm tracing a potential bug in the composer logic. Following how file open events pass an attachment id through to isVideoAttachment and openFileAttachment, confirming the opening state gets set correctly, and that local files skip the loading state entirely. I'm also verifying the ChatView effect that revokes blob URLs for expanded video previews only fires cleanup for blob-based sources, and tracing whether composer's onExpandImage is properly wired to ChatView's setExpandedImage state setter. Now I'm spotting a real issue: the expanded image dialog's fallback download button uses an anchor with a download attribute, but for cross-origin video URLs served from the environment, browsers ignore that attribute and navigate the whole tab away from the app instead of downloading — a legitimate regression risk that could wipe out app state. The fix would be adding target and rel attributes to open it safely in a new tab instead. Actually, that comment confirms the download attribute is ignored in cross-origin environment servers — previously the server compensated with a Content-Disposition attachment header, but now videos are served inline. So the fallback "Download video" button, in that cross-origin scenario, won't actually download: the browser will just navigate and try to play the unsupported video again, breaking the exact case the fallback exists to handle. The minimal fix would need to fetch the bytes and create a blob URL, or request a download-scoped asset URL — but that requires changes beyond the dialog itself, touching ChatView/AssetAccess, so it's not a clean isolated fix. I'm now double-checking the server-side header logic to confirm this behavior around asset.download and mimeType checks.

7. browse_code — apps/server/src/http.ts

💭 For videos without Content-Disposition, the fallback download anchor won't actually download in a cross-origin environment—it'll just navigate the tab to the inline video instead. Since the environment server can be cross-origin from the web app in supported deployments, I need to check how the asset URL gets resolved against the environment's base URL. This does seem like a legitimate concrete finding within scope: the shared ExpandedImageDialog's download fallback loses proper interaction semantics (navigating away instead of downloading) for cross-origin browser mode, plus missing target/rel attributes. That's a genuine behavioral regression worth flagging, since the check explicitly covers preserving browser interaction behavior in shared components. Now I'm scanning other candidates in the diff for anything more clear-cut, but they mostly look consistent with existing patterns—raw button tiles matching existing image tiles, focus-ring gaps that already exist elsewhere, minor accessibility nuances, and a fine optional type discriminator field. None of these rise to the level of a solid finding on their own. Recomputing filtered video/file lists on every render is a minor perf issue, not a correctness bug. Checking the gradient overlay span positioned absolutely inside the button — since the wrapping div is already relative and the button fills it entirely, the positioning context works out fine either way. Now looking at MessagesTimeline's disabled state tied to file.downloadable === false. Non-downloadable images still render previews, and optimistic just-sent videos show as disabled with reduced opacity, name, and a play icon — that earlier concern about focus loss was about the loading state and doesn't apply here. The grid layout shares grid-cols-2 between images and videos, with video tiles adding a minimum height, which looks fine. I want to double check the React version to settle the ref cleanup question.

8. git_tools — "react":

9. git_tools — isVideoAttachment|downloadable

💭 Ref cleanup looks fine in React 19. Now I want to check ChatView around line 5960 for the optimistic attachments handling, trace the expandedImage plumbing and how the composer wires up onExpandImage, and confirm where ExpandedImageDialog actually gets rendered. I also need to verify whether MessagesTimeline is used elsewhere, since openingVideoAttachmentId now appears to be a required prop on its type.

10. git_tools — MessagesTimeline|openingVideoAttachmentId|onFileOpen|onExpandImage|setExpandedImage

💭 I'm noticing the expand-timeline-image handler in ChatView resets the video preview request ref and clears the opening attachment state before setting the expanded image, and there's likely a related close/reset handler nearby worth checking too.

11. browse_code — apps/web/src/components/ChatView.tsx

12. browse_code — apps/web/src/components/ChatView.tsx

💭 I'm checking that the blob revocation timing works correctly with the download click—since the effect only tracks the current image/video at that index, it should be fine for single-item previews. I'm also considering whether the "Download video" fallback link in ExpandedImageDialog could have any issues with the blob URL still being valid when clicked. The bigger question is whether the fallback download actually works when the codec is unsupported: in a browser deployment with a cross-origin asset URL, clicking download would just navigate to a URL the browser also can't play, leading to a dead end and lost app state. I want to confirm this by checking how resolveAssetUrl and the connection's httpBaseUrl work, since the ChatView comment about "cross-origin environment servers" suggests this cross-origin scenario is the normal case when the web app connects to multiple environment servers with different asset hosts.

13. git_tools — resolveAssetUrl

💭 I confirm that the download attribute only works for same-origin URLs per the HTML spec, and browsers like Chrome ignore it cross-origin, navigating instead—so since the httpBaseUrl is remote, this affects the fallback "Download video" anchor in ExpandedImageDialog. This is the sole recovery path this PR introduces, so it's worth flagging. The cleanest fix would be to either keep the download-scoped asset URL for videos rather than stripping it in ChatView/AssetAccess, or have the dialog fetch the src into a blob URL and set target/rel attributes to at least prevent navigating the whole app away—though since this touches other files, I don't have a clean single-file diff to offer, so I'll try to propose the smallest safe change instead. I'm now scanning for other issues I haven't already flagged. Checking ExpandedImageDialog's nav button labels, the cursor style on video play buttons, and focus-visible ring consistency between video tiles and existing image/file rows — none of these seem worth raising since they're either non-issues (video previews are single-item) or consistent with existing patterns. The video tile using disabled={file.downloadable === false} correctly renders as inert and dimmed right after sending, and the missing name issue from an earlier pass has already been fixed. I'm also confirming onFileDownload was renamed consistently everywhere, and checking that composer videos from a different upload environment correctly fall back to non-clickable file rows. The openingVideoAttachmentId being required while onFileOpen is optional is a minor API inconsistency, not worth flagging. Now I'm wondering whether the composer's media grid properly surfaces the draft-attachment warning badge and needsReattach state for videos, so I want to check composerFileNeedsReattach.

14. browse_code — apps/web/src/components/chat/composerAttachmentFiles.ts

💭 That function must be defined elsewhere, so I should search for it.

15. git_tools — export function composerFileNeedsReattach

16. git_tools — composerFileNeedsReattach|fileAttachmentCapabilityBlockReason|composerOtherFiles|composerFiles =

💭 I'm tracing through the different video attachment states: needing reattachment goes to the "other files" section with an "Attach again" prompt, wrong-environment videos show their size there too, and oversized videos with a valid file need similar handling in both the file row and composer block. The upload-blocked banner still covers oversized videos since its reason logic pulls from composerFiles regardless of type, so nothing's missed there. Video tiles don't show file size (consistent with how images behave), and the "not yet persisted" warning set only applies to images anyway, so there's no regression on that front either. I'm now revisiting the earlier review comment about treating all video attachments as inline-playable, since it removes a fallback for undecodable formats — the author addressed this with an onError handler that shows a download button, but I suspect that button won't actually trigger a real download for cross-origin video since the response lacks a Content-Disposition header for inline serving. I want to verify this by tracing the browser preview URL path once more, since this is a legitimate follow-up on a fix made in direct response to my prior comment. I'm working through the download behavior across cases: desktop uses a blob URL so the download attribute works fine, same-origin browser downloads work, but cross-origin browser downloads would likely get ignored and just navigate instead. I'm also considering whether the SPA link would trigger full-page navigation and how Chrome handles unsupported video MIME types like video/quicktime — since Chrome's built-in player only supports mp4/webm/ogg, it would probably fall back to downloading unsupported formats even without a Content-Disposition header, though without a proper filename. Now I want to check what the actual asset URL path looks like, since the filename would get derived from the URL if there's no disposition header — I need to see if the relative URL structure in AssetAccess ends with something recognizable as a filename or just a signature/token that would produce a garbage download name.

17. browse_code — apps/server/src/assets/AssetAccess.ts

💭 I'm checking how relativeUrl gets constructed to see if it ends with the fileName.

18. browse_code — apps/server/src/assets/AssetAccess.ts

19. browse_code — apps/server/src/assets/AssetAccess.ts

💭 Since the filename is the last URL path segment, even a cross-origin navigation-download would preserve a reasonable filename like demo.mov. That softens the risk somewhat, but if the browser attempts to render a file it can't play — like an H.265 codec failure in mp4 — it'll navigate to a broken video player page, replacing the SPA entirely, which is a genuine state-loss concern. This actually crystallizes into a real, common scenario: iPhone-recorded videos are HEVC in an mp4/mov container, which Chrome can't decode. So the error handler fires, the user clicks download, the cross-origin download attribute gets ignored, and Chrome navigates directly to the video URL instead — landing on a failed media player page and losing the SPA state (thread, composer draft) with no way back. I want to flag this as a concrete dead-end regression, with a minimal fix being to add target="_blank" rel="noreferrer" so the app tab isn't replaced, though the more durable fix would be to fetch the bytes and trigger a blob download instead. I'll draft this as a self-contained inline suggestion touching just the diff lines, swapping the anchor rendering to include download, target="_blank", and rel="noreferrer". Now I want to check if there's a second, UI-consistency-specific issue worth raising in these new tiles — looking at how the Button primitive's variants and sizes are reused, and whether the raw button play tiles follow existing patterns. One thing stands out: the video tile button seems to be missing focus-visible handling, and for videos where downloadable is optimistically false, the button is disabled entirely, cutting off the download path — though that seems like an intentional transitional state rather than a bug. I'm also considering that videos may now never be downloadable directly from the timeline itself, only via the dialog's error fallback, though native browser controls could still offer a save option depending on whether the source is a blob URL. I want to double check the composer's isOpening logic to make sure the attachment ID comparisons can't produce false positives when IDs are undefined or null, and also trace how onFileOpen passes along the attachment object to make sure the ID mapping stays consistent. Good, spread properties are actually exempt from excess property checking, so that's fine. Now I'm checking the ExpandedImageDialog and composer tile aria-labels, which look fine, and turning to the effect in ChatView that revokes blob URLs when the expanded image changes, along with how composer image previews are built. Tracing through the blob URL lifecycle for both composer previews and desktop video previews, it all looks properly cleaned up—each is revoked when the dialog closes or the user navigates to another item. I think one finding is enough, but let me consider a bit more before wrapping up. Given this is already my 8th comment across runs on this PR and the author's been responsive, I want to avoid a false positive here—so I'm cross-checking the cross-origin download claim against how the repo already handles similar cases elsewhere, like the file rows in the messages timeline that use anchor tags with download attributes pointing to preview URLs. That confirms the pattern: server-hosted assets rely on Content-Disposition rather than the download attribute, and this new dialog fallback breaks that by relying solely on the download attribute against an inline-served URL. That's a solid consistency finding, so I'll post a comment on the relevant button lines in ExpandedImageDialog.tsx, likely with an inline suggestion once I confirm the exact indentation. I'm checking the character width fits under the line limit before drafting the review comment explaining that the PR removes download: true for video assets, meaning the response won't include a Content-Disposition header for remote video sources. Since the anchor's download attribute is ignored cross-origin, clicking to save an undecodable video would instead navigate the tab away, breaking the chat view for HEVC files that can't be played inline. I'm leaning toward suggesting either opening links externally as a minimal fix, or better, giving the dialog a separate download-scoped asset URL rather than reusing the inline playback one — then posting this as an inline PR review comment with a short body and deciding whether the check should fail or stay neutral. Given the instructions say a clear consistency or regression risk should fail the check, I'll mark it as a failure. I'm confirming that line 93 in ExpandedImageDialog.tsx falls within the added lines of the diff, so the comment is properly anchored.

20. modify_pr — One finding on the new unplayable-video fallback in ExpandedImageDialog.


Agent Credits: 154 credits

Token usage: 34 uncached input · 1,150,574 cache-read input · 69,078 cache-write input · 18,104 output
Agent Credits may also include non-token charges from external tools such as web research.