Skip to content

fix(web): stabilize attachment recall lifecycle

MacroscopeApp / Macroscope - UI Consistency failed Aug 23, 2026 in 6m 16s

UI Consistency: 1 issue found

apps/web/src/components/chat/ChatComposer.tsx — The new image-attachment collapse wrapper (data-chat-composer-image-attachments) derives both its row classes and its children from visibleComposerImages.length, so on removal of the last attachment the thumbnails unmount in the same render that flips to grid-rows-[0fr]. The grid-template-rows exit transition therefore never runs, while the margin-bottom transition keeps animating for 200ms after the content is gone, producing a staged shrink (thumbnails pop out, then a 12px gap closes). Suggested smallest fix: move the bottom spacing (mb-3) inside the clipped inner content and drop margin-bottom from the transition list, or keep the last thumbnails mounted and inert during the collapse as PullRequestDetailPanel does.

An inline review comment with a suggested diff has been posted.

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/components/chat/ChatComposer.tsx, apps/web/src/components/ComposerPromptEditor.tsx, apps/web/src/components/ChatView.tsx, apps/web/src/components/chat/composerPromptHistory.ts(.test.ts), apps/web/src/types.ts.

Checked and found consistent:

  • Shared primitives: the attachment remove control still uses Button variant="ghost" size="icon-xs", the non-persisted badge still uses Tooltip/TooltipPopup, and the raw <button> around the thumbnail remains an intentional image target rather than a reconstructed primitive.
  • Collapse pattern matches existing repo usage (grid + grid-rows-[0fr]/[1fr] + min-h-0 overflow-hidden, motion-reduce:transition-none) as in ComposerBannerStack, PullRequestDetailPanel, and DesktopNotificationsSettings; durations align with surrounding composer transitions.
  • Always-mounted collapsed wrapper is harmless for layout: the parent is a padded block container with no flex gap, and the collapsed row has no focusable content, so no inert/aria-hidden gap is introduced.
  • New overflow-hidden wrapper does not introduce clipping regressions: absolute badges/remove button sit inside the already-clipped 64px thumbnail, and tooltip popups are portaled.
  • Keyboard handling: history navigation is gated on menu state, modifiers, IME composition, pending approvals/inputs, and context/attachment emptiness; the caret-edge check delegates to the editor handle only when already inside history. The oldest-entry ArrowUp clamp flagged in the prior run now returns null (covered by composerPromptHistory.test.ts), leaving native caret behavior intact.
  • Blob lifecycle: recalled attachments are materialized into fresh object URLs and prior recall images are revoked by clearComposerPromptAndImages in composerDraftStore, and stale async applies revoke their own URLs via the generation guard.
  • Tests: pure prompt-history logic is covered by a new focused test file, matching the repo's existing composer test conventions (no ChatComposer component harness exists).
  • No CSS/theme/scroll-mask ownership changes in this PR; index.css untouched.

🔍 Agent activity — 14 tool calls

💭 I'm examining the ChatComposer.tsx diff, where the image attachments container now uses a grid-based transition animation for showing and hiding pending images with smooth height and opacity changes. I'm checking whether there's an accessibility issue with focusable elements remaining tabbable while visually hidden during the collapse, but since the images unmount when the list is empty, that shouldn't be a real problem. Though I'm noticing the exit animation may look abrupt since the children disappear from the DOM in the same render as the collapse starts, rather than shrinking gradually alongside the visible content. I want to check whether overflow-hidden on the inner wrapper could clip the focus ring or remove buttons on image thumbnails if they're positioned outside the container bounds, so I should look at the actual chip rendering code around where composer images are displayed. I'm also wondering if always rendering the wrapper (even when empty) could introduce a stray flex item that affects spacing between elements in the parent container. Separately, I want to verify keyboard edge-detection behavior in the prompt editor and confirm the previously flagged history-navigation clamping issue is actually resolved, since a test now expects null at the oldest recalled prompt.

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

2. git_tools — apps/web/src/components/chat/ChatComposer.tsx 30be311..44d9ae7

💭 I'm realizing the collapse animation might actually be a no-op since the content disappears in the same render as the height transition starts, so there's nothing visibly shrinking—same issue potentially applies in reverse for the enter animation when an image gets added. Since the 1fr resolves against content that's simultaneously removed, the exit animation really is dead in practice — confirming this is a genuine but minor issue, since mb-3 to mb-0 also transitions. This feels like a real but small finding, distinct from the history navigation clamp issue I already flagged as fixed. I'm now checking whether the overflow-hidden on the inner wrapper interacts badly with the remove button overlay or tooltip positioning, since tooltips are likely portaled elsewhere. I want to check whether the parent container introduces a flex gap that would add unwanted spacing even at zero height, so I should look upward at the surrounding structure. I also notice an unused test data-attribute that isn't referenced elsewhere, though that's minor. Next I want to look at the keyboard handling logic in ChatComposer. When history navigation returns null, it just falls through, so that's fine, and keyboard handling is within scope here since the prior run flagged something similar. I'm looking closer at isCaretOnVisualEdge's logic for detecting whether the caret sits on the first or last visual line by checking for newlines before/after the cursor, but I notice a potential edge case where an empty snapshot value always returns true regardless of which edge is being checked. Now I'm checking whether the raw <button> used for the image preview is pre-existing and intentional rather than something to flag, and confirming the remove button already uses the shared Button primitive. I'm also looking at the parent element's spacing and whether the always-mounted collapsed div could disrupt the layout.

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

4. git_tools — grid-rows-[0fr]

💭 This grid collapse pattern already exists elsewhere in the repo, so it's consistent. I'm checking whether ComposerBannerStack and DesktopNotificationsSettings keep their children mounted during the exit animation—they do, unlike our case where children unmount when the count hits zero, meaning the collapse animation might not fully play out. That's a subtle behavioral gap worth noting but not a hard failure, since with 4+ call sites now using this pattern, there's a case for a shared collapse primitive—though the existing repo already duplicates this, so I won't flag a fifth instance as a new problem. I want to look at ComposerBannerStack directly to confirm whether any shared collapse component already exists.

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

💭 I'm checking whether the always-mounted wrapper causes layout issues, but since the parent is a plain block div without flex gap, the collapsed zero-height div should be harmless. Now I'm looking at the animation asymmetry when transitioning between empty and populated states. On adding an image, the grid-template-rows transitions smoothly from 0fr to 1fr as content mounts, giving a proper height animation. But on removal, the children unmount instantly while the margin still transitions over 200ms, creating a visual glitch where images vanish immediately but a small margin collapse lingers afterward — arguably worse than the previous fully-instant unmount behavior, though it's a minor inconsistency worth flagging. I'm also checking whether clearing and re-adding images (as happens when replacing composer prompt history) could cause a flash between renders, but React 18's auto-batching should keep these store updates synchronized. Beyond that, I'm scanning for other issues within scope: the motion-reduce fallback looks correct, the image filtering logic matches prior behavior, and now I'm looking at the arrow-key navigation handling next. Good, that flagged test case now passes correctly. Checking isCaretOnVisualEdge in ComposerPromptEditor, it seems reasonable, though the edge-element lookup for "end" could be slightly off if a decorator block sits last — minor concern. I'm now looking at whether toastManager.add follows the standard pattern used elsewhere in the codebase. I want to check whether the data-chat-composer-image-attachments attribute is actually consumed anywhere, since a grep shows it unused — I should check if similar data-chat-composer-* attributes are typically load-bearing for CSS or selectors, and search index.css for any dependency on composer row heights or scroll behavior.

6. git_tools — data-chat-composer-

7. git_tools — toastManager.add

💭 I'm now checking whether blob URLs created by materialize get properly revoked, since that could be a memory leak when leaving composer history or replacing images without cleaning up the old object URLs. Since entering history requires composerImages to be empty, draft attachments during navigation are always empty — so the real question is whether clearComposerDraftPromptAndImages revokes object URLs when cycling backward through multiple history entries with attached images.

8. browse_code — In apps/web/src, where is clearComposerDraftPromptAndImages defined (composer draft store)? Does it revoke image previewUrl object URLs when clearing/removing composer draft images? Show the implementation and any URL.revokeObjectURL handling for composer draft images.

💭 I'm tracing how the clear-draft wrapper handles blob URLs, and noting that recalled attachments keep their file reference so recalling again can regenerate a fresh blob rather than reusing a stale server or optimistic URL. The materialized attachment always gets a distinct URL from the timeline message's own URL, so revoking on clear shouldn't ever touch a URL still in use elsewhere -- the two are always separate objects. Now I'm considering whether attaching a raw File object to the ChatImageAttachment type could break serialization if messages are persisted or sent over IPC/structured clone, since File objects aren't JSON-serializable. I want to check whether optimistic messages with this new file field ever get persisted somewhere that would choke on a non-serializable value. Given the check's scope is UI consistency, though, I should focus my findings there. The strongest candidate is that the collapse animation doesn't actually animate the image content disappearing since children unmount simultaneously — only the margin transitions from 12px to 0, so images vanish instantly while just the spacing animates away, unlike other components where content stays mounted during the transition. The grid-template-rows trick from 0fr to 1fr actually does animate expansion since browsers interpolate fr values, but collapse never gets that treatment since content unmounts first — so the animation is asymmetric, working on expand but not collapse. That's finding #1: worth flagging as a potential inconsistency, though I'm not sure it's severe enough to fail review on its own, so I want to check for a stronger issue with the opacity transition during expand. Now I'm shifting focus to the caret edge detection logic in ComposerPromptEditor, thinking through what happens when a soft-wrapped single-line prompt has no newline and the user presses ArrowUp — checking whether the caret rect comparison against the first line's rect correctly identifies edge position when the caret sits on a wrapped second line. Then verifying the multi-paragraph case with actual newlines behaves correctly, and confirming the same logic mirrors for the "end" edge with afterCaret. Now I'm moving to check the ChatComposer keyboard gating around whether the composer menu is open. I'm wondering whether a separate mention/skill autocomplete interacts differently with this gating than the composer menu — likely treated the same way. Then I check that pending-progress state is already handled elsewhere. I confirm that returning true from the history-nav check triggers preventDefault/stopPropagation as expected. But I notice a risk: while promptHistoryApplyPendingRef.current is true, arrow key presses are swallowed entirely, and if the async materialization step stalls — say due to a slow attachment download — the arrow keys would remain unresponsive for that whole duration. I decide the collapse animation asymmetry is still the strongest finding, since other candidates like the image thumbnail button, the opacity/grid-rows behavior when collapsed, and the missing live region for accessibility all seem pre-existing or out of scope. I check DesktopNotificationsSettings to see if it keeps content always mounted, which confirms the pattern I suspected. I'm still weighing whether this collapse finding is significant enough to fail the check, given it feels moderate at best, and I want to be more confident before reporting it — so I'm reconsidering the actual expand/collapse scenario when navigating between prompts with and without images. Now I'm checking whether the store updates during recall get batched by React, since separate re-renders could cause a visible collapse-then-expand flicker, though it's likely batched under React 18. I'm also verifying that overflow-hidden on the expanded row doesn't clip anything important, like the tooltip popup (which is portaled, so it's fine) or the focus ring on the remove button. Since the thumbnail already clips its children, the child's outline being clipped too isn't a new regression—no outline exists on the thumbnail div itself, so this is fine. So the main finding remains the asymmetric collapse animation, possibly alongside the margin transition creating a two-stage layout shift during collapse. Now I want to check whether adding the new required promptHistoryEntries prop to ChatComposer breaks other call sites that construct it, so I'm searching for other usages that would need updating.

9. git_tools — ChatComposer$|<ChatComposer

10. browse_code — apps/web/src/components/settings/DesktopNotificationsSettings.tsx

💭 Only one call site, so that's fine. I'm checking whether the composer image wrapper is properly guarded against always rendering, and considering whether the mobile collapse behavior for the image row has changed compared to before, when it was hidden entirely with no images. Beyond that, mounting behavior on initial load with existing images seems fine—no unwanted animation there. What stands out more is that this PR adds significant keyboard handling (arrow key history navigation) plus a new isCaretOnVisualEdge editor method, and while there's a pure logic test for composer prompt history, I don't see integration tests covering the keyboard handling in ChatComposer itself. I want to check whether the repo already has an established pattern for testing composer key handling, since that would make the missing coverage here a stronger finding.

11. browse_code — apps/web/src/components/chat/.test.

12. git_tools — isCaretOnVisualEdge

💭 Since there's no ChatComposer component test file, expecting a keyboard integration test would be unreasonable given the repo's pattern of pure-logic tests, so that's not a real finding. I want to double-check my collapse-animation claim by looking at how PullRequestDetailPanel handles similar content mounting behavior.

13. browse_code — apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx

💭 I notice the repo pattern keeps content mounted and uses inert for the collapsed state, but the new composer wrapper unmounts its children in the same render as the collapse — meaning the grid-rows exit transition can't actually run, so only margin-bottom ends up animating. The fix is to keep the row content mounted while collapsed, matching the established pattern from PullRequestDetailPanel and similar components, rather than driving unmount off the same state. Alternatively, moving the margin into the inner overflow-hidden container so it's part of the animated height would let the collapse happen as one smooth motion instead of two visible stages. Tracing through the actual visual sequence confirms it: the thumbnail row vanishes instantly when content unmounts, but the margin-bottom keeps animating for 200ms afterward, leaving a visible staged collapse. That's a real, if minor, behavioral defect from the changed lines — worth flagging as a single finding rather than dismissing as cosmetic, since the whole point of the transition code was to animate this row smoothly. Checking whether the opacity-0 collapse leaves any lingering ghost during removal — it doesn't, since content is fully removed. I'll leave an inline comment on the wrapper lines around 3380-3389 in the head file, pinpointing the collapsed class line and offering a suggested diff for those lines. Writing suggested diff... Checking indentation levels and confirming these lines fall within the diff hunk range. Since exact indentation is hard to verify character-by-character, I'll switch to a unified diff format for this hunk instead of an inline suggestion, since it spans a single contiguous block from lines 3383-3390. Checking indentation consistency... Now I'm confirming the fix by removing the margin-bottom transition property from that grid class, keeping only the template-rows and opacity transitions. I'm considering keyboard arrow behavior during prompt recall when attachments need a network fetch — that seems like a transient edge case I'll skip. Checking a couple other spots (recalled images marked non-persisted, visibleComposerImages vs composerImages usage) but those look fine and out of scope, so I'll settle on just the one finding to report. Since the spec defaults to failure when there's a finding, and "all clear" only applies with none, I'll mark this as failure and go ahead and post the review comment.

14. modify_pr — One finding on the new composer image-attachment collapse wrapper. Everything else in scope (Button/Tooltip primitive usage, thumbnail geometry, prompt-history keyboard gating and editor handle) looks consistent with the existing patterns.


Agent Credits: 222 credits