fix(desktop): reserve autocomplete Enter before paragraph splitting

Keep the editor from invalidating a live channel query before the
composer can select it. Retain immediate stale-query dismissal and
cover channel, thread, forum, mention and emoji keyboard paths in the
browser. Clarify that the cursor test exercises a supplied hook update.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-09-25 11:58:05 -06:00
parent 9c4f582eba
commit 6efe009228
4 changed files with 121 additions and 3 deletions

View File

@@ -127,6 +127,7 @@ export default defineConfig({
"**/timeline-no-shift.spec.ts",
"**/human-edit-agent-content.spec.ts",
"**/empty-edit-delete.spec.ts",
"**/channel-autocomplete.spec.ts",
"**/reaction-order.spec.ts",
"**/reaction-names.spec.ts",
"**/inbox-reactions.spec.ts",

View File

@@ -46,7 +46,11 @@ function enterEvent() {
for (const [name, text, cursor] of [
["replacing the loaded edit", "Edited, not deleted", 19],
["clearing the loaded edit", "", 0],
["moving before the channel reference", "Welcome to #general", 0],
[
"receiving an update with the cursor before the reference",
"Welcome to #general",
0,
],
]) {
test(`${name} releases Enter before the debounce expires`, async (t) => {
const { act, renderHook } = await import("@testing-library/react");

View File

@@ -107,7 +107,7 @@ export type RichTextEditorOptions = {
* ArrowUp fall through to normal caret movement.
*/
onEditLastOwnMessage?: () => boolean;
/** When true, plain Enter is passed through (e.g. to select an autocomplete item). */
/** When true, plain Enter is reserved for the composer's autocomplete handler. */
isAutocompleteOpen?: React.RefObject<boolean>;
/**
* Called when the user clicks an existing link in the editor. The link
@@ -315,7 +315,9 @@ export function useRichTextEditor({
addKeyboardShortcuts() {
return {
Enter: ({ editor: ed }) => {
if (isAutocompleteOpen?.current) return false;
// Block splitBlock without stopping DOM propagation: the
// composer's React handler selects the autocomplete item.
if (isAutocompleteOpen?.current) return true;
if (!onSubmitRef.current) return false;
const fenceResult = handleCodeFenceEnter(ed);

View File

@@ -0,0 +1,111 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function openComposer(
page: Page,
surface: "channel" | "thread" | "forum",
) {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
if (surface === "thread") {
await page.locator('[data-message-id="mock-general-alice"]').hover();
await page.getByTestId("reply-message-mock-general-alice").click();
return page
.getByTestId("message-thread-panel")
.getByTestId("message-input");
}
if (surface === "forum") {
await page.getByTestId("channel-watercooler").click();
await page.getByRole("button", { name: "Start a new post..." }).click();
}
return page.getByTestId("message-input");
}
function channelSuggestion(page: Page) {
return page.getByRole("button", { name: "#general stream", exact: true });
}
for (const surface of ["channel", "thread", "forum"] as const) {
test(`Enter selects a channel in the ${surface} composer without splitting or submitting`, async ({
page,
}) => {
const input = await openComposer(page, surface);
await input.pressSequentially("#gener");
await expect(channelSuggestion(page)).toBeVisible();
await input.press("Enter");
// Assert literal text (including the separator), document shape and focus:
// hook-only tests cannot catch ProseMirror splitting before React handles Enter.
await expect.poll(() => input.textContent()).toBe("#general ");
await expect(input.locator("p")).toHaveCount(1);
await expect(input.locator("br")).toHaveCount(0);
await expect(input).toBeFocused();
await expect(channelSuggestion(page)).toBeHidden();
// Autocomplete must relinquish the next Enter to ordinary submission.
await page.keyboard.type("selected channel");
await input.press("Enter");
await expect
.poll(() =>
page.evaluate(() => {
const content = "#general selected channel";
const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter(
(event) => event.content === content,
);
if (signed.length) return signed.length;
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(call) =>
call.command === "send_channel_message" &&
(call.payload as { content?: string }).content === content,
).length;
}),
)
.toBe(1);
});
}
for (const key of ["Tab", "Escape", "Shift+Enter"] as const) {
test(`channel autocomplete preserves ${key} behavior`, async ({ page }) => {
const input = await openComposer(page, "channel");
await input.fill("#gener");
await expect(channelSuggestion(page)).toBeVisible();
await input.press(key);
await expect
.poll(() => input.textContent())
.toBe(key === "Tab" ? "#general " : "#gener");
await expect(channelSuggestion(page)).toBeHidden();
await expect(input.locator("p")).toHaveCount(1);
if (key === "Shift+Enter") {
await page.keyboard.type("next line");
await expect(input).toHaveText("#genernext line");
await expect(input.locator("br")).toHaveCount(1);
} else {
await expect(input.locator("br")).toHaveCount(0);
}
});
}
for (const [prefix, expected] of [
["@ali", "@alice "],
[":joy", "😂 "],
] as const) {
test(`Enter still selects ${prefix} autocomplete without splitting`, async ({
page,
}) => {
const input = await openComposer(page, "channel");
const dropdown = page.getByTestId(
prefix === "@ali" ? "mention-autocomplete" : "emoji-autocomplete",
);
await input.fill(prefix);
await expect(dropdown).toBeVisible();
await input.press("Enter");
await expect(dropdown).toBeHidden();
await expect.poll(() => input.textContent()).toBe(expected);
await expect(input.locator("p")).toHaveCount(1);
await expect(input.locator("br")).toHaveCount(0);
await expect(input).toBeFocused();
});
}