Skip to content

fix(channels): let shift+escape through the channel settings panel - #2402

Merged
wesbillman merged 4 commits into
block:mainfrom
cheng-block:cheng/fix-channel-settings-shift-esc
Nov 5, 2026
Merged

wesbillman merged 4 commits into
block:mainfrom
cheng-block:cheng/fix-channel-settings-shift-esc

Conversation

@cheng-block

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

Copy link
Copy Markdown
Contributor

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 fix(channels): render the channel details description as linked Markdown with a label-only Edit target #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

cheng-block and others added 2 commits November 3, 2026 22:57
The channel settings panel closed on any Escape and stopped propagation,
so Shift+Escape (Mark all as read) never reached the window shortcut
dispatcher. Guard it with !event.shiftKey like the sibling panels.

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>
The new case mounted the same fixture as the existing Escape test and
repeated its plain Escape assertion. Extend that test instead, as
.agents/skills/test-audit asks for near-duplicates.

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

* 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.

🤖 Guard matches the sibling panels and the test covers both Shift+Esc and plain Esc. Merge state is DIRTY, needs a rebase.

…ettings-shift-esc

* upstream/main: (40 commits)
  feat(conversation): let plugin rows manage messages without visiting a channel (block#2512)
  feat(github): make side-panel link opening optional (block#2629)
  feat(messages): show a blurred thumbnail until an image loads (block#2556)
  fix(relay): give large roster and read-state reads a 30s deadline (block#2627)
  fix(user-status): show a status emoji's :name: on hover where the full status shows (block#2384)
  fix(inbox): keep the list's place when returning in the same session (block#2134)
  fix(messages): pass verified labels to the forward recipient picker (block#2633)
  feat(messages): forward a message to a person (block#2167)
  docs(agents): design relay-facing paths for the whole fleet at once (block#2620)
  Route debug sessions through the credential helper (block#2541)
  perf(search): skip no-op slow-search resets on each keystroke (block#2605)
  perf(agents): dedupe channel members only for dm connection setup (block#2603)
  perf(search): keep the search tab row across palette keystrokes (block#2588)
  perf(sidebar): skip the hidden actions menu in solo and team headers (block#2582)
  perf(search): rank each palette name once per render (block#2606)
  perf(sidebar): skip re-rendering unchanged animated channel rows (block#2572)
  feat(settings): follow the system clock and let people hide message times (block#2440)
  feat(plugins): repository access rules for Git imports (block#2484)
  feat(desktop): add Settings to the macOS app menu (block#2594)
  Preserve and renew external app approval (block#2464)
  ...

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

@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. The ⇧Esc guard matches the sibling panels (PanelCard, PanelSubview, useChannelPanels). Plain Esc still closes and stops propagation, and the test covers both directions. ⇧Esc while typing in the settings editor now reaches the Mark all as read confirmation, which is consistent with that shortcut's allowInEditable. Independent of #2526, so they can merge in either order.

Validation: Court review of the source at this head plus green CI. No local runs.

@wesbillman
wesbillman merged commit 880e09d into block:main Nov 5, 2026
43 checks passed
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 pushed a commit that referenced this pull request Nov 5, 2026
…b-minimum window (#2405)

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

---------

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
…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
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