feat(ui): touch-friendly message actions, header spacing, scroll-to-latest (#402) - #403
Conversation
…atest (#402) Mobile/touch UX improvements from iPad + Pixel 7a feedback (#402): 1. Touch action menu (kebab). The per-message hover action bar is gated by Tailwind's automatic @media (hover:hover) wrapper, so it can never appear on a touch device. Add an always-visible kebab (shown only via @media (hover:none), the exact complement) that opens a Reply/React/Edit/Delete menu, reusing the existing handlers. The menu flips above the kebab near the bottom of the viewport so it is not clipped by the composer. The invisible hover bar is now pointer-events-none until hovered so it cannot intercept a stray gutter tap on touch. Desktop is unchanged. 2. Header spacing. Add gap between the header hamburger and the room-name button, and drop the room-title button's outward negative margin on mobile, so reaching for the room list no longer opens the room-details modal. 3. Scroll-to-latest + room-switch reset. A floating jump-to-latest button appears whenever the history is not pinned to the bottom (reusing the is_at_bottom IntersectionObserver state), and a room-change effect resets the scroll state so switching rooms always lands at the newest message. UI only; no wire/protocol/contract changes. Tests: new ui/tests/mobile-touch-ux.spec.ts (all 5 Playwright projects incl. mobile-chrome/mobile-safari). Verified interactively with screenshots on mobile (touch-emulated hover:none) and desktop. Closes #402 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
Multi-model review (Codex + two adversarial Claude reviewers): - Room-switch scroll no longer relies on effect ordering: a persistent `force_scroll` Cell drives the mount-triggered snap regardless of `is_at_bottom`, so the IntersectionObserver can't cancel it in the gap between the room-change effect and the scroll effect. - Desktop hover action bar stays hit-testable: `pointer-events:none` is now applied only under `@media (hover:none)` (touch) via `.hover-actions`, not through a `group-hover:` gate that dropped hover while crossing the gutter gap. - Dismiss backdrop now covers the viewport: the kebab container uses `right-full`/`left-full` instead of `translate`, so no transformed ancestor shrinks the `fixed inset-0` backdrop. - Menu kept on-screen on narrow phones via `max-w-[calc(100vw-1rem)]` and centre-opening anchors. - All kebab-menu signal mutations wrapped in `crate::util::defer()` per .claude/rules/dioxus-signal-safety.md (Firefox-mobile re-entrancy). - Scroll-to-latest button no longer optimistically sets `is_at_bottom`; the observer is the sole driver so an interrupted scroll can't strand it. - React-from-kebab inherits the flip direction for the emoji picker. - Kebab gets aria-haspopup/aria-expanded. Tests: added narrow-viewport menu-overflow + far-tap-dismiss test and a room A->B->A switch test; existing suite green on all 5 Playwright projects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
… round 2) Second Codex pass on the review fixes: - The scroll-to-latest button made `is_at_bottom` reactive (subscribed in render), so the IntersectionObserver's raw-closure `is_at_bottom.set()` — which runs with no Dioxus scope on the stack — could fire a subscriber notification from an empty scope and panic on Firefox mobile. Deferred that write via `crate::util::defer()` per dioxus-signal-safety.md. - Added `overflow-x-hidden` to the chat scroll container as a backstop so a kebab action menu on a very short self message can't produce a horizontal scrollbar (menu content is left-aligned and stays visible). Also updated the #205 edit-box-width test to open edit via the kebab on touch devices (the hover action bar is intentionally non-interactive there now). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
Multi-model review + how findings were addressedRan an external Codex pass plus two adversarial Claude reviewers (Dioxus/reactivity lens and touch/CSS/UX lens). Every finding below is either fixed or dismissed with justification. Fixed
Dismissed, with justification
[AI-assisted - Claude] |
review round 3) Third Codex pass: - `handle_send_message` set `is_at_bottom` synchronously from the send event handler; now that the scroll-to-latest button subscribes to that signal, the write is deferred (same Firefox-mobile re-entrancy fix as the observer callback). This completes the is_at_bottom signal-safety class: the two event-context writes defer, the use_effect write stays sync. - The kebab action menu now chooses its horizontal anchor from the tap X (open toward the viewport centre) instead of from self/other side, so its content can never run off a screen edge regardless of bubble width. The on-screen test now also asserts the first menu item stays fully visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
…review round 4) Fourth Codex pass: - The kebab menu's up/down flip is now chosen from the actual space below the tap (~220px) rather than a fixed 60% viewport fraction, so its actions stay reachable on short/landscape viewports. - Hoisted `open_action_menu` from per-MessageGroupComponent to the parent Conversation, so only one action menu is open at a time across the whole history (opening one closes any other). Added a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
…view round 5) The menu's up/down flip now measures space below the tap against the chat scroll container's bottom edge (which sits above the composer) rather than window.innerHeight, so a downward own-message menu can't clip Edit/Delete just above the scrollport. Falls back to a window estimate if the element is absent. Two other round-5 P2s handled without code change here: the per-group emoji picker (two pickers across groups) is pre-existing and folded into #404; the hybrid hover:hover-plus-touch device gap is a documented conscious tradeoff (@sanity to weigh) since any-pointer coverage has its own regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
Review iteration summary (rounds 2–5)After the initial multi-model review, I re-ran Codex on each fix commit (the review is per-code-content). Each round's findings, and how they were handled: Round 2 — observer signal write (P1): making Round 3 — send-path signal write (P1): Round 4 — menu vertical flip (P2): based the up/down flip on available space below the tap rather than a fixed 60% fraction. Two menus across groups (P2): hoisted Round 5 — menu space vs scrollport (P2): the flip now measures against the chat scroll container's bottom (which sits above the composer), not Dismissed with justification:
The [AI-assisted - Claude] |
#402 review round 6) Sixth Codex pass: - Every `.touch-actions` wrapper was z-50, so a later message's kebab painted above an open menu and could intercept taps on its Reply/Edit rows. The open wrapper is now z-[60], lifting the whole popover (and its backdrop, which then covers sibling kebabs — a tap on one just dismisses) above them. - The up/down flip now compares the menu's height (4 rows own / 2 rows received) against the space in BOTH directions within the scrollport, so a received menu that fits below is not force-flipped up and clipped at the top. Regression test updated: tapping a sibling kebab while a menu is open dismisses it (never two open; menu rows not intercepted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
…iew round 7) Seventh Codex pass: - `handle_send_message` now raises `force_scroll` (consumed by the mount effect when the sent message appears) instead of writing `is_at_bottom`. A send that fails mounts no message, so the scroll position — and the scroll-to-latest button — are left untouched rather than the button being wrongly hidden. Also removes a signal write from the send event handler entirely. - Hoisted `open_emoji_picker` to Conversation (like open_action_menu) so at most one picker is open across groups, and opening an action menu now dismisses any open picker — the two popovers can no longer stack. Added a React->picker test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
… round 8) Eighth Codex pass: - Raise the open reaction picker's wrapper to z-[60] (like the action menu) so a nearby closed kebab can't paint over the emoji grid and steal a tap; the picker's backdrop then covers the kebabs so tapping one just dismisses it. - Cap the action menu at max-h-[80vh] with overflow-y-auto so an own-message menu is never taller than the viewport with unreachable actions on a short/landscape scrollport. Also hardened the WebKit-flaky scroll-to-latest test: its IntersectionObserver lags on a programmatic scroll, so the helper now jumps to the top once, holds briefly to defeat the entry-scroll, then waits on the stable position (constant re-scrolling kept rescheduling the deferred observer so it never settled). Two round-8 P2s handled without code change: the force_scroll-on-failed-send leak is a strict improvement over the prior unconditional is_at_bottom write and is benign (rare failure + later inbound message); the React-inherited picker direction is an acceptable approximation. Both noted on the PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
Review rounds 6–8 (continued)Kept re-running Codex on each fix commit until it stopped finding crash-class issues (none since round 3; the
Dismissed (justified): the Full suite green on all 5 Playwright projects; 425 native unit tests pass. [AI-assisted - Claude] |
…th (#402 review round 9) Ninth Codex pass found a P1 the round-7 hoist introduced: lifting `open_emoji_picker` to a shared signal made it read by every MessageGroupComponent, but the pre-existing "+" / emoji-selection handlers still write it directly, so a direct set would notify all group subscribers synchronously and hit the documented Firefox-mobile RefCell panic. The round-8 picker `z-[60]` fix already enforces single-popover behaviour — an open picker's backdrop covers every other group's kebabs and "+" buttons, so tapping one dismisses the picker instead of stacking a second popover. The shared signal is therefore redundant. Revert it to a per-group `use_signal` (narrow subscription, no new crash path); coordination comes from the z-order. Also tightened the action menu's cap to max-h-[calc(100vh-9rem)] so its internal scroll actually engages within the scrollport on short/landscape viewports. Dismissed (justified): the force_scroll-on-failed-send leak (a strict improvement over the prior unconditional is_at_bottom write; triple-rare) and the React- inherited picker flip (acceptable approximation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
…und 10) Round 10 confirmed the signal-safety crash class is fully converged (no P1). Closing the last recurring P2: the action menu's max-height now uses the space actually measured on the chosen side at tap time (space_above/space_below within the chat scrollport), applied as an inline style, instead of a viewport-relative cap. On a short/landscape scrollport where the menu fits neither side, it now scrolls internally rather than being clipped by the scroll container with Edit/Delete unreachable. Added a short-viewport regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
…ed space (#402 review round 11) Round 11 (crash class still converged — no P1): - `handle_send_message` now raises `force_scroll` inside the successful `apply_delta` branch (cloned into the async send) instead of unconditionally at the top. A rejected send — empty, over `max_message_size`, or a serialize/sign/delta failure — no longer leaves the flag set for a later unrelated incoming message to consume and yank the reader to the bottom. - The action menu's max-height now uses exactly the measured space on the chosen side (the roomier one) with only a 1px degenerate floor, so on a tiny landscape scrollport it can never exceed the scroll container and clip its own rows — the previous 140px floor could overshoot a shorter available space. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
…d 12) Round 12 (crash class still converged — no P1). Two ways the shared force_scroll flag could be armed without a matching mount to consume it, then snap an unrelated room to the bottom on a later message: - Re-selecting the already-open room in the sidebar rewrites CURRENT_ROOM with the same key, and Dioxus re-runs the reset effect on any write. Now guarded on an actual owner_key change (tracked in a per-component Cell). - A send whose async signing/apply completes after the user switched rooms armed the conversation-wide flag while a different room was visible. Now only armed if the send's target room is still the current one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
Review converged (rounds 9–13)Re-ran Codex on each fix commit until it reported no findings. Round 13: "No actionable regressions were identified in the diff." Rounds 9–12 closed the remaining touch-popover and scroll edge cases:
The Firefox-mobile signal-safety crash class has had no P1 for five consecutive rounds. The one remaining item is a conscious design tradeoff for @sanity to weigh: touch affordances are gated on Full Playwright suite green on all 5 projects (CI [AI-assisted - Claude] |
The touch branch iterated every message, opening each kebab and dismissing received-message menus between iterations. The menu close is deferred, so in CI the next kebab tap raced the still-open (z-[60]) backdrop and timed out. Target a self message's kebab directly and open Edit from it — no iterate/dismiss race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JyYK79ygyquHkDqnnBQ9Xe
Problem
Feedback from using River on an iPad and a Pixel 7a (#402) identified three mobile/touch pain points:
group-hover:utilities in@media (hover: hover), so on a device with no hover pointer that bar can never appear — touch users have no discoverable way to quote-reply to others or edit/delete their own messages.Conversationcomponent is reused across rooms, so scroll state persists), forcing a lot of manual scrolling to follow new messages. There was also no affordance to jump back to the latest message.Approach
.touch-actionsCSS class gated on@media (hover: none)— the exact complement of Tailwind's automatic@media (hover: hover)on the hover bar. Tapping it opens a menu with Reply / React (for all messages) and Edit / Delete (own messages only), reusing the existing reply/edit/delete/emoji-picker handlers. The menu flips above the kebab when tapped near the bottom of the viewport (mirrors the existing emoji-pickerpicker_show_abovelogic) so it is never clipped by the composer. Desktop is unchanged: the kebab staysdisplay:noneand the hover bar behaves exactly as before. The invisible hover bar is nowpointer-events-noneuntil hovered so it cannot intercept a stray gutter tap on touch.gap-2/md:gap-3between the header controls, amr-1on the hamburger, and moved the room-title button's outward negative margin behindmd:so it no longer pulls its tap target under the hamburger on mobile.FaChevronDown) appears whenever the history is not pinned to the bottom, reusing the existingis_at_bottomIntersectionObserver state; clicking it smooth-scrolls to the newest message. A room-change effect resetsis_at_bottom/first_scrollso switching rooms always snaps to the bottom.No wire-format, protocol, or contract changes — UI only.
Testing
New Playwright suite
ui/tests/mobile-touch-ux.spec.ts(runs on all 5 CI projects, incl.mobile-chrome/mobile-safari):matchMedia('(hover: none)'));Verified interactively with Playwright screenshots on both mobile (Pixel 5, touch-emulated
hover:none) and desktop (1280px): kebab menus render correctly for own vs received messages, the bottom-message menu flips upward, the header is well-spaced, the scroll button appears/works, and desktop hover actions are unchanged with the kebab hidden.Closes #402
[AI-assisted - Claude]