fix(desktop): trigger #channel/@mention autocomplete after opening brackets

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 <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-16 20:15:36 -04:00
co-authored by npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
parent 1d8d006aaf
commit f51807ac8d
2 changed files with 120 additions and 3 deletions
@@ -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);
});
+11 -3
View File
@@ -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);