Skip to content

feat(ui): touch-friendly message actions, header spacing, scroll-to-latest (#402) - #403

Merged
sanity merged 14 commits into
mainfrom
fix/402-mobile-touch-ux
Jul 14, 2026
Merged

feat(ui): touch-friendly message actions, header spacing, scroll-to-latest (#402)#403
sanity merged 14 commits into
mainfrom
fix/402-mobile-touch-ux

Conversation

@sanity

@sanity sanity commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Feedback from using River on an iPad and a Pixel 7a (#402) identified three mobile/touch pain points:

  1. Message actions are unreachable on touch. Reply / edit / delete / react live in a per-message action bar revealed on hover. Tailwind v4 wraps 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.
  2. The header hamburger sits too close to the room title. On a phone, the "open room list" hamburger is immediately adjacent to the room-name button (which opens the room-details modal), so reaching for the room list frequently opens room details by mistake.
  3. Room history isn't pinned to the bottom. Switching rooms often leaves the view scrolled up (the Conversation component 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

  1. Touch action menu (kebab). Each message now renders an always-visible kebab (⋮) button only on devices without a hover pointer, via a new .touch-actions CSS 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-picker picker_show_above logic) so it is never clipped by the composer. Desktop is unchanged: the kebab stays display:none and the hover bar behaves exactly as before. The invisible hover bar is now pointer-events-none until hovered so it cannot intercept a stray gutter tap on touch.
  2. Header spacing. Added gap-2/md:gap-3 between the header controls, a mr-1 on the hamburger, and moved the room-title button's outward negative margin behind md: so it no longer pulls its tap target under the hamburger on mobile.
  3. Scroll-to-latest + room-switch reset. A floating "jump to latest" button (FaChevronDown) appears whenever the history is not pinned to the bottom, reusing the existing is_at_bottom IntersectionObserver state; clicking it smooth-scrolls to the newest message. A room-change effect resets is_at_bottom/first_scroll so 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):

  • kebab visible on touch / hidden on hover devices (asserts against matchMedia('(hover: none)'));
  • kebab menu opens with Reply/Edit/Delete on own messages, dismisses via backdrop;
  • Reply from the kebab opens the composer reply-preview;
  • header hamburger does not overlap the room-name tap target (bounding-box gap check);
  • scroll-to-latest button appears when scrolled up and returns to the bottom on click;
  • switching rooms lands at the bottom even after scrolling up.

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]

sanity and others added 2 commits July 14, 2026 09:36
…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
@sanity

sanity commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Multi-model review + how findings were addressed

Ran 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

  • Room-switch scroll relied on unguaranteed effect ordering (Dioxus reviewer, MAJOR). The room-change effect only set is_at_bottom=true; the actual scroll ran in a separate effect that read is_at_bottom.peek(), so if the observer flipped it back to false (new room's persisted scroll) in between, the snap was silently skipped. Fixed with a persistent force_scroll Cell that the mount-triggered effect honors regardless of is_at_bottom; ordering is now causal (the new room's bubble mount necessarily happens after the room change), not scheduler-dependent. New regression test switching rooms lands at the bottom even after scrolling up.
  • Desktop hover action bar became unreachable (Codex, P1). The pointer-events-none group-hover:pointer-events-auto I'd added made the invisible bar drop hit-testing while the mouse crossed the empty gap toward it, deadlocking the hover. Reverted to a CSS rule that makes the bar pointer-events:none only under @media (hover: none) (touch), leaving desktop fully hit-testable.
  • Dismiss backdrop was trapped inside a transformed element (Codex P2 + touch reviewer, major). The kebab container's translate established a containing block, so the fixed inset-0 backdrop covered only the ~32px kebab, not the viewport (tap-outside-to-dismiss was broken, and the old test was a false green — it clicked the backdrop's own local origin). Replaced translate with right-full/left-full (no transform, same placement); the backdrop now covers the viewport. New test taps a real far viewport coordinate.
  • Menu could overflow a narrow phone (touch reviewer, major). Anchored the menu to open toward the bubble/centre and added max-w-[calc(100vw-1rem)]; new test opens menus on both a self and a received message at the mobile viewport and asserts the menu stays within the viewport with no horizontal page scroll.
  • Signal writes in the new event handlers (Codex, P1). Wrapped every kebab-menu mutation (open_action_menu, menu_show_above, edit_text, editing_message, reply/delete calls, React) in crate::util::defer() per .claude/rules/dioxus-signal-safety.md (Firefox-mobile re-entrant borrow crashes).
  • Scroll-to-latest button could stick hidden (Codex, P2). Removed the optimistic is_at_bottom.set(true) in the button's onclick; the IntersectionObserver is now the sole driver, so interrupting the smooth scroll no longer strands the button hidden.
  • React from the kebab ignored the flip direction (Codex + Dioxus, P2). It now inherits the kebab's menu_show_above so the emoji picker for a bottom message also opens upward.
  • A11y (touch reviewer, minor): added aria-haspopup="menu" / aria-expanded to the kebab and role="menu"/role="menuitem" to the popover.

Dismissed, with justification

  • Use try_read() in the room-change effect (Codex, P1). Every existing CURRENT_ROOM read in this file (4 effects/memos) uses plain .read(), because CURRENT_ROOM writes are always deferred. .claude/rules/dioxus-signal-safety.md warns that try_read() does NOT register a subscription on Err, so a reactive effect that must re-run on room change would stop firing — switching to try_read here would break the feature, not harden it. Kept .read(), consistent with the established pattern.
  • Hybrid hover:hover + touch devices (touch reviewer). @media (hover: none) is the exact complement of Tailwind's @media (hover: hover) gate and correctly covers the issue's targets (iPad, Android phone, iPadOS Safari — all report hover:none). Genuinely-hybrid devices (touchscreen laptop primarily driven by mouse) would get always-visible kebabs on every message, cluttering an otherwise desktop UI. Keeping the clean hover:none gate is a conscious v1 tradeoff.
  • Two menus open across message groups (Dioxus + touch, minor). open_action_menu is per-group, matching the existing per-group open_emoji_picker; consistent, not a regression.
  • Scroll FAB / kebab z-overlap (touch reviewer, nit) and reply-preview computed twice (Dioxus, nit): low-impact; left as-is to keep the diff minimal.

[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
@sanity
sanity marked this pull request as ready for review July 14, 2026 15:42
sanity and others added 2 commits July 14, 2026 11:00
…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
@sanity

sanity commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

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 2observer signal write (P1): making is_at_bottom reactive (the scroll-to-latest button) turned the IntersectionObserver's raw-closure is_at_bottom.set() into a Firefox-mobile crash path → deferred it. Menu-overflow (P2) → overflow-x-hidden backstop.

Round 3send-path signal write (P1): handle_send_message set is_at_bottom synchronously from the send handler → deferred. This completed the is_at_bottom signal-safety class (both event-context writes defer; the use_effect write stays synchronous, as the rule requires). Menu anchoring (P2) → chosen from the tap X so the menu always opens toward the viewport centre regardless of side/width.

Round 4menu 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 open_action_menu to Conversation so only one menu is open at a time; added a regression test.

Round 5menu space vs scrollport (P2): the flip now measures against the chat scroll container's bottom (which sits above the composer), not window.innerHeight, so an own-message menu's Edit/Delete can't clip in the band near the scrollport bottom.

Dismissed with justification:

  • try_read in the room-change effect — every existing CURRENT_ROOM read uses plain .read() (writes are deferred); try_read drops the subscription on Err and would stop the effect re-running on room change (dioxus-signal-safety.md).
  • Hybrid hover:hover + touch devices (raised 3×) — @media (hover: none) correctly covers the issue's targets (iPad, Android, iPadOS Safari). any-pointer: coarse would show always-visible kebabs on mouse-driven touchscreen laptops (or re-introduce the tap-interception it fixed); a full solution needs JS pointer-type detection. Conscious v1 tradeoff — flagging for @sanity to weigh.
  • Per-group open_emoji_picker (two pickers across groups) — pre-existing (the inline "+" button already has it); tracked in Pre-existing: some local signal mutations in conversation.rs aren't wrapped in defer() #404 alongside the open_emoji_picker hoist.

The is_at_bottom Firefox-mobile crash class is fully closed; remaining items are minor/edge-case UX with justifications above. Full Playwright suite green on all 5 projects; 425 native unit tests pass.

[AI-assisted - Claude]

sanity and others added 3 commits July 14, 2026 11:26
#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
@sanity

sanity commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

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 is_at_bottom Firefox-mobile signal-safety class is fully closed). Subsequent rounds hardened the touch popovers:

  • R6: open action menu raised to z-[60] so a later message's kebab can't paint over its rows and steal a tap; up/down flip now compares menu height against space in both directions.
  • R7: send uses force_scroll (a failed send no longer wrongly hides the scroll button); open_emoji_picker hoisted to Conversation so at most one picker is open and a menu dismisses it.
  • R8: open reaction picker raised to z-[60] too; action menu capped at max-h-[80vh] with internal scroll so its actions are never unreachable on a short/landscape viewport.

Dismissed (justified): the force_scroll-on-failed-send leak (strict improvement over the prior unconditional is_at_bottom write; rare + benign), the React-inherited picker flip direction (acceptable approximation), and hybrid hover:hover+touch devices (conscious @media (hover:none) v1 tradeoff — @sanity to weigh; any-pointer has its own regressions). Pre-existing non-deferred local signal writes tracked in #404.

Full suite green on all 5 Playwright projects; 425 native unit tests pass.

[AI-assisted - Claude]

sanity and others added 4 commits July 14, 2026 12:21
…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
@sanity

sanity commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • R9: reverted the emoji-picker signal hoist (it widened the subscription and turned pre-existing "+" handler writes into a Firefox-mobile crash path) — the round-8 z-[60] picker fix already provides single-popover coordination via the backdrop, so the shared signal was redundant.
  • R10–R11: action menu capped to the measured available scrollport space (internal scroll, never clipped); force_scroll set only on a successful send.
  • R12: force_scroll guarded against re-selecting the same room and against a send that completes after switching rooms.

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 @media (hover: none), which covers phones/tablets/iPadOS but not hybrid hover:hover+touchscreen devices (a full fix needs JS pointer-type detection; any-pointer has its own regressions). Pre-existing non-deferred local signal writes are tracked in #404.

Full Playwright suite green on all 5 projects (CI ui-playwright-tests confirmed passing); 425 native unit tests pass.

[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
@sanity
sanity merged commit fdaa8d0 into main Jul 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggestions for UI improvements for mobile and touch users

1 participant