From f51807ac8dc4181d74b29f6ab6539806bbf9f6eb Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:15:36 -0400 Subject: [PATCH] fix(desktop): trigger #channel/@mention autocomplete after opening brackets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectPrefixQuery required the trigger char to sit at start-of-string or after whitespace, so typing `(#channel` (or `[@name`, `{#chan`) glued to an opening bracket never opened the autocomplete — the user had to add a space after the bracket. Allow opening brackets ([{ as a word boundary in both the fast-path regex and the multi-word backward scan, kept in sync via a single isBoundaryChar definition. Fixes 7796a4f4. Adds detectPrefixQuery.test.mjs (first test on this helper): bracket boundaries for # and @, single- and multi-word paths, and negatives (prefix glued to a word char still does not trigger). Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc --- .../src/shared/lib/detectPrefixQuery.test.mjs | 109 ++++++++++++++++++ desktop/src/shared/lib/detectPrefixQuery.ts | 14 ++- 2 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 desktop/src/shared/lib/detectPrefixQuery.test.mjs diff --git a/desktop/src/shared/lib/detectPrefixQuery.test.mjs b/desktop/src/shared/lib/detectPrefixQuery.test.mjs new file mode 100644 index 000000000..d0ed9c4d3 --- /dev/null +++ b/desktop/src/shared/lib/detectPrefixQuery.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { detectPrefixQuery } from "./detectPrefixQuery.ts"; + +const CHANNELS = ["buzz-bugs", "buzz dev", "general"]; +const PEOPLE = ["alice", "bob jones"]; + +// Helper: detect at end-of-string (the usual cursor position while typing). +const at = (prefix, text, names) => + detectPrefixQuery(prefix, text, text.length, names); + +// ── Existing behavior: start-of-string and after whitespace still trigger ───── + +test("triggers at start of string", () => { + assert.deepEqual(at("#", "#buzz", CHANNELS), { + query: "buzz", + startIndex: 0, + }); +}); + +test("triggers after whitespace", () => { + assert.deepEqual(at("#", "go to #buzz", CHANNELS), { + query: "buzz", + startIndex: 6, + }); +}); + +test("does NOT trigger when glued to a word character", () => { + assert.equal(at("#", "foo#buzz", CHANNELS), null); + assert.equal(at("@", "email@bob", PEOPLE), null); +}); + +// ── The bug fix: opening brackets count as a boundary ───────────────────────── + +test("7796a4f4: ( before # triggers channel query", () => { + assert.deepEqual(at("#", "(#buzz", CHANNELS), { + query: "buzz", + startIndex: 1, + }); +}); + +test("7796a4f4: [ and { before # also trigger", () => { + assert.deepEqual(at("#", "[#buzz", CHANNELS), { + query: "buzz", + startIndex: 1, + }); + assert.deepEqual(at("#", "{#buzz", CHANNELS), { + query: "buzz", + startIndex: 1, + }); +}); + +test("7796a4f4: ( before @ triggers mention query", () => { + assert.deepEqual(at("@", "(@alice", PEOPLE), { + query: "alice", + startIndex: 1, + }); +}); + +test("bracket mid-sentence (word before the bracket) still triggers", () => { + // The char immediately before the prefix is `(`, which is the boundary — + // what precedes the bracket is irrelevant. + assert.deepEqual(at("#", "see also(#general", CHANNELS), { + query: "general", + startIndex: 9, + }); +}); + +test("nested brackets: (( before # triggers, startIndex at the prefix", () => { + assert.deepEqual(at("#", "((#buzz", CHANNELS), { + query: "buzz", + startIndex: 2, + }); +}); + +// ── Multi-word path: bracket boundary works for space-containing names ───────── + +test("multi-word channel name after ( resolves via multi-word path", () => { + assert.deepEqual(at("#", "(#buzz de", CHANNELS), { + query: "buzz de", + startIndex: 1, + }); +}); + +test("multi-word person name after ( resolves via multi-word path", () => { + assert.deepEqual(at("@", "ping (@bob jo", PEOPLE), { + query: "bob jo", + startIndex: 6, + }); +}); + +test("multi-word: glued-to-word prefix still rejected", () => { + // `x#buzz de` — `#` preceded by `x`, no boundary → no query even though + // "buzz de" is a prefix of a known channel. + assert.equal(at("#", "x#buzz de", CHANNELS), null); +}); + +// ── Empty / no-match guards unchanged ───────────────────────────────────────── + +test("bare prefix after ( yields empty single-word query, not multi-word", () => { + // Fast path matches with empty query (dropdown shows all) — consistent with + // bare `#` at start. + assert.deepEqual(at("#", "(#", CHANNELS), { query: "", startIndex: 1 }); +}); + +test("no prefix present → null", () => { + assert.equal(at("#", "just text", CHANNELS), null); +}); diff --git a/desktop/src/shared/lib/detectPrefixQuery.ts b/desktop/src/shared/lib/detectPrefixQuery.ts index fd7e20214..e2ad768eb 100644 --- a/desktop/src/shared/lib/detectPrefixQuery.ts +++ b/desktop/src/shared/lib/detectPrefixQuery.ts @@ -21,9 +21,17 @@ export function detectPrefixQuery( ): { query: string; startIndex: number } | null { const beforeCursor = value.slice(0, cursorPosition); + // A prefix only triggers a query when it opens a "word" — i.e. it sits at the + // start of the text or right after whitespace or an opening bracket. Opening + // brackets are included so `(#channel`, `[@name`, `{#chan` all autocomplete + // the same as ` #channel` (previously only whitespace/start counted, so a + // prefix glued to a `(` never fired). Keep the two detection paths below in + // sync with this single definition of "boundary before the prefix". + const isBoundaryChar = (ch: string) => /[\s([{]/.test(ch); + // Fast path: single-word query (no spaces after the prefix) const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const simplePattern = new RegExp(`(?:^|[\\s])${escapedPrefix}([^\\s]*)$`); + const simplePattern = new RegExp(`(?:^|[\\s([{])${escapedPrefix}([^\\s]*)$`); const simpleMatch = beforeCursor.match(simplePattern); if (simpleMatch) { const query = simpleMatch[1]; @@ -37,8 +45,8 @@ export function detectPrefixQuery( for (let i = beforeCursor.length - 1; i >= scanStart; i--) { const ch = beforeCursor[i]; if (ch === prefix) { - // Ensure prefix is at start or preceded by whitespace - if (i > 0 && !/\s/.test(beforeCursor[i - 1])) { + // Ensure prefix is at start or preceded by whitespace/opening bracket + if (i > 0 && !isBoundaryChar(beforeCursor[i - 1])) { continue; } const candidate = beforeCursor.slice(i + 1);