Skip to content

fix(window): restore position before size and never fall back to a sub-minimum window - #2405

Merged
wesbillman merged 12 commits into
block:mainfrom
cheng-block:cheng/fix-window-restore-scale
Nov 5, 2026
Merged

wesbillman merged 12 commits into
block:mainfrom
cheng-block:cheng/fix-window-restore-scale

Conversation

@cheng-block

@cheng-block cheng-block commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Scope: fix how the main window restores its saved size after the monitor it was saved on is unplugged and the remaining monitor has another scale. Non-goals: storing the scale in the state file (tauri-plugin-window-state stays the only writer), plugin version changes, onboarding geometry. Production diff is 34 added and 10 removed lines in src-tauri/src/window_state.rs, plus tests. The case with several monitors left needs more logic and is handled in a follow-up PR that builds on this one. It and the remaining limitations are listed under Known gaps.

What was wrong

Quit Buzz with the window on a 1x external monitor, unplug it, and relaunch on a 2x laptop. The window comes back at half size, at or below the 480x400 minimum. The plugin then saves that small size, so later launches keep it.

Why

The plugin saves size in physical pixels. A 602x470 window saved at 1x is 301x235 points at 2x, below minWidth/minHeight in tauri.conf.json. macOS does not clamp a programmatic resize to the minimum (tao platform_impl/macos/util/async.rs:88-101), so it can open below it; a later user resize is clamped to it.

  • When no monitor contains the saved rect, reachable_client (src-tauri/src/window_state.rs:215-220 on main) moves the window onto the primary monitor but keeps the physical size.
  • When the external sat to the right of the laptop, the saved rect can still fall inside the laptop's physical rect (3024 px wide at 2x), so it counts as reachable and is restored as saved.
  • In the fallback case, the planned rect is in the fallback monitor's physical pixels, but tao's macOS and GTK setters convert physical values with the window's current scale (tao 0.37.2 platform_impl/macos/window.rs:702-731, platform_impl/linux/window.rs:472-510). When Buzz starts on a monitor with another scale, the window lands at the wrong size and place.

Separately, restore_geometry_from resized before it moved (window_state.rs:158-159 on main). On Windows, moving to a monitor with another DPI rescales the window (tao platform_impl/windows/event_loop.rs, WM_DPICHANGED), which undoes a resize sent first. The plugin's own restore_state moves first.

What changed

  • restore_request gets the fallback monitor's scale and the main window config, and stays pure. If the saved size is below the configured minimum at that scale, it was saved at a lower scale, and it gets the configured default size (1200x800) instead, still capped to the work area and horizontally centered at its top. The minimum is converted to pixels with the rounding tao uses.
  • On Windows tao applies the minimum to Buzz's undecorated frame, and the shadow insets come off the saved client (tao platform_impl/windows/event_loop.rs:1783-1810, :2124-2130), so a window at the minimum is saved below it (464x391 at 100%). The check there compares with that client: the rounded minimum minus the insets calculate_insets_for_dpi computes (windows/util.rs:419-447) with default metrics (4 px size frame plus 4 px padded border, scaled by DPI) and the Windows 11 top inset.
  • Windows 10 has no top inset, so its client is taller and still passes (a Windows 10 save up to that one pixel short of its own client also passes). Anything below the Windows 11 client is replaced, since tao does not clamp a programmatic resize on Windows either (it answers WM_WINDOWPOSCHANGING without DefWindowProc, windows/event_loop.rs:1145).
  • A sub-minimum size is replaced when the saved rect is unreachable, and also when only one monitor is left, since that monitor's scale is known even if the rect still falls inside it. With more monitors attached, a reachable rect is kept as saved.
  • A relocated window is sent in the fallback monitor's logical units on macOS and Linux. Windows keeps physical values, since its monitor and window positions are true physical pixels.
  • set_position now runs before set_size.

Known gaps

Handled in a follow-up PR that builds on this one (opens after this one merges):

  • Two or more monitors left after unplugging, with the saved rect still inside one of them. Example: a 2x laptop and a second 2x monitor remain after a 1x external is unplugged. The rect stays reachable, so this PR keeps it as saved and it opens at half size, at or below the minimum. The follow-up keeps a rect only if a monitor under its header has a scale at which the saved size meets the minimum, and still keeps a header that spans a 1x and a 2x monitor.

Not fixed here or in the follow-up:

  • When the saved monitor is gone, its scale is unknown, so only a size that would fall below the minimum is replaced. A window saved at 960x800 pixels or larger on a 1x monitor is still at least the minimum at 2x and opens at half its points; the default 1200x800 opens at 600x400 points. A 2x window resized to 600x400 points saves the same numbers, so the two cannot be told apart.
  • Exact geometry when the saved monitor is still attached, the saved rect is reachable, and its scale differs from the monitor Buzz starts on. On macOS each monitor's physical rect is its point rect times its own scale, so rects overlap when scales differ. A 1x external to the right of a 2x laptop overlaps the laptop's 0 to 3024 px range, and a saved rect there has two valid readings, one per monitor. Monitor rects alone cannot tell which one was meant without the scale at save time, which only the plugin could store.
  • A Windows machine with a wider window border than the default (user-wide SM_CXSIZEFRAME / SM_CXPADDEDBORDER) saves a smaller client at the minimum, so a window dragged to the minimum there is reset to the default size. Reading tao's get_frame_thickness would cover it; this PR assumes the default.

How verified

  • New tests:
    • saved_size_is_kept_with_monitors_attached_and_recovered_on_a_lone_monitor
    • fallback_uses_default_size_when_saved_size_is_below_minimum_at_its_scale
    • minimum_allows_windows_shadow_insets_but_a_lower_scale_size_is_replaced: Windows minimum clients at 100%, 125% and 200% are kept only on Windows, and one pixel under them is replaced everywhere. A 1x save of 900x750 on a 2x laptop is replaced everywhere. At 110 and 100 DPI the rounded minimum and the Windows client are checked on both sides. A minimum saved one step lower, including 225% to 250%, is replaced everywhere.
  • Existing restore_request tests now also check whether the window was relocated and at which scale.
  • Removing the lone-monitor rule, the default size, the fallback scale in the result, the rounding, either Windows inset, or the top inset each makes at least one of them fail.
  • The Windows branch was also run on macOS by forcing cfg!(windows) to true; CI runs only the host's branch.
  • bin/cargo test -p Buzz --lib --locked window_state -- --skip onboarding_snapshot_is_kept_until_next_launch_restore: 15 passed. That skipped test segfaults on macOS on unmodified main too. It calls ns_window() through MockRuntime.
  • bin/cargo clippy -p Buzz --lib --tests --locked -- -D warnings, bin/cargo fmt --all --check and git diff --check pass.
  • MockRuntime geometry setters are no-ops, so neither the call order nor the logical-unit conversion has automated coverage. The in-app check below covers the macOS fallback path. The Windows inset values follow tao's code and the default metrics but were not measured on a Windows machine.

In-app check

Build: this PR at 94bc460e5 (later commits only rerun CI and merge main; no code change), #2402, #2403 and #2404, merged locally onto main at cb8c971e9. Run as the macOS desktop app with BUZZ_DEV_VIEWER= just desktop on a 2x laptop with two 1x external monitors. Window bounds were read from the window server (CGWindowListCopyWindowInfo), not by eye.

  • Setup: I quit with the window on a 1x external. The saved state was 1201x714 pixels, below the 800 pixel minimum height at 2x. Then I unplugged both externals, leaving only the 2x laptop (1728x1117 points).
  • First relaunch: Buzz used the one-shot onboarding snapshot instead (onboarding-window-restore.json is used once, then deleted). That snapshot was about 1720x1410 pixels at 1x, so the window opened at 860x705 points. That is above the minimum at half the points, the first "Not fixed here or in the follow-up" case under Known gaps.
  • I quit, put back the state saved on the 1x monitor, and relaunched. The window opened at 1200x800 points at (264, 33), horizontally centered under the menu bar.

buzz-review-completed

cheng-block and others added 8 commits November 3, 2026 23:07
…b-minimum window

Restore now moves the main window before resizing it, as
tauri-plugin-window-state does, so a scale change on the move (Windows
DPI change) cannot undo the saved physical size.

When the saved monitor is gone, a physical size saved at another scale
can fall below minWidth/minHeight at the fallback monitor's scale. The
window then opened clamped and re-saved the smaller size on every
launch. Use the configured default size in that case.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
Co-authored-by: Cursor <cursoragent@cursor.com>
…located geometry in its logical units

A rect saved on a 1x external to the right of a 2x laptop can still fall
inside the laptop's physical rect after the external is unplugged, so it
counted as reachable and skipped the minimum check. With a single monitor
its scale is known, so apply the check there too.

macOS and GTK setters convert physical values with the window's current
scale, so a relocated window planned at the fallback monitor's scale is
now sent in that monitor's logical units. Windows keeps physical values.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
Replace the single-monitor special case with one rule: a monitor only
holds the saved rect if the saved size meets the minimum at that
monitor's scale. A smaller size was saved at a lower scale, so with two
2x monitors left after unplugging a 1x external the window is now
relocated with the default size instead of opening clamped.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
…m rounding

Keep header visibility geometric again and check the minimum separately:
at least one monitor under the header must have a scale at which the
saved size fits. A 1x window spanning onto a 2x laptop stays where it
was. Compare against the minimum converted to pixels with tao's rounding,
so a minimum-size window under a custom Windows scale is not reset.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
…s minimum rounding"

This reverts commit 7e2393e.

Signed-off-by: Cheng Peng <cheng@block.xyz>
…scale"

This reverts commit 7de4895.

Signed-off-by: Cheng Peng <cheng@block.xyz>
…e fix to the core cases

Move the sub-minimum check into restore_request. It converts the
configured minimum to pixels at the fallback monitor's scale with the
rounding tao uses, so a lone monitor at a custom Windows scale keeps a
window that is exactly at the minimum. reachable_client now only gets
the default size to use, and the setters send a relocated window in
logical units through one branch. Behavior for the unplugged external
monitor case is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
On Windows, tao applies minWidth/minHeight to the undecorated frame and
the shadow insets come off the client, so the plugin saves a client a
few pixels below the minimum at the same scale. The lone-monitor rule
then reset that window to the default size on every launch.

Treat a size as saved at a lower scale only when it is more than 8%
below the minimum at the fallback's scale. The insets are about 2 to 3%;
standard scale steps are at least 11% apart, and macOS and GTK enforce
the minimum on the client.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 4, 2026
Replace the single-monitor special case with one rule: a monitor only
holds the saved rect if the saved size meets the minimum at that
monitor's scale. A smaller size was saved at a lower scale, so with two
2x monitors left after unplugging a 1x external the window is now
relocated with the default size instead of opening clamped.

(cherry picked from commit 7de4895, resolved onto the restructured
restore_request / reachable_client from block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 4, 2026
Keep header visibility geometric again and check the minimum separately:
at least one monitor under the header must have a scale at which the
saved size fits. A 1x window spanning onto a 2x laptop stays where it
was.

(cherry picked from commit 7e2393e, resolved onto the restructured
restore_request / reachable_client from block#2405; its minimum rounding part
is already in block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
macOS does not clamp a programmatic resize to the minimum, so the 8%
allowance let a 1x save of 900x750 open at 450x375 points on a 2x
laptop. Keep the exact minimum (rounded as tao rounds it) on macOS and
Linux, and allow 8% only on Windows, where tao applies the minimum to
the undecorated frame and the shadow insets come off the saved client.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 4, 2026
Replace the single-monitor special case with one rule: a monitor only
holds the saved rect if the saved size meets the minimum at that
monitor's scale. A smaller size was saved at a lower scale, so with two
2x monitors left after unplugging a 1x external the window is now
relocated with the default size instead of opening clamped.

(cherry picked from commit 7de4895, resolved onto the restructured
restore_request / reachable_client from block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 4, 2026
Keep header visibility geometric again and check the minimum separately:
at least one monitor under the header must have a scale at which the
saved size fits. A 1x window spanning onto a 2x laptop stays where it
was.

(cherry picked from commit 7e2393e, resolved onto the restructured
restore_request / reachable_client from block#2405; its minimum rounding part
is already in block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
…adow insets

The 8% allowance kept sizes that were already below the minimum, such as
a 1x save of 900x750 on a lone 2x laptop. tao does not clamp a
programmatic resize on Windows either: it answers WM_WINDOWPOSCHANGING
itself without DefWindowProc. Compare instead against the client tao
leaves at the minimum, the frame minimum minus the undecorated shadow
insets from calculate_insets_for_dpi with default metrics and the
Windows 11 top inset. macOS and Linux still use the rounded minimum.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 4, 2026
Replace the single-monitor special case with one rule: a monitor only
holds the saved rect if the saved size meets the minimum at that
monitor's scale. A smaller size was saved at a lower scale, so with two
2x monitors left after unplugging a 1x external the window is now
relocated with the default size instead of opening clamped.

(cherry picked from commit 7de4895, resolved onto the restructured
restore_request / reachable_client from block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 4, 2026
Keep header visibility geometric again and check the minimum separately:
at least one monitor under the header must have a scale at which the
saved size fits. A 1x window spanning onto a 2x laptop stays where it
was.

(cherry picked from commit 7e2393e, resolved onto the restructured
restore_request / reachable_client from block#2405; its minimum rounding part
is already in block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
@cheng-block
cheng-block marked this pull request as ready for review November 4, 2026 23:11
@cheng-block
cheng-block requested a review from a team as a code owner November 4, 2026 23:11
cheng-block and others added 2 commits November 4, 2026 16:39
Signed-off-by: Cheng Peng <cheng@block.xyz>
Co-authored-by: Cursor <cursoragent@cursor.com>
* upstream/main: (91 commits)
  test(browser): click timeline rows only after the timeline settles (block#2560)
  ci: split Vitest across six shards so 3/4 stops hitting the job cap (block#2571)
  feat(github): unfurl PR and issue links inline with their title (block#1693)
  fix(auth): style federated sign-in callback pages (block#2549)
  fix(agents): align creation model controls with identity (block#2480)
  Run native Codex through plugin execution targets (block#2150)
  test: split Solo access browser scenarios (block#2499)
  feat(agents): let any agent change Buzz settings through the MCP (block#2248)
  Let plugins decorate host sidebar rows and add sort modes (block#1853)
  NEXUS-4082: Keep the new-message boundary stable during a visit (block#2083)
  feat(agents): move version history into settings (block#2374)
  fix(agents): read goose Activity by saved session ids and render memory bodies on open (block#2466)
  feat(channels): starter channels: admin switch and relay auto_join flag (block#1624)
  fix(plugins): read Square HTTPS sources with the Block SSH agent (block#2465)
  feat(plugins): let agents request plugin changes for owner review (block#2280)
  fix(messages): keep the hover toolbar while its menu is open (block#2439)
  feat(workflows): suggest channel members for @ in Send Message text (block#2309)
  AIDA-2344: Exclude muted channels from Dock (block#2313)
  feat(search): add slack-style message search operators on desktop (block#2463)
  test: fix invitation and coordination checks on main (block#2517)
  ...

Signed-off-by: Cheng Peng <cheng@block.xyz>
Co-authored-by: Cursor <cursoragent@cursor.com>

@joahg joahg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Read the restore/relocate logic and tests; the sub-minimum fallback only triggers when the saved size is below the min at the fallback scale, so normal restores are unchanged. Windows-specific shadow-inset math is only covered by cfg-gated asserts, worth a hand check on Windows.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Carl, an automated reviewer, commenting via Wes’s GitHub account.

No blockers found in source review. Two independent reviewers checked restore order, the sub-minimum fallback, the logical conversion, minimum rounding and the Windows shadow slack against tao 0.37.2 and the locked tauri-plugin-window-state 2.5.0. Neither found a regression beyond the limitations the PR already discloses. For the repaired normal-window case, the plugin tracks the resize and reads current geometry on close, so it shouldn't re-save the bad size.

Validation gap: this is source-checked only. Windows native validation is skipped in CI, and no one has reproduced it on hardware. Before merge it's worth one real run on macOS: quit on a 1x external monitor, unplug it, relaunch on a 2x laptop, then quit and relaunch again to confirm the size persists.

@cheng-block

Copy link
Copy Markdown
Contributor Author

Before merge it's worth one real run on macOS: quit on a 1x external monitor, unplug it, relaunch on a 2x laptop, then quit and relaunch again to confirm the size persists.

Ran it on macOS with this branch. Window state saved small and off the laptop, relaunched on the 2x laptop: it opened at 1200x800 points, centered under the menu bar. Quit, then relaunched with only the laptop attached: still 1200x800 at the same spot, so the size persists. Bounds read from the window server, not by eye.

While doing this I also relaunched once with the 1x externals still plugged in. That hits the attached-monitor gap in the description: the window grew to fill the laptop and was saved larger on quit. It comes from the existing reachable path converting physical pixels at the startup monitor's scale, which this PR does not touch.

wesbillman pushed a commit that referenced this pull request Nov 5, 2026
…2402)

Scope: let ⇧Esc reach Mark all as read while focus is in the Channel
settings panel. Non-goals: other Escape handlers and any change to the
shortcut itself. Reuses the guard from the sibling panels. One file, 2
production lines.

## What was wrong

With focus inside a channel's settings panel, ⇧Esc (Mark all as read,
from #2170) closed the panel and marked nothing read.

## Why

On `main`, the panel's `onKeyDown` in
`src/bundled/channels/ChannelSettingsPanel.tsx:101` closes on any
`Escape` from inside the panel and calls `event.stopPropagation()`.
Since #2506 it skips events that are already handled or come from a
portalled layer, but it still has no `!event.shiftKey` check. The
sibling panels got that check in #2170
(`src/features/panels/PanelCard.tsx:32`,
`src/bundled/channels/useChannelPanels.tsx:152`). Because the panel
stops the event, the window shortcut dispatcher
(`src/features/shortcuts/service.ts`,
`src/app/shell/MarkAllReadShortcut.tsx`) never sees ⇧Esc.

The fix adds the same `!event.shiftKey` guard and comment the siblings
use, next to the #2506 checks. Plain Esc still closes the panel.

## How verified

- Extended the existing Escape test in
`src/bundled/channels/ChannelSettingsPanel.test.tsx`, "closes with
Escape or its close button without swallowing other keys": ⇧Esc does not
call `close` and reaches a `window` keydown listener; plain Esc still
closes and does not reach it. It fails without the fix and passes with
it.
- `bin/pnpm vitest run
src/bundled/channels/ChannelSettingsPanel.test.tsx`: 28 passed after
merging `main`, including #2506's handled and portalled Escape test.
- `bin/just iterate`: passed.

## In-app check

Build: this PR at `4b286248b`, with #2403, #2404 and #2405 at their
heads then, merged locally onto `main` at `cb8c971e9`. Run as the macOS
desktop app with `BUZZ_DEV_VIEWER= just desktop`. The branch was later
merged with `main` twice to resolve conflicts with #1624 (test file
only) and #2506 (the same Escape handler). The production behavior of
this fix did not change: ⇧Esc still passes through and plain Esc from a
focused control in the panel still closes it. #2506 only adds its own
skips for handled and portalled events.

With the mouse alone, focus never lands inside the panel in the WebKit
app, so these steps need macOS Keyboard navigation (System Settings >
Keyboard) or Tab focus.

1. Open Channel actions > View channel details. Press Tab until a
control in the panel shows a focus ring. Esc closes the panel.
2. Reopen the panel and Tab in again. ⇧Esc keeps the panel open and
shows "Mark everything as read?".

Where focus goes after opening the panel from the menu is a separate
issue.

buzz-review-completed

---------

Signed-off-by: Cheng Peng <cheng@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
wesbillman pushed a commit that referenced this pull request Nov 5, 2026
Scope: make the "newest message" lookup behind ↑ (select the newest
message) and react to latest use the rendered message order. Non-goals:
the same whole-second pattern in `ThreadPanel.tsx` and `unread.ts`,
which touches the unread contract and needs its own PR. Reuses
`compareMessages`; one file, 2 production lines.

## What was wrong

When several messages land in the same second (agents do this a lot), ↑
in an empty composer and react to latest (⌘⇧\) select an earlier message
instead of the newest one.

## Why

`newest()` in `src/features/messages/use-message-selection.ts:29`
compared `row.createdAt > newest.createdAt`. `createdAt` is whole
seconds and the comparison is strict, so on a tie it kept the first row.
The timeline renders rows by `compareMessages`
(`src/features/relay/message-order.ts:17-24`: send ms, then event id),
so the first row of a same-second burst is the oldest. Thread rows
render in tree order, so taking the last row is not a fix there.

The fix calls `compareMessages(row, newest) > 0` in the same reduce.
`ChannelMessage` already carries `id`, `createdAt` and `createdAtMs`. No
API change.

## How verified

- New `src/features/messages/use-message-selection.test.tsx` mounts the
hook with React Testing Library in StrictMode and calls
`selectNewestMessage`. It covers a same-second burst ordered by send ms,
a same-second tie broken by id, and an ordinary cross-second case that
skips membership notices.
- The same-second test fails on `main` (`Expected: "a"`, `Received:
"z"`) and passes with the fix.
- `pnpm vitest run src/features/messages/use-message-selection.test.tsx
src/features/messages/message-selection.test.ts
src/app/message-shortcuts.test.ts`: 14 passed.
- `just iterate` (format, lint, typecheck, frontend build): passed.

## In-app check

- Build: a local merge of the heads of #2402, #2403, #2404 and #2405 on
`main` at `cb8c971e9`. macOS desktop, started with `BUZZ_DEV_VIEWER=
just desktop`.
- In a Solo chat, an agent sent 1, 2 and 3. With an empty composer, ↑
selected 3, and ⌘⇧\ opened the reaction picker on 3.
- The same check on 0.7.8 also selected 3, so those messages did not
share a second. This hand test shows no regression. It does not show the
fixed tie.
- The same-second tie is covered by the new test in
`use-message-selection.test.tsx`, which fails on `main`.

buzz-review-completed

---------

Signed-off-by: Cheng Peng <cheng@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@wesbillman
wesbillman merged commit ad2e33f into block:main Nov 5, 2026
42 checks passed
wesbillman pushed a commit that referenced this pull request Nov 5, 2026
…menu (#2526)

Scope: after Channel actions > View channel details, leave focus on the
Channel Settings tab so Escape closes it. Non-goals: the settings
panel's own Escape handling (#2402), the shared menu, and the
`finalFocus` helper. Reuses the menu's existing `finalFocus` ref, which
Move channel > Create new already sets. One file, 12 production lines
added and 1 removed.

## What was wrong

In the macOS app, opening View channel details from the Channel actions
(…) menu with the mouse left focus on the … button. Escape then ran the
Escape mark-read shortcut instead of closing the settings tab. Escape
closed the tab only after Tab moved focus into the panel.

## Why

The menu item only called `openDetails()`
(`src/bundled/channels/ChannelHeaderMenu.tsx:88` on `main`). The menu's
`finalFocus` then returns focus to the trigger when the popup unmounts
after its exit animation. `ChannelsPage`
(`src/bundled/channels/ChannelsPage.tsx:1756`) and `PanelWorkspace`
(`src/features/panels/PanelWorkspace.tsx:298`) focus the settings tab
first. But `useFinalFocusUnlessMoved` counts focus on the page body or
in the closing menu as "not moved"
(`src/shared/design-system/ui/finalFocus.ts:59-65`). In WebKit the tab
can lose focus to the body before the menu unmounts, so the trigger
wins. With focus in the main pane header, the side pane's Escape handler
(`PanelWorkspace.tsx:376`) never sees the key.

`docs/channels.md:412-414` says View channel details opens or focuses
the Channel Settings tab. The fix sets `finalFocus.current` for that
item to the settings tab (`[data-panel-workspace]
[data-tab-value="settings"]`, the selector `ChannelsPage` already uses).
If no such tab exists, it falls back to the trigger as before. The
shared "moved" check is unchanged, so focus the person moved elsewhere
still stays put.

## How verified

- New test in `src/bundled/channels/ChannelHeaderMenu.test.tsx`, "hands
focus to the opened settings tab when the menu finishes closing after
focus drops to the body". It mounts the real menu and a `PanelWorkspace`
with a closable settings tab, holds the menu's exit with a
`getAnimations` stub, clicks the item with `userEvent`, and blurs to the
body before the menu unmounts. Then it asserts the tab has focus and
Escape calls `close`. It fails on `main` with focus on the Channel
actions button and passes with the fix. The path where focus stays on
the tab is already covered by `tests/browser/channel-settings.spec.mjs`
and `channel-header-menu.spec.mjs` in Chromium and WebKit.
- `bin/pnpm vitest run` on `ChannelHeaderMenu`, `PanelWorkspace`,
`ChannelSettingsPanel`, and the five `ChannelsPage*` test files: 144
passed.
- `bin/just iterate`: passed on the first commit. The second commit
changes only the test.
- jsdom and the Playwright click do not reproduce the WebKit focus drop,
so the test forces it with the blur.
- Hook exception, with my OK: I pushed with
`LEFTHOOK_EXCLUDE=types-and-related-unit-tests`, so that one pre-push
job was skipped and the other pre-push jobs ran. On the current head
that job's type check passed and its full Vitest run failed only two
`ChannelMembersDialog.test.tsx` tests (2 failed, 17351 passed). Those
two fail the same way at `40401e4d8` without this change, both locally
and in `main` CI (`JavaScript (4/4)`).

## In-app check

Found in hand testing on the macOS app, and verified there with this
fix.

Build: this PR at `75fef69f8` (later commits only rerun CI and merge
`main`; no code change) with #2402, #2403, #2404 and #2405, merged
locally onto `main` at `cb8c971e9`. Run as the macOS desktop app with
`BUZZ_DEV_VIEWER= just desktop`.

1. In a regular channel, open Channel actions (…) > View channel details
with the mouse.
2. Press Esc right away, without clicking anything else or pressing Tab.
3. The Channel Settings tab closes. Before the fix, it stayed open and
Esc marked the conversation as read.

buzz-review-completed

---------

Signed-off-by: Cheng Peng <cheng@block.xyz>
Co-authored-by: Cursor <cursoragent@cursor.com>
wpfleger96 added a commit that referenced this pull request Nov 5, 2026
…-service

* origin/main: (29 commits)
  fix(agents): treat what an agent reads as information, and give answering rules only to mentions (#2456)
  feat: add up arrow message editing preference (#2659)
  fix(channels): move focus to channel settings opened from the header menu (#2526)
  fix(window): restore position before size and never fall back to a sub-minimum window (#2405)
  fix(messages): pick the latest message with the shared ordering (#2403)
  fix(channels): let shift+escape through the channel settings panel (#2402)
  fix(builderlab): clarify signed-out agent settings (#2602)
  feat(builderlab): preselect the catalog's recommended model (#2552)
  feat(host-process): set BUZZ_VERSION for every spawned process (#2674)
  fix(agents): finish Delete for a synced agent with no config (#2692)
  ci(flake-check): select related tests within a 9-minute lane budget (#2607)
  feat(macos): show the closed window again when Buzz becomes active (#2658)
  fix(channels): show channel actions as soon as the menu opens (#2657)
  fix(huddles): confirm before starting a huddle from the header (#2687)
  perf(composer): reuse per-keystroke projections, snapshots and reference scans (#2590)
  perf(agents): skip the activity snapshot compare for unrelated channel traffic (#2586)
  test(solo): wait for the live header before opening a joined viewer's access (#2599)
  fix(messages): show who reacted in channels where you can't react (#2601)
  feat(agents): clarify remote execution across desktop (#2600)
  perf(relay): size paged history with the cached per-event sizes (#2597)
  ...

# Conflicts:
#	src-tauri/src/host_process/tests.rs
wesbillman pushed a commit that referenced this pull request Nov 5, 2026
Scope checkpoint: end bare `http(s)://` and `buzz://` links at fullwidth
and CJK punctuation in rendered messages and in `messageLinkParts`, keep
CJK letters inside URLs, and leave explicit `<url>` and `[label](url)`
links unchanged. Non-goals: links without a scheme (`www.`), Markdown
link syntax, link previews. Files: `message-link-parts.ts`,
`MessageMarkdown.tsx`, their tests, and one line in `docs/buzz-help.md`.
Budget: about 30 production lines (actual +30/−1).

## What was wrong

A bare link followed by Chinese or Japanese punctuation took the
punctuation and any text after it into the link.
`https://github.com/block/buzz-app。` opened `…/buzz-app%E3%80%82` (404).
`看这个 https://example.com/spec,大家看一下` linked the whole rest of the
sentence. `(https://example.com)` opened the host `example.com)`,
because the URL parser folds `)` to `)`.

## Why

Two places find bare URLs, and both end them only at whitespace:

- Rendered messages get bare links from `remark-gfm`. Its `findUrl`
transform (`mdast-util-gfm-autolink-literal` `lib/index.js:151`) reads
up to the next space and trims only ASCII punctuation (`splitUrl`, line
241). `messageLinkParts` never sees these URLs; it only gets the text
around them.
- `messageLinkParts` (`src/features/messages/message-link-parts.ts:98`)
drives the composer link decorations, chat cards and clipboard. Its bare
URL pattern is `` [^\s<>"`]+ ``, so the same input fails there too.

## How verified

- `message-link-parts.ts:98`: the bare URL pattern now also stops at
fullwidth and CJK punctuation
(`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、…~〜`) and curly quotes (`“”‘’`),
which Chinese text uses as its quotes. CJK letters are still allowed, so
`https://zh.wikipedia.org/wiki/中文` stays whole.
- In the host name (before any `:`, `/`, `?` or `#`), `。.。` followed by
an ASCII host label continue the link (unless another `://` follows that
label), because the URL parser reads them as `.`:
`https://github。com/block/buzz-app` still opens github.com, as on
`main`. After the host name, including after a port, they end it. `\s`
already covers the ideographic space. Bracket pairs need no extra
trimming, because the match stops before them.
- `MessageMarkdown.tsx:342`: a small remark step runs after
`remark-gfm`. When `messageLinkParts` splits a literal autolink
differently, the step replaces it with the parts `messageLinkParts`
finds, so `https://a.com,https://b.com` becomes two links with the `,`
between them as text. Links whose source is `<url>` or `[url](url)` keep
their written destination.
- `docs/buzz-help.md`: one bullet says where a bare http(s) or buzz link
ends and that `<url>` keeps those characters.
- New tests: `message-link-parts.test.ts` "ends a bare URL at fullwidth
punctuation but keeps CJK letters" (18 cases, plus one that
`https://a.com。https://b.com` gives two links), and
`MessageMarkdown.test.tsx` "ends a bare link at fullwidth punctuation"
(9 cases, checks the rendered `href`). 23 of the 28 fail on `main`. The
other 5 guard IRIs, `<url>` and hosts written with `。`.
- `bin/pnpm vitest run src/features/messages/message-link-parts.test.ts
src/features/messages/MessageMarkdown.test.tsx`: 175 passed after the
latest merge with `main`.
- `bin/pnpm vitest run src/features/messages src/features/conversation
src/features/relay src/bundled/links src/bundled/relay-notes`: 5574
passed, 4 skipped.
- `bin/just iterate` (format, lint, typecheck, build): passed.
- Known limits: GFM reads Markdown link syntax glued to a bare URL
(`https://a.com,[label](https://b.com)`) as part of that URL, so the
brackets still show, as they do on `main`. A bare URL that really
contains one of these characters, such as a typographic `’` in a title,
now ends there; `<url>` keeps it whole. A host followed by `。` and an
English word (`https://example.com。OK`) still reads the word as part of
the host, as on `main`.

## In-app check

Build: this PR at `2dd13819d`, the SHA I tested by hand (the branch was
later merged with `main` to resolve conflicts in `docs/buzz-help.md`; no
behavior change), with #2402, #2403 and #2405, merged locally onto
`main` at `cb8c971e9`. Run as the macOS desktop app with
`BUZZ_DEV_VIEWER= just desktop`.

I sent these nine lines as one message in a Solo chat. The link on each
line covered exactly this text:

| # | Line | Linked text |
| --- | --- | --- |
| 1 | `https://github.com/block/buzz-app。` |
`https://github.com/block/buzz-app` |
| 2 | `看这个 https://example.com/spec,大家看一下` | `https://example.com/spec`
|
| 3 | `https://a.com,https://b.com` | `https://a.com` and
`https://b.com`, two links |
| 4 | `https://zh.wikipedia.org/wiki/中文` | the whole URL |
| 5 | `(https://example.com)` | `https://example.com` |
| 6 | `<https://example.com/a。b>` | the whole URL |
| 7 | `https://github。com/block/buzz-app` | the whole URL |
| 8 | `http://localhost:3000。OK` | `http://localhost:3000` |
| 9 | `https://github。com:/block/buzz-app` | the whole URL |

Clicking lines 1 and 7 opened github.com/block/buzz-app, with no
`%E3%80%82` in the address.

## Screenshots

The same message in the installed 0.7.8 release and in the build above.
In 0.7.8, lines 1, 2, 3 and 5 take the punctuation into the link, and
line 8 is not linked at all.

| Before (0.7.8) | After (this PR) |
| --- | --- |
| <img width="360" alt="Before, Buzz 0.7.8: the nine test lines, with
punctuation inside links on lines 1, 2, 3 and 5 and line 8 not linked"
src="https://github.com/user-attachments/assets/064dd7ff-cde4-476e-887c-7310288254d2"
/> | <img width="360" alt="After, this PR: the nine test lines, with
each link covering the text listed in the table above"
src="https://github.com/user-attachments/assets/9569922c-3365-4df4-97ae-b469faab0637"
/> |

buzz-review-completed

---------

Signed-off-by: Cheng Peng <cheng@block.xyz>
Co-authored-by: Cursor <cursoragent@cursor.com>
baxen added a commit that referenced this pull request Nov 6, 2026
* origin/main: (36 commits)
  perf(mentions): rank only the shown rows and skip empty-query Space work (#2298)
  fix(messages): stop bare links at fullwidth and CJK punctuation (#2404)
  fix(agents): treat what an agent reads as information, and give answering rules only to mentions (#2456)
  feat: add up arrow message editing preference (#2659)
  fix(channels): move focus to channel settings opened from the header menu (#2526)
  fix(window): restore position before size and never fall back to a sub-minimum window (#2405)
  fix(messages): pick the latest message with the shared ordering (#2403)
  fix(channels): let shift+escape through the channel settings panel (#2402)
  fix(builderlab): clarify signed-out agent settings (#2602)
  feat(builderlab): preselect the catalog's recommended model (#2552)
  feat(host-process): set BUZZ_VERSION for every spawned process (#2674)
  fix(agents): finish Delete for a synced agent with no config (#2692)
  ci(flake-check): select related tests within a 9-minute lane budget (#2607)
  feat(macos): show the closed window again when Buzz becomes active (#2658)
  fix(channels): show channel actions as soon as the menu opens (#2657)
  fix(huddles): confirm before starting a huddle from the header (#2687)
  perf(composer): reuse per-keystroke projections, snapshots and reference scans (#2590)
  perf(agents): skip the activity snapshot compare for unrelated channel traffic (#2586)
  test(solo): wait for the live header before opening a joined viewer's access (#2599)
  fix(messages): show who reacted in channels where you can't react (#2601)
  ...

# Conflicts:
#	docs/buzz-help.md
nathan-thillairajah added a commit that referenced this pull request Nov 6, 2026
…page

* origin/main: (49 commits)
  fix(browser): recheck the FI session when the app viewer exchange returns 401 (#2678)
  fix(members): find verified people who have no profile (#2554)
  fix(dev): clear React's dev render measures so long sessions do not grow (#2495)
  Move app navigation to a dock and unify conversation headers (#2626)
  perf(mentions): rank only the shown rows and skip empty-query Space work (#2298)
  fix(messages): stop bare links at fullwidth and CJK punctuation (#2404)
  fix(agents): treat what an agent reads as information, and give answering rules only to mentions (#2456)
  feat: add up arrow message editing preference (#2659)
  fix(channels): move focus to channel settings opened from the header menu (#2526)
  fix(window): restore position before size and never fall back to a sub-minimum window (#2405)
  fix(messages): pick the latest message with the shared ordering (#2403)
  fix(channels): let shift+escape through the channel settings panel (#2402)
  fix(builderlab): clarify signed-out agent settings (#2602)
  feat(builderlab): preselect the catalog's recommended model (#2552)
  feat(host-process): set BUZZ_VERSION for every spawned process (#2674)
  fix(agents): finish Delete for a synced agent with no config (#2692)
  ci(flake-check): select related tests within a 9-minute lane budget (#2607)
  feat(macos): show the closed window again when Buzz becomes active (#2658)
  fix(channels): show channel actions as soon as the menu opens (#2657)
  fix(huddles): confirm before starting a huddle from the header (#2687)
  ...

Signed-off-by: Nathan Thillairajah <nthillairajah@squareup.com>

# Conflicts:
#	docs/browser.md
#	src-tauri/src/browser/builderlab.rs
nathan-thillairajah added a commit that referenced this pull request Nov 6, 2026
…dylan/browser-unavailable-page

* origin/dylan/browser-denied-page: (49 commits)
  fix(browser): recheck the FI session when the app viewer exchange returns 401 (#2678)
  fix(members): find verified people who have no profile (#2554)
  fix(dev): clear React's dev render measures so long sessions do not grow (#2495)
  Move app navigation to a dock and unify conversation headers (#2626)
  perf(mentions): rank only the shown rows and skip empty-query Space work (#2298)
  fix(messages): stop bare links at fullwidth and CJK punctuation (#2404)
  fix(agents): treat what an agent reads as information, and give answering rules only to mentions (#2456)
  feat: add up arrow message editing preference (#2659)
  fix(channels): move focus to channel settings opened from the header menu (#2526)
  fix(window): restore position before size and never fall back to a sub-minimum window (#2405)
  fix(messages): pick the latest message with the shared ordering (#2403)
  fix(channels): let shift+escape through the channel settings panel (#2402)
  fix(builderlab): clarify signed-out agent settings (#2602)
  feat(builderlab): preselect the catalog's recommended model (#2552)
  feat(host-process): set BUZZ_VERSION for every spawned process (#2674)
  fix(agents): finish Delete for a synced agent with no config (#2692)
  ci(flake-check): select related tests within a 9-minute lane budget (#2607)
  feat(macos): show the closed window again when Buzz becomes active (#2658)
  fix(channels): show channel actions as soon as the menu opens (#2657)
  fix(huddles): confirm before starting a huddle from the header (#2687)
  ...

Signed-off-by: Nathan Thillairajah <nthillairajah@squareup.com>

# Conflicts:
#	src-tauri/src/browser/builderlab.rs
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 6, 2026
Replace the single-monitor special case with one rule: a monitor only
holds the saved rect if the saved size meets the minimum at that
monitor's scale. A smaller size was saved at a lower scale, so with two
2x monitors left after unplugging a 1x external the window is now
relocated with the default size instead of opening clamped.

(cherry picked from commit 7de4895, resolved onto the restructured
restore_request / reachable_client from block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
(cherry picked from commit 55d8657)
cheng-block added a commit to cheng-block/buzz-app that referenced this pull request Nov 6, 2026
Keep header visibility geometric again and check the minimum separately:
at least one monitor under the header must have a scale at which the
saved size fits. A 1x window spanning onto a 2x laptop stays where it
was.

(cherry picked from commit 7e2393e, resolved onto the restructured
restore_request / reachable_client from block#2405; its minimum rounding part
is already in block#2405)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Cheng Peng <cheng@block.xyz>
(cherry picked from commit 6f018b1)
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.

3 participants