Skip to content

fix(web): preview restored draft videos

MacroscopeApp / Macroscope - UI Consistency failed Aug 30, 2026 in 6m 14s

UI Consistency: 1 issue found

apps/web/src/components/chat/ChatComposer.tsx — the new composer video tile sets disabled={isOpening} while the preview loads. Browsers blur a control the moment it becomes disabled, so focus falls to <body>: the aria-busy/Loading… state is never announced and there is no return target when the expanded preview closes. Sibling image tiles in the same grid stay enabled. Suggested fix posted inline: keep the button enabled, guard re-entry in the handler, and dim via the aria-busy variant.

Still outstanding from an earlier run (not re-posted): the identical pattern on the timeline video tile in apps/web/src/components/chat/MessagesTimeline.tsx (~line 1085).

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.

Scope reviewed: apps/web/src/**/*.{ts,tsx,css} in the merge-base..head diff, with server/electron changes used only as context.

Checked and found acceptable:

  • ExpandedImageDialog.tsx: the video branch now carries aria-label={item.name}, the dialog label switches on media type, and the backdrop close target is a separate absolutely positioned button, so native video controls do not bubble into a close. The window key handler only intercepts arrows when images.length > 1, so arrow-key seeking on a single-item video preview is preserved.
  • ExpandedImagePreview.tsx: the new image.type === "image" filter is safe — both remaining call sites (regularImages, annotation ChatImageAttachment, composerImages) carry type: "image".
  • Environment routing: isPreviewableComposerVideo gates the uploaded-attachment preview path on uploadEnvironmentId === environmentId, and ChatComposer receives the same environmentId that openFileAttachment uses for createAttachmentAssetUrl, so no cross-environment asset request is issued; non-matching videos correctly fall back to the file row with its reattach affordance.
  • Blob lifecycle: locally created object URLs for video previews are revoked by the expandedImage cleanup effect in ChatView.tsx; remote (non-desktop) URLs are not blobs and are left alone.
  • Primitive usage: remove/retry actions in the new tile use the shared Button primitive; the raw <button> play target mirrors the existing image-tile pattern (image/media expand target), which is an accepted exception.

🔍 Agent activity — 17 tool calls

💭 I'm checking the current code state to see what's changed since the last flagged commits, comparing against the diff from d366dce up to the current head at 9015613.

1. git_tools — d366dce..9015613

2. git_tools — 2daff8c..9015613

💭 The short sha for that commit failed with the diff tool, so I should try the full sha instead.

3. git_tools — d366dce..9015613

💭 The MessagesTimeline focus issue flagged earlier at line 1085 wasn't touched by the latest diff, so I shouldn't re-flag it as new. But the new commit 9015613 introduces a similar disabled-while-loading pattern in ChatComposer's new tile for restored video previews — this looks like a fresh occurrence of the same issue, so I need to check whether it was already flagged before deciding to report it. I'm turning my attention to what's genuinely new here, particularly the environment-aware video preview check and how the restored draft composer tile handles file opening. I need to trace whether the environmentId used by ChatView's openFileAttachment actually matches what ChatComposer passes down, since a mismatch could break attachment URL construction. I'm also verifying the identity comparison logic between openingVideoAttachmentId and each file's uploadedAttachmentId to make sure edge cases like undefined values don't cause false matches.

4. git_tools — openFileAttachment|createAttachmentAssetUrl|openingVideoAttachmentId

💭 I want to check how openFileAttachment handles attachments in ChatView, specifically whether the composer's uploaded video path works correctly, so I'll look at lines 2519-2610.

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

💭 I'm checking whether ChatComposer's environmentId prop matches the one ChatView uses in openFileAttachment, tracing where it's passed down to ChatComposer to confirm it reflects the thread's environment.

6. git_tools — environmentId={

💭 Environment routing for Composer matches ChatView's, so that's consistent. Now I'm looking at the new commit's ChatComposer video tile for restored uploaded drafts, and noticing the same disabled={isOpening} focus-destruction pattern already flagged in MessagesTimeline shows up again here — worth considering whether flagging it in this new file counts as a duplicate or a fresh instance worth calling out separately. There's also an edge case where the opening-video attachment id could theoretically match both a timeline video and a draft video simultaneously, though that's unlikely in practice. More importantly, I'm checking whether spreading a ComposerFileAttachment into a ChatFileAttachment when handling restored videos could leak extra fields like file, uploadEnvironmentId, or uploadedAttachmentId that don't belong on that type. Since excess property checks don't apply to spread properties in object literals, this compiles fine, and passing the extra fields through is harmless since only id, name, and mimeType are actually used. I also want to compare the composer video tile against the composer image tiles to see if there's an inconsistency in styling or reused Button component usage.

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

💭 I'm comparing how the new composer video tiles behave against sibling image and file tiles — checking that the warning badge for unpersisted items is image-specific, and thinking through how videos without a file or matching uploaded attachment should be handled versus the "Attach again" staging behavior used in the other-files list. Now I want to verify what happens when a restored video already has an uploaded attachment ID in the same environment — does it still need reattachment, and how does that check work?

8. git_tools — composerFileNeedsReattach|uploadEnvironmentId|uploadedAttachmentId

💭 I'm questioning whether the composer's restored-video path actually resolves correctly—it passes a pending upload id rather than a confirmed attachment id into openFileAttachment, so I need to check how the server's asset access logic handles pending versus sent attachments.

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

💭 Pending attachments look resolvable via asset URLs, so that seems fine. Now I'm checking the composer video tile against consistency rules, particularly around preserving accessibility and interaction semantics like focus-visible rings and disabled states. I already flagged the disabled-while-loading focus issue in MessagesTimeline, and ChatComposer now repeats that same pattern in newly added code — technically a distinct instance rather than a duplicate, but posting it again would feel repetitive, so I'm weighing whether it's worth raising separately. I'm also considering another possible issue in commit 9015613: when a composer video has both local bytes and a completed upload id, clicking the tile takes the local blob path without ever setting the opening state, which seems fine on its own, but I want to trace through what happens next. Checking related edge cases: restored video tiles correctly compare uploadedAttachmentId when computing isOpening, null vs undefined comparisons resolve safely, the "Loading…" label swap in the 64px tile looks fine, and aria-labels differ appropriately between image and video tiles ("Preview X" vs "Play X"), so nothing new stands out there yet. The zoom-in cursor on the play button is a minor aesthetic quirk but consistent with expand-to-dialog behavior, and focus-visible styling is consistent with the sibling image tile since neither has a ring. Looking for a real regression from the recent change: videos still uploading correctly show progress overlays in the grid, restored videos lose their file-size/status display compared to the old list view (a minor acceptable tradeoff), and removeComposerFileFromDraft is still properly wired up. I'm now tracing blob URL lifecycle for expanded video previews — when a draft video's local bytes are expanded via buildExpandedImagePreview, it creates an object URL, and I want to confirm ChatView's cleanup effect properly revokes it on close or attachment change by checking the video/blob condition in that effect. Since videos only have one item, nav arrows aren't a concern. I want to check the full ExpandedImageDialog file to see if clicking the backdrop closes the dialog, and whether that click-outside behavior might accidentally trigger when clicking on the video element itself.

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

💭 The backdrop button is separate from the video, so clicking the video itself won't trigger a close. Checking the keyboard handling too — the Escape/Arrow key listener only navigates when there are multiple images, so for a single video preview it skips past without interfering, letting the video's native arrow-key seeking work as expected. Turning back to the composer tile, the disabled={isOpening} pattern is the same accessibility issue flagged earlier, just appearing in a new location. Since the prior finding about the timeline video tile remains unaddressed, the PR still has outstanding findings, so it wouldn't qualify for an "all clear" outcome. Rather than re-posting a duplicate comment, I'm looking for something genuinely new in this occurrence — checking how the restored-video path handles the file attachment, including its uploadedAttachmentId, downloadable, and previewUrl fields to see if there's a distinct issue worth flagging.

11. browse_code — apps/web/src/types.ts

💭 I'm checking whether the composer's video-open logic conflicts with the ref reset on thread changes, and confirming both timeline and composer share the same onFileOpen callback correctly. I'm also noting that ChatComposer is memoized, so changes to openingVideoAttachmentId will trigger a rerender. Tracing through the click flow confirms the composer's disabled-video-tile state is genuinely reachable, meaning the same focus-loss issue that exists in MessagesTimeline is now also present in the composer. Since I already flagged this pattern before and the underlying issue hasn't changed, I'm inclined not to repeat that accessibility comment — instead I want to look for something new in this latest commit, starting with how the environment gating around previewable composer videos handles a restored upload. Checking through it, cross-environment uploads correctly fall back to a plain file row rather than triggering an RPC, and the environmentId resolution matches between the composer and ChatView, so that routing rule holds. Timeline file-open behavior also lines up with pre-existing patterns. The one minor thing I notice is a slight size inconsistency between the composer's fixed 64px video tiles and the timeline's flexible media grid tiles, though this seems minor. Everything else about the composer's video tile styling, overlay buttons, upload progress indicator, and play icon rendering matches the existing image tile conventions, so there's nothing structurally different there. The conditional gating that hides the media grid also mirrors the previous file-list behavior, so that's consistent too. Given that, the disabled-button focus issue I identified earlier in the timeline (and now the same pattern in the composer) is the only real finding — I still need to decide whether that counts as a failure or whether I should report all clear. I think flagging the ChatComposer tile makes sense since it's newly introduced in this commit (9015613) after my earlier feedback on the older commit (d366dce), so it's a distinct new occurrence rather than a duplicate. Let me also reconsider whether the "disabled destroys focus" concern is actually solid — when a focused button gets disabled, browsers blur it and shift focus to body, which could break the dialog's focus flow, though I'm second-guessing how this interacts with aria-busy announcements for screen readers. Checking the restored-draft case where file is null, the click handler's guard is actually unreachable since isPreviewableComposerVideo already guarantees the uploaded attachment id exists, so that's fine. I'm also double-checking the type union between ChatImageAttachment and ComposerFileAttachment in buildExpandedImagePreview, verifying the type: "image" field lines up correctly for composer images across call sites. Now I'm wondering if requiring image.type === "image" is a regression from earlier behavior that just checked for a previewUrl, so I want to grep the call sites to confirm none of them pass objects missing the type field.

12. git_tools — buildExpandedImagePreview

💭 I'm looking at line 1831 where buildExpandedImagePreview is called with props.image — I need to check whether that image object actually has type: "image", since the new filter checking image.type === "image" could silently break this preview expansion if it doesn't. Let me examine lines 1800-1840 to confirm the shape of props.image.

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

💭 Confirming that ChatImageAttachment has type "image" so the filter passes, and now checking whether ComposerImageAttachment in composerDraftStore also carries that same type field.

14. browse_code — apps/web/src/composerDraftStore.ts

💭 I'm checking that ComposerImageAttachment's type field matches the expected literal type, and confirming the array can be passed into the preview builder since it's assignable to the broader union type. But the video branch relies on a file property that isn't part of this wider type, so I need to check that access still works safely. I trace through the object URL lifecycle for composer video previews versus desktop video previews, confirming revocation happens correctly for blob-based URLs but not for plain http video URLs, since those don't need cleanup. Weighing whether to flag the ChatComposer disabled-button pattern again, I lean toward not duplicating my earlier feedback since it's a separate file, though the composer tile's inclusion of Remove and Retry buttons alongside the disabled state might make the issue more impactful there. Checking whether pending attachment IDs could collide with sent attachment IDs after a message is sent — pending IDs look like "pending-environment-1-report.pdf" while sent timeline attachments use uuid-based IDs, so they shouldn't collide, but I want to verify whether the client actually passes the pending ID through when sending. I keep coming back to the same disabled-while-loading focus/announcement issue on the new composer tile, and I'm now leaning toward flagging it as a new occurrence in this new file with an inline suggestion, then failing the check, though I'm second-guessing whether repeating the same finding is the right call. So a simpler fix would be to keep the button enabled and instead guard against re-entry inside the handler itself, checking the loading flag before proceeding. I'll draft the inline suggestion for that ChatComposer region, making sure to preserve indentation and match the correct diff line numbers for the review comment. I'm double-checking whether aria-busy:opacity-50 is a valid Tailwind variant, and confirming this pattern is used elsewhere in the repo. I also want to add a guard in the click handler so clicking while isOpening is true doesn't trigger a re-entry. Finishing the click handler logic with the fallback to open the uploaded attachment, covering through line 3844. Now I want to double check whether the repo's Tailwind config actually supports the aria-busy: variant syntax I used, so let me grep for existing aria variant usage.

15. git_tools — aria-(busy|disabled|expanded|selected|checked|pressed):

💭 I'm noting that the sidebar uses aria-disabled:pointer-events-none aria-disabled:opacity-50 as the established pattern for visually disabling something while keeping it keyboard-focusable. I want to check how the sidebar's busy rows handle this, since they keep the control interactive and only set aria-busy, to mirror that same approach here. Actually, aria-busy:opacity-50 should work fine since Tailwind v4 includes a built-in aria-busy variant. I'm deciding to post one inline comment on ChatComposer about the newly added play tile, but first I want to make sure there's something genuinely distinct to flag rather than just repeating myself — I'm looking at the isOpening check comparing file.uploadedAttachmentId against openingVideoAttachmentId to see if there's a real edge case with a composer draft video whose upload is still pending. I trace through the click and restore scenarios and both check out fine — nothing new there, and the aria-label change from "Play x" to "Loading x" is the same finding I already flagged. The only other thing I notice is a trivial style nit in ChatComposer.tsx where the import switched to using inline type modifiers instead of a separate import type statement, which is harmless under verbatimModuleSyntax and not worth commenting on.

16. browse_code — Does the repo's ESLint/oxlint/biome config enforce consistent-type-imports (e.g. prefer top-level import type { X } over inline import { type X }) for apps/web? Show the relevant lint config rules.

💭 Not a lint issue, so I'll skip that. I'm weighing whether flagging the same disabled play tile issue in ChatComposer would be redundant given I already raised it for MessagesTimeline — it's the same underlying pattern but occurs at a different site, so it may still be worth noting even though it risks repeating myself. The timing suggests the author may not have seen my earlier comment yet when they added the same pattern to ChatComposer, so re-flagging isn't necessarily arguing against a rejected point. I'm leaning toward not re-posting the identical a11y argument, and instead checking whether the newer commit introduces anything meaningfully different that would justify a fresh comment. Since the underlying issue is still unresolved in the code and now duplicated, I don't think this qualifies for an "All clear" outcome — the check should fail, with the summary explaining the remaining concern rather than repeating a duplicate inline comment. I think the cleanest approach is to post one inline comment specifically on the new ChatComposer tile, since it's a new site introduced by this commit rather than a repeat of the same lines, keeping it concise and pointing back to the same underlying pattern. I'm confirming exact indentation and line numbers in the file (button element starting around line 3829, with disabled state, click handler, and guard logic spanning to 3836) before drafting the suggested replacement. I'm confirming the line range 3831-3836 falls within the diff's added hunk (3817-3897), so the review comment placement is valid. I'm also verifying that aria-busy:opacity-50 is a legitimate Tailwind v4 aria variant before finalizing the suggestion. Now I'm drafting the comment body: disabling the tile during preview loading removes keyboard focus since the browser blurs a control once it's disabled, meaning the loading state never gets announced and there's no focus target to return to when the dialog closes—unlike the sibling image tiles which stay enabled, mirroring the timeline tile's existing pattern.

17. modify_pr — One finding: the new composer video tile repeats the disable-while-loading pattern, which drops keyboard focus. Same issue as the timeline tile flagged earlier — fixing both keeps the play affordance keyboard-usable.


Agent Credits: 200 credits

Token usage: 48 uncached input · 1,307,449 cache-read input · 107,632 cache-write input · 22,940 output
Agent Credits may also include non-token charges from external tools such as web research.