From 3a395de66af2be620eb7f2e38aa2a44e24e1de8c Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Thu, 5 Nov 2026 21:40:31 -0800 Subject: [PATCH 1/3] feat(messages): mark a message unread with Option-click Slack marks a message unread when you Option-click it (Alt-click on Windows and Linux). In Buzz that action was only on U for a selected message and in the message menu. An Option-click on a message's box now runs U's action on that message, through the same canMarkUnread and markUnread the U shortcut calls, without selecting it. Clicks that belong to something else are left alone: links, buttons, inputs, menu items, control groups such as reactions, images, video, audio, code blocks, the day divider, a click that ends a text selection, any other modifier, and clicks a handler already stopped or prevented. The listener is on the window so React's handlers run first. The help guide lists the gesture next to U. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- docs/buzz-help.md | 3 +- src/app/message-shortcuts.test.ts | 53 ++++++++++++++++++++++ src/app/shortcuts.ts | 4 ++ src/features/messages/message-selection.ts | 26 +++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index e29914ce89..c4767a1ffa 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -365,7 +365,8 @@ revokes all of them, and a pairing unused for 30 days expires. you change most of them and also shows the fixed message-formatting keys. In an empty composer, Up selects a message unless **Edit last message with Up Arrow** is on. With a message selected, E edits it, R reacts, T or → replies in thread, - U marks it unread, and Delete deletes your own message. + U marks it unread, and Delete deletes your own message. Option-click a message + (Alt-click on Windows and Linux) to mark it unread without selecting it. ⌘1–9 switch communities, and ⇧Esc marks everything read. If a plugin uses the same key as another shortcut, Settings → Shortcuts marks it "Also used by"; change one of them to choose which fires. In the Mac app, **Buzz → diff --git a/src/app/message-shortcuts.test.ts b/src/app/message-shortcuts.test.ts index f3ee5629e6..9dba476573 100644 --- a/src/app/message-shortcuts.test.ts +++ b/src/app/message-shortcuts.test.ts @@ -78,6 +78,59 @@ it("deletes, marks unread and replies to the selected message with Slack's keys" expect(reply).toHaveBeenCalledTimes(2); }); +it("marks a message unread with Option-click, but not from its controls, media, code, day divider or a text selection", () => { + const { controller, reply } = setup(true); + (document.activeElement as HTMLElement | null)?.blur(); + const row = document.querySelector("[data-message-id]"); + row?.insertAdjacentHTML( + "afterbegin", + "
Today
", + ); + row + ?.querySelector("[data-layout]") + ?.insertAdjacentHTML( + "beforeend", + '

Text link

code
' + + '
', + ); + const text = row?.querySelector("p"); + const click = (selector: string, init: MouseEventInit = { altKey: true }) => + row + ?.querySelector(selector) + ?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, ...init }), + ); + click("p", {}); + for (const modifier of ["shiftKey", "metaKey", "ctrlKey"]) + click("p", { altKey: true, [modifier]: true }); + for (const selector of [ + "a", + "img", + "pre", + "[aria-label='Reactions']", + "[aria-label='Reply']", + "[data-day]", + ]) + click(selector); + expect(reply).toHaveBeenCalledTimes(1); + // The end of a drag, including WebKit's Option-drag, leaves text selected. + const range = document.createRange(); + if (text) range.selectNodeContents(text); + document.getSelection()?.addRange(range); + click("p"); + document.getSelection()?.removeAllRanges(); + text?.addEventListener("click", (event) => event.preventDefault(), { + once: true, + }); + click("p"); + vi.spyOn(controller, "canMarkUnread").mockReturnValueOnce(false); + click("p"); + expect(controller.markUnread).not.toHaveBeenCalled(); + click("p"); + expect(controller.markUnread).toHaveBeenCalledExactlyOnceWith({ id: "one" }); + expect(document.activeElement).toBe(document.body); +}); + it("leaves Delete alone on someone else's message and without a selection", () => { const { controller } = setup(false); expect(press("Backspace")).toBe(false); diff --git a/src/app/shortcuts.ts b/src/app/shortcuts.ts index 7ef145dd0c..7d571ab7fe 100644 --- a/src/app/shortcuts.ts +++ b/src/app/shortcuts.ts @@ -5,6 +5,7 @@ import type { Appearance } from "../shared/theme/service"; import { statusShortcut } from "../features/user-status/status-shortcut"; import { activeMessageList, + markUnreadOnAltClick, reactionTrigger, replyTrigger, selectedMessage, @@ -203,6 +204,9 @@ export function registerAppShortcuts( const settings = () => shortcuts.runHost("settings"); window.addEventListener("buzz:open-settings", settings); remove.push(() => window.removeEventListener("buzz:open-settings", settings)); + // After React's handlers, so clicks they stop or prevent are left alone. + window.addEventListener("click", markUnreadOnAltClick); + remove.push(() => window.removeEventListener("click", markUnreadOnAltClick)); return () => { for (const dispose of remove) dispose(); }; diff --git a/src/features/messages/message-selection.ts b/src/features/messages/message-selection.ts index 9af3faff06..b387303724 100644 --- a/src/features/messages/message-selection.ts +++ b/src/features/messages/message-selection.ts @@ -193,3 +193,29 @@ export function moveMessageSelection(event: KeyboardEvent) { focusWithKeyboard(composer); } } + +const OWN_CLICKS = + "a, button, input, textarea, select, summary, label, [role='button'], [role='menuitem'], [role='group'], [contenteditable='true'], img, video, audio, pre, [data-copy-code]"; + +/** Slack's Option-click marks a message unread, as U does, from its box only: + * not its day divider, controls, media, code or reactions, nor the end of a + * text selection, which WebKit's Option-drag also makes. */ +export function markUnreadOnAltClick(event: MouseEvent) { + const { target } = event; + if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.defaultPrevented || !(target instanceof Element)) return; + const element = target.closest(ROW); + if (!element) return; + const box = focusTarget(element); + if ( + !box.contains(target) || + box.contains(target.closest(OWN_CLICKS)) || + !target.ownerDocument.getSelection()?.isCollapsed + ) + return; + const owner = listFor(element); + const list = owner && lists.get(owner); + const id = element.dataset.messageId; + const row = id ? list?.row(id) : undefined; + if (row && list?.canMarkUnread(row)) list.markUnread(row); +} From 13137f5d1ee5d9086865d9d1e03aa9231ed114af Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Thu, 5 Nov 2026 22:30:01 -0800 Subject: [PATCH 2/3] fix(messages): let Option-click mark failure replies and custom emoji An open agent row folds on any click in its box and does not stop the click. An agent's "Couldn't finish" reply can be marked unread, so one Option-click both folded it and marked it. The fold now leaves Option-clicks to Mark unread. Custom emoji are images in the message text, so the image guard skipped them and an Option-click on an emoji-only message did nothing. The guard now skips images other than custom emoji. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/app/message-shortcuts.test.ts | 8 +++++--- src/features/messages/MessageRow.test.tsx | 3 +++ src/features/messages/MessageRow.tsx | 4 +++- src/features/messages/message-selection.ts | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/app/message-shortcuts.test.ts b/src/app/message-shortcuts.test.ts index 9dba476573..302298c0ba 100644 --- a/src/app/message-shortcuts.test.ts +++ b/src/app/message-shortcuts.test.ts @@ -90,7 +90,8 @@ it("marks a message unread with Option-click, but not from its controls, media, ?.querySelector("[data-layout]") ?.insertAdjacentHTML( "beforeend", - '

Text link

code
' + + '

Text link :wave:

' + + '
code
' + '
', ); const text = row?.querySelector("p"); @@ -105,7 +106,7 @@ it("marks a message unread with Option-click, but not from its controls, media, click("p", { altKey: true, [modifier]: true }); for (const selector of [ "a", - "img", + "img:not([data-copy-emoji])", "pre", "[aria-label='Reactions']", "[aria-label='Reply']", @@ -126,7 +127,8 @@ it("marks a message unread with Option-click, but not from its controls, media, vi.spyOn(controller, "canMarkUnread").mockReturnValueOnce(false); click("p"); expect(controller.markUnread).not.toHaveBeenCalled(); - click("p"); + // A custom emoji is part of the text, not media. + click("[data-copy-emoji]"); expect(controller.markUnread).toHaveBeenCalledExactlyOnceWith({ id: "one" }); expect(document.activeElement).toBe(document.body); }); diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 8075fadb24..912656d3b8 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -85,6 +85,9 @@ it("discloses an automatic failure reply while keeping its original text availab expect(details).toHaveAccessibleName( "Spongebob: Couldn’t finish · Hide details", ); + // Option-click marks the message unread, so it leaves the details open. + fireEvent.click(screen.getByText(content), { altKey: true }); + expect(details).toHaveAttribute("aria-expanded", "true"); fireEvent.click(details); expect(details).toHaveAttribute("aria-expanded", "false"); view.rerender( diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 9bd3dcf6dc..73add6cf14 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -1698,11 +1698,13 @@ function AgentsMessageRow(props: MessageRowProps & { failure?: boolean }) { // button: revealing an anchor takes it out of the Tab order. data-message-id={row.id} // Open, a click anywhere in the box folds it, except on its own - // controls and links or at the end of a text selection. + // controls and links, at the end of a text selection, or with + // Option, which is Mark unread's gesture (message-selection.ts). onClick={(event) => { const target = event.target as Element; if ( !open || + event.altKey || !event.currentTarget.contains(target) || target.closest( "a, button, input, textarea, select, summary, [role='button'], [role='menuitem'], [contenteditable='true']", diff --git a/src/features/messages/message-selection.ts b/src/features/messages/message-selection.ts index b387303724..5720a446d7 100644 --- a/src/features/messages/message-selection.ts +++ b/src/features/messages/message-selection.ts @@ -195,7 +195,7 @@ export function moveMessageSelection(event: KeyboardEvent) { } const OWN_CLICKS = - "a, button, input, textarea, select, summary, label, [role='button'], [role='menuitem'], [role='group'], [contenteditable='true'], img, video, audio, pre, [data-copy-code]"; + "a, button, input, textarea, select, summary, label, [role='button'], [role='menuitem'], [role='group'], [contenteditable='true'], img:not([data-copy-emoji]), video, audio, pre, [data-copy-code]"; /** Slack's Option-click marks a message unread, as U does, from its box only: * not its day divider, controls, media, code or reactions, nor the end of a From 814feaef8875ef738f5c41e99a13992a352b423c Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Thu, 5 Nov 2026 22:51:27 -0800 Subject: [PATCH 3/3] docs(help): say Option-click marks a message unread, selected or not The help guide said Option-click marks a message unread without selecting it. The handler never moves focus, but the browser's own click focus still applies: a message selected earlier with the keyboard keeps a focusable box, so any click on it selects it again, with or without Option, as before this change. Say what the gesture does instead: it works on a message whether or not it is selected. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- docs/buzz-help.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index c4767a1ffa..429d22ea0c 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -366,7 +366,7 @@ revokes all of them, and a pairing unused for 30 days expires. an empty composer, Up selects a message unless **Edit last message with Up Arrow** is on. With a message selected, E edits it, R reacts, T or → replies in thread, U marks it unread, and Delete deletes your own message. Option-click a message - (Alt-click on Windows and Linux) to mark it unread without selecting it. + (Alt-click on Windows and Linux) to mark it unread, selected or not. ⌘1–9 switch communities, and ⇧Esc marks everything read. If a plugin uses the same key as another shortcut, Settings → Shortcuts marks it "Also used by"; change one of them to choose which fires. In the Mac app, **Buzz →