From 3616d92be4e11ab68c5e7deb360bf2bb3003dbea Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 22 Sep 2026 13:49:02 +0100 Subject: [PATCH 01/12] feat(messages): polish sidebar interactions Signed-off-by: klopez4212 --- docs/shell-design.md | 15 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 76 ++++++++ src/app/shell/AppShell.tsx | 17 +- src/app/shell/title-bar.test.ts | 76 ++++++++ src/app/shell/title-bar.ts | 64 +++++++ src/bundled/channels/ChannelSidebarRow.tsx | 2 - src/bundled/channels/Channels.module.css | 176 ++++++++++++++++-- src/bundled/channels/ChannelsPage.tsx | 98 +++++++++- src/bundled/channels/SidebarSectionIcon.tsx | 68 +++++++ src/bundled/channels/sidebar-sections.test.ts | 3 +- src/bundled/channels/useSidebarView.test.ts | 18 ++ src/bundled/channels/useSidebarView.ts | 29 ++- src/features/messages/Messages.module.css | 17 ++ src/shared/styles/globals.css | 3 + tests/browser/appearance.spec.mjs | 6 +- tests/browser/layout.spec.mjs | 2 +- tests/browser/navigation-sidebar.spec.mjs | 89 +++++++++ 18 files changed, 726 insertions(+), 35 deletions(-) create mode 100644 src/app/shell/title-bar.test.ts create mode 100644 src/app/shell/title-bar.ts create mode 100644 src/bundled/channels/SidebarSectionIcon.tsx create mode 100644 src/bundled/channels/useSidebarView.test.ts diff --git a/docs/shell-design.md b/docs/shell-design.md index 1d0744185..2519e8ee3 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -65,10 +65,14 @@ lights have a reserved 104px left area before the community switcher only in the macOS desktop runtime. This inset does not move the centered tabs. Web gets no inset or imitation window controls. Other platforms retain their native decorations. Drag regions are limited to the -header background; controls remain clickable. The main-window capability grants -only titlebar dragging and the internal native maximize action used by Tauri's drag -handler, plus scoped HTTP(S) opening for [external links](channels.md#run-the-integration). -See [Tauri window customization](https://v2.tauri.app/learn/window-customization/). +header background; controls remain clickable. On macOS, double-clicking that +background follows the current system title-bar preference (Fill/Zoom, Minimize, +or no action); changing the preference does not require restarting Buzz. Other +platforms retain Tauri's native drag-region behavior. The main-window capability +grants only titlebar dragging and the internal maximize action used by that +handler, plus scoped HTTP(S) opening for +[external links](channels.md#run-the-integration). See +[Tauri window customization](https://v2.tauri.app/learn/window-customization/). The top-right group contains enabled plugin launchers (Bestie supplies the snake), a page finder, and the local avatar. `ProfileButton.tsx` subscribes to the community @@ -106,7 +110,8 @@ behind, never over, opaque cards; it makes no relay request at runtime. Run `just iterate` for UI changes and `just scan` for the broader review checks. Check Home, Messages, and Settings; toggle a bundled plugin off/on and confirm its navigation entry follows; inspect a narrow viewport. On macOS, verify titlebar -alignment, dragging, double-click zoom, and Settings access in a built app. +alignment, dragging, each macOS title-bar double-click preference, and Settings +access in a built app. ## Messages diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e8ceea5a1..ec12bfbcb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,7 +41,7 @@ libc = "0.2" mac-notification-sys = "=0.6.15" block2 = "=0.6.2" objc2 = "=0.6.4" -objc2-foundation = { version = "=0.3.2", default-features = false, features = ["NSBundle", "NSString", "NSError"] } +objc2-foundation = { version = "=0.3.2", default-features = false, features = ["NSBundle", "NSString", "NSError", "NSUserDefaults"] } objc2-user-notifications = { version = "=0.3.2", default-features = false, features = ["UNUserNotificationCenter", "UNNotificationSettings", "block2"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3dd7ec4fb..104d984ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,52 @@ use terminal::{ #[derive(Clone, Default)] struct Imports(Arc>>); +#[derive(Debug, PartialEq, Eq)] +enum TitleBarDoubleClickAction { + Maximize, + Minimize, + None, +} + +fn title_bar_double_click_action(preference: Option<&str>) -> TitleBarDoubleClickAction { + match preference { + Some("Maximize" | "Zoom" | "Fill") => TitleBarDoubleClickAction::Maximize, + Some("Minimize") => TitleBarDoubleClickAction::Minimize, + _ => TitleBarDoubleClickAction::None, + } +} + +#[tauri::command] +fn title_bar_double_click(window: tauri::Window) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use objc2_foundation::{ns_string, NSUserDefaults}; + + let preference = NSUserDefaults::standardUserDefaults() + .stringForKey(ns_string!("AppleActionOnDoubleClick")) + .map(|value| value.to_string()); + match title_bar_double_click_action(preference.as_deref()) { + TitleBarDoubleClickAction::Maximize => { + if window.is_maximized().map_err(|error| error.to_string())? { + window.unmaximize() + } else { + window.maximize() + } + .map_err(|error| error.to_string())?; + } + TitleBarDoubleClickAction::Minimize => { + window.minimize().map_err(|error| error.to_string())?; + } + TitleBarDoubleClickAction::None => {} + } + } + + #[cfg(not(target_os = "macos"))] + let _ = window; + + Ok(()) +} + async fn prepare_import( imports: Imports, operation: impl FnOnce() -> Result, String> + Send + 'static, @@ -164,6 +210,7 @@ async fn plugin_recover( } fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ + title_bar_double_click, plugin_import_folder, plugin_import_git, plugin_import_install, @@ -260,3 +307,32 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests { + use super::{title_bar_double_click_action, TitleBarDoubleClickAction}; + + #[test] + fn title_bar_double_click_preferences_map_to_native_actions() { + assert_eq!( + title_bar_double_click_action(Some("Maximize")), + TitleBarDoubleClickAction::Maximize + ); + assert_eq!( + title_bar_double_click_action(Some("Fill")), + TitleBarDoubleClickAction::Maximize + ); + assert_eq!( + title_bar_double_click_action(Some("Minimize")), + TitleBarDoubleClickAction::Minimize + ); + assert_eq!( + title_bar_double_click_action(Some("None")), + TitleBarDoubleClickAction::None + ); + assert_eq!( + title_bar_double_click_action(None), + TitleBarDoubleClickAction::None + ); + } +} diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index 02d1b74b8..65837b8d7 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -8,8 +8,10 @@ import { ProfileButton } from "./ProfileButton"; import { PageSearch } from "./PageSearch"; import { orderPages, pagePresentation } from "./presentation"; import { PanelFrame } from "../../features/panels/PanelFrame"; +import { macTitleBarDragHandlers } from "./title-bar"; const macDesktop = isTauri() && /Mac/i.test(navigator.platform); +const titleBarDragProps = macDesktop ? macTitleBarDragHandlers : {}; export function AppShell({ pages, @@ -55,10 +57,15 @@ export function AppShell({ Skip to content
-
+
{navigationControls} -
+
{launchers} = {}, +) { + const currentTarget = {}; + return { + button: overrides.button ?? 0, + clientX: overrides.clientX ?? 40, + clientY: overrides.clientY ?? 12, + currentTarget, + detail, + preventDefault: vi.fn(), + target: overrides.sameTarget === false ? {} : currentTarget, + } as unknown as Parameters< + ReturnType["onMouseDown"] + >[0]; +} + +describe("macOS title bar dragging", () => { + it("starts dragging on a primary single click of the region itself", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + const event = mouseEvent(1); + + handlers.onMouseDown(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(actions.startDragging).toHaveBeenCalledOnce(); + expect(actions.doubleClick).not.toHaveBeenCalled(); + }); + + it("runs the native preference action after an unmoved double click", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + const down = mouseEvent(2); + const up = mouseEvent(2); + + handlers.onMouseDown(down); + handlers.onMouseUp(up); + + expect(up.preventDefault).toHaveBeenCalledOnce(); + expect(actions.doubleClick).toHaveBeenCalledOnce(); + expect(actions.startDragging).not.toHaveBeenCalled(); + }); + + it("cancels the double-click action after movement", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + + handlers.onMouseDown(mouseEvent(2)); + handlers.onMouseUp(mouseEvent(2, { clientX: 41 })); + + expect(actions.doubleClick).not.toHaveBeenCalled(); + }); + + it("ignores interactive descendants and non-primary clicks", () => { + const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; + const handlers = createTitleBarDragHandlers(actions); + + handlers.onMouseDown(mouseEvent(1, { sameTarget: false })); + handlers.onMouseDown(mouseEvent(1, { button: 1 })); + handlers.onMouseDown(mouseEvent(2, { sameTarget: false })); + handlers.onMouseUp(mouseEvent(2, { sameTarget: false })); + + expect(actions.startDragging).not.toHaveBeenCalled(); + expect(actions.doubleClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/shell/title-bar.ts b/src/app/shell/title-bar.ts new file mode 100644 index 000000000..17abcf58d --- /dev/null +++ b/src/app/shell/title-bar.ts @@ -0,0 +1,64 @@ +import type { MouseEventHandler } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +type TitleBarActions = Readonly<{ + startDragging: () => void; + doubleClick: () => void; +}>; + +export type TitleBarDragHandlers = Readonly<{ + onMouseDown: MouseEventHandler; + onMouseUp: MouseEventHandler; +}>; + +export function createTitleBarDragHandlers( + actions: TitleBarActions, +): TitleBarDragHandlers { + let doubleClickStart: Readonly<{ x: number; y: number }> | undefined; + + return { + onMouseDown(event) { + if ( + event.button !== 0 || + event.target !== event.currentTarget || + (event.detail !== 1 && event.detail !== 2) + ) { + return; + } + + if (event.detail === 2) { + doubleClickStart = { x: event.clientX, y: event.clientY }; + return; + } + + event.preventDefault(); + actions.startDragging(); + }, + onMouseUp(event) { + const start = doubleClickStart; + doubleClickStart = undefined; + if ( + event.button !== 0 || + event.detail !== 2 || + event.target !== event.currentTarget || + start?.x !== event.clientX || + start.y !== event.clientY + ) { + return; + } + + event.preventDefault(); + actions.doubleClick(); + }, + }; +} + +export const macTitleBarDragHandlers = createTitleBarDragHandlers({ + startDragging: () => { + void getCurrentWindow().startDragging(); + }, + doubleClick: () => { + void invoke("title_bar_double_click"); + }, +}); diff --git a/src/bundled/channels/ChannelSidebarRow.tsx b/src/bundled/channels/ChannelSidebarRow.tsx index 67a0eb2b9..d8634f0a3 100644 --- a/src/bundled/channels/ChannelSidebarRow.tsx +++ b/src/bundled/channels/ChannelSidebarRow.tsx @@ -52,7 +52,6 @@ export function ChannelSidebarRow({
- +
); diff --git a/tests/fixtures/relay-composer.tsx b/tests/fixtures/relay-composer.tsx index 69f93b2b2..0e4083ce6 100644 --- a/tests/fixtures/relay-composer.tsx +++ b/tests/fixtures/relay-composer.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { Context } from "@deepseek-ai/cordis"; import { ChannelsPage } from "../../src/bundled/channels/ChannelsPage"; import { PanelsService } from "../../src/features/panels/service"; +import { PagesService } from "../../src/features/pages/service"; import { createRelaySession } from "../../src/features/relay/session"; import { keypair, @@ -98,6 +99,10 @@ const container = document.getElementById("root"); if (!container) throw new Error("Missing fixture root"); createRoot(container).render(
- +
, ); From 1ff39540e7bdeb3908861709e17e4cbdc74da924 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 22 Sep 2026 16:18:49 +0100 Subject: [PATCH 08/12] fix(messages): address sidebar review feedback Signed-off-by: klopez4212 --- docs/shell-design.md | 15 ++--- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 78 ----------------------- src/app/shell/AppShell.tsx | 17 +---- src/app/shell/title-bar.test.ts | 76 ---------------------- src/app/shell/title-bar.ts | 64 ------------------- src/bundled/channels/Channels.module.css | 8 ++- src/bundled/channels/ChannelsPage.tsx | 22 ++++++- tests/browser/layout.spec.mjs | 33 ++++++++++ tests/browser/navigation-sidebar.spec.mjs | 64 +++++++++++++++---- 10 files changed, 118 insertions(+), 261 deletions(-) delete mode 100644 src/app/shell/title-bar.test.ts delete mode 100644 src/app/shell/title-bar.ts diff --git a/docs/shell-design.md b/docs/shell-design.md index 2519e8ee3..1d0744185 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -65,14 +65,10 @@ lights have a reserved 104px left area before the community switcher only in the macOS desktop runtime. This inset does not move the centered tabs. Web gets no inset or imitation window controls. Other platforms retain their native decorations. Drag regions are limited to the -header background; controls remain clickable. On macOS, double-clicking that -background follows the current system title-bar preference (Fill/Zoom, Minimize, -or no action); changing the preference does not require restarting Buzz. Other -platforms retain Tauri's native drag-region behavior. The main-window capability -grants only titlebar dragging and the internal maximize action used by that -handler, plus scoped HTTP(S) opening for -[external links](channels.md#run-the-integration). See -[Tauri window customization](https://v2.tauri.app/learn/window-customization/). +header background; controls remain clickable. The main-window capability grants +only titlebar dragging and the internal native maximize action used by Tauri's drag +handler, plus scoped HTTP(S) opening for [external links](channels.md#run-the-integration). +See [Tauri window customization](https://v2.tauri.app/learn/window-customization/). The top-right group contains enabled plugin launchers (Bestie supplies the snake), a page finder, and the local avatar. `ProfileButton.tsx` subscribes to the community @@ -110,8 +106,7 @@ behind, never over, opaque cards; it makes no relay request at runtime. Run `just iterate` for UI changes and `just scan` for the broader review checks. Check Home, Messages, and Settings; toggle a bundled plugin off/on and confirm its navigation entry follows; inspect a narrow viewport. On macOS, verify titlebar -alignment, dragging, each macOS title-bar double-click preference, and Settings -access in a built app. +alignment, dragging, double-click zoom, and Settings access in a built app. ## Messages diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ec12bfbcb..e8ceea5a1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,7 +41,7 @@ libc = "0.2" mac-notification-sys = "=0.6.15" block2 = "=0.6.2" objc2 = "=0.6.4" -objc2-foundation = { version = "=0.3.2", default-features = false, features = ["NSBundle", "NSString", "NSError", "NSUserDefaults"] } +objc2-foundation = { version = "=0.3.2", default-features = false, features = ["NSBundle", "NSString", "NSError"] } objc2-user-notifications = { version = "=0.3.2", default-features = false, features = ["UNUserNotificationCenter", "UNNotificationSettings", "block2"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9541561e8..3dd7ec4fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,54 +26,6 @@ use terminal::{ #[derive(Clone, Default)] struct Imports(Arc>>); -#[cfg(any(target_os = "macos", test))] -#[derive(Debug, PartialEq, Eq)] -enum TitleBarDoubleClickAction { - Maximize, - Minimize, - None, -} - -#[cfg(any(target_os = "macos", test))] -fn title_bar_double_click_action(preference: Option<&str>) -> TitleBarDoubleClickAction { - match preference { - Some("Maximize" | "Zoom" | "Fill") => TitleBarDoubleClickAction::Maximize, - Some("Minimize") => TitleBarDoubleClickAction::Minimize, - _ => TitleBarDoubleClickAction::None, - } -} - -#[tauri::command] -fn title_bar_double_click(window: tauri::Window) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - use objc2_foundation::{ns_string, NSUserDefaults}; - - let preference = NSUserDefaults::standardUserDefaults() - .stringForKey(ns_string!("AppleActionOnDoubleClick")) - .map(|value| value.to_string()); - match title_bar_double_click_action(preference.as_deref()) { - TitleBarDoubleClickAction::Maximize => { - if window.is_maximized().map_err(|error| error.to_string())? { - window.unmaximize() - } else { - window.maximize() - } - .map_err(|error| error.to_string())?; - } - TitleBarDoubleClickAction::Minimize => { - window.minimize().map_err(|error| error.to_string())?; - } - TitleBarDoubleClickAction::None => {} - } - } - - #[cfg(not(target_os = "macos"))] - let _ = window; - - Ok(()) -} - async fn prepare_import( imports: Imports, operation: impl FnOnce() -> Result, String> + Send + 'static, @@ -212,7 +164,6 @@ async fn plugin_recover( } fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ - title_bar_double_click, plugin_import_folder, plugin_import_git, plugin_import_install, @@ -309,32 +260,3 @@ pub fn run() { } }); } - -#[cfg(test)] -mod tests { - use super::{title_bar_double_click_action, TitleBarDoubleClickAction}; - - #[test] - fn title_bar_double_click_preferences_map_to_native_actions() { - assert_eq!( - title_bar_double_click_action(Some("Maximize")), - TitleBarDoubleClickAction::Maximize - ); - assert_eq!( - title_bar_double_click_action(Some("Fill")), - TitleBarDoubleClickAction::Maximize - ); - assert_eq!( - title_bar_double_click_action(Some("Minimize")), - TitleBarDoubleClickAction::Minimize - ); - assert_eq!( - title_bar_double_click_action(Some("None")), - TitleBarDoubleClickAction::None - ); - assert_eq!( - title_bar_double_click_action(None), - TitleBarDoubleClickAction::None - ); - } -} diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index 272dd3c78..e99d864c8 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -9,10 +9,8 @@ import { ProfileButton } from "./ProfileButton"; import { PageSearch } from "./PageSearch"; import { orderPages, pagePresentation } from "./presentation"; import { PanelFrame } from "../../features/panels/PanelFrame"; -import { macTitleBarDragHandlers } from "./title-bar"; const macDesktop = isTauri() && /Mac/i.test(navigator.platform); -const titleBarDragProps = macDesktop ? macTitleBarDragHandlers : {}; export function AppShell({ pages, @@ -58,15 +56,10 @@ export function AppShell({ Skip to content
-
+
{navigationControls} -
+
{launchers} = {}, -) { - const currentTarget = {}; - return { - button: overrides.button ?? 0, - clientX: overrides.clientX ?? 40, - clientY: overrides.clientY ?? 12, - currentTarget, - detail, - preventDefault: vi.fn(), - target: overrides.sameTarget === false ? {} : currentTarget, - } as unknown as Parameters< - ReturnType["onMouseDown"] - >[0]; -} - -describe("macOS title bar dragging", () => { - it("starts dragging on a primary single click of the region itself", () => { - const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; - const handlers = createTitleBarDragHandlers(actions); - const event = mouseEvent(1); - - handlers.onMouseDown(event); - - expect(event.preventDefault).toHaveBeenCalledOnce(); - expect(actions.startDragging).toHaveBeenCalledOnce(); - expect(actions.doubleClick).not.toHaveBeenCalled(); - }); - - it("runs the native preference action after an unmoved double click", () => { - const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; - const handlers = createTitleBarDragHandlers(actions); - const down = mouseEvent(2); - const up = mouseEvent(2); - - handlers.onMouseDown(down); - handlers.onMouseUp(up); - - expect(up.preventDefault).toHaveBeenCalledOnce(); - expect(actions.doubleClick).toHaveBeenCalledOnce(); - expect(actions.startDragging).not.toHaveBeenCalled(); - }); - - it("cancels the double-click action after movement", () => { - const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; - const handlers = createTitleBarDragHandlers(actions); - - handlers.onMouseDown(mouseEvent(2)); - handlers.onMouseUp(mouseEvent(2, { clientX: 41 })); - - expect(actions.doubleClick).not.toHaveBeenCalled(); - }); - - it("ignores interactive descendants and non-primary clicks", () => { - const actions = { startDragging: vi.fn(), doubleClick: vi.fn() }; - const handlers = createTitleBarDragHandlers(actions); - - handlers.onMouseDown(mouseEvent(1, { sameTarget: false })); - handlers.onMouseDown(mouseEvent(1, { button: 1 })); - handlers.onMouseDown(mouseEvent(2, { sameTarget: false })); - handlers.onMouseUp(mouseEvent(2, { sameTarget: false })); - - expect(actions.startDragging).not.toHaveBeenCalled(); - expect(actions.doubleClick).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/shell/title-bar.ts b/src/app/shell/title-bar.ts deleted file mode 100644 index 17abcf58d..000000000 --- a/src/app/shell/title-bar.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { MouseEventHandler } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { getCurrentWindow } from "@tauri-apps/api/window"; - -type TitleBarActions = Readonly<{ - startDragging: () => void; - doubleClick: () => void; -}>; - -export type TitleBarDragHandlers = Readonly<{ - onMouseDown: MouseEventHandler; - onMouseUp: MouseEventHandler; -}>; - -export function createTitleBarDragHandlers( - actions: TitleBarActions, -): TitleBarDragHandlers { - let doubleClickStart: Readonly<{ x: number; y: number }> | undefined; - - return { - onMouseDown(event) { - if ( - event.button !== 0 || - event.target !== event.currentTarget || - (event.detail !== 1 && event.detail !== 2) - ) { - return; - } - - if (event.detail === 2) { - doubleClickStart = { x: event.clientX, y: event.clientY }; - return; - } - - event.preventDefault(); - actions.startDragging(); - }, - onMouseUp(event) { - const start = doubleClickStart; - doubleClickStart = undefined; - if ( - event.button !== 0 || - event.detail !== 2 || - event.target !== event.currentTarget || - start?.x !== event.clientX || - start.y !== event.clientY - ) { - return; - } - - event.preventDefault(); - actions.doubleClick(); - }, - }; -} - -export const macTitleBarDragHandlers = createTitleBarDragHandlers({ - startDragging: () => { - void getCurrentWindow().startDragging(); - }, - doubleClick: () => { - void invoke("title_bar_double_click"); - }, -}); diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index 4e21aba94..d5b51b4b9 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -296,13 +296,16 @@ html[data-keyboard-navigation] .channelSection > summary:focus-visible { } .withPanel .panelStack { position: absolute; - grid-column: auto; + grid-column: 3; inset: 0 0 0 auto; - width: min(420px, calc(100% - 206px)); + width: min(420px, 100%); z-index: 2; box-shadow: var(--elevation-dock); margin-left: 0; } + .withPanel .sidebarResizeHandle { + z-index: 1; + } } @media (max-width: 650px) { .board, @@ -314,6 +317,7 @@ html[data-keyboard-navigation] .channelSection > summary:focus-visible { pointer-events: none; } .withPanel .panelStack { + grid-column: 1 / -1; width: 100%; } .roster { diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index b01bceac0..269060af4 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1044,6 +1044,8 @@ function ChannelSidebarResizeHandle({ width: number; setWidth(width: number): void; }) { + const handle = useRef(null); + const [renderedWidth, setRenderedWidth] = useState(width); const drag = useRef< { pointerId: number; startX: number; width: number } | undefined >(undefined); @@ -1063,16 +1065,28 @@ function ChannelSidebarResizeHandle({ document.documentElement.style.removeProperty("cursor"); document.body.style.removeProperty("user-select"); }, [move]); + const measure = useCallback(() => { + const sidebar = handle.current?.previousElementSibling; + if (!(sidebar instanceof HTMLElement)) return; + const next = Math.round(sidebar.getBoundingClientRect().width); + setRenderedWidth((current) => (current === next ? current : next)); + }, []); + useLayoutEffect(measure); + useEffect(() => { + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [measure]); useEffect(() => () => finish(), [finish]); return (
setWidth(CHANNEL_SIDEBAR_DEFAULT_WIDTH)} @@ -1088,10 +1102,14 @@ function ChannelSidebarResizeHandle({ onPointerDown={(event) => { if (event.button !== 0) return; event.preventDefault(); + const sidebar = event.currentTarget.previousElementSibling; drag.current = { pointerId: event.pointerId, startX: event.clientX, - width, + width: + sidebar instanceof HTMLElement + ? sidebar.getBoundingClientRect().width + : renderedWidth, }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", finish, { once: true }); diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index 3c8795464..aa08a1b0a 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -288,6 +288,39 @@ test("bento surfaces, centered tabs, real link panel and compact community navig ).toBeVisible(); }); +test("narrow link panels begin after the rendered sidebar", async ({ + page, + app, +}) => { + await page.setViewportSize({ width: 1280, height: 832 }); + await open(page, app); + await page + .getByRole("separator", { name: "Resize channel sidebar" }) + .press("End"); + await page.setViewportSize({ width: 800, height: 600 }); + await link(page, app, "https://github.com/block/buzz/pull/7"); + + const sidebar = await box( + page.getByRole("complementary", { name: "Channel sidebar" }), + ); + const conversation = await box( + page.getByRole("article", { name: "Conversation", exact: true }), + ); + const dock = await box(panel(page)); + near(conversation.x - sidebar.x - sidebar.width, 8); + near(dock.x, conversation.x); + expect(dock.x).toBeGreaterThanOrEqual(sidebar.x + sidebar.width); + expect( + await page + .getByRole("separator", { name: "Resize channel sidebar" }) + .evaluate((element) => Number(getComputedStyle(element).zIndex)), + ).toBeLessThan( + await panel(page).evaluate((element) => + Number(getComputedStyle(element.parentElement).zIndex), + ), + ); +}); + readingTest( "panel resizing preserves bottom follow and the visible reading anchor", async ({ page, app }) => { diff --git a/tests/browser/navigation-sidebar.spec.mjs b/tests/browser/navigation-sidebar.spec.mjs index a65f33aa8..39c45b716 100644 --- a/tests/browser/navigation-sidebar.spec.mjs +++ b/tests/browser/navigation-sidebar.spec.mjs @@ -76,20 +76,18 @@ test("channel sidebar resizes from the full gutter and persists", async ({ "Drag to resize · Double-click to reset", ); await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); - expect( - await handle.evaluate( - (element) => getComputedStyle(element, "::before").opacity, - ), - ).toBe("0"); - await expect - .poll( - () => - handle.evaluate( - (element) => getComputedStyle(element, "::before").opacity, - ), - { timeout: 1_000 }, - ) - .toBe("1"); + const tooltip = () => + handle.evaluate((element) => { + const style = getComputedStyle(element, "::before"); + return { delay: style.transitionDelay, opacity: style.opacity }; + }); + await expect.poll(tooltip).toEqual({ delay: "0.6s", opacity: "0" }); + await page.addStyleTag({ + content: `[aria-label="Resize channel sidebar"]:hover::before { + transition: none !important; + }`, + }); + await expect.poll(async () => (await tooltip()).opacity).toBe("1"); await handle.press("ArrowRight"); await expect @@ -130,6 +128,44 @@ test("channel sidebar resizes from the full gutter and persists", async ({ await expect .poll(async () => (await sidebar.boundingBox())?.width) .toBeCloseTo(260, 0); + + await handle.press("End"); + await expect + .poll(async () => (await sidebar.boundingBox())?.width) + .toBeCloseTo(520, 0); + const wideViewport = page.viewportSize(); + await page.setViewportSize({ width: 800, height: wideViewport.height }); + const constrained = await sidebar.boundingBox(); + expect(constrained.width).toBeLessThan(520); + await expect(handle).toHaveAttribute( + "aria-valuenow", + String(Math.round(constrained.width)), + ); + + await page.setViewportSize(wideViewport); + await expect + .poll(async () => (await sidebar.boundingBox())?.width) + .toBeCloseTo(520, 0); + await page.setViewportSize({ width: 800, height: wideViewport.height }); + const constrainedGrip = await handle.boundingBox(); + await page.mouse.move( + constrainedGrip.x + constrainedGrip.width / 2, + constrainedGrip.y + constrainedGrip.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + constrainedGrip.x + constrainedGrip.width / 2 - 32, + constrainedGrip.y + constrainedGrip.height / 2, + ); + await page.mouse.up(); + await expect + .poll(async () => (await sidebar.boundingBox())?.width) + .toBeLessThan(constrained.width - 24); + const explicitlyResized = await sidebar.boundingBox(); + await page.setViewportSize(wideViewport); + await expect + .poll(async () => (await sidebar.boundingBox())?.width) + .toBeCloseTo(explicitlyResized.width, 0); }); sessionSidebar( From 460f37e7a2e7ed22f95ca5485c5be5b84923bcba Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 22 Sep 2026 16:32:10 +0100 Subject: [PATCH 09/12] fix(messages): align keyboard sidebar resizing Signed-off-by: klopez4212 --- src/bundled/channels/ChannelsPage.tsx | 4 ++-- tests/browser/navigation-sidebar.spec.mjs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 269060af4..5b3fa6169 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1092,8 +1092,8 @@ function ChannelSidebarResizeHandle({ onDoubleClick={() => setWidth(CHANNEL_SIDEBAR_DEFAULT_WIDTH)} onKeyDown={(event) => { const step = event.shiftKey ? 48 : 16; - if (event.key === "ArrowLeft") setWidth(width - step); - else if (event.key === "ArrowRight") setWidth(width + step); + if (event.key === "ArrowLeft") setWidth(renderedWidth - step); + else if (event.key === "ArrowRight") setWidth(renderedWidth + step); else if (event.key === "Home") setWidth(CHANNEL_SIDEBAR_MIN_WIDTH); else if (event.key === "End") setWidth(CHANNEL_SIDEBAR_MAX_WIDTH); else return; diff --git a/tests/browser/navigation-sidebar.spec.mjs b/tests/browser/navigation-sidebar.spec.mjs index 39c45b716..2d9de697e 100644 --- a/tests/browser/navigation-sidebar.spec.mjs +++ b/tests/browser/navigation-sidebar.spec.mjs @@ -147,6 +147,14 @@ test("channel sidebar resizes from the full gutter and persists", async ({ .poll(async () => (await sidebar.boundingBox())?.width) .toBeCloseTo(520, 0); await page.setViewportSize({ width: 800, height: wideViewport.height }); + await handle.press("ArrowLeft"); + await expect + .poll(async () => (await sidebar.boundingBox())?.width) + .toBeLessThan(constrained.width - 8); + await handle.press("End"); + await expect + .poll(async () => (await sidebar.boundingBox())?.width) + .toBeCloseTo(constrained.width, 0); const constrainedGrip = await handle.boundingBox(); await page.mouse.move( constrainedGrip.x + constrainedGrip.width / 2, From 778457daeba2505f9587b2cc17ae0ce1726da5a3 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 22 Sep 2026 16:33:01 +0100 Subject: [PATCH 10/12] fix(messages): measure keyboard resize origin Signed-off-by: klopez4212 --- src/bundled/channels/ChannelsPage.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 5b3fa6169..3b6e02e38 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1092,8 +1092,13 @@ function ChannelSidebarResizeHandle({ onDoubleClick={() => setWidth(CHANNEL_SIDEBAR_DEFAULT_WIDTH)} onKeyDown={(event) => { const step = event.shiftKey ? 48 : 16; - if (event.key === "ArrowLeft") setWidth(renderedWidth - step); - else if (event.key === "ArrowRight") setWidth(renderedWidth + step); + const sidebar = event.currentTarget.previousElementSibling; + const currentWidth = + sidebar instanceof HTMLElement + ? sidebar.getBoundingClientRect().width + : renderedWidth; + if (event.key === "ArrowLeft") setWidth(currentWidth - step); + else if (event.key === "ArrowRight") setWidth(currentWidth + step); else if (event.key === "Home") setWidth(CHANNEL_SIDEBAR_MIN_WIDTH); else if (event.key === "End") setWidth(CHANNEL_SIDEBAR_MAX_WIDTH); else return; From 8489936a197c7e49ae1f1939dac00aac0e7e36bb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 22 Sep 2026 17:44:43 +0100 Subject: [PATCH 11/12] test(messages): stabilize sidebar tooltip reveal Signed-off-by: klopez4212 --- tests/browser/navigation-sidebar.spec.mjs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/browser/navigation-sidebar.spec.mjs b/tests/browser/navigation-sidebar.spec.mjs index 2d9de697e..74aa5da65 100644 --- a/tests/browser/navigation-sidebar.spec.mjs +++ b/tests/browser/navigation-sidebar.spec.mjs @@ -75,19 +75,21 @@ test("channel sidebar resizes from the full gutter and persists", async ({ "data-tooltip", "Drag to resize · Double-click to reset", ); - await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); const tooltip = () => handle.evaluate((element) => { const style = getComputedStyle(element, "::before"); - return { delay: style.transitionDelay, opacity: style.opacity }; + return { + delay: style.transitionDelay, + opacity: style.opacity, + visibility: style.visibility, + }; }); - await expect.poll(tooltip).toEqual({ delay: "0.6s", opacity: "0" }); - await page.addStyleTag({ - content: `[aria-label="Resize channel sidebar"]:hover::before { - transition: none !important; - }`, + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); + await expect.poll(async () => (await tooltip()).delay).toBe("0.6s"); + await expect.poll(tooltip).toMatchObject({ + opacity: "1", + visibility: "visible", }); - await expect.poll(async () => (await tooltip()).opacity).toBe("1"); await handle.press("ArrowRight"); await expect From 21bec3516c946b68b83f6899b6f56c3999eaf3f7 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 22 Sep 2026 20:07:51 +0100 Subject: [PATCH 12/12] test(messages): restore child session search coverage Signed-off-by: klopez4212 --- tests/browser/session-search.spec.mjs | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/browser/session-search.spec.mjs diff --git a/tests/browser/session-search.spec.mjs b/tests/browser/session-search.spec.mjs new file mode 100644 index 000000000..1e6c164bb --- /dev/null +++ b/tests/browser/session-search.spec.mjs @@ -0,0 +1,70 @@ +import { test, expect } from "./fixture.mjs"; + +const parent = "11111111-1111-4111-8111-111111111111"; +test.use({ + sessionChannels: ["alpha"], + sessionParents: { alpha: parent }, + historyCounts: { alpha: 5, beta: 5 }, +}); + +// Native details toggle events and hidden descendants need real browser coverage. +test("global search opens a child session without changing collapsed sidebar levels", async ({ + page, + app, +}) => { + await page.goto(app.origin); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const parentRow = page.locator(`button[data-channel-id="${parent}"]`); + const child = sidebar.locator('button[data-channel-id="alpha"]'); + const section = sidebar.locator("details").filter({ has: parentRow }); + await expect(child).toBeVisible(); + await sidebar + .getByRole("button", { name: `Collapse sessions in ${parent}` }) + .click(); + await expect(child).toBeHidden(); + await section.locator("summary").click(); + await expect(section).not.toHaveAttribute("open"); + + await page.getByRole("button", { name: "Search Buzz", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Search Buzz" }); + const search = dialog.getByRole("combobox", { name: "Search Buzz" }); + await search.fill("Alpha"); + const result = dialog + .locator("[data-search-result]") + .filter({ hasText: "Alpha" }) + .first(); + await expect(result).toBeVisible(); + await search.press("ArrowDown"); + await expect(search).toBeFocused(); + await expect(result).toHaveAttribute("aria-selected", "true"); + await expect(search).toHaveAttribute( + "aria-activedescendant", + await result.getAttribute("id"), + ); + await page.keyboard.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect(child).toBeHidden(); + await expect( + page.getByRole("textbox", { name: "Message this session", exact: true }), + ).toBeVisible(); + + // Remount so accidental onToggle persistence cannot be hidden by local state. + await page.getByRole("button", { name: "Home", exact: true }).first().click(); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await expect(section).not.toHaveAttribute("open"); + await section.locator("summary").focus(); + await page.keyboard.press("Enter"); + await expect( + sidebar.getByRole("button", { name: `Expand sessions in ${parent}` }), + ).toBeVisible(); + await expect(child).toBeHidden(); + await page.keyboard.press("Space"); + await expect(section).not.toHaveAttribute("open"); +});