From 5fa03c6f3ecfb4ef45c28c1ec9324d39b95c7959 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 19:01:12 -0500 Subject: [PATCH] fix(desktop): keep chips atomic to composer trigger detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textBeforeCaret serialized chip labels into the string the trigger regexes scan, so a leading /work pill made the anchored command regex swallow the rest of the line as its argument — silencing the @ popover for the whole message. Chips now contribute an object-replacement placeholder and
a newline, so committed pills can't poison detection. --- .../src/app/chat/composer/text-utils.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index bf91f1db6a4..e471daecf5e 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -36,9 +36,14 @@ export interface TriggerState { // The inline shape is what makes skills reachable anywhere in a prompt. Both // shapes need the trailing `$`: detection runs against the text BEFORE the // caret, so the match must end where the user is typing. -const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/ +// +// U+FFFC is the placeholder textBeforeCaret emits for a committed chip. A chip +// edge is a token boundary just like whitespace (upstream assistant-ui's +// Lexical DirectivePlugin gets the same semantics from node boundaries), so +// `@` or `/` typed immediately after a pill still opens the popover. +const AT_TRIGGER_RE = /(?:^|[\s\uFFFC])(@)([^\s@\uFFFC]*)$/ const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ -const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/ +const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ export function blobDedupeKey(blob: Blob): string { @@ -112,7 +117,17 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { return blobs } -/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */ +/** Caret-anchored text before the cursor, or null if the selection isn't a + * collapsed caret inside `editor`. + * + * Chips are ATOMIC to trigger detection: a committed pill must not leak its + * label text into the string the trigger regexes see. A `/work` pill whose + * label serialized into this text made the `^`-anchored command regex treat + * everything after it as that command's argument — which silenced the `@` + * popover for the rest of the message (`/work @Desk` → no trigger → the + * typed path never chips and submits as plain text). Each chip contributes + * an object-replacement placeholder instead, and
contributes a newline + * so a trigger at the start of a wrapped line still detects. */ export function textBeforeCaret(editor: HTMLDivElement): string | null { const sel = window.getSelection() const range = sel?.rangeCount ? sel.getRangeAt(0) : null @@ -125,7 +140,19 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { before.selectNodeContents(editor) before.setEnd(range.startContainer, range.startOffset) - return before.toString() + const scratch = document.createElement('div') + + scratch.append(before.cloneContents()) + + for (const chip of scratch.querySelectorAll('[data-ref-text]')) { + chip.replaceWith('\uFFFC') + } + + for (const br of scratch.querySelectorAll('br')) { + br.replaceWith('\n') + } + + return scratch.textContent ?? '' } export function detectTrigger(textBefore: string): TriggerState | null {