Skip to content

feat(web): cite assistant responses with inline citations

MacroscopeApp / Macroscope - UI Consistency failed Sep 2, 2026 in 5m 10s

UI Consistency: 3 issues found

Three consistency findings in the new citation UI:

  • apps/web/src/components/chat/AssistantCitationChip.tsx — the read-only chat variant uses the composer label class (select-none), unlike FileTagChipContent/SkillInlineText, so the quote label cannot be selected or copied from a sent message; contentEditable={false} is also only needed for the composer variant.
  • apps/web/src/components/chat/AssistantCitationCommentEditor.tsx — a raw <textarea> re-implements the shared Textarea primitive and loses its aria-invalid/focus/disabled contract; DiffCommentAnnotation shows the <Textarea unstyled> precedent for a borderless inline comment box.
  • apps/web/src/components/chat/AssistantSelectionToolbar.tsx — the "Cite in composer" button duplicates MenuItem geometry but drops cursor-pointer and select-none, giving the only control in the popup a default arrow cursor and a draggable label that can clobber the captured selection.

No issues found in the shared selectionActions extraction, the ThreadTerminalDrawer migration, the ::highlight() / @property additions in index.css, the ChatMarkdown sanitize-protocol change, or citation navigation (which resolves the target environment from the citation itself rather than the active environment).

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: AssistantCitationChip.tsx, AssistantCitationCommentEditor.tsx, AssistantCitationSource.tsx, AssistantSelectionToolbar.tsx, useAssistantCitationTarget.ts, ComposerCitationNode.tsx, ComposerPromptEditor.tsx, ChatMarkdown.tsx, ChatView.tsx, MessagesTimeline.tsx, ThreadTerminalDrawer.tsx, lib/selectionActions.ts, lib/assistantTextSelection.ts, lib/assistantCitationNavigation.ts, index.css.

Comparisons used: components/ui/menu.tsx (MenuItem geometry, cursor, select-none), components/ui/button.tsx, components/ui/textarea.tsx plus its unstyled consumer diffs/DiffCommentAnnotation.tsx, components/composerInlineChip.ts, and the existing chat chips (FileTagChip.tsx, SkillInlineText.tsx, ComposerPendingReviewComments.tsx, ComposerPendingElementContexts.tsx).

Checked and considered acceptable: the terminal selection helpers moving into the shared selectionActions observer (behavior differences are covered by updated tests), the pointer-preferred anchor in resolveSelectionActionPosition, the CSS Highlight API rules and registered --assistant-citation-highlight-opacity property staying in global CSS (browser integration, generated/imperative DOM), and the t3-citation protocol addition to the markdown sanitize schema.


🔍 Agent activity — 24 tool calls

💭 I'm scanning the diff for UI consistency issues, focusing on new citation-related components like the composer citation node, assistant citation chip, and citation comment editor files. I'm now examining AssistantSelectionToolbar.tsx more closely — it uses a raw button with a long class string that replicates menu item styling, and a div with role="toolbar", which raises the question of whether shared primitives like MenuItem or Button should be used instead. I need to check the existing codebase patterns before flagging this as a genuine inconsistency. The AssistantCitationChip's remove button also uses a raw button, but that matches other chips' conventions, so it's likely fine. The index.css changes add ::highlight() rules and an @property declaration — I want to verify these sit inside the right layer context, though they seem like reasonable global additions. ChatMarkdown's new t3-citation protocol for sanitizing schema looks fine, and the citation chip's routing to /$environmentId/$threadId appears properly scoped. I should pull up the actual files to verify these details against the real code.

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

2. browse_code — apps/web/src/components/composerInlineChip.ts

💭 I wonder if there's already a menu item primitive suited for a floating toolbar like this. Let me check the ui directory for menu.tsx or a similar selection toolbar pattern, maybe something the terminal uses with native context menus.

3. browse_code — apps/web/src/components/ui/*

4. git_tools — dropdown-glass

💭 I notice the AssistantSelectionToolbar builds its floating popup manually using dropdown-glass styling plus a raw button that recreates MenuItem's classes by hand, rather than reusing the component. That's different from contextMenuFallback.ts, which handles the terminal case with imperative DOM manipulation instead. I want to check menu.tsx's actual MenuItem class to see how they compare.

5. browse_code — apps/web/src/components/ui/menu.tsx

💭 I'm checking whether the AssistantSelectionToolbar's custom button truly replicates MenuItem's styling, and noticing it's missing cursor-pointer and select-none along with proper icon sizing conventions that the standard MenuItem includes. The design system's rules do flag exactly this pattern — large class strings on raw elements that duplicate a primitive's height, radius, padding, or cursor behavior — but I'm weighing whether this toolbar's raw button/div is actually justified since a real Base UI Menu would steal focus and destroy the text selection this feature depends on. Still, the missing cursor-pointer stands out as a small, concrete inconsistency versus every other menu item and button in the system, so I want to verify what the Button primitive does with cursor styling before calling this a finding.

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

💭 I'm noticing the codebase has consistent conventions like cursor-pointer and coarse-pointer hit targets on interactive elements. Comparing AssistantSelectionToolbar's button geometry to MenuItem, I see MenuItem is missing the coarse-pointer treatment and lacks cursor-pointer and select-none that this button has.

7. git_tools — COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME

8. browse_code — apps/web/src/components/ui/popover.tsx

💭 I notice the chip's remove button fills its whole box with the X icon at full size, unlike the repo convention of a smaller padded X used elsewhere for dismiss buttons—so this chip's icon sizing looks inconsistent with the rest of the codebase, and the pencil icon nearby uses yet another size value. It's a minor aesthetic issue, so I want to check how similar dismiss buttons are rendered in other chip components like the terminal-context node to see if this em-based sizing pattern is actually the norm before flagging it.

9. git_tools — size-full|size-[0.8|XIcon|X className

10. git_tools — COMPOSER_INLINE_CHIP_ICON_CLASS_NAME

💭 I'm noting that the chat rendering uses a read-only chip label class while the composer version adds select-none, and that AssistantCitationChip always uses the composer's class regardless of context. This raises a possible inconsistency: setting select-none plus contentEditable={false} on the chip inside sent messages could interfere with users selecting or copying the citation text, though contenteditable=false spans inside non-editable content typically still allow selection in Chrome, so I'm not certain this is actually a real bug.

11. git_tools — selectable

12. browse_code — apps/web/src/components/chat/FileTagChip.tsx

💭 I'm confirming that chat-rendered chips keep the label selectable since message text needs to stay copyable, whereas the new AssistantCitationChip in ChatMarkdown is read-only with select-none and contentEditable false—meaning text selection across a message with a citation chip will behave inconsistently compared to composer chips. Confirming this in ComposerPromptEditor's skill chip decorator settles that finding. Next I'm flagging the toolbar's cite button missing cursor-pointer, treating it as an interaction inconsistency rather than a full Menu-primitive migration demand, and briefly noting select-none could matter since selecting toolbar text would clear the source selection. Then I'm moving on to look at AssistantCitation next.

13. browse_code — apps/web/src/components/ui/textarea.tsx

14. browse_code — apps/web/src/components/chat/AssistantCitationCommentEditor.tsx

💭 I'm comparing the raw textarea approach against using the shared Textarea component with its unstyled prop, since the latter still adds padding and sizing even without visible borders or focus rings. The custom textarea also skips aria-invalid styling, relying purely on the error message text instead of a destructive border, which feels like a deliberate choice for this borderless inline editor. Let me check if there's precedent elsewhere in the repo for raw textareas inside popovers, like other inline comment composers.

15. git_tools — <textarea

16. git_tools — textarea

💭 I'm thinking about DiffCommentAnnotation.tsx as a close precedent since it's an inline comment editor using the shared Textarea component, customized through wrapper classes rather than unstyled variants. Let me check that file to see how it structures the inline comment box.

17. browse_code — apps/web/src/components/diffs/DiffCommentAnnotation.tsx

💭 I'm noting that the existing inline comment editors use the styled Textarea primitive, but this new AssistantCitationCommentEditor uses a raw textarea with a hand-rolled class string reimplementing that styling — losing aria-invalid handling and the disabled/focus contract. This looks like a clear violation of the rule against recreating Input/InputGroup manually. The fix should mirror DiffCommentAnnotation: use the shared Textarea with unstyled and class overrides, confirming refs still forward properly through mergeProps and that wrapping in FieldPrimitive.Control works fine standalone without a Field context. That's finding #2 confirmed. Now I'm looking at ThreadTerminalDrawer's changes, which replaced local position helpers with shared selectionActions logic — a good consistency move, but I want to check for behavioral regressions since the rules emphasize preserving behavior. I'm noticing that resolveSelectionActionPosition now prioritizes pointer position over selectionRect, whereas the old terminal-specific logic preferred selectionRect over pointer, which could be a subtle regression worth flagging. This priority flip is intentional, though, since the new tests explicitly document preferring the cursor release over the selection's bounding box, so it's not a finding. I'm also checking the clamping logic differences: the old code only clamped pointer-based positions to the drawer bounds while selectionRect was clamped only to the viewport, but the new shared function clamps both to drawer bounds consistently, which seems fine for the terminal's use case. I'm also confirming the removed pointer-tracking and mouseup handling in ThreadTerminalDrawer are properly replaced by dismissSelectionAction, then moving on to look at the index.css changes involving a new @property declaration for the assistant citation highlight opacity at the top level. I want to check where exactly the ::highlight(...) rules and this property declaration sit within the CSS layers, so I'm pulling up the surrounding lines to see if they're nested inside @layer base or some other variant block.

18. browse_code — apps/web/src/index.css

💭 I'm checking whether CSS placement in index.css is appropriate, and thinking through a subtlety: the custom property driving the highlight opacity is animated via WAAPI on the root element, but ::highlight() pseudo styles actually resolve against the originating element containing the highlighted text, so I need to verify that inheritance chain works correctly. Then I'm noticing the AssistantCitationChip's focus styling uses focus-visible:outline-2 focus-visible:outline-primary rather than the repo's more conventional focus-visible:ring-* pattern used elsewhere, like the dismiss button. It's a minor inconsistency worth checking against other components before deciding whether it's worth flagging.

19. git_tools — focus-visible:outline-2

20. git_tools — cursor-pointer

💭 I'm checking cursor-pointer consistency across interactive elements — the chip's Link relies on default anchor cursor behavior since browsers apply pointer to hrefs even without Tailwind's utility, while the PopoverTrigger button already gets cursor-pointer through its class name, so both are fine. The toolbar's cite button, though, is the one interactive control missing cursor-pointer, which stands out since buttons default to cursor: default per browser and Tailwind preflight doesn't override that — making it inconsistent with every other menu item and button in the app. I also confirm the disabled states and hit-target sizing (min-h-8/7) match the pattern used elsewhere, so those aren't issues. Now I want to look more closely at lines 60-120 of the AssistantCitationChip in chat mode to verify this holds there too. Checking that wrapper, I see the chip in chat mode uses CHAT_INLINE_CHIP_CLASS_NAME, which correctly omits select-none, but the label still applies COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME which includes select-none — creating a mismatch with how FileTagChip handles selectability. That means in chat mode the citation label text can't be selected even though the surrounding wrapper allows selection, so a user trying to select an assistant message containing a citation chip would skip over that label's text. I'm also noting that setting contentEditable={false} on this read-only chat chip is inconsistent with how FileTagChip and SkillInlineText handle chat-mode chips, since they don't set that attribute at all — combined with select-none, this could make the chip behave atomically during selection in unexpected ways across browsers. I think the right fix is to make the chat variant use the same selectable class pattern FileTagChip uses so message text stays selectable and copyable, while keeping contentEditable={false} restricted to the composer variant, since ComposerCitationNode's decorator already wraps its own span with that attribute separately. Checking ChatMarkdown's anchor renderer confirms it substitutes an AssistantCitationChip in place of the link entirely when a citation exists, so it's read-only there — no nested anchor issue since the chip fully replaces the <a>. Now I'm checking whether the markdown-copy attributes on both the wrapper and inner Link cause duplicate copying, but the test confirms it only copies once, so that's fine. I want to check whether wrapping ChatMarkdown in a plain div inside AssistantCitationSource could break the surrounding flex/grid layout that previously relied on ChatMarkdown being a direct child with min-w-0, so I'm looking at the MessagesTimeline code around that area.

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

💭 I'm checking whether wrapping ChatMarkdown in a plain div with padding classes could break its layout, but since the parent is a block element rather than flex, the wrapper should be safe—though I want to confirm ChatMarkdown's internal overflow handling won't be disrupted. Turning to the citation system, I trace how ChatMarkdown sanitizes the "t3-citation" protocol and how AssistantCitationChip uses navigation with an explicit environmentId from the citation itself, which looks correct for cross-environment routing. I'm now looking at how AssistantCitationSource in MessagesTimeline attaches the thread's environmentId as a data attribute to trace citation source anchors. I'm checking toast usage consistency across AssistantCitationSource and useAssistantCitationTarget, and noting the AssistantSelectionToolbar's positioning logic that overrides styles in a layout effect. The more pressing concern is a possible z-index conflict: the toolbar uses z-50 while popovers, menus, and dialogs use z-130 and higher, meaning an open comment popover could visually occlude the selection toolbar when both are active simultaneously.

22. git_tools — createPortal(

💭 I'm second-guessing whether z-50 is actually high enough for the selection toolbar, since the composer drawer sits at z-[70] and menus at z-[130]. The main composer isn't portal'd though, so its stacking context might not actually conflict with the toolbar near the bottom of the viewport. Actually, the toolbar's position gets clamped to the timeline element's bounds, so it can't overlap the composer in practice — that risk is speculative and I'll skip it. Now I'm settling on the real finding: in AssistantCitationChip's chat variant, the label reuses the composer's select-none class and keeps contentEditable={false} even for read-only chat rendering, unlike FileTagChip and SkillInlineText, which makes sent-message citation chips uncopyable. The fix is to pick the label class and contentEditable behavior based on whether onRemove is present (composer vs. chat), though I need to double-check whether the composer's own decorator wrapper already handles contentEditable so I'm not duplicating that logic. I'm also considering whether select-none on the chat chip actually breaks copy behavior — since the markdown-clipboard serializer relies on selection, a chip marked unselectable would likely get skipped when a user selects and copies message text, even though there's a test asserting the chip's citation content copies correctly. That test calls the serializer directly on a container rather than through a real browser selection, so it wouldn't catch this regression — this looks like a real, concrete bug rather than a speculative one. I want to pin down exact line numbers in the new AssistantCitationChip.tsx file so I can leave a precise inline review comment referencing the label span and its contentEditable attribute. I'll also look at whether AssistantCitationCommentEditor is duplicating the shared Textarea primitive instead of reusing it. For the citation editor, I'm noting it's a raw textarea recreating what Textarea unstyled already handles elsewhere in the repo, and that aria-invalid is set but produces no visible invalid styling without the primitive — worth flagging as a should-use-shared-component issue. Now I'm also checking the AssistantSelectionToolbar cite button for missing cursor-pointer and select-none classes that duplicate MenuItem's expected interactive styling. I want to keep the fix suggestion minimal — just add the cursor/select-none classes, maybe note aligning with Button/MenuItem styling as optional — and now I'm verifying the exact line numbers in the new files before writing this up. Since these are new files, the diff's right-side numbers match the file's own line numbers, so I've pinned AssistantSelectionToolbar.tsx line 147 as the className string in question. Now checking the corresponding lines in the chip file.

23. browse_code — apps/web/src/components/chat/AssistantCitationChip.tsx

💭 I'm drafting a review comment suggesting the label span use a conditional class name based on whether onRemove is set, applying a max-width constraint, though this would require importing the appropriate chip label class. Since this involves changes at two separate locations—the import and the span itself—I'll skip providing a formal diff and just describe the fix in prose instead. I'm also considering whether to fold in the contentEditable={false} concern from an earlier line into the same comment, since both relate to the read-only chat variant, but decide it's cleaner to anchor one comment there separately. Reconsidering the contentEditable issue further—since the chip's wrapper already handles the non-editable boundary via the Lexical decorator node in the composer context, keeping it on the chip is harmless there, and in chat it likely has no real effect since it's not inside editable content. So I'll narrow my comment to focus on the select-none behavior, mentioning contentEditable only as a secondary, optional note. Then I move on to the next comment target in AssistantCitationCommentEditor. For the third comment, on AssistantSelectionToolbar, I'm proposing a small inline fix to add cursor-pointer and select-none classes to the button's className, keeping the change self-contained to that one line with proper indentation preserved. Given these findings represent a real consistency deviation, this check should default to failure state with a title noting three issues found. Double-checking the pattern: in chat mode FileTagChip doesn't apply select-none, while composer mode does via FILE_TAG_CHIP_CLASS_NAME — confirming this new chip genuinely breaks from the established pattern, so I want to verify CHAT_INLINE_CHIP_LABEL_CLASS_NAME actually exists to be sure this holds up. Checking imports in the chip file — only CHAT_INLINE_CHIP_CLASS_NAME, COMPOSER_INLINE_CHIP_CLASS_NAME, DISMISS, ICON, and LABEL are pulled in, so the chat label class isn't imported at all, confirming the gap. Now I'll write up the review.

24. modify_pr — Submitted review with 3 inline comments


Agent Credits: 353 credits

Token usage: 38 uncached input · 3,185,201 cache-read input · 209,589 cache-write input · 18,221 output
Agent Credits may also include non-token charges from external tools such as web research.