Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ that agent in Buzz; they do not notify the external app through this connection.
⌘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.
- **Send key**: choose **Settings → Appearance → Conversations → Send messages
with**. Enter sends by default. Choose Cmd+Enter (Ctrl+Enter on Windows/Linux)
to make Enter add a new line instead. Shift+Enter always adds a new line.
This choice is saved on this device and applies to messages and thread replies.
- **Spelling**: in the Mac app or Safari, the message box underlines
misspellings but does not correct them unless you turn on **Settings →
Appearance → Allow automatic spelling correction**. Correct Spelling
Expand Down
63 changes: 63 additions & 0 deletions src/app/AppearanceSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
messageSendKeyPreference,
setMessageSendKeyPreference,
} from "../features/messages/send-key-preference";
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import {
Expand Down Expand Up @@ -43,6 +47,7 @@ afterEach(() => {
setThreadPlacementPreference("side");
setFollowUpBehaviorPreference("immediate");
setMessageAutocorrectPreference(false);
setMessageSendKeyPreference("enter");
localStorage.clear();
vi.unstubAllGlobals();
});
Expand Down Expand Up @@ -521,3 +526,61 @@ it.each([
).toBe(shown);
},
);

it.each([
["MacIntel", "Cmd+Enter"],
["Win32", "Ctrl+Enter"],
])(
"saves the %s send-key choice and observes other windows",
async (platform, label) => {
vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
const user = userEvent.setup();
render(preferencesPage());
expect(
screen.getByRole("combobox", { name: "Send messages with" }),
).toHaveTextContent("Enter");
await user.click(
screen.getByRole("combobox", { name: "Send messages with" }),
);
// Base UI mounts/exposes the popup asynchronously after opening it.
await user.click(await screen.findByRole("option", { name: label }));
expect(messageSendKeyPreference()).toBe("mod-enter");
expect(localStorage.getItem("buzz-message-send-key.v1")).toBe("mod-enter");
act(() => {
localStorage.setItem("buzz-message-send-key.v1", "enter");
window.dispatchEvent(
new StorageEvent("storage", { key: "buzz-message-send-key.v1" }),
);
});
expect(
screen.getByRole("combobox", { name: "Send messages with" }),
).toHaveTextContent("Enter");
expect(messageSendKeyPreference()).toBe("enter");
},
);

it("keeps an unsaved send-key choice across remounts and retries saving", async () => {
const write = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new Error("denied");
});
setMessageSendKeyPreference("mod-enter");
const view = render(preferencesPage(), { wrapper: ToastProvider });
expect(messageSendKeyPreference()).toBe("mod-enter");
expect(
await screen.findByRole("button", { name: "Retry saving send key" }),
).toBeInTheDocument();
view.unmount();
render(preferencesPage(), { wrapper: ToastProvider });
write.mockRestore();
fireEvent.click(
await screen.findByRole("button", { name: "Retry saving send key" }),
);
expect(localStorage.getItem("buzz-message-send-key.v1")).toBe("mod-enter");
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Retry saving send key" }),
).not.toBeInTheDocument(),
);
});
46 changes: 46 additions & 0 deletions src/app/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
setMessageSendKeyPreference,
useMessageSendKeyPreference,
} from "../features/messages/send-key-preference";
import { isApplePlatform } from "../features/shortcuts/format";
import { Select } from "../shared/design-system/ui/Select";
import { ACCENTS, accentButtonStyle } from "../shared/theme/accent";

Expand Down Expand Up @@ -46,6 +51,10 @@ export function AppearanceSettings({
/** Only Mac WebKit applies the composer's autocorrect attribute. */
autocorrectAvailable?: boolean;
}) {
const sendKey = useMessageSendKeyPreference();
const sendChord = isApplePlatform(navigator.platform)
? "Cmd+Enter"
: "Ctrl+Enter";
const followUp = useFollowUpBehaviorPreference();
const autocorrect = useMessageAutocorrectPreference();
const { placement, unsaved } = useThreadPlacementPreference();
Expand Down Expand Up @@ -238,6 +247,30 @@ export function AppearanceSettings({
<div className="mt-section-gap">
<InlineHeader title="Conversations" />
<SettingsGroup>
<PreferenceRow
title="Send messages with"
subtitle="Choose Enter to send, or use Enter for a new line. Shift+Enter always adds a new line."
trailing={
<Select
variant="compact"
label="Send messages with"
value={sendKey.behavior}
groups={[
{
label: "Send key",
options: [
{ value: "enter", label: "Enter" },
{ value: "mod-enter", label: sendChord },
],
},
]}
onValueChange={(value) => {
if (value === "enter" || value === "mod-enter")
setMessageSendKeyPreference(value);
}}
/>
}
/>
<PreferenceRow
title="Follow-up behavior"
subtitle="In Solo, send follow-ups immediately or queue them until the agent finishes its previous response."
Expand Down Expand Up @@ -296,6 +329,19 @@ export function AppearanceSettings({
/>
)}
</SettingsGroup>
{active && sendKey.unsaved && (
<ToastNotice
title="Send-key preference wasn’t saved"
description="This choice is active, but could not be saved on this device."
>
<Button
size="sm"
onClick={() => setMessageSendKeyPreference(sendKey.behavior)}
>
Retry saving send key
</Button>
</ToastNotice>
)}
{active && followUp.unsaved && (
<ToastNotice
title="Follow-up preference wasn’t saved"
Expand Down
2 changes: 2 additions & 0 deletions src/features/agent-harness/kit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ describe("harnessKit.conversation", () => {
await kit.conversation(delivery({ type: "mention", event }), "thread"),
).toBeUndefined();
const workflow = message("3", "@Ampy go", {
// Workflow-shaped messages must not inherit the fixture's owner signer.
pubkey: mallory,
tags: [
["h", channel],
["buzz:workflow", "w"],
Expand Down
80 changes: 80 additions & 0 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setMessageSendKeyPreference } from "./send-key-preference";
// @vitest-environment jsdom
import { Context } from "@deepseek-ai/cordis";
import { PluginRuntime } from "../../plugins/runtime";
Expand Down Expand Up @@ -172,6 +173,7 @@ beforeEach(() => {
});
afterEach(() => {
cleanup();
setMessageSendKeyPreference("enter");
for (const owner of owners.splice(0)) owner.dispose();
Reflect.deleteProperty(navigator, "locks");
vi.unstubAllGlobals();
Expand Down Expand Up @@ -4733,6 +4735,84 @@ it("sends channel messages and thread replies through real form and keyboard eve
expect(h.input()).toHaveValue("");
});

it.each(["MacIntel", "Win32", "Linux x86_64"])(
"uses the chosen send key on %s without sending line breaks or IME confirmation",
async (platform) => {
vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
onTestFinished(() => {
vi.restoreAllMocks();
});
const h = mount();
await h.user.type(h.input(), "first");
act(() => setMessageSendKeyPreference("mod-enter"));
await h.user.keyboard("{Enter}second{Shift>}{Enter}{/Shift}third");
expect(h.input()).toHaveValue("first\nsecond\nthird");
const mod = platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true };
fireEvent.keyDown(h.input(), { key: "Enter", ...mod, repeat: true });
fireEvent.keyDown(h.input(), { key: "Enter", ...mod, isComposing: true });
fireEvent.keyDown(h.input(), { key: "Enter", ...mod, keyCode: 229 });
expect(h.messages.send).not.toHaveBeenCalled();
fireEvent.keyDown(h.input(), { key: "Enter", ...mod });
await waitFor(() =>
expect(h.messages.send).toHaveBeenCalledExactlyOnceWith(
"channel",
"first\nsecond\nthird",
[],
[],
),
);
h.retarget({ threadRootId: "root" });
await h.user.type(h.input(), "reply{Enter}next");
expect(h.messages.reply).not.toHaveBeenCalled();
fireEvent.keyDown(h.input(), { key: "Enter", ...mod });
await waitFor(() =>
expect(h.messages.reply).toHaveBeenCalledExactlyOnceWith(
"channel",
"root",
"reply\nnext",
[],
[],
),
);
},
);

it("continues lists with Enter but sends them with the chosen modified Enter", async () => {
vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
onTestFinished(() => {
vi.restoreAllMocks();
});
const h = mount();
act(() => setMessageSendKeyPreference("mod-enter"));
await h.user.type(h.input(), "- first{Enter}second");
expect(h.input().querySelectorAll("ul > li")).toHaveLength(2);
expect(h.messages.send).not.toHaveBeenCalled();
fireEvent.keyDown(h.input(), { key: "Enter", metaKey: true });
await waitFor(() =>
expect(h.messages.send).toHaveBeenCalledExactlyOnceWith(
"channel",
"- first\n- second",
[],
[],
),
);
});

it("switches a mounted composer back to Enter-to-send", async () => {
const h = mount();
act(() => setMessageSendKeyPreference("mod-enter"));
await h.user.type(h.input(), "draft{Enter}next");
expect(h.messages.send).not.toHaveBeenCalled();
act(() => setMessageSendKeyPreference("enter"));
await h.user.keyboard("{Enter}");
expect(h.messages.send).toHaveBeenCalledExactlyOnceWith(
"channel",
"draft\nnext",
[],
[],
);
});

it.each([
["- ", "ul", "- first\n- second"],
["3. ", "ol", "3. first\n4. second"],
Expand Down
16 changes: 12 additions & 4 deletions src/features/messages/MessageComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useMessageSendKeyPreference } from "./send-key-preference";
import { isApplePlatform } from "../shortcuts/format";
import { useChatLinkSharing } from "./useChatLinkSharing";
import {
draftGif,
Expand Down Expand Up @@ -491,6 +493,8 @@ function Composer({
trailingTool,
}: MessageComposerProps) {
const active = useConversationPresentation();
const sendKey = useMessageSendKeyPreference();
const apple = isApplePlatform(navigator.platform);
// Previews replace the list on every message; subscribe to this channel's facts.
const listed =
session.channels?.get || soloConversation ? session.channels : undefined;
Expand Down Expand Up @@ -3190,15 +3194,19 @@ function Composer({
event.key === "Enter" &&
!event.shiftKey &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
(sendKey.behavior === "enter"
? !event.ctrlKey && !event.metaKey
: apple
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey)
) {
event.preventDefault();
// Holding Enter must not send after an empty list item exits.
if (event.repeat) return;
if (
activeFormats.includes("bullet_list") ||
activeFormats.includes("ordered_list")
sendKey.behavior === "enter" &&
(activeFormats.includes("bullet_list") ||
activeFormats.includes("ordered_list"))
) {
event.currentTarget.insertLineBreak();
return;
Expand Down
50 changes: 50 additions & 0 deletions src/features/messages/send-key-preference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from "react";

export type MessageSendKey = "enter" | "mod-enter";
const preferenceKey = "buzz-message-send-key.v1";
const changedEvent = "buzz-message-send-key-changed";
let unsavedPreference: MessageSendKey | undefined;

/** Device-local send key; Enter-to-send remains the default. */
export function messageSendKeyPreference(): MessageSendKey {
if (unsavedPreference !== undefined) return unsavedPreference;
try {
return localStorage.getItem(preferenceKey) === "mod-enter"
? "mod-enter"
: "enter";
} catch {
return "enter";
}
}
function subscribe(listener: () => void) {
const storage = (event: StorageEvent) => {
if (event.key !== preferenceKey && event.key !== null) return;
unsavedPreference = undefined;
listener();
};
window.addEventListener(changedEvent, listener);
window.addEventListener("storage", storage);
return () => {
window.removeEventListener(changedEvent, listener);
window.removeEventListener("storage", storage);
};
}
export function useMessageSendKeyPreference() {
const behavior = useSyncExternalStore(subscribe, messageSendKeyPreference);
// Saving the same active choice changes recovery state, not its behavior.
const unsaved = useSyncExternalStore(
subscribe,
() => unsavedPreference !== undefined,
);
return { behavior, unsaved };
}
export function setMessageSendKeyPreference(value: MessageSendKey): void {
unsavedPreference = value;
try {
localStorage.setItem(preferenceKey, value);
unsavedPreference = undefined;
} catch {
// Retain the active choice and its recovery state across settings remounts.
}
window.dispatchEvent(new Event(changedEvent));
}
Loading
Loading