From 8d7be1f4296822def4c502d631c2b8fcbfa654ad Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 18 Jul 2026 16:18:14 -0400 Subject: [PATCH 1/2] fix(web): paint text selection over composer chips Signed-off-by: Yordis Prieto --- .../src/components/ComposerPromptEditor.tsx | 45 +++++++++++++++++-- apps/web/src/index.css | 12 +++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 585d46150a8..4c9fcb47bc8 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -185,7 +185,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -323,7 +323,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -394,7 +394,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -1167,6 +1167,44 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +/** + * Chips render as non-editable decorators, so the browser never paints the + * native text selection over them; without help, a selection spanning chips + * is only visible in the slivers between them. Mirror the selection onto the + * chips with a data attribute the stylesheet turns into a highlight overlay. + */ +function ComposerChipSelectionPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + let selectedKeys = new Set(); + return editor.registerUpdateListener(() => { + const nextKeys = new Set(); + editor.getEditorState().read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + for (const node of selection.getNodes()) { + if (node instanceof DecoratorNode) { + nextKeys.add(node.getKey()); + } + } + } + }); + for (const key of selectedKeys) { + if (!nextKeys.has(key)) { + editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); + } + } + for (const key of nextKeys) { + editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); + } + selectedKeys = nextKeys; + }); + }, [editor]); + + return null; +} + function ComposerInlineTokenPastePlugin() { const [editor] = useLexicalComposerContext(); @@ -1701,6 +1739,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c500eb68d7f..8c8a773c58d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1031,3 +1031,15 @@ label:has(> select#reasoning-effort) select { .dark .model-picker-list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); } + +/* Composer chips are non-editable decorators, so the browser skips them when + painting text selection; this overlay stands in for the native highlight. */ +.composer-inline-chip[data-composer-chip-selected]::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 6px; + background-color: Highlight; + opacity: 0.3; + pointer-events: none; +} From fa3a5a1cd9a78b2ac8e88c7e55d6939be2489a68 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 18 Jul 2026 16:32:55 -0400 Subject: [PATCH 2/2] fix(web): drop chip selection highlight when the editor blurs Lexical retains the range selection on blur without emitting an update, so the mirrored highlight outlived the native one. Signed-off-by: Yordis Prieto --- .../src/components/ComposerPromptEditor.tsx | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 4c9fcb47bc8..169126788ae 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -28,7 +28,10 @@ import { KEY_ENTER_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, + BLUR_COMMAND, + FOCUS_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -1178,7 +1181,24 @@ function ComposerChipSelectionPlugin() { useEffect(() => { let selectedKeys = new Set(); - return editor.registerUpdateListener(() => { + // Lexical keeps the range selection on blur without emitting an update, + // so focus is tracked separately; while blurred the native highlight is + // gone and the mirrored one has to go with it. + let hasFocus = editor.getRootElement() === document.activeElement; + + const applyKeys = (nextKeys: Set) => { + for (const key of selectedKeys) { + if (!nextKeys.has(key)) { + editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); + } + } + for (const key of nextKeys) { + editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); + } + selectedKeys = nextKeys; + }; + + const readSelectedKeys = () => { const nextKeys = new Set(); editor.getEditorState().read(() => { const selection = $getSelection(); @@ -1190,16 +1210,35 @@ function ComposerChipSelectionPlugin() { } } }); - for (const key of selectedKeys) { - if (!nextKeys.has(key)) { - editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); - } - } - for (const key of nextKeys) { - editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); - } - selectedKeys = nextKeys; + return nextKeys; + }; + + const unregisterUpdate = editor.registerUpdateListener(() => { + applyKeys(hasFocus ? readSelectedKeys() : new Set()); }); + const unregisterFocus = editor.registerCommand( + FOCUS_COMMAND, + () => { + hasFocus = true; + applyKeys(readSelectedKeys()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + const unregisterBlur = editor.registerCommand( + BLUR_COMMAND, + () => { + hasFocus = false; + applyKeys(new Set()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + return () => { + unregisterUpdate(); + unregisterFocus(); + unregisterBlur(); + }; }, [editor]); return null;