fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures (#3778)

## Problem

Two related gaps in global back/forward navigation. Fixes #3775.

1. The keyboard shortcuts almost never fire in real use — users fall
back to clicking the toolbar chevrons and assume the shortcuts don't
exist.
2. On macOS, mouse back/forward buttons (X1/X2) and horizontal swipe
gestures do nothing, although they navigate in every browser and in
Slack.

**Duplicate check:** searched open PRs and issues — none found beyond
#3775 (filed alongside this fix). #3078 / #3377 are
next/previous-*channel* navigation, a different feature.

## Root causes

**Keyboard:** `useBackForwardControls`'s keydown handler bailed whenever
the event target was editable — but `useComposerAutofocus` deliberately
focuses the message composer (a ProseMirror contenteditable) on mount
and on every channel switch. In steady state focus almost always lives
in the composer, so the chords were silently swallowed. Invisible to CI
because `navigation.spec.ts` only ever clicked the `global-back` /
`global-forward` buttons, never pressed the keys.

**Mouse/swipe:** on macOS, WKWebView never delivers X1/X2 button events
or swipe gestures to the page (Safari handles them natively in the app
layer, not in page JS), and Buzz had no native handler.

## Fix

### Keyboard chords (web layer)

Match the existing platform chord regardless of the event target and
drop the editable-target guard:

- `⌘[` / `⌘]` have no text-editing semantics in macOS text fields, and
the TipTap/StarterKit editor config binds no `Mod-[` / `Mod-]` shortcuts
(checked `useRichTextEditor.ts` — list indentation is Tab/Shift-Tab).
- `preventDefault()` keeps the chord out of the editor — asserted in the
e2e test.

This matches browsers and Slack, where back/forward chords work while a
text field is focused. Chord matching is extracted into a pure helper,
`app/navigation/backForwardChords.ts`, so it can be unit tested;
behavior (bindings, modifier exclusivity, `code`-based matching for
non-US layouts) is unchanged.

### macOS mouse buttons and swipe gestures (native layer)

An NSEvent local monitor in `mouse_nav.rs` catches what the webview
can't see and emits a `mouse-nav` Tauri event to the main window
(`emit_to`, so navigation stays scoped if multi-window ever lands) that
the frontend acts on. Two AppKit event shapes map to navigation:

- `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons arrive as
plain button events. These are swallowed after emitting so nothing
downstream double-handles them.
- `swipe` with a horizontal delta — AppKit's page-swipe gesture
(`swipeWithEvent:`): `deltaX > 0` back, `deltaX < 0` forward. Sent by
mouse drivers that synthesize a page-swipe gesture for the back/forward
buttons instead of button-3/4 events (the hardware this was verified
on). Stock Apple trackpad and Magic Mouse swipes arrive as phased
scroll-wheel events instead, which this PR does not handle — that path
(`ScrollWheel` + `trackSwipeEventWithOptions:`, which also needs
scroll-edge detection) is deferred to a follow-up. Swipes are passed
through (swallowing mid-gesture events could confuse AppKit gesture
tracking).

The swipe path was verified end to end on hardware whose back/forward
buttons emit only swipe gestures, never button-3/4 events — an
instrumented event monitor confirmed the events arrive as
`NSEventType::Swipe` with `deltaX ±1`, and navigation worked after
mapping them.

## Tests

- **13 unit tests** for the web-side chord matcher
(`backForwardChords.test.mjs`): supported chords, modifier exclusivity,
`code` fallback, and preservation of line-editing shortcuts.
- **6 Rust unit tests** for the native mapping helpers (`mouse_nav.rs`):
button 3/4 directions, other buttons ignored, swipe delta sign →
direction, zero-delta (gesture-begin) ignored.
- **e2e regression case** in `navigation.spec.ts`: presses the platform
chord *while the composer is focused* — the missing coverage. Verified
it fails against the pre-fix implementation and passes with the fix.
- Full desktop unit suite: 3832/3832 pass. Full Rust suite (`cargo
test`, buzz-desktop): 1888 passed / 0 failed. `pnpm typecheck`, `biome
check`, `pnpm check`, `cargo fmt --check`, `cargo clippy`: clean (no new
warnings).
- Full Playwright e2e: 958 passed; 6 failures are relay-infrastructure
tests (live relay seeding / relay state seam) that fail identically
without this change — `navigation.spec.ts` is fully green.

## Manual test

1. Open a channel, then another (composer autofocuses on each switch).
2. `⌘[` — returns to the previous channel; `⌘]` — forward again. Typing
`[` / `]` in the composer inserts normally.
3. Mouse back/forward buttons navigate the same way, from anywhere in
the window (verified on macOS on hardware using both event shapes).

## Update — 2026-07-31

Removed the redundant DOM mouse-button handler after verifying it was
unnecessary. The native macOS path remains unchanged and was revalidated
manually.

---------

Signed-off-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz>
Signed-off-by: Matheus Iser <matheusiser@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Matheus
2026-08-02 14:53:01 -04:00
committed by GitHub
co-authored by npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Will Pfleger
parent 6530b58a61
commit f86cfc7369
8 changed files with 413 additions and 43 deletions
+1
View File
@@ -1044,6 +1044,7 @@ dependencies = [
"audioadapter-buffers",
"axum",
"base64 0.22.1",
"block2",
"buzz-agent",
"buzz-core",
"buzz-media",
+2 -1
View File
@@ -46,8 +46,9 @@ notify-rust = "4"
webkit2gtk = { version = "=2.0.2", features = ["v2_22"] }
[target.'cfg(target_os = "macos")'.dependencies]
block2 = { version = "0.6", default-features = false, features = ["std"] }
objc2 = { version = "0.6.4", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem"] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] }
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
+142
View File
@@ -0,0 +1,142 @@
//! Native macOS handler for back/forward navigation inputs (mouse X1/X2
//! buttons and horizontal swipe gestures).
//!
//! WKWebView never delivers these inputs to the web content layer, so a DOM
//! listener can't see them (Safari itself handles them natively in the app
//! layer, not in the page). This module installs an NSEvent local monitor
//! and emits a `mouse-nav` Tauri event that `useBackForwardControls` acts on
//! in the frontend. Two event shapes map to navigation:
//!
//! - `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons reach the app
//! as plain mouse buttons.
//! - `swipe` with a horizontal delta — AppKit's page-swipe gesture
//! (`swipeWithEvent:`): `deltaX > 0` is back, `deltaX < 0` is forward.
//! Sent by mouse drivers that synthesize a page-swipe gesture for the
//! back/forward buttons instead of button-3/4 events (the hardware this
//! was verified on). Stock Apple trackpad and Magic Mouse swipes arrive
//! as phased scroll-wheel events instead, which this module does not
//! handle — that path (`ScrollWheel` + `trackSwipeEventWithOptions:`,
//! which also needs scroll-edge detection) is a follow-up.
//!
//! Compiled macOS-only (via `tray_menu`). Non-macOS X1/X2 behavior is left
//! to the underlying webview.
/// Maps an `otherMouseUp` button number to a navigation direction.
/// Buttons 3 and 4 are X1 (back) and X2 (forward).
fn direction_for_button(button: isize) -> Option<&'static str> {
match button {
3 => Some("back"),
4 => Some("forward"),
_ => None,
}
}
/// Maps a swipe gesture's horizontal delta to a navigation direction,
/// following the AppKit `swipeWithEvent:` convention: positive is back,
/// negative is forward. A swipe arrives as a begin/end pair and only the
/// end event carries the direction, so `deltaX == 0` maps to `None`.
fn direction_for_swipe(delta_x: f64) -> Option<&'static str> {
if delta_x > 0.0 {
Some("back")
} else if delta_x < 0.0 {
Some("forward")
} else {
None
}
}
pub fn init<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) {
use block2::RcBlock;
use objc2_app_kit::{NSEvent, NSEventMask, NSEventType};
use tauri::Emitter;
let app = app_handle.clone();
let block = RcBlock::new(move |event: std::ptr::NonNull<NSEvent>| -> *mut NSEvent {
// SAFETY: the monitor hands us a valid NSEvent for the matched mask.
let ev = unsafe { event.as_ref() };
match ev.r#type() {
NSEventType::OtherMouseUp => {
if let Some(direction) = direction_for_button(ev.buttonNumber()) {
// Emit to the main window explicitly instead of
// broadcasting (`emit`) so navigation stays scoped if
// multi-window ever lands. "main" is the default label
// for the single configured window (see deep_link.rs).
let _ = app.emit_to("main", "mouse-nav", direction);
// Swallow the release: nothing downstream should also act
// on it. The matching press deliberately passes through:
// WKWebView never delivers X1/X2 to the page, so the
// unmatched down is inert, and swallowing presses risks
// interfering with AppKit behaviors keyed off mouse-down.
return std::ptr::null_mut();
}
}
NSEventType::Swipe => {
if let Some(direction) = direction_for_swipe(ev.deltaX()) {
let _ = app.emit_to("main", "mouse-nav", direction);
}
// Pass swipes through: nothing else navigates on them, and
// swallowing mid-gesture events could confuse AppKit's
// gesture tracking.
}
_ => {}
}
event.as_ptr()
});
// SAFETY: the block returns either null or the pointer it was given, both
// valid per the monitor contract. The returned monitor token is
// deliberately leaked: the monitor must live for the whole app lifetime.
let monitor = unsafe {
NSEvent::addLocalMonitorForEventsMatchingMask_handler(
NSEventMask::OtherMouseUp | NSEventMask::Swipe,
&block,
)
};
if let Some(monitor) = monitor {
std::mem::forget(monitor);
} else {
eprintln!("buzz-desktop: mouse-nav: failed to install NSEvent monitor");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn button_3_is_back() {
assert_eq!(direction_for_button(3), Some("back"));
}
#[test]
fn button_4_is_forward() {
assert_eq!(direction_for_button(4), Some("forward"));
}
#[test]
fn other_buttons_do_not_navigate() {
for button in [0, 1, 2, 5, -1] {
assert_eq!(direction_for_button(button), None);
}
}
#[test]
fn positive_swipe_delta_is_back() {
assert_eq!(direction_for_swipe(1.0), Some("back"));
assert_eq!(direction_for_swipe(0.5), Some("back"));
}
#[test]
fn negative_swipe_delta_is_forward() {
assert_eq!(direction_for_swipe(-1.0), Some("forward"));
assert_eq!(direction_for_swipe(-0.5), Some("forward"));
}
#[test]
fn zero_delta_swipe_begin_event_is_ignored() {
assert_eq!(direction_for_swipe(0.0), None);
}
}
+6
View File
@@ -3,6 +3,11 @@
//! The webview owns the live agent-turn state. It sends the small display
//! projection here so the native menu can remain useful while Buzz is hidden.
// Mouse back/forward (X1/X2 buttons and swipe) is also macOS-only native I/O;
// group it here so both platform-layer init paths share one call site in lib.rs.
#[path = "mouse_nav.rs"]
pub(crate) mod mouse_nav;
use std::{
sync::{Mutex, OnceLock},
time::{Duration, Instant},
@@ -488,6 +493,7 @@ pub fn init<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
if let Err(error) = apply_activity_presentation(&tray, activities, recent_activities) {
eprintln!("buzz-desktop: failed to apply tray menu presentation: {error}");
}
mouse_nav::init(app);
Ok(())
}
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";
import { matchBackForwardChord } from "./backForwardChords.ts";
function chord(overrides = {}) {
return {
altKey: false,
code: "",
ctrlKey: false,
key: "",
metaKey: false,
shiftKey: false,
...overrides,
};
}
// ── macOS: ⌘[ / ⌘] ───────────────────────────────────────────────────────────
test("mac: ⌘[ matches back", () => {
assert.equal(
matchBackForwardChord(chord({ key: "[", metaKey: true }), true),
"back",
);
});
test("mac: ⌘] matches forward", () => {
assert.equal(
matchBackForwardChord(chord({ key: "]", metaKey: true }), true),
"forward",
);
});
test("mac: matches by code for non-US layouts", () => {
assert.equal(
matchBackForwardChord(
chord({ code: "BracketLeft", key: "Dead", metaKey: true }),
true,
),
"back",
);
assert.equal(
matchBackForwardChord(
chord({ code: "BracketRight", key: "Dead", metaKey: true }),
true,
),
"forward",
);
});
test("mac: requires meta", () => {
assert.equal(matchBackForwardChord(chord({ key: "[" }), true), null);
});
test("mac: rejects extra modifiers", () => {
for (const extra of [
{ altKey: true },
{ ctrlKey: true },
{ shiftKey: true },
]) {
assert.equal(
matchBackForwardChord(chord({ key: "[", metaKey: true, ...extra }), true),
null,
);
}
});
test("mac: Alt+arrows do not match (that is the win/linux chord)", () => {
assert.equal(
matchBackForwardChord(chord({ altKey: true, key: "ArrowLeft" }), true),
null,
);
});
test("mac: ⌘←/⌘→ never match — they are line start/end in text editing", () => {
// Deliberately unbound: editable targets must keep receiving ⌘←/⌘→ so
// line-start/line-end editing still works. Only ⌘[ / ⌘] navigate.
assert.equal(
matchBackForwardChord(chord({ key: "ArrowLeft", metaKey: true }), true),
null,
);
assert.equal(
matchBackForwardChord(chord({ key: "ArrowRight", metaKey: true }), true),
null,
);
});
// ── Windows/Linux: Alt+← / Alt+→ ─────────────────────────────────────────────
test("win/linux: Alt+ArrowLeft matches back", () => {
assert.equal(
matchBackForwardChord(chord({ altKey: true, key: "ArrowLeft" }), false),
"back",
);
});
test("win/linux: Alt+ArrowRight matches forward", () => {
assert.equal(
matchBackForwardChord(chord({ altKey: true, key: "ArrowRight" }), false),
"forward",
);
});
test("win/linux: requires alt", () => {
assert.equal(matchBackForwardChord(chord({ key: "ArrowLeft" }), false), null);
});
test("win/linux: rejects extra modifiers", () => {
for (const extra of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
]) {
assert.equal(
matchBackForwardChord(
chord({ altKey: true, key: "ArrowLeft", ...extra }),
false,
),
null,
);
}
});
test("win/linux: ⌘[ does not match (that is the mac chord)", () => {
assert.equal(
matchBackForwardChord(chord({ key: "[", metaKey: true }), false),
null,
);
});
// ── Non-chord keys never match ────────────────────────────────────────────────
test("plain bracket / arrow keys without the platform modifier never match", () => {
for (const isMac of [true, false]) {
for (const key of ["[", "]", "ArrowLeft", "ArrowRight", "a", "Enter"]) {
assert.equal(matchBackForwardChord(chord({ key }), isMac), null);
}
}
});
@@ -0,0 +1,51 @@
/**
* Global back/forward navigation chords.
*
* macOS: [ / ] matching Safari, Chrome, Finder, and Slack.
* Windows/Linux: Alt+ / Alt+ matching browsers and Slack.
*
* Kept pure (no DOM access) so chord matching can be unit tested; the
* window listener wiring lives in `useBackForwardControls`.
*/
export type BackForwardDirection = "back" | "forward";
export type BackForwardChordEvent = Pick<
KeyboardEvent,
"altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "shiftKey"
>;
export function matchBackForwardChord(
event: BackForwardChordEvent,
isMac: boolean,
): BackForwardDirection | null {
if (isMac) {
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return null;
}
if (event.key === "[" || event.code === "BracketLeft") {
return "back";
}
if (event.key === "]" || event.code === "BracketRight") {
return "forward";
}
return null;
}
if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) {
return null;
}
if (event.key === "ArrowLeft") {
return "back";
}
if (event.key === "ArrowRight") {
return "forward";
}
return null;
}
@@ -4,7 +4,10 @@ import {
useRouter,
useRouterState,
} from "@tanstack/react-router";
import { isTauri } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { matchBackForwardChord } from "@/app/navigation/backForwardChords";
import { isMacPlatform } from "@/shared/lib/platform";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
@@ -14,19 +17,6 @@ type RouterHistoryState = {
key?: string;
};
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.isContentEditable ||
target.closest(
'input, textarea, select, [contenteditable=""], [contenteditable="true"]',
) !== null
);
}
export function useBackForwardControls() {
const router = useRouter();
const canGoBack = useCanGoBack();
@@ -81,46 +71,38 @@ export function useBackForwardControls() {
}, [canGoForward, router.history]);
const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {
if (isEditableTarget(event.target)) {
return;
}
// Note: the chords deliberately fire even when focus is inside an
// editable element. The composer autofocuses on every channel switch
// (`useComposerAutofocus`), so in steady state focus almost always
// lives in a contenteditable — an editable-target guard here made the
// shortcuts effectively dead (#3775). Safe because neither ⌘[ / ⌘]
// (macOS) nor Alt+←/→ (Windows/Linux) carry text-editing semantics,
// and the TipTap editor binds no conflicting shortcuts.
const direction = matchBackForwardChord(event, isMacPlatform());
const isMac = isMacPlatform();
const isBackShortcut = isMac
? event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.key === "[" || event.code === "BracketLeft")
: event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
event.key === "ArrowLeft";
const isForwardShortcut = isMac
? event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.key === "]" || event.code === "BracketRight")
: event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
event.key === "ArrowRight";
if (isBackShortcut) {
if (direction === "back") {
event.preventDefault();
goBack();
return;
}
if (isForwardShortcut) {
if (direction === "forward") {
event.preventDefault();
goForward();
}
});
const handleMouseNav = React.useEffectEvent((direction: string) => {
if (direction === "back") {
goBack();
return;
}
if (direction === "forward") {
goForward();
}
});
React.useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
@@ -128,6 +110,23 @@ export function useBackForwardControls() {
};
}, []);
// macOS: WKWebView never delivers X1/X2 button events or horizontal
// swipe gestures to the DOM, so the native layer catches them
// (`mouse_nav.rs`) and forwards them as a Tauri event.
React.useEffect(() => {
if (!isTauri()) {
return;
}
const unlistenPromise = listen<string>("mouse-nav", (event) => {
handleMouseNav(event.payload);
});
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
}, []);
return {
canGoBack,
canGoForward,
+31
View File
@@ -48,6 +48,37 @@ test("global back and forward move across channel routes", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("random");
});
test("back/forward keyboard chords work while the composer has focus", async ({
page,
}) => {
const backChord = process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft";
const forwardChord =
process.platform === "darwin" ? "Meta+]" : "Alt+ArrowRight";
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
// The composer autofocuses on channel switch; make the regression
// condition explicit by clicking into it. The chords must still fire
// from inside the contenteditable (#3775).
await page.getByTestId("message-input").click();
await expect(page.getByTestId("message-input")).toBeFocused();
await page.keyboard.press(backChord);
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.keyboard.press(forwardChord);
await expect(page.getByTestId("chat-title")).toHaveText("random");
// preventDefault kept the chord out of the editor — no stray characters.
await expect(page.getByTestId("message-input")).toHaveText("");
});
// FIXME: the forum post "Back to posts" header renders under the fixed top
// chrome drag region, which intercepts the click. Pre-existing breakage —
// this spec file was never registered in playwright.config.ts until now.