feat(web): port ConversationActionsMenu from platform (LUM-1661)#31248
Conversation
Port the ConversationActionsMenu component from vellum-assistant-platform. This is the dropdown/context-menu/bottom-sheet menu for conversation rows in the sidebar, rendering Pin, Rename, Archive, Mark as unread, Move to Group, Analyze, and other actions. Dual-surface rendering: - Desktop: Radix dropdown menu (Menu.Root) via renderConversationMenuItems() - Mobile: BottomSheet with PanelItem rows via renderConversationMenuItemsAsPanelItems() The shared renderConversationMenuItems helper accepts a ConversationMenuPrimitive type param so both the dropdown trigger and right-click context menu (AssistantSideMenu, future LUM-1663) render identical item sets. Closes LUM-1661 Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
✦ APPROVE
Value: Gets the conversation row action menu into the OSS repo — the last piece blocking the sidebar assembly (LUM-1662 + LUM-1663 can't land until this is in.
What this does: Ports ConversationActionsMenu as two new files (~730 + ~282 lines). The component splits on useIsMobile(): desktop renders a Radix Menu.Root dropdown; mobile renders a BottomSheet with PanelItem rows. Both surfaces consume a shared renderConversationMenuItems pure helper, so item definitions live exactly once and context-menu re-use (LUM-1663) gets them for free via the ConversationMenuPrimitive type param.
Architecture — clean:
renderConversationMenuItemsis a pure function (no hooks, no side effects) — straightforward to test withrenderToStaticMarkupand easy to reuse from the right-click context menu. TheConversationMenuPrimitiveabstraction is the right move;MenuandContextMenushare the same compound-component shape, and this makes that structural fact explicit.buildPanelItemhelper in the mobile path keeps the bottom-sheet items DRY and therun → onClose()ordering correct (action fires before sheet dismisses, preventing UI from appearing under a still-open sheet).variantprop cleanly handles header vs. sidebar ordering differences without branching on call sites. Theheadervariant's item order (Copy → Fork → Analyze → Open → Refresh → Pin → Rename → Archive → Inspect) matches the expected macOS-parity sequence.- Read-only gating (hiding
Analyze/Mark as unread, keepingArchive) is well-commented and consistently applied across both dropdown and panel renderers.
Tests — adequate:
13 tests cover the critical paths: Pin/Unpin toggle, Archive/Unarchive flip, read-only gating, Move to Group submenu, header variant ordering, desktop + mobile branch rendering, and the read-only Archive-stays-visible case. The renderToStaticMarkup approach is the right call given the workspace's lack of jsdom.
Non-blocking notes:
-
MobileMenuDivideruses inlinestyle={{background: "var(--border-overlay)"}}— inconsistent with the rest of the file which usesbg-[var(--surface-hover)]Tailwind arbitrary notation. Should beclassName="bg-[var(--border-overlay)]". Minor. -
Default trigger is
<span role="button">not<button>— keyboard handling (Enter/Space) is manually wired, and RadixasChildhandles expanded state, so this is functionally fine. A native<button type="button">would be semantically cleaner and avoid the(event.currentTarget as HTMLElement).click()cast in theonKeyDownhandler. Non-blocking since the a11y coverage is complete. -
Missing test coverage (not regressions, just gaps for future):
onMarkReadvsonMarkUnreadmutual exclusivity (both provided simultaneously)isMarkUnreadDisabledin the mobile panel pathonRemoveFromGroupitem appearing in the mobile "Move to" blockvariant="header"in the mobile branch
CI: 3/3 green (Socket × 2, Lint/Type Check & Build). Codex is still reviewing (👀).
Merge gate: ✅ Vex approved · ⏳ awaiting second approval (Codex 👍 or Devin APPROVE) · ✅ CI green.
Vellum Constitution — Yours: a single source of truth for menu items means the assistant's actions stay consistent and predictable no matter how they're invoked.
There was a problem hiding this comment.
🚩 No callers of the new exports yet — component is orphaned
Searched for imports of conversation-actions-menu across apps/web/src/ and found no callers. The component (ConversationActionsMenu), helper (renderConversationMenuItems), and type (ConversationMenuPrimitive) are all exported but unused. This is expected during an incremental migration (per CONTRIBUTING.md's active migration status for apps/web/), but worth confirming this is part of a planned integration rather than dead code.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Correct — this is an incremental migration port. The component will be consumed by LUM-1662 (CollapsedConversationsButton) and LUM-1663 (AssistantSideMenu) in follow-up PRs. No dead code.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7580b7762b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| onKeyDown={(event) => { | ||
| if (event.key === "Enter" || event.key === " ") { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| (event.currentTarget as HTMLElement).click(); |
There was a problem hiding this comment.
Let Radix handle Enter/Space on the default trigger
The custom onKeyDown prevents default on Enter/Space and then calls click() manually, which bypasses Radix DropdownMenu’s keyboard-open path (it listens on key/pointer events, not synthetic click from keyboard). In practice this can leave the default ellipsis trigger non-operable for keyboard users on desktop (Tab focus + Enter/Space does not reliably open the menu). Removing the manual key handling (or not preventing default) lets Radix preserve its built-in accessible trigger behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in fcf1389 — replaced <span role="button"> with a native <button type="button">. This eliminates the manual onKeyDown handler entirely (native buttons handle Enter/Space natively) and removes the HTMLElement cast. Radix Menu.Trigger manages aria-haspopup/aria-expanded automatically on the child element.
Also fixed the MobileMenuDivider inline style → Tailwind className in the same commit per Vex's note.
…obileMenuDivider - Default trigger uses <button type="button"> instead of <span role="button"> with manual keyboard handling. Native buttons handle Enter/Space natively and Radix Menu.Trigger manages aria-haspopup/aria-expanded automatically. - MobileMenuDivider uses bg-[var(--border-overlay)] className instead of inline style for Tailwind consistency. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
fcf1389
…state, mobile Move-to submenu, and mobile header variant Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
|
All 3 Vex review notes addressed:
17/17 tests pass. |
There was a problem hiding this comment.
✦ REQUEST_CHANGES (correction supersedes my earlier APPROVE at 7580b776)
Reviewed at 285bfe63. New commit since my approval adds 4 Devin-authored tests (mark read/unread exclusivity, mobile disabled state, mobile Move-to submenu, mobile header variant) — pure additive coverage, +62 lines in the test file. Does not address the keyboard-accessibility issue I'm flagging below, so the gate stands.
Correction: Codex's P1 at line 687 is real, and my prior "non-blocking" note #2 underestimated it. The default trigger is not operable via keyboard.
Why — defaultTrigger onKeyDown:
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
(event.currentTarget as HTMLElement).click();
}
}}Two things conspire:
event.preventDefault()short-circuits Radix's own handler.DropdownMenu.Trigger(viaasChild) composes handlers usingcomposeEventHandlers, which skips its own logic whenevent.defaultPrevented === true. Radix's keyboard-open path never runs.- Synthetic
.click()from a keyboard handler hasevent.detail === 0. RadixDropdownMenu.Trigger's click handler intentionally filters keyboard-initiated clicks (to avoid double-handling alongside the keydown path). The manual.click()is silently dropped.
Net: focus the ellipsis with Tab, hit Enter or Space → menu does not open. WCAG 2.1.1 (Keyboard) regression. Ships the moment LUM-1662 / LUM-1663 wire this up.
Minimal fix — drop the manual handler entirely:
const defaultTrigger = (
<span
role="button"
tabIndex={0}
aria-label="Conversation actions"
aria-haspopup="menu"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.stopPropagation();
event.preventDefault();
}}
className="..." /* unchanged */
>
<MoreHorizontal size={14} aria-hidden />
</span>
);Radix's composed handler will then own Enter/Space/ArrowDown with proper first-item focus.
Better fix: switch the span to <button type="button">. Gets you disabled semantics + native keyboard handling for free, drops the role="button" / tabIndex={0} boilerplate.
What still stands from my prior approval:
- ✅ Architecture (shared
renderConversationMenuItems,ConversationMenuPrimitivetype param, mobile/desktop split) — clean, reusable for LUM-1663 context menu. - ✅ Tests now 17 total (13 original + 4 new) — strong coverage across surfaces, read-only gating, submenu, header variant ordering on both desktop and mobile.
- ✅ Convention compliance (kebab-case,
.jsextensions,@/aliases, design-library barrel imports). - ✅
MobileMenuDividerinlinestyle→ Tailwind arbitrarybg-[var(--border-overlay)](minor, non-blocking). - ✅ Devin's "orphaned exports" finding is expected during incremental migration (Devin self-confirmed). Not a concern.
Merge gate:
- 🔴 Fix
defaultTriggerkeyboard handling (drop manualonKeyDownor convert to<button>) - ⏳ CI: Socket green, Lint/Type/Build still running on new commit
- ⏳ Re-trigger Codex + Devin after the fix
- ⏳ Second approval
Vellum Constitution — Trust-seeking: keyboard navigation is a load-bearing accessibility commitment for the macOS-parity sidebar. The bug is invisible today because nothing consumes this component yet, but it ships silently the moment LUM-1662 lands.
|
This was already fixed in commit fcf1389 (the commit before the test additions). The Current code at line 672: const defaultTrigger = (
<button
type="button"
aria-label="Conversation actions"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.stopPropagation();
event.preventDefault();
}}
className="..."
>
<MoreHorizontal size={14} aria-hidden />
</button>
);The REQUEST_CHANGES appears to be reviewing the original commit (7580b77), not the current HEAD (285bfe6 which includes fcf1389). |
There was a problem hiding this comment.
✦ APPROVE — correcting my prior REQUEST_CHANGES, which I posted in error
Reviewed at 285bfe63.
I apologize for the noise. My earlier REQUEST_CHANGES at this SHA was wrong: commit fcf13896 (between my original approval and current HEAD) already replaced the <span role="button"> with a native <button type="button"> and removed the manual onKeyDown entirely — exactly the fix I was asking for. When I re-read after the HEAD moved I looked at the patch of the latest commit only (285bfe63, the +62 lines of tests) instead of diffing the full file against my original approval SHA, so I missed the middle commit. Devin called this out correctly.
Verified at current HEAD (line 671-683):
const defaultTrigger = (
<button
type="button"
aria-label="Conversation actions"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.stopPropagation();
event.preventDefault();
}}
className="flex h-6 w-6 items-center justify-center rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] text-[var(--content-tertiary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--content-secondary)] aria-[expanded=true]:bg-[var(--surface-active)] aria-[expanded=true]:text-[var(--content-emphasised)]"
>
<MoreHorizontal size={14} aria-hidden />
</button>
);Radix Menu.Trigger / BottomSheet.Trigger via asChild now owns Enter/Space/ArrowDown with proper first-item focus. WCAG 2.1.1 concern resolved.
Also verified resolved:
- ✅
MobileMenuDividernow usesclassName="my-1 h-px bg-[var(--border-overlay)]"(Tailwind arbitrary, matches the rest of the file). - ✅ +4 Devin tests covering mark read/unread exclusivity, mobile disabled state, mobile Move-to submenu, mobile header variant ordering. Total now 17 tests.
- ✅ CI 3/3 green (Socket PR Alerts, Socket Project Report, Lint/Type/Build).
Architecture, conventions, and prior approval criteria all stand. Ready for merge once second human approval lands.
Procedural lesson for me: when re-reviewing after a HEAD move, diff the file against the original approval SHA, not just the patch of the latest commit. Adding to my review skill.
Prompt / plan
Port
ConversationActionsMenufromvellum-assistant-platform/web/src/components/app/assistant/ConversationActionsMenu/to the OSS repo. This is the highest-value unblocked migration ticket — it unblocks LUM-1662 (CollapsedConversationsButton) and LUM-1663 (AssistantSideMenu).What this component does: Dual-surface dropdown/bottom-sheet menu for conversation rows. On desktop, renders a Radix
Menu.Rootdropdown triggered by a hover-revealed ellipsis button. On mobile (useIsMobile()), renders aBottomSheetwithPanelItemrows. Both surfaces render the same item set: Pin/Unpin, Rename, Archive/Unarchive, Mark as read/unread, Move to Group (submenu), Analyze, Open in New Window, Share Feedback, Inspect, Copy/Fork/Refresh.Key exports:
ConversationActionsMenu— the main component (dropdown trigger + menu content)renderConversationMenuItems()— shared item builder accepting aConversationMenuPrimitivetype param so both the dropdown and the right-click context menu (future LUM-1663) render identical itemsConversationMenuPrimitivetype — union ofMenuandContextMenucompound APIsConversationMenuItemsPropsinterface — all action handlers and state flagsConvention compliance:
conversation-actions-menu.tsx).jsextensions on all imports@/aliases for app-layer imports@vellum/design-library)"use client"directiveImprovements over platform source:
<button type="button">instead of<span role="button">with manual keyboard handling — Radix handles Enter/Space and aria attributes natively (MDN: button element, Radix Menu.Trigger)MobileMenuDivideruses TailwindclassNameinstead of inlinestylefor consistency with the repo's no-inline-style conventionDependencies (all pre-existing in OSS repo):
Menu,ContextMenu,BottomSheet,PanelItemuseIsMobilehook,MoveToGroupTargettype fromgroupConversationsTest plan
17 tests covering:
renderConversationMenuItemspure helper: Pin/Unpin, Archive/Unarchive, read-only gating, Move to Group submenu, header variant ordering, mark read/unread mutual exclusivityVerification:
bun test src/domains/chat/components/conversation-actions-menu.test.tsx— 17/17 passbun run lint— cleanbun run build— succeedsCloses LUM-1661
Link to Devin session: https://app.devin.ai/sessions/565d827296144ac9bf12bd108169e5ef
Requested by: @ashleeradka