Repository navigation
UI Consistency: 1 issue found
apps/web/src/components/chat/MessagesTimeline.tsx — the new video play tile sets disabled while the preview loads (disabled={file.downloadable === false || isOpening}). Disabling a focused button moves focus to <body>, so the aria-busy / Loading… state is never announced and focus is not restored when the expanded preview closes. Sibling image tiles in the same grid stay enabled and keep focus across the dialog round trip, and the repo's other busy affordance (Sidebar rows) marks aria-busy without disabling. Suggested fix posted inline: keep the button enabled, use aria-disabled + aria-busy, guard the click, and reserve disabled for the permanent downloadable === false case.
No other findings in the in-scope web UI files.
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 examined (in scope)
apps/web/src/components/ChatView.tsxapps/web/src/components/ChatView.logic.tsapps/web/src/components/chat/ChatComposer.tsxapps/web/src/components/chat/MessagesTimeline.tsxapps/web/src/components/chat/ExpandedImageDialog.tsxapps/web/src/components/chat/ExpandedImagePreview.tsxapps/web/src/components/pullRequest/PullRequestMarkdown.tsxapps/web/src/types.ts
Checked and clear
- Raw
<button>video tiles are media targets with tile geometry, not reconstructions of theButtonprimitive; remove/retry affordances still useButtonwithsize="icon-xs", matching the image tiles. - Composer video tiles reuse the image tile container geometry (
h-16 w-16,rounded-lg,border-border/80) and the same upload progress / retry / remove composition; only the surface color and play glyph differ. buildExpandedImagePreviewonly mints an object URL for composer files that actually hold bytes (file: File | null), and hydrated byte-less videos fall through to the existing needs-reattach file rows.- Blob lifetime is handled:
ChatComposeris rendered from one call site whoseonExpandImageisonExpandTimelineImage, so every videoblob:src reachesChatView's revoke effect; thread changes bump the request id and clear both preview and pending state. ExpandedImageDialogvideo branch carriesaria-label={item.name}and the dialog/close labels now vary by media kind (previous finding resolved).MessagesTimelinevideo tiles now show the filename caption (previous finding resolved) and the requiredopeningVideoAttachmentIdprop is threaded from the only production call site plus updated tests.PullRequestMarkdownretains the CSP rationale alongside the codec rationale (previous finding resolved).
🔍 Agent activity — 18 tool calls
💭 It looks like prior feedback was addressed (aria-label, loading state, filename caption, CSP comment), so now I'm reviewing the current head for new issues, focusing on files under apps/web/src. I'm checking ChatComposer.tsx and MessagesTimeline.tsx video tiles, which use raw buttons with large class strings that might need accessibility attention. I'm checking whether the composer video tile is missing a disabled state, and whether the Retry button's render pattern in ChatComposer matches existing conventions elsewhere. I should pull up the actual files to verify these details.
1. browse_code — apps/web/src/components/chat/ExpandedImagePreview.tsx
2. browse_code — apps/web/src/components/chat/ExpandedImageDialog.tsx
💭 I'm checking where the composer preview's object URL gets cleaned up—looks like ChatView has an effect that revokes blob URLs for video items whenever expandedImage changes, tying the cleanup to that state transition. That handles the ChatView side, and
onExpandImagein ChatComposer likely routes through that same setExpandedImage call, so it should be consistent. Now I want to review the composer and timeline tiles in full to make sure they match the image tile behavior.
3. browse_code — apps/web/src/components/chat/MessagesTimeline.tsx
4. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 I notice the composer video tile skips the "needsReattach" handling present in the file list. Videos with a null file get filtered into composerOtherFiles instead, so they'd naturally show up there with "Attach again" — which actually seems like reasonable behavior. I'm also checking whether object URLs created for composer video previews get properly revoked. ChatView has an effect that revokes blob URLs for expanded video items on close, so I want to confirm composer's expand handler wires into that same cleanup path. Then I'm looking at focus/accessibility consistency across timeline tiles — the new video button lacks a focus-visible ring, but so do the existing image tiles, so it's following existing precedent rather than introducing a new inconsistency. I should check whether Tailwind's preflight strips the default browser focus ring, or if there's a global CSS reset for outlines, before concluding this is a real regression. Separately, I'm noting that the error toast message switched from a template literal to string concatenation — stylistically inconsistent with the rest of the file, but that's a lint concern rather than a UI consistency issue, so probably not worth flagging here. More importantly,
openingVideoAttachmentIdis now a required prop onMessagesTimelinePropswhileonFileOpenstays optional with a default no-op. If there are other call sites ofMessagesTimelinethat don't pass this new required prop, that would break the TypeScript build, so I want to grep for all usages to confirm. I also want to check whether the composer's video tile UI properly surfaces file-size-limit failures for oversized attachments, similar to how other file types in the composer handle it. When not downloadable, it stays labeled "Play" instead, which seems fine. The expanded image dialog handles video previews as single items without multi-image navigation issues, and the optional video type field on images looks correctly scoped. Now I want to search for where MessagesTimeline is used and check the focus styles applied there.
5. git_tools — MessagesTimeline
6. git_tools — focus-visible
💭 MessagesTimeline is only used in ChatView, so the required prop is fine given the test updates. Image tiles also lack focus rings, so that's probably not a significant issue -- the bigger consideration is whether the timeline video tile's keyboard/pointer semantics match the previous file row, since the loading state and filename issues were already addressed. Now let me diff the last reviewed commit (7f072fb) against head to see what's changed since then.
7. git_tools — 7f072fb..d366dce
💭 This looks like the branch was force-pushed or rebased, so I should check the commits in the PR.
8. github_api_read_only — repos/{owner}/{repo}/pulls/{pull_number}/commits?per_page=30&page=1
💭 Two new commits, 18fea75 and d366dce, showed up since the last run, so I want to diff 7f072fb..d366dce to see exactly what changed.
9. git_tools — 7f072fb..d366dce
💭 I'm checking whether the video preview success path in the browser bypasses
onExpandTimelineImageby callingsetExpandedImagedirectly, which could causeopeningVideoAttachmentIdto not reset properly in some flows. Tracing what happens when a user clicks another video while one is still loading: since each click gets its own request id and the id check infinishVideoPreviewRequestonly clears state when ids match, the earlier request's cleanup correctly no-ops instead of clobbering the newer one's state. I am wondering whether disabling the button during load causes a focus-loss issue for keyboard users, but I already flagged the loading affordance pattern, so raising it again might feel like churn rather than a fresh issue. I'm now looking for something more concrete, and I notice the truncated label sitting inside a flex column button might need a min-width adjustment for truncate to actually work. Tracing through the flexbox behavior, I confirm that with align-items: center the label shrinks to fit and gets capped by max-width, so the truncation should actually render correctly despite my earlier worry. I also notice the composer tile usestext-[9px]while other small labels nearby usetext-[10px]— a minor one-off inconsistency, but it's just an aesthetic nitpick, not worth flagging as a real issue. The more substantial concern is that the upload progress overlay on the video tile is positioned absolutely at the bottom, which could visually collide with the centered filename label underneath the icon. Now I'm shifting to checking whether video attachments moved to the media grid only apply to user messages, or whether assistant messages render attachments through a different component — I want to grep for other usages ofonFileOpen/onFileDownloadto be sure there isn't a second rendering path I'm missing. I'm also double-checking thatisVideoAttachmentcorrectly handles theComposerFileAttachmenttype since it's typed asChatFileAttachmentwith amimeTypefield, which looks fine. I'm now looking at howChatComposerfilters video files by checkingfile.file !== null, but I suspectfilemight actually be optional (undefined) rather than strictly nullable, based on howExpandedImagePreviewhandles it elsewhere. If that's the case, a rehydrated draft whose video blob never got persisted would still pass the filter and render as a clickable play tile, but clicking it would silently do nothing since there's no file or image to preview — that's a real bug worth confirming against the actual type definition in the composer draft store.
10. git_tools — ComposerFileAttachment|composerFileNeedsReattach
💭 I'm noting that a hydrated video whose upload finished before reload will fall back to the plain file row instead of the play tile, since it lacks raw bytes for a preview. It's a minor UI inconsistency but understandable given the constraints, so it's acceptable to leave as is. Turning to disabled/loading states, the tile keeps a fixed minimum height so nothing jumps, and the video button's cursor styling stays consistent with the rest of the codebase. I'm now second-guessing the string concatenation used for the toast message versus the template literals used everywhere else in the file—if the lint config enforces
prefer-template, this could break CI, so I want to check that rule before deciding whether it needs fixing.
11. browse_code — apps/web/src/components/ChatView.tsx
12. browse_code — **/{eslint.config.,.oxlintrc.json,biome.json,.eslintrc*}
💭 I'll skip chasing down the lint config issue since it's not central here. Looking more closely, I notice that in the browser path the preview URL is just the signed asset URL, and setExpandedImage gets called with a video type—the ChatView effect only revokes blob URLs, so that's fine. But I'm now considering whether setOpeningVideoAttachmentId being cleared in the finally block behaves correctly for that browser path specifically. Thinking about the loading button's disabled state, I realize clicking the play tile via keyboard disables the button and pulls focus away to the body, so when the dialog eventually opens or closes there's nowhere for focus to naturally return to—unlike the image tile button, which stays enabled and retains focus throughout, letting Escape restore focus properly afterward. I'm noting this is a real, if modest, keyboard regression introduced specifically by adding
|| isOpeningto the disabled condition (thedownloadable === falsepart was already there before). The fix would be swappingdisabledforaria-disabledplus ignoring clicks while loading, so the trigger keeps focus. I want to weigh whether this is significant enough to flag, or whether there's a bigger issue elsewhere, like differences between how the composer's video and image tiles handle persistence warnings. I check whether the "may not persist" indicator applies to files at all, and it doesn't seem like existing file rows show that warning either, so no regression there. I also check the video tile's placement in the flex row and its handling of size/needsReattach for files too large to upload, finding the existing file row behavior only shows size without an explicit "too large" hint — so nothing new is broken there either. Videos are exactly the file type most prone to exceeding size limits, though: a large screen recording would show as a play tile with no size shown and no clue about why sending is blocked. I want to check whether there's a separate banner elsewhere that surfaces oversized attachments, so I'll look at how the too-large message is used in the composer.
13. git_tools — fileAttachmentCapabilityBlockReason|TooLarge|tooLarge|maxFileAttachmentBytes
💭 Since there's already a global tooltip covering oversized attachments, the missing size text on video tiles isn't really a problem — image tiles in the composer don't show size either, so video tiles being consistent with that isn't worth flagging. I'm starting to weigh which findings are actually worth reporting, beginning with the timeline video tile's disabled state while loading. I'm going through the remaining candidates one by one — most turn out to be non-issues since the composer handles blobs synchronously and the aesthetic details aren't worth flagging. The one thing that does seem worth digging into is the blob URL revoke effect in ChatView, since I need to verify whether the composer's expand-image handler properly resets state the same way the timeline's does, or if there's a mismatch between
onExpandTimelineImageand whatever the composer actually wires up. Video attachments are excluded from the file list now, which raises a question about optimistic locally-sent videos that already have a preview blob — clicking the play icon still triggers a server round-trip viaonFileOpeninstead of using the local preview, so I should check howoptimisticAttachmentshandlesdownloadableandpreviewUrlfor videos in ChatView.
14. git_tools — downloadable: false
💭 Optimistic video files without a previewUrl now show as a dimmed play tile with the filename rather than an inert row with no icon, which resolves my earlier concern. The remaining findings seem minor, so I want to check ChatComposer more closely around composerVideos/composerOtherFiles and the file removal handler for anything I've missed. Checking layering, sizing, and focus behavior of the composer video tile confirms everything is fine — the remove button correctly overlaps the play tile since it comes later in the DOM, and there are no nested interactive element issues. Reviewing the accessibility rules once more, I confirm the video play tile counts as a media target and is exempt from the raw-button finding, leaving the disabled-button-stealing-focus issue as my main candidate finding. Weighing whether that focus issue is severe enough to flag: keyboard users pressing Enter on the tile lose focus when the button becomes disabled, so Tab restarts from the top of the page after the dialog closes, unlike images where focus is retained — a real but modest regression. I also check whether attachment IDs could collide across messages, causing loading state to appear on multiple tiles simultaneously, but treat that as a minor edge case not worth pursuing further. The lack of a
role="status"or live region means screen reader users get no announcement when the button switches to "Loading…" — combined with the lost focus, this reinforces that the state change is both silent and non-focusable. The cleanest fix would be to keep the button enabled witharia-disabledplus an early-return in the click handler, preserving focus so the label change gets announced naturally, and I'm now weighing whether this rises to the level of a real regression versus a minor accessibility nit before moving to check the non-desktop browser path where the preview URL might be a plain HTTP link rather than a blob. I've traced through the desktop dialog-close, thread-navigation, and composer-send paths and confirmed the blob URL cleanup logic handles all of them correctly, so nothing further to flag there. Now I'm checking whetherbuildExpandedImagePreviewcreates a fresh object URL per call for composer videos, and want to confirm whether ChatComposer'sonExpandImagealways routes to the sameexpandedImagesetter in ChatView — if it's wired differently in another parent component, that could leak the blob. Let me grep foronExpandImage=to see everywhere ChatComposer is used.
15. git_tools — onExpandImage
💭 I confirm there's only one ChatComposer call site passing the expand handler, so blob revocation is properly covered there. The remaining candidates feel weak, so I'm doing another careful pass focused on accessibility and control consistency—checking whether the composer/timeline play tiles' raw button usage falls under the media target exception, and whether accessibility is preserved on the timeline play tile. Both the composer and timeline play tiles include filenames as visible text alongside aria-labels like "Play demo.mp4," so the accessible name still contains the visible label—no violation there. The only genuine issue left is the disabled-focus problem during loading states, but before flagging it I want to check whether this repo's shared button primitive already has an established pending/disabled convention that the video tile is simply following, which would make it a non-issue.
16. git_tools — aria-busy
17. git_tools — Loading…
💭 I notice the tile swaps in "Loading…" text for the PlayIcon during the pending state, which shifts the size slightly—icon size versus text width—though it's a minor visual quirk. The disabled-during-load finding seems legitimate but not a major issue overall. Tracing the keyboard flow further: clicking Play destroys focus, the dialog opens with no focus management (pre-existing) though Escape still works globally, and closing it loses focus entirely so Tab restarts at the top of a long chat timeline—unlike the image tile flow, where focus stays on the tile. I'll report this as one combined finding with an inline fix suggesting
aria-disabledplus a click guard, keeping in mind that the permanently non-downloadable case should stay trulydisabledsince it's inert from the start anyway. I'm drafting the suggested code change for the button's disabled and className attributes now. Still writing button attributes... I'm checking whether Tailwind v4 actually supports thearia-disabled:variant—yes, it's one of the built-in ARIA variants alongside aria-checked, aria-expanded, aria-hidden, aria-pressed, aria-readonly, aria-required, and aria-selected, so this should work without extra config. I'm weighing whether this is overkill given the test only checksaria-busy="true"and doesn't assert on disabled state, so a simpler approach keepingdisabledmight suffice instead. I should also check the composer path — the video filtering logic there looks consistent enough with hydrated uploads showing in the file list without issue. One more thing worth checking: the composer's play tile calls the preview builder directly, and I need to verify whether that preview can end up null for a video with actual byte data, which would make the click silently do nothing. That's likely fine since TypeScript would catch any real compilation issue anyway. I'm leaning toward flagging the disabled-while-focused trigger in MessagesTimeline.tsx around line 1081, but I want to weigh whether this is too minor given this is already the fourth review pass and I don't want to pile on nitpicks after the author already addressed my previous feedback — still, it's a genuine, small, actionable accessibility issue worth raising. Let me think through the actual behavior when a focused button becomes disabled. I'm working through the divergence: for images, clicking a tile opens the dialog instantly while focus stays on the tile behind the overlay, so Escape and Tab still work sensibly, but for videos the focus gets lost during loading, which breaks that same pattern within the same grid. Given the instructions default to failure unless everything is clear, I'll post this as a single comment with a failure state. I'm locating the exact button markup around lines 1079-1086 so I can craft an inline suggestion with the correct indentation. Finishing the click handler to guard against reopening while already loading, then closing the button. This confirms line 1081 is an added line in the diff, so I'll use start_line 1081 through line 1085 for the multi-line comment and post it.
18. modify_pr — One accessibility finding on the new video play tile in the timeline. Everything else in the web UI scope (composer video tiles, expanded dialog video branch, preview builder) matches the sibling image-tile patterns.
Agent Credits: 177 credits
Token usage: 28 uncached input · 994,923 cache-read input · 116,679 cache-write input · 18,052 output
Agent Credits may also include non-token charges from external tools such as web research.