Skip to content

fix(web): honest block reasons and safer stash and annotation edges

MacroscopeApp / Macroscope - UI Consistency failed Aug 26, 2026 in 11m 36s

UI Consistency: 1 issue found

apps/web/src/components/chat/ChatComposer.tsx — the composer's needs-reattach file row renders the label "Attach again" unconditionally, while the attach trigger (paperclip) only renders when maxFileAttachmentBytes !== null and the send-block reason deliberately switches to "Remove the interrupted file to send" in that same state via canReattachFiles. On a server without file-attachment support, and on every reload until the environment config arrives, the row instructs an action the UI provides no control for and contradicts the send button's own reason. Suggested fix (posted inline): reuse canReattachFiles for the row label.

No other consistency or regression issues found in the in-scope changes.

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/chat/ChatComposer.tsx, apps/web/src/components/chat/MessagesTimeline.tsx, apps/web/src/components/chat/ComposerStashMenu.tsx, apps/web/src/components/chat/composerAttachmentFiles.ts, apps/web/src/components/ChatView.tsx, apps/web/src/components/Sidebar.tsx, apps/web/src/components/LegacySidebar.tsx, apps/web/src/components/settings/ProjectSettingsPanel.tsx, apps/web/src/composerDraftStore.ts, apps/web/src/hooks/useHandleNewThread.ts, apps/web/src/lib/* attachment modules, apps/web/src/types.ts.

Checks performed and cleared:

  • Shared primitives: new attach trigger, retry, and remove controls use Button (variant="ghost", size="icon-sm" / icon-xs) inside the repo's Tooltip/TooltipTrigger render={...} pattern, matching the existing image-tile controls. ghost and ghost-muted both route --control-icon-color to var(--contrast-muted-foreground), so the paperclip icon tone matches adjacent footer controls. Button defaults type="button", so the new in-form controls cannot submit the composer.
  • Raw elements in MessagesTimeline: the <a> / <button> / <div> attachment rows implement a semantic row, which is an allowed exception; all three branches now share text-left/start alignment and there is no global outline reset, so the UA focus ring remains.
  • Gating consistency: the composer file list and the footer (hence the paperclip) are both suppressed for collapsed-mobile, approval state, and pending user inputs, so no attach affordance appears while its rows are hidden.
  • Environment routing: downloadFileAttachment resolves readPreparedConnection/createUrl from the route-scoped environmentId prop, not the globally active environment; MessagesTimeline's new onFileDownload prop defaults to a no-op (no active-environment fallback) and the only production call site passes it explicitly.
  • Tailwind/CSS ownership: all new presentation is call-site static Tailwind; no global CSS, theme variable, or selector changes were introduced, so emitted-CSS and theme-variant gates do not apply.
  • Toast usage: type: "warning" in useHandleNewThread is a supported toast type.
  • Stash menu file badge follows the existing count/indicator markup and spacing conventions.

🔍 Agent activity — 28 tool calls

💭 I want to check the current state of the code at head d8a024f, particularly the new UI in ChatComposer.tsx and MessagesTimeline.tsx, to see what's been fixed since prior runs and whether any issues remain or new ones have appeared.

1. git_tools — userFiles.map|unknownAttachments.map|aria-label={`Download

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

💭 The text-left fix on the button looks good now. I'm weighing whether the raw button element is actually a finding worth flagging, since it seems to intentionally implement a semantic row rather than misuse a primitive—though I notice neither the button nor the anchor has a focus-visible ring, which is a minor gap. Now I want to check ChatComposer next.

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

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

💭 I'm checking the paperclip button and comparing its icon sizing against the other toolbar buttons for consistency.

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

💭 I'm noting that the paperclip button is new since attachments were previously drag/paste only, and I'm checking whether it's conditionally hidden when the composer collapses on mobile or during the approval state. The file-list rows only render when the composer isn't collapsed, isn't in approval state, and there are no pending inputs -- but the paperclip itself still appears during approval state, meaning users could attach files that then get silently hidden with no visible confirmation. That looks like a plausible UI inconsistency, though images have a similar underlying hazard via paste. I want to trace isComposerApprovalState usage around the footer to confirm whether the right actions group actually renders in that state.

6. git_tools — isComposerApprovalState

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

💭 I notice the paperclip's gating differs from the file row/image preview gating, which only checks pendingUserInputs.length while the others also check approval state and collapsed mobile. I want to trace whether the footer itself is conditionally rendered during approval state or collapsed mobile, so I'm looking for where that footer block's opening condition is defined.

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

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

💭 Good, the footer gating checks out consistently since approval and collapsed mobile states both skip rendering the paperclip along with the rest of the footer. Now I want to check how the paperclip button sizing compares to the other icon actions in the right group, like the inline tasks badge and stash badge, to make sure they're visually consistent.

10. git_tools — inlineStashBadge|inlineTasksBadge

💭 The grep stopped because of a binary-file warning, likely a stray byte unrelated to the escaped null character in the source. I'll switch to searching for where inlineStashBadge is defined using a more targeted code search instead.

11. browse_code — In apps/web/src/components/chat/ChatComposer.tsx (branch t3code/file-attachments-web, commit d8a024f), show the definitions of inlineStashBadge and inlineTasksBadge including the Button variant/size and classNames used, and their line numbers. Also show any other icon-only Button in the composer footer right-side actions group with its size prop.

💭 I'm checking whether this render pattern for the Tooltip/TooltipTrigger matches existing usage elsewhere in the file, and it does. Now I want to verify the paperclip icon button's size against the convention used in ComposerFooterPrimaryActions, since other footer buttons use size="sm" while this one uses icon-sm. Separately, I'm considering the MessagesTimeline file rows with raw anchor/button elements—these are semantic and allowed, but I'm wondering about focus-visible styling on the download button, since it only has hover:underline. I should check the global CSS to see if there's a rule stripping default focus outlines that would make keyboard navigation invisible.

12. git_tools — outline

💭 The remove/retry buttons in the composer file row use the same ghost icon-xs styling as image tiles, which is consistent. I want to trace where FileIcon and DownloadIcon are imported from in MessagesTimeline—likely a local icons module re-exporting lucide icons—so I'll check that import path. I'm comparing the paperclip button's placement against other footer icon controls, checking its size and variant. The paperclip uses plain ghost variant giving full foreground-tone icon color, while neighboring footer controls like the model picker and stash/tasks badges use muted styling — this inconsistency looks like a genuine finding worth flagging.

13. git_tools — ghost-muted|control-icon-color

14. browse_code — apps/web/src/components/chat/ComposerFooter*.tsx

💭 Good, both ghost variants share the same muted icon color, so the paperclip icon stays consistent with the others. Now I want to check its size against the primary send button in the footer actions.

15. browse_code — In apps/web/src/components/chat (commit d8a024f), show the Button size/variant props used in ComposerPrimaryActions.tsx (send/stop button and any secondary icon buttons like compact-context), and list the available Button size variants defined in apps/web/src/components/ui/button.tsx with their heights.

💭 I'm comparing icon sizes between the paperclip and send button, noting the coarse-pointer minimum tap target handling, then turning to check where the paperclip sits within the right actions group. Now I'm wondering whether the hidden file input follows an existing shared pattern in the codebase, so I should search for other type="file" usages in apps/web to confirm consistency.

16. git_tools — type="file"

💭 I'm comparing the hidden-input approaches: ThemeImportDialog uses sr-only to keep the file input accessible to screen readers and keyboard, while the new one uses hidden, which removes it from the accessibility tree entirely since the button's aria-label covers that role instead. Both patterns are common, so it's a legitimate design choice either way. Now I'm shifting to check ComposerStashMenu's new file count span for consistency, comparing it against how the existing image count indicator is styled with its icon, so I'll look at the surrounding markup around lines 170-200.

17. browse_code — apps/web/src/components/chat/ComposerStashMenu.tsx

💭 I'm noticing the font sizes are slightly inconsistent between the file count span, the saving/dropped counters, and the timestamp, though that's a minor styling detail rather than a functional issue. I'm also checking the non-null assertion on file length and the attachment progress/size formatting, but nothing stands out as a real problem there. Now I want to look deeper for an actual UI-consistency issue worth flagging. Let me check whether the toast manager actually supports the "warning" type being used in the new thread handler.

18. git_tools — warning|type:

💭 I'm checking that the toast message grammar handles singular vs plural attachment counts correctly, and confirming the import placement in useHandleNewThread.ts doesn't cause lint ordering issues. Now I want to look at Sidebar.tsx line 486, where composer.files.length gets added to a count, to confirm it's tallying attachments for a badge display.

19. browse_code — apps/web/src/components/Sidebar.tsx

💭 I should check if LegacySidebar has a similar draft row with the same attachment count issue that wasn't updated with files, so let me look at the parallel code there.

20. git_tools — attachmentCount

21. git_tools — attachment${|attachment$

💭 I'm checking other consumers of the composer state helpers to see if the same environment-scoping rules apply. ChatView's file download logic correctly resolves environment from its own thread-scoped ID, but I want to verify MessagesTimeline handles this the same way. Its onFileDownload prop defaults to a no-op rather than falling back to an active environment, which is safe per the rules. Still, that raises a UI concern — if a caller omits onFileDownload, the download button renders fully interactive-looking but silently does nothing when clicked, creating a dead control. I want to find other call sites of MessagesTimeline to check whether this actually happens in practice.

22. git_tools — <MessagesTimeline

💭 The only production call site is ChatView, which does supply onFileDownload, so the noop default only surfaces in tests — meaning there isn't really a concrete finding here, just an inert fallback that isn't exercised in practice. Now I want to verify whether environmentId in ChatView's download logic reliably reflects the thread's own environment rather than some mismatched one, since ChatView is presumably thread-scoped but I should confirm how that value gets derived in ChatViewContent.

23. git_tools — const environmentId =

24. browse_code — In apps/web/src/components/ChatView.tsx at commit d8a024f: how is the environmentId variable used by downloadFileAttachment (around line 2514) derived — is it the active thread's environmentId or the globally active environment? Show the declaration lines.

💭 I'm noting that the attachment id collection now only adds image attachments, meaning file attachments no longer get preview URLs minted in bulk and rely on download-on-demand instead, which matches the timeline's conditional link/button logic. Optimistic file attachments also get downloadable: false, so they render as inert rows without a download icon or retry option, which is fine since it's short-lived. I'm also weighing whether the file rows are missing indicators the image previews have, checking the layout styling against other composer context strips, and considering whether overlapping touch targets on the retry/remove buttons in the compact row could be an issue — though this mirrors existing image row behavior. The remove and retry buttons sit close together with only 8px of gap between them, and rows are stacked with just 4px between them at a 28px row height, so on coarse-pointer devices their expanded 44x44px hit areas could overlap both horizontally and into neighboring rows. This raises a real concern that a tap meant for one row's remove button could accidentally trigger the button on an adjacent row, since the touch target expansion uses an absolutely positioned pseudo-element with no explicit inset to keep it centered within its own row bounds. Since row2's button sits on top there, actual mis-taps seem limited, and this coarse-pointer sizing matches the repo-wide pattern for icon-xs buttons in dense lists like the stash menu, so this isn't a new violation worth flagging. Let me look for something more concrete instead. I want to double check the block-reason logic from the prior run now handles the capability-unknown case correctly — it looks like that was addressed. Now I'm tracing how the attachment upload capability and max file size get derived in ChatView from the environment config's capabilities. If the config is null, the max attachment size comes back null, which just hides the paperclip until connected — that's fine. But there's a subtler bug I'm spotting: paperclip visibility depends only on whether a max byte size exists, not on whether the server actually supports attachment uploads. So if a server exposes a max upload size but has uploads disabled, the paperclip still shows, the user attaches a file, nothing uploads, and the send button locks with a rejection message — a dead-end affordance where the UI invites an action it can't actually complete. Let me check whether these two capabilities are actually defined as independent flags in the server capability schema, since that would confirm this scenario is possible.

25. browse_code — In packages/contracts (commit d8a024f), show the definition of the environment capabilities object fields attachmentUploads and fileAttachments (including maxUploadBytes), and show where the server sets them (apps/server or packages) — can a server advertise fileAttachments while attachmentUploads is false/absent?

💭 That attachment limit check is logic-related, not a UI-consistency issue, so I'll set it aside. The composer file rows styling seems fine too, so I'm moving on to check formatting logic elsewhere. Actually, I notice something more concerning: the file rows' remove/retry Buttons lack an explicit type="button", unlike the paperclip Button. Since these live inside a <form>, if the Button primitive defaults to type="submit", clicking Remove or Retry could accidentally submit the composer and send the message—I need to verify what the Button primitive's default type actually is.

26. browse_code — apps/web/src/components/ui/button.tsx

💭 I'm checking that buttons default to type="button" when no render prop is passed, so the remove/retry buttons are safe. Now I'm working through the trickier case where TooltipTrigger render={<Button/>} clones the Button with trigger props instead of the other way around, tracing how that prop composition actually flows. I've confirmed there's no issue there and the paperclip code already sets type="button" explicitly. Now I want to check the remaining smaller changed files, and I'm looking at the composer row to see whether "Attach again" is just static text or an actual actionable button, since that affects discoverability of the re-attach flow. Actually, this exposes a real inconsistency introduced by the same change: the row label is unconditional on canReattachFiles, so when there's no paperclip, the send-blocked message says "Remove the interrupted file to send" while the row itself still claims "Attach again" — contradicting itself with no way to act. This state is reachable whenever attachment capability is unknown or absent, like on initial connect or reconnecting to a server without file-attachment support, since maxFileAttachmentBytes is null in both cases and hides the paperclip while the stale label persists. The fix is to reuse canReattachFiles in the row label so it reads "Remove to send" instead of "Attach again" when reattachment isn't possible, matching the guard already used elsewhere. I'm confirming that variable is in scope at this point in the component before writing up the suggested code change for those lines. Checking indentation levels... Adding the nested ternary for reattach permissions... I also need to figure out the right line numbers in the head file for the review comment, since GitHub's diff line references need to match actual file positions. Looking at the timeline component, I check the download link's accessibility and icon styling — those seem fine as-is. I'm also verifying the composer's file row behavior when an upload fails without needing reattachment, where the retry button should appear alongside the size indicator.

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

💭 I want to verify this row is actually reachable when maxFileAttachmentBytes is null and there's a file needing reattachment. Tracing the reload path: the draft persists a file marker, and on mount, since the attachment config hasn't arrived yet, the capability limit stays null, hiding the paperclip while still rendering the file row—so the "Attach again" state shows up with no way to attach. This confirms the bug is reachable, so I'll report this one finding with moderate but concrete confidence. Since supportsAttachmentUploads is false while capability is unknown, the send button falls into the else branch and shows "Waiting for the server..." rather than the reattach-specific message. So during initial connect, the row displays "Attach again" while the tooltip says something about waiting for the server. After connecting to a server that supports image uploads but has no file byte limit, the tooltip instead becomes "Remove the interrupted file to send" while the row still just says "Attach again" — a genuine mismatch between the two UI elements. That's the contradiction I want to flag in the review, and given the check defaults to failure and I have a real (if minor) finding, I should report it as a failure rather than "All clear."

28. modify_pr — One finding: the needs-reattach file row label ignores the canReattachFiles guard that the send-block reason uses, so on a server without file-attachment support (or before the capability arrives) the row tells the user to attach again while no attach control is rendered.


Agent Credits: 699 credits