From 065d6d3944ed48f1fb12c6b41e724db75e82a558 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 11:23:11 +0800 Subject: [PATCH 1/8] fix(ui): rank `/` menu matches by name before description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/` menu filtered Skills by id, name and description with no order, so one or two typed letters of a Skill's own name sorted below every Skill whose description merely mentioned the word: the list looked like it had not matched until the query grew long enough to exclude those descriptions. Rank every candidate — id/name prefix, id/name substring, every query word present in id/name, description only — and sort inside each group. Equal ranks keep the catalog order, so an empty query is unchanged. Commands are ranked the same way, with their keywords counting as their name. Generated-by: Maka --- .../src/__tests__/chat-input-behavior.test.ts | 21 ++++++++++++++ packages/ui/src/chat-input-behavior.ts | 28 +++++++++++++++++++ packages/ui/src/composer.tsx | 17 +++++++++++ 3 files changed, 66 insertions(+) diff --git a/packages/ui/src/__tests__/chat-input-behavior.test.ts b/packages/ui/src/__tests__/chat-input-behavior.test.ts index 0d9a01adee..151aa21de8 100644 --- a/packages/ui/src/__tests__/chat-input-behavior.test.ts +++ b/packages/ui/src/__tests__/chat-input-behavior.test.ts @@ -25,6 +25,7 @@ import { composerWireText, createTriggerSearchSource, isChatInputComposing, + mentionMatchRank, selectedSkillIds, skillMentionQuery, slashCommandQuery, @@ -140,6 +141,26 @@ describe('shared chat input behavior', () => { assert.equal(slashCommandQuery('comp', '', 'comp'), null); }); + it('ranks a name match above a description match', () => { + // `debug` vs `avoid-ai-writing`: typing `de` must surface the Skill whose + // own id answers the query, not the one whose prose happens to contain it. + const debug = mentionMatchRank('de', 'debug debug'); + const proseOnly = mentionMatchRank('de', 'avoid-ai-writing avoid-ai-writing'); + assert.equal(debug, 0); + assert.equal(proseOnly, 3); + assert.ok(debug < proseOnly); + // Prefix, then anywhere in the id/name, then every word of a multi-word + // query, then prose only. + assert.equal(mentionMatchRank('pro', 'project-only Project Only'), 0); + assert.equal(mentionMatchRank('only', 'project-only Project Only'), 1); + // Words out of order: not a substring, but every word is in the name. + assert.equal(mentionMatchRank('logger debug', 'debug logger'), 2); + assert.equal(mentionMatchRank(' ', 'project-only Project Only'), 0); + assert.equal(mentionMatchRank('comp', 'compact'), 0); + assert.equal(mentionMatchRank('pact', 'compact'), 1); + assert.equal(mentionMatchRank('compact', 'side'), 3); + }); + it('reads `/skill:` and a bare `/` as the same Skill search', () => { assert.equal(skillMentionQuery('skill:comp'), 'comp'); assert.equal(skillMentionQuery('SKILL:Comp'), 'Comp'); diff --git a/packages/ui/src/chat-input-behavior.ts b/packages/ui/src/chat-input-behavior.ts index 657f9dfbb4..5eb5f2c37d 100644 --- a/packages/ui/src/chat-input-behavior.ts +++ b/packages/ui/src/chat-input-behavior.ts @@ -120,6 +120,34 @@ export function mentionQueryMatches(query: string, text: string): boolean { .every((token) => haystack.includes(token)); } +/** + * How well one `/`-menu candidate answers the typed query, lower first: 0 for a + * prefix of its id or name, 1 for an id/name substring, 2 when every query word + * appears across id and name, 3 when only the description or the keywords + * explain the match. + * + * The menu orders by this ahead of its catalog order. `mentionQueryMatches` + * alone treats a description as good as a name, so one or two typed letters of + * a Skill's own name sorted below every Skill that merely mentions the word in + * its prose — the list looked like it had not matched at all until the query + * grew long enough to exclude those descriptions. + * + * `primary` is what a user is naming: a Skill's id and name, a command's id, + * name and keywords. + */ +export function mentionMatchRank(query: string, primary: string): 0 | 1 | 2 | 3 { + const normalized = query.trim().toLowerCase(); + if (!normalized) return 0; + const haystack = primary.toLowerCase(); + if (haystack.startsWith(normalized)) return 0; + if (haystack.includes(normalized)) return 1; + const words = normalized.split(/\s+/); + // A multi-word query ("project only") rarely appears verbatim in an id/name, + // but every word being there still makes it a name match rather than prose. + if (words.every((word) => haystack.includes(word))) return 2; + return 3; +} + /** Normalize `/skill:` and bare `/` into the same Skill search query. */ export function skillMentionQuery(query: string): string { return query.toLowerCase().startsWith('skill:') ? query.slice('skill:'.length) : query; diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 380f238c48..e9756efbcf 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -77,6 +77,7 @@ import { createTriggerSearchSource, fileTransferContainsFiles, isChatInputComposing, + mentionMatchRank, mentionQueryMatches, selectedSkillIds, slashCommandQuery, @@ -194,6 +195,11 @@ function skillTokenValue(id: string): string { return `/skill:${id}`; } +/** What a `/` command is named by, for `mentionMatchRank`: not its description. */ +function commandPrimaryText(command: ComposerSlashCommandOption): string { + return `${command.id} ${command.name} ${(command.keywords ?? []).join(' ')}`; +} + /** * Rows the input grows to before it scrolls. `ChatComposerInput` prices this in * its own hardcoded 22px line, so the cap is 220px — one line under the 240px @@ -1032,6 +1038,9 @@ export const Composer = forwardRef< const commandQuery = slashCommandQuery(textBeforeCaret, textAfterCaret, rawQuery); const query = skillMentionQuery(rawQuery); const selectedSkills = selectedSkillIds(textPort.getValue(), rawQuery); + // Ranked, then catalog order: a candidate whose own id/name answers the + // query leads the ones only their description mentions (mentionMatchRank). + // `Array.prototype.sort` is stable, so equal ranks keep the catalog order. const commandItems = commandQuery === null ? [] : (source.slashCommands ?? []) @@ -1041,6 +1050,10 @@ export const Composer = forwardRef< `${command.id} ${command.name} ${command.description ?? ''} ${(command.keywords ?? []).join(' ')}`, ), ) + .sort((left, right) => + mentionMatchRank(commandQuery, commandPrimaryText(left)) - + mentionMatchRank(commandQuery, commandPrimaryText(right)), + ) .map((command) => ({ id: `command:${command.id}`, label: command.name, @@ -1055,6 +1068,10 @@ export const Composer = forwardRef< .filter((skill) => mentionQueryMatches(query, `${skill.id} ${skill.name} ${skill.description ?? ''}`), ) + .sort((left, right) => + mentionMatchRank(query, `${left.id} ${left.name}`) - + mentionMatchRank(query, `${right.id} ${right.name}`), + ) .map((skill) => ({ id: `skill:${skill.id}`, label: skill.name, From 3744b464a0d9b58fdb45a940fab569108fe007ed Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 11:23:41 +0800 Subject: [PATCH 2/8] fix(ui): center an inline token chip on its line `insertToken` anchors a chip with `vertical-align: middle`, which centers the chip's box on the parent's x-height midline. A 20px chip in a 22px line is therefore ~1px low against CJK and mixed-case text, which is what a staged Skill chip next to typed Chinese showed. Give the chip's box the height of one line box and center the chip inside it (`height: 1lh`, `align-items: center`), aligned to the line box with `vertical-align: top`. `middle` ignores a box height, so it cannot express "centered on the line". The transcript's token wrapper in ChatTokenizedText carries the same declarations: the two must agree, or a token moves when the message is sent. Generated-by: Maka --- .../stories/composer-skill-draft.stories.tsx | 53 ++++++++++++ patches/@astryxdesign+core+0.6.2.patch | 85 +++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/apps/desktop/stories/composer-skill-draft.stories.tsx b/apps/desktop/stories/composer-skill-draft.stories.tsx index 3e9e31b8fd..cc9ab4c958 100644 --- a/apps/desktop/stories/composer-skill-draft.stories.tsx +++ b/apps/desktop/stories/composer-skill-draft.stories.tsx @@ -177,3 +177,56 @@ export const ImeCommitDoesNotSend: Story = { await expect(sent).toHaveBeenCalledWith('中文草稿'); }, }; + +/** + * The chip's box is one line box tall and the pill is centred inside it, so the + * chip sits on the text's line. `vertical-align: middle` centred the box on the + * x-height midline instead, which left a 20px chip ~1px low beside CJK text. + * + * Both halves are asserted on the production editor: the box height (the + * contract the CSS states) and the resulting centres (what a reader sees). The + * same declarations are in the transcript's token wrapper, so a token does not + * move when the message is sent. + */ +function lineGeometry(composer: HTMLElement, chip: HTMLElement) { + const lineHeight = Number.parseFloat(getComputedStyle(composer).lineHeight); + const chipRect = chip.getBoundingClientRect(); + // The text run's own box, centred in its line box by the line height. + let textRect: DOMRect | undefined; + for (const node of composer.childNodes) { + if (node.nodeType !== Node.TEXT_NODE || !(node.textContent ?? '').trim()) continue; + const range = document.createRange(); + range.selectNodeContents(node); + textRect = range.getClientRects()[0]; + if (textRect) break; + } + if (!textRect) throw new Error('the composer has no text to measure against'); + const lineCenter = textRect.top - (lineHeight - textRect.height) / 2 + lineHeight / 2; + return { + lineHeight, + chipBoxHeight: chipRect.height, + chipCenter: (chipRect.top + chipRect.bottom) / 2, + lineCenter, + }; +} + +// Real path: 在 Session 的 composer 里用 `/` 选一个 Skill,然后在 chip 后面接着输入中文。 +export const StagedSkillSitsOnTheTextLine: Story = { + play: async ({ canvasElement }) => { + const composer = editor(canvasElement); + await pickSkill(composer, 'project', /Project Only/); + await userEvent.keyboard('测试测试测试'); + // The chip's label arrives with its portal, one commit after the token. + const staged = await waitFor(() => { + const found = chip('project-only'); + if (!found || !(found.textContent ?? '').includes('Project Only')) { + throw new Error('the staged chip has not rendered yet'); + } + return found; + }); + + const geometry = lineGeometry(composer, staged); + await expect(Math.abs(geometry.chipBoxHeight - geometry.lineHeight)).toBeLessThan(1); + await expect(Math.abs(geometry.chipCenter - geometry.lineCenter)).toBeLessThan(1); + }, +}; diff --git a/patches/@astryxdesign+core+0.6.2.patch b/patches/@astryxdesign+core+0.6.2.patch index 291ccf8e33..05f8d8bff4 100644 --- a/patches/@astryxdesign+core+0.6.2.patch +++ b/patches/@astryxdesign+core+0.6.2.patch @@ -111,6 +111,29 @@ index ff9b9fa..28afe4e 100644 }); const newMsgs = useChatNewMessages({ isLocked: scroll.isLocked, +diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatTokenizedText.js b/node_modules/@astryxdesign/core/dist/Chat/ChatTokenizedText.js +index 5edb8f6..d80a970 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/ChatTokenizedText.js ++++ b/node_modules/@astryxdesign/core/dist/Chat/ChatTokenizedText.js +@@ -109,8 +109,16 @@ function renderTokens(text, tokens) { + const token = tokenMap.get(matched); + if (token) { + parts.push(/*#__PURE__*/_jsx("span", { +- ...{ +- className: "x3nfvp2 xxymvpz" ++ // One line box tall with the chip centered inside it, so the chip ++ // centers on the line rather than on the x-height midline (which leaves ++ // a 20px chip ~1px low against CJK and mixed-case text). Must stay ++ // equal to `insertToken`'s span in useChatComposerTokens, or a token ++ // moves when the message is sent. ++ style: { ++ display: 'inline-flex', ++ alignItems: 'center', ++ height: '1lh', ++ verticalAlign: 'top' + }, + children: isCustomToken(token) ? token.render() : /*#__PURE__*/_jsx(Badge, { + label: token.label, diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js b/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js index 3c457d1..7d5a11a 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js @@ -143,6 +166,26 @@ index 3c457d1..7d5a11a 100644 call: call }, getToolCallKey(call))) }) +diff --git a/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js b/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js +index bee6075..228510a 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js ++++ b/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js +@@ -79,8 +79,14 @@ export function useChatComposerTokens({ + span.setAttribute('data-astryx-token-value', token.value); + span.setAttribute('data-astryx-token-id', id); + span.contentEditable = 'false'; ++ // One line box tall with the chip centered inside it, so the chip centers on ++ // the line rather than on the x-height midline (which leaves a 20px chip ++ // ~1px low against CJK and mixed-case text). `vertical-align: middle` would ++ // ignore the height; `top` puts this box on the line box itself. + span.style.display = 'inline-flex'; +- span.style.verticalAlign = 'middle'; ++ span.style.alignItems = 'center'; ++ span.style.height = '1lh'; ++ span.style.verticalAlign = 'top'; + range.deleteContents(); + range.insertNode(span); + diff --git a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js index 47d4e52..d705783 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js @@ -968,6 +1011,28 @@ index d45a912..7dba080 100644 const newMsgs = useChatNewMessages({ isLocked: scroll.isLocked, onResize: scroll.scrollIfLocked, +diff --git a/node_modules/@astryxdesign/core/src/Chat/ChatTokenizedText.tsx b/node_modules/@astryxdesign/core/src/Chat/ChatTokenizedText.tsx +index 7b6bf87..c9943bf 100644 +--- a/node_modules/@astryxdesign/core/src/Chat/ChatTokenizedText.tsx ++++ b/node_modules/@astryxdesign/core/src/Chat/ChatTokenizedText.tsx +@@ -36,8 +36,16 @@ const styles = stylex.create({ + display: 'inline', + }, + token: { ++ // One line box tall with the chip centered inside it, so the chip centers ++ // on the line rather than on the x-height midline (which leaves a 20px chip ++ // ~1px low against CJK and mixed-case text). `vertical-align: middle` would ++ // ignore the height; `top` puts this box on the line box itself. Must stay ++ // equal to `insertToken`'s span in useChatComposerTokens, or a token moves ++ // when the message is sent. + display: 'inline-flex', +- verticalAlign: 'middle', ++ alignItems: 'center', ++ height: '1lh', ++ verticalAlign: 'top', + }, + }); + diff --git a/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx b/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx index 68a424e..d4f0c5e 100644 --- a/node_modules/@astryxdesign/core/src/Chat/ChatToolCalls.tsx @@ -1005,6 +1070,26 @@ index 68a424e..d4f0c5e 100644 +diff --git a/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts b/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts +index 071303f..8f8938b 100644 +--- a/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts ++++ b/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts +@@ -119,8 +119,14 @@ export function useChatComposerTokens({ + span.setAttribute('data-astryx-token-value', token.value); + span.setAttribute('data-astryx-token-id', id); + span.contentEditable = 'false'; ++ // One line box tall with the chip centered inside it, so the chip centers ++ // on the line rather than on the x-height midline (which leaves a 20px ++ // chip ~1px low against CJK and mixed-case text). `vertical-align: ++ // middle` would ignore the height; `top` puts this box on the line box. + span.style.display = 'inline-flex'; +- span.style.verticalAlign = 'middle'; ++ span.style.alignItems = 'center'; ++ span.style.height = '1lh'; ++ span.style.verticalAlign = 'top'; + + range.deleteContents(); + range.insertNode(span); diff --git a/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx b/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx index 764be45..10a2de9 100644 --- a/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx From bf46e25f898548624d5ccdb85d954a9062e0dfd6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 11:23:56 +0800 Subject: [PATCH 3/8] fix(ui): land a programmatic caret inside a text node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium anchors an IME composition to the DOM boundary it starts from. A caret sitting on a *child offset of the contenteditable* — what `setStartAfter` and `selectNodeContents` + `collapse(false)` produce — is the same visual caret as the end of the adjacent text node, but from it the first preedit commits as its raw letters instead of the composed character. So the first Chinese word typed after a Skill chip arrived as pinyin. Land the caret inside the text node it points at: in Astryx's `insertToken`, `expandToken`, token paste, `insertTextAtCursor` and the two end-of-content helpers, and in the composer's own `caretToContentEnd`. Tokens are `contenteditable="false"`, so a trailing chip is never entered. Generated-by: Maka --- packages/ui/src/composer.tsx | 37 ++++ patches/@astryxdesign+core+0.6.2.patch | 274 ++++++++++++++++++++++++- 2 files changed, 305 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index e9756efbcf..e0f41442fa 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -200,6 +200,42 @@ function commandPrimaryText(command: ComposerSlashCommandOption): string { return `${command.id} ${command.name} ${(command.keywords ?? []).join(' ')}`; } +/** + * Move a collapsed range that sits on an element boundary into the text node it + * visually points at. + * + * `selectNodeContents` + `collapse(false)` leaves the caret on the *editable's* + * child offset, and Astryx's `insertToken` anchors it the same way with + * `setStartAfter`. Chromium's IME anchors a composition to the boundary it + * starts from, and from an element boundary the first preedit commits as its + * raw letters instead of the composed character — the first CJK word typed + * after a Skill chip, or after a restored draft, arrived as pinyin. The two + * boundaries are the same visual caret; only the text-node one composes. + * + * A chip is `contenteditable="false"`, so a trailing token is not entered: the + * walk stops there and the element boundary stands. + * + * Astryx's own `chatComposerSelection` helpers (`placeCaretAtEnd`, + * `ensureCaretInside`, which the input's focus and imperative-insert paths use) + * carry the same guard; both are in the dependency patch and have to move + * together. + */ +function landCaretInsideTextNode(range: Range): void { + const container = range.startContainer; + if (!container || container.nodeType === Node.TEXT_NODE) return; + let node: Node | null = container.childNodes[range.startOffset - 1] ?? null; + while (node) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node as Text; + range.setStart(text, text.textContent?.length ?? 0); + range.collapse(true); + return; + } + if (!(node instanceof HTMLElement) || !node.isContentEditable) return; + node = node.lastChild; + } +} + /** * Rows the input grows to before it scrolls. `ChatComposerInput` prices this in * its own hardcoded 22px line, so the cap is 220px — one line under the 240px @@ -681,6 +717,7 @@ export const Composer = forwardRef< const range = document.createRange(); range.selectNodeContents(editable); range.collapse(false); + landCaretInsideTextNode(range); selection?.removeAllRanges(); selection?.addRange(range); } diff --git a/patches/@astryxdesign+core+0.6.2.patch b/patches/@astryxdesign+core+0.6.2.patch index 05f8d8bff4..45163cb1d7 100644 --- a/patches/@astryxdesign+core+0.6.2.patch +++ b/patches/@astryxdesign+core+0.6.2.patch @@ -12,7 +12,7 @@ index 90022b3..36c0637 100644 * Search source providing items for this trigger. * Reuses the same SearchSource interface as Typeahead \u2014 diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js -index 87dfdea..e2041ce 100644 +index 87dfdea..e3982e9 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js @@ -22,7 +22,7 @@ @@ -24,6 +24,15 @@ index 87dfdea..e2041ce 100644 import { createPortal } from 'react-dom'; import * as stylex from '@stylexjs/stylex'; import "../theme/tokens.stylex.js"; +@@ -30,7 +30,7 @@ import { colorVars, spacingVars, typeScaleVars, typographyVars } from "../theme/ + import { mergeProps, isImeKeyEvent } from "../utils/index.js"; + import { useTriggerMenu } from "./useTriggerMenu.js"; + import { useChatComposerTokens, isCustomToken } from "./useChatComposerTokens.js"; +-import { ensureCaretInside, insertTextAtCursor, isSelectionAtStart, isSelectionAtEnd, placeCaretAtEnd, getSelectionRangeInside, restoreSelectionRange } from "./chatComposerSelection.js"; ++import { ensureCaretInside, insertTextAtCursor, isSelectionAtStart, isSelectionAtEnd, landInsideTrailingTextNode, placeCaretAtEnd, getSelectionRangeInside, restoreSelectionRange } from "./chatComposerSelection.js"; + import { ChatPastedTextToken } from "./ChatPastedTextToken.js"; + import { useChatPasteAsToken } from "./useChatPasteAsToken.js"; + import { Badge } from "../Badge/index.js"; @@ -261,7 +261,7 @@ export function ChatComposerInput(props) { inputControlRef.current = null; }; @@ -33,6 +42,14 @@ index 87dfdea..e2041ce 100644 if (controlledValue === undefined || !editableRef.current) { return; } +@@ -293,6 +293,7 @@ export function ChatComposerInput(props) { + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); ++ landInsideTrailingTextNode(range); + selection.removeAllRanges(); + selection.addRange(range); + } diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts index ff34874..28aaaab 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts @@ -166,8 +183,83 @@ index 3c457d1..7d5a11a 100644 call: call }, getToolCallKey(call))) }) +diff --git a/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js b/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js +index 97b1a3b..8581890 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js ++++ b/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js +@@ -66,6 +66,41 @@ export function restoreSelectionRange(range) { + selection.addRange(range); + } + ++/** ++ * Move a collapsed range that sits on an element boundary into the text node it ++ * visually points at. ++ * ++ * `selectNodeContents` + `collapse(false)` leaves the caret on the *editable's* ++ * child offset. Chromium anchors an IME composition to the boundary it starts ++ * from, and from an element boundary the first preedit commits as its raw ++ * letters instead of the composed character — the first CJK word typed after a ++ * programmatic caret move arrived as pinyin. The two boundaries are the same ++ * visual caret; only the text-node one composes. ++ * ++ * Tokens are `contenteditable="false"`, so a trailing token is not entered: the ++ * walk stops there and the element boundary stands. ++ */ ++export function landInsideTrailingTextNode(range) { ++ // A DOM shim can implement `selectNodeContents` without exposing the ++ // container (linkedom); there is no element boundary to move away from. ++ const container = range.startContainer; ++ if (!container || container.nodeType === Node.TEXT_NODE) { ++ return; ++ } ++ let node = container.childNodes[range.startOffset - 1] ?? null; ++ while (node) { ++ if (node.nodeType === Node.TEXT_NODE) { ++ range.setStart(node, node.data.length); ++ range.collapse(true); ++ return; ++ } ++ if (!(node instanceof HTMLElement) || !node.isContentEditable) { ++ return; ++ } ++ node = node.lastChild; ++ } ++} ++ + /** + * Collapse the Selection to the very end of `editable`'s content, + * replacing whatever the Selection held before. +@@ -86,6 +121,7 @@ export function placeCaretAtEnd(editable) { + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); // collapse to end ++ landInsideTrailingTextNode(range); + selection.removeAllRanges(); + selection.addRange(range); + return true; +@@ -118,6 +154,7 @@ export function ensureCaretInside(editable) { + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); // collapse to end ++ landInsideTrailingTextNode(range); + selection.removeAllRanges(); + selection.addRange(range); + return selection; +@@ -220,7 +257,11 @@ export function insertTextAtCursor(editable, text) { + range.deleteContents(); + const textNode = document.createTextNode(text); + range.insertNode(textNode); +- range.setStartAfter(textNode); ++ // Inside the inserted text node, not on the editable's child offset that ++ // setStartAfter produces: Chromium anchors an IME composition to the boundary ++ // it starts from, and from an element boundary the first preedit commits as ++ // its raw letters (see landInsideTrailingTextNode). ++ range.setStart(textNode, text.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); diff --git a/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js b/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js -index bee6075..228510a 100644 +index bee6075..378af1e 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js +++ b/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js @@ -79,8 +79,14 @@ export function useChatComposerTokens({ @@ -186,6 +278,49 @@ index bee6075..228510a 100644 range.deleteContents(); range.insertNode(span); +@@ -88,7 +94,11 @@ export function useChatComposerTokens({ + const space = document.createTextNode('\u00A0'); + span.after(space); + const newRange = document.createRange(); +- newRange.setStartAfter(space); ++ // Inside the NBSP rather than on the editable's child offset that ++ // setStartAfter produces: Chromium anchors an IME composition to the ++ // boundary it starts from, and from an element boundary the first preedit ++ // commits as its raw letters instead of the composed character. ++ newRange.setStart(space, space.data.length); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); +@@ -158,11 +168,12 @@ export function useChatComposerTokens({ + e.preventDefault(); + const text = e.clipboardData.getData('text/plain'); + +- // Move cursor after the token's trailing space ++ // Move cursor after the token's trailing space — inside the text node, ++ // not on the editable's child offset (see insertToken). + const space = tokenEl.nextSibling; + const newRange = document.createRange(); + if (space && space.nodeType === Node.TEXT_NODE) { +- newRange.setStartAfter(space); ++ newRange.setStart(space, space.data.length); + } else { + newRange.setStartAfter(tokenEl); + } +@@ -199,11 +210,12 @@ export function useChatComposerTokens({ + } + span.replaceWith(textNode); + +- // Place cursor at end of inserted text ++ // Place cursor at end of inserted text — inside the text node, not on the ++ // editable's child offset (see insertToken). + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); +- range.setStartAfter(textNode); ++ range.setStart(textNode, value.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); diff --git a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js index 47d4e52..d705783 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js @@ -902,7 +1037,7 @@ index 693d728..7d5724e 100644 } \ No newline at end of file diff --git a/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx b/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx -index a45cc2f..75d0bf4 100644 +index a45cc2f..f676118 100644 --- a/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx +++ b/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx @@ -28,6 +28,7 @@ import { @@ -913,7 +1048,15 @@ index a45cc2f..75d0bf4 100644 useImperativeHandle, type ReactNode, type KeyboardEvent, -@@ -114,6 +115,8 @@ export type ChatComposerTriggerItem = SearchableItem; +@@ -51,6 +52,7 @@ import { + insertTextAtCursor, + isSelectionAtStart, + isSelectionAtEnd, ++ landInsideTrailingTextNode, + placeCaretAtEnd, + getSelectionRangeInside, + restoreSelectionRange, +@@ -114,6 +116,8 @@ export type ChatComposerTriggerItem = SearchableItem; export type ChatComposerTrigger = { /** Character that activates this trigger menu (e.g. '@', '/') */ character: string; @@ -922,7 +1065,7 @@ index a45cc2f..75d0bf4 100644 /** * Search source providing items for this trigger. * Reuses the same SearchSource interface as Typeahead \u2014 -@@ -465,7 +468,7 @@ export function ChatComposerInput(props: ChatComposerInputProps) { +@@ -465,7 +469,7 @@ export function ChatComposerInput(props: ChatComposerInputProps) { }; }, [inputControlRef, focusEditableAtEnd]); @@ -931,6 +1074,14 @@ index a45cc2f..75d0bf4 100644 if (controlledValue === undefined || !editableRef.current) { return; } +@@ -497,6 +501,7 @@ export function ChatComposerInput(props: ChatComposerInputProps) { + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); ++ landInsideTrailingTextNode(range); + selection.removeAllRanges(); + selection.addRange(range); + } diff --git a/node_modules/@astryxdesign/core/src/Chat/ChatLayout.tsx b/node_modules/@astryxdesign/core/src/Chat/ChatLayout.tsx index d45a912..7dba080 100644 --- a/node_modules/@astryxdesign/core/src/Chat/ChatLayout.tsx @@ -1070,8 +1221,84 @@ index 68a424e..d4f0c5e 100644 +diff --git a/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts b/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts +index 0f31ca8..61ee6ff 100644 +--- a/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts ++++ b/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts +@@ -69,6 +69,42 @@ export function restoreSelectionRange(range: Range): void { + selection.addRange(range); + } + ++/** ++ * Move a collapsed range that sits on an element boundary into the text node it ++ * visually points at. ++ * ++ * `selectNodeContents` + `collapse(false)` leaves the caret on the *editable's* ++ * child offset. Chromium anchors an IME composition to the boundary it starts ++ * from, and from an element boundary the first preedit commits as its raw ++ * letters instead of the composed character — the first CJK word typed after a ++ * programmatic caret move arrived as pinyin. The two boundaries are the same ++ * visual caret; only the text-node one composes. ++ * ++ * Tokens are `contenteditable="false"`, so a trailing token is not entered: the ++ * walk stops there and the element boundary stands. ++ */ ++export function landInsideTrailingTextNode(range: Range): void { ++ // A DOM shim can implement `selectNodeContents` without exposing the ++ // container (linkedom); there is no element boundary to move away from. ++ const container = range.startContainer; ++ if (!container || container.nodeType === Node.TEXT_NODE) { ++ return; ++ } ++ let node: Node | null = container.childNodes[range.startOffset - 1] ?? null; ++ while (node) { ++ if (node.nodeType === Node.TEXT_NODE) { ++ const text = node as Text; ++ range.setStart(text, text.data.length); ++ range.collapse(true); ++ return; ++ } ++ if (!(node instanceof HTMLElement) || !node.isContentEditable) { ++ return; ++ } ++ node = node.lastChild; ++ } ++} ++ + /** + * Collapse the Selection to the very end of `editable`'s content, + * replacing whatever the Selection held before. +@@ -89,6 +125,7 @@ export function placeCaretAtEnd(editable: HTMLElement): boolean { + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); // collapse to end ++ landInsideTrailingTextNode(range); + selection.removeAllRanges(); + selection.addRange(range); + return true; +@@ -123,6 +160,7 @@ export function ensureCaretInside(editable: HTMLElement): Selection | null { + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); // collapse to end ++ landInsideTrailingTextNode(range); + selection.removeAllRanges(); + selection.addRange(range); + return selection; +@@ -262,7 +300,11 @@ export function insertTextAtCursor( + const textNode = document.createTextNode(text); + range.insertNode(textNode); + +- range.setStartAfter(textNode); ++ // Inside the inserted text node, not on the editable's child offset that ++ // setStartAfter produces: Chromium anchors an IME composition to the boundary ++ // it starts from, and from an element boundary the first preedit commits as ++ // its raw letters (see landInsideTrailingTextNode). ++ range.setStart(textNode, text.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); diff --git a/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts b/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts -index 071303f..8f8938b 100644 +index 071303f..d28601a 100644 --- a/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts +++ b/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts @@ -119,8 +119,14 @@ export function useChatComposerTokens({ @@ -1090,6 +1317,41 @@ index 071303f..8f8938b 100644 range.deleteContents(); range.insertNode(span); +@@ -130,7 +136,11 @@ export function useChatComposerTokens({ + span.after(space); + + const newRange = document.createRange(); +- newRange.setStartAfter(space); ++ // Inside the NBSP rather than on the editable's child offset that ++ // setStartAfter produces: Chromium anchors an IME composition to the ++ // boundary it starts from, and from an element boundary the first preedit ++ // commits as its raw letters instead of the composed character. ++ newRange.setStart(space, space.data.length); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); +@@ -219,7 +229,9 @@ export function useChatComposerTokens({ + const space = tokenEl.nextSibling; + const newRange = document.createRange(); + if (space && space.nodeType === Node.TEXT_NODE) { +- newRange.setStartAfter(space); ++ // Inside the text node, not on the editable's child offset (see ++ // insertToken). ++ newRange.setStart(space as Text, (space as Text).data.length); + } else { + newRange.setStartAfter(tokenEl); + } +@@ -266,7 +278,9 @@ export function useChatComposerTokens({ + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); +- range.setStartAfter(textNode); ++ // Inside the text node, not on the editable's child offset (see ++ // insertToken). ++ range.setStart(textNode, value.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); diff --git a/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx b/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx index 764be45..10a2de9 100644 --- a/node_modules/@astryxdesign/core/src/Chat/useTriggerMenu.tsx From 804892cde18f65668a42215d89a840f8913b6961 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 11:24:07 +0800 Subject: [PATCH 4/8] fix(ui): draw a Skill chip in the transcript from the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user row rendered `inlineReferences` when the field was present and the raw `/skill:` text otherwise. The field is a frozen hint the Host composes from the invocation receipts, so the optimistic row, the desktop's local copy and any invocation with no successful receipt carry the token as plain text *and* an empty array — truthy, so the row took the branch that had no Skill chip to draw. The same message then flipped to a chip once its canonical copy arrived. Make InlineReferenceText the one renderer for a user row: it draws the references the message carries and the `/skill:` invocations the text itself spells, so the chip no longer depends on a hint being populated. Token-only chips are labelled with the id, which is what the Host's frozen reference labels itself from whenever a Skill's name is its id. Generated-by: Maka --- docs/astryx-surface-file-inventory.md | 2 +- .../chat-turn-inline-references.test.tsx | 186 ++++++++++++++++++ packages/ui/src/chat-turn.tsx | 17 +- packages/ui/src/inline-reference.tsx | 47 ++++- packages/ui/stories/attachment.stories.tsx | 36 ++++ 5 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/__tests__/chat-turn-inline-references.test.tsx diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4ea43c894b..cbc9280d05 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -261,7 +261,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/chat-empty-hero.tsx` | shell-chrome-or-panel | Item | aligned — uses Astryx (Item) | aligned | | `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, Selector, SelectorOption | aligned — uses Astryx (Button, Selector, SelectorOption) | aligned | | `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned | -| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned | +| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, HStack) | aligned | | `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned | | `packages/ui/src/choice-panel.tsx` | shell-chrome-or-panel | Badge, Item | aligned — uses Astryx (Badge, Item) | aligned | | `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | diff --git a/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx new file mode 100644 index 0000000000..dfaa890522 --- /dev/null +++ b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A user row draws its Skill chips from the text it carries. + * + * `inlineReferences` is a frozen rendering hint the Host composes from the + * invocation receipts, so the optimistic row, the desktop's local copy and any + * invocation with no successful receipt carry the token as plain text and an + * empty array. Reading that array as "this message has no tokens" left those + * rows showing `/skill:writer` while the canonical copy of the same message + * showed a chip. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { InlineReference } from '@maka/core/events'; +import { TurnView } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; +import type { ChatItem, TurnViewModel } from '../materialize.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function domRoot() { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { container, root }; +} + +function userTurn(user: ChatItem): TurnViewModel { + return { + turnId: 'turn-1', + status: 'completed', + user, + tools: [], + notes: [], + startedAt: 1, + timeline: [], + }; +} + +async function renderUserRow(user: ChatItem): Promise { + const { container, root } = domRoot(); + await act(() => + root.render( + + + , + ), + ); + const bubble = container.querySelector('.maka-chat-message-bubble-user'); + assert.ok(bubble, 'the user row rendered no bubble'); + return bubble as unknown as HTMLElement; +} + +function chipLabels(bubble: HTMLElement): string[] { + return [...bubble.querySelectorAll('.astryx-badge')].map( + (badge) => (badge.textContent ?? '').trim(), + ); +} + +const WRITER_FILE: InlineReference = { + kind: 'workspace_file', + value: '@notes/writer.md', + label: 'writer.md', + start: 5, +}; + +test('draws a Skill chip from the token when the row carries no references', async () => { + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: 'run /skill:writer on this', + ts: 1, + inlineReferences: [], + }); + + assert.deepEqual(chipLabels(bubble), ['writer']); + assert.ok( + !(bubble.textContent ?? '').includes('/skill:writer'), + 'the raw token must not survive beside its chip', + ); + assert.equal(bubble.textContent, 'run writer on this'); +}); + +test('draws the same chip when the row carries no reference field at all', async () => { + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: 'run /skill:writer on this', + ts: 1, + }); + + assert.deepEqual(chipLabels(bubble), ['writer']); +}); + +test('prefers the frozen label when the Host composed one', async () => { + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: 'run /skill:writer on this', + ts: 1, + inlineReferences: [ + { kind: 'skill', value: '/skill:writer', label: 'Writer', start: 4 }, + ], + }); + + assert.deepEqual(chipLabels(bubble), ['Writer']); + assert.ok(!(bubble.textContent ?? '').includes('/skill:writer')); +}); + +test('keeps a file chip and a Skill chip side by side', async () => { + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: 'read @notes/writer.md then /skill:writer', + ts: 1, + inlineReferences: [WRITER_FILE], + }); + + assert.deepEqual(chipLabels(bubble), ['writer.md', 'writer']); + assert.ok(!(bubble.textContent ?? '').includes('/skill:writer')); + assert.equal(bubble.textContent, 'read writer.md then writer'); +}); + +test('leaves a reference the text no longer holds as text, and still chips the token', async () => { + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: 'read something else then /skill:writer', + ts: 1, + inlineReferences: [WRITER_FILE], + }); + + assert.deepEqual(chipLabels(bubble), ['writer']); + assert.equal(bubble.textContent, 'read something else then writer'); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index eeb1dcd2a9..522350af8b 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -33,7 +33,6 @@ import { ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, - ChatTokenizedText, HStack, IconButton as UiIconButton, Spinner, @@ -46,7 +45,6 @@ import { import { ChatReasoning } from './astryx-chat-reasoning.js'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Icon } from '@astryxdesign/core/Icon'; -import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { type AttachmentRef, type InlineReference, @@ -96,13 +94,6 @@ export function LocalizedChatMessage({ ); } -function legacySentSkillTokens(text: string) { - const values = new Set( - [...text.matchAll(new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'))].map((match) => match[0]), - ); - return [...values].map((value) => ({ value, label: value, variant: 'neutral' as const })); -} - function AttachmentImage(props: { attachment: AttachmentRef }) { const preview = resolvePreviewKind({ name: props.attachment.name, @@ -273,13 +264,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { className="maka-chat-message-bubble maka-chat-message-bubble-user" metadata={userMetadata} > - {props.inlineReferences ? ( - - ) : ( - - {props.text} - - )} + ) : ( userMetadata diff --git a/packages/ui/src/inline-reference.tsx b/packages/ui/src/inline-reference.tsx index 8213010cee..199770afba 100644 --- a/packages/ui/src/inline-reference.tsx +++ b/packages/ui/src/inline-reference.tsx @@ -18,6 +18,7 @@ */ import type { InlineReference } from '@maka/core/events'; +import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { ChatTokenizedText, type ChatComposerToken } from '@astryxdesign/core'; import type { ReactNode } from 'react'; import { ICON_SIZE, Sparkles } from './icons.js'; @@ -89,6 +90,17 @@ export function inlineReferenceToken(reference: InlineReferenceVisual): ChatComp }; } +/** + * The one renderer for a user row's text: the references the message carries, + * plus the `/skill:` invocations the text itself spells. + * + * A Skill chip arrives structurally only once the Host has frozen it from the + * invocation receipts, so the optimistic row, the local store's copy and any + * invocation with no successful receipt carry the token as plain text. Drawing + * only what `references` holds left those rows showing `/skill:writer` while the + * canonical copy of the same message showed a chip — the token is the grammar + * and the chip is one of its renderings, so it is drawn from the text here too. + */ export function InlineReferenceText(props: { text: string; references: readonly InlineReference[]; @@ -105,7 +117,9 @@ export function InlineReferenceText(props: { ) { continue; } - if (reference.start > cursor) parts.push(props.text.slice(cursor, reference.start)); + if (reference.start > cursor) { + parts.push(skillTokenizedText(props.text.slice(cursor, reference.start), `gap:${cursor}`)); + } parts.push( {parts}; } + +/** A text run with its `/skill:` invocations drawn as chips, or plain. */ +function skillTokenizedText(text: string, key: string): ReactNode { + const values = new Set( + [...text.matchAll(new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'))].map((match) => match[0]), + ); + if (values.size === 0) return text; + return ( + + inlineReferenceToken({ kind: 'skill', value, label: skillTokenLabel(value) }), + )} + > + {text} + + ); +} + +/** + * What a token-only chip is labelled with. The id is what the text carries and + * what the Host's frozen reference labels itself from when the Skill's name is + * the id — the common case, so the chip does not rename itself mid-flight. + */ +function skillTokenLabel(value: string): string { + return value.slice('/skill:'.length); +} diff --git a/packages/ui/stories/attachment.stories.tsx b/packages/ui/stories/attachment.stories.tsx index f6c687a7e2..4f8c7531d1 100644 --- a/packages/ui/stories/attachment.stories.tsx +++ b/packages/ui/stories/attachment.stories.tsx @@ -18,6 +18,7 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, waitFor } from 'storybook/test'; import type { ComponentProps } from 'react'; import type { AttachmentRef } from '@maka/core/events'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; @@ -258,3 +259,38 @@ export const SentReferenceHierarchy: Story = { ), }; + +// Real path: 用 `/` 选一个 Skill 发送 → Host 从 invocation receipts 冻结出 chip 之前的那一行 +// (乐观行,以及本地存储里同一行的副本)只带着文本,没有任何 inline reference。 +// The Host composes a Skill's chip into `inlineReferences` from the invocation +// receipts, so a row that carries the `/skill:` token can carry no +// reference at all. The chip is a rendering of the token, not of that hint. +export const SkillTokenWithoutAFrozenReference: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // The transcript virtualizes, so the row mounts after its scroller measures. + const chip = await waitFor(() => { + const found = canvasElement.querySelector('.astryx-badge'); + if (!found) throw new Error('the row has not rendered a chip yet'); + return found; + }); + await expect(chip.textContent).toBe('writer'); + await expect(chip.closest('.astryx-chat-message-bubble')?.textContent).not.toContain( + '/skill:writer', + ); + }, +}; From 3f222954216a9f436f8905df8e7a98548766748b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 22:44:17 +0800 Subject: [PATCH 5/8] test(ui): share the transcript DOM harness The hand-rolled linkedom mount was a third copy of what transcript-test-dom.ts already installs; the row tests need only render() and cleanup(). Generated-by: Devin Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../chat-turn-inline-references.test.tsx | 55 ++++--------------- 1 file changed, 10 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx index dfaa890522..d71c0688be 100644 --- a/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx @@ -30,52 +30,19 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; import type { InlineReference } from '@maka/core/events'; import { TurnView } from '../chat-turn.js'; import { LocaleProvider } from '../locale-context.js'; import type { ChatItem, TurnViewModel } from '../materialize.js'; +import { installTranscriptDom, type TranscriptDom } from './transcript-test-dom.js'; -const originalGlobals = { - document: globalThis.document, - matchMedia: globalThis.matchMedia, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; - -const mountedRoots: ReturnType[] = []; +let dom: TranscriptDom | undefined; afterEach(async () => { - for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); + await dom?.cleanup(); + dom = undefined; }); -function domRoot() { - const { document, window } = parseHTML('
'); - Object.assign(globalThis, { - document, - window, - matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), - requestAnimationFrame: () => 1, - cancelAnimationFrame() {}, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoots.push(root); - return { container, root }; -} - function userTurn(user: ChatItem): TurnViewModel { return { turnId: 'turn-1', @@ -89,15 +56,13 @@ function userTurn(user: ChatItem): TurnViewModel { } async function renderUserRow(user: ChatItem): Promise { - const { container, root } = domRoot(); - await act(() => - root.render( - - - , - ), + dom = installTranscriptDom(); + await dom.render( + + + , ); - const bubble = container.querySelector('.maka-chat-message-bubble-user'); + const bubble = dom.container.querySelector('.maka-chat-message-bubble-user'); assert.ok(bubble, 'the user row rendered no bubble'); return bubble as unknown as HTMLElement; } From 819feeac7febe44f50ab06d2b637ce972be2fcd1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 22:44:32 +0800 Subject: [PATCH 6/8] fix(ui): draw Skill chips at grammar match positions ChatTokenizedText re-locates a token by its value, so tokenizing a gap by value set chipped positions the grammar rejects: an 'a/skill:x' URL shape beside a real invocation (permanent when its receipt fails), a token glued to a reference span (a false '^' at the gap's start), and a longer id split by a shorter one. Each grammar match now gets an island holding exactly the matched text. Generated-by: Devin Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../chat-turn-inline-references.test.tsx | 46 +++++++++++++++++ packages/ui/src/inline-reference.tsx | 51 ++++++++++++------- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx index d71c0688be..2a104244d2 100644 --- a/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx @@ -149,3 +149,49 @@ test('leaves a reference the text no longer holds as text, and still chips the t assert.deepEqual(chipLabels(bubble), ['writer']); assert.equal(bubble.textContent, 'read something else then writer'); }); + +test('does not chip a token the grammar rejects at its position', async () => { + // `a/skill:writer` is a URL-shaped mention the grammar's `(?<=\s)` excludes; + // drawing chips by token *value* chips it anyway — and when the invocation's + // receipt failed the wrong chip stayed in the final transcript forever. + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: '参考 a/skill:writer 再调 /skill:writer', + ts: 1, + inlineReferences: [], + }); + + assert.deepEqual(chipLabels(bubble), ['writer']); + assert.equal(bubble.textContent, '参考 a/skill:writer 再调 writer'); +}); + +test('does not chip a token that starts right after a reference span', async () => { + // The text's own boundary check sees `d/`, not the whitespace the grammar + // requires — the gap slice's start is not a real `^`. + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: 'read @notes/writer.md/skill:review', + ts: 1, + inlineReferences: [WRITER_FILE], + }); + + assert.deepEqual(chipLabels(bubble), ['writer.md']); + assert.equal(bubble.textContent, 'read writer.md/skill:review'); +}); + +test('chips prefix-related ids at their own positions', async () => { + // Value-set tokenization let the shorter `review` match inside `reviewer` + // first, leaving a `review` chip plus a stray `er`. + const bubble = await renderUserRow({ + id: 'ask', + role: 'user', + text: 'run /skill:review and /skill:reviewer', + ts: 1, + inlineReferences: [], + }); + + assert.deepEqual(chipLabels(bubble), ['review', 'reviewer']); + assert.equal(bubble.textContent, 'run review and reviewer'); +}); diff --git a/packages/ui/src/inline-reference.tsx b/packages/ui/src/inline-reference.tsx index 199770afba..43bb25f2fb 100644 --- a/packages/ui/src/inline-reference.tsx +++ b/packages/ui/src/inline-reference.tsx @@ -118,7 +118,7 @@ export function InlineReferenceText(props: { continue; } if (reference.start > cursor) { - parts.push(skillTokenizedText(props.text.slice(cursor, reference.start), `gap:${cursor}`)); + parts.push(...skillTokenizedParts(props.text, cursor, reference.start)); } parts.push( {parts}; } -/** A text run with its `/skill:` invocations drawn as chips, or plain. */ -function skillTokenizedText(text: string, key: string): ReactNode { - const values = new Set( - [...text.matchAll(new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'))].map((match) => match[0]), - ); - if (values.size === 0) return text; - return ( - - inlineReferenceToken({ kind: 'skill', value, label: skillTokenLabel(value) }), - )} - > - {text} - - ); +/** + * `/skill:` invocations in `text[start..end)` drawn as chips at their + * grammar positions. `ChatTokenizedText` re-locates a token by its value, so + * each match gets an island holding exactly the matched text: a value can + * never chip a position the grammar rejected (`a/skill:x`), and a shorter id + * can never split a longer one. + */ +function skillTokenizedParts(text: string, start: number, end: number): ReactNode[] { + const parts: ReactNode[] = []; + let cursor = start; + for (const match of text.slice(start, end).matchAll(new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'))) { + if (match.index === undefined) continue; + const at = start + match.index; + // The grammar's `^` reads the slice's start; the real boundary is the + // character before it in the full text. + if (match.index === 0 && start > 0 && !/\s/.test(text[start - 1])) continue; + if (at > cursor) parts.push(text.slice(cursor, at)); + parts.push( + + {match[0]} + , + ); + cursor = at + match[0].length; + } + if (cursor < end) parts.push(text.slice(cursor, end)); + return parts; } /** From 8b9b11c2bf291e064149a43103659753743c26fa Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 22:44:38 +0800 Subject: [PATCH 7/8] fix(ui): land the caret in a text node even at a textless end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the editable ends in a token with its trailing space deleted, a
, or nothing, the boundary walk left the caret on the element boundary — the same IME anchor defect this PR fixes elsewhere. The walk now appends an empty text node (serializes to '') and lands inside it. The ./Chat barrel re-export of placeCaretAtEnd lets the composer's own caret-to-end use the vendored path instead of duplicating the walk; upstream asked for the helpers to be exported in facebook/astryx#6411. README gains the rationale and delete-when entries the patch convention requires, covering the caret, chip-geometry and re-export hunks. Generated-by: Devin Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/ui/src/composer.tsx | 45 +------------ patches/@astryxdesign+core+0.6.2.patch | 88 +++++++++++++++++++++----- patches/README.md | 28 ++++++++ 3 files changed, 102 insertions(+), 59 deletions(-) diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index e0f41442fa..ab8216eba3 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -103,6 +103,7 @@ import { ChatComposerInput, IconButton, Lightbox, + placeCaretAtEnd, Token, Tooltip, useChatPasteAsToken, @@ -200,42 +201,6 @@ function commandPrimaryText(command: ComposerSlashCommandOption): string { return `${command.id} ${command.name} ${(command.keywords ?? []).join(' ')}`; } -/** - * Move a collapsed range that sits on an element boundary into the text node it - * visually points at. - * - * `selectNodeContents` + `collapse(false)` leaves the caret on the *editable's* - * child offset, and Astryx's `insertToken` anchors it the same way with - * `setStartAfter`. Chromium's IME anchors a composition to the boundary it - * starts from, and from an element boundary the first preedit commits as its - * raw letters instead of the composed character — the first CJK word typed - * after a Skill chip, or after a restored draft, arrived as pinyin. The two - * boundaries are the same visual caret; only the text-node one composes. - * - * A chip is `contenteditable="false"`, so a trailing token is not entered: the - * walk stops there and the element boundary stands. - * - * Astryx's own `chatComposerSelection` helpers (`placeCaretAtEnd`, - * `ensureCaretInside`, which the input's focus and imperative-insert paths use) - * carry the same guard; both are in the dependency patch and have to move - * together. - */ -function landCaretInsideTextNode(range: Range): void { - const container = range.startContainer; - if (!container || container.nodeType === Node.TEXT_NODE) return; - let node: Node | null = container.childNodes[range.startOffset - 1] ?? null; - while (node) { - if (node.nodeType === Node.TEXT_NODE) { - const text = node as Text; - range.setStart(text, text.textContent?.length ?? 0); - range.collapse(true); - return; - } - if (!(node instanceof HTMLElement) || !node.isContentEditable) return; - node = node.lastChild; - } -} - /** * Rows the input grows to before it scrolls. `ChatComposerInput` prices this in * its own hardcoded 22px line, so the cap is 220px — one line under the 240px @@ -713,13 +678,7 @@ export const Composer = forwardRef< return; } caretPendingRef.current = false; - const selection = document.getSelection(); - const range = document.createRange(); - range.selectNodeContents(editable); - range.collapse(false); - landCaretInsideTextNode(range); - selection?.removeAllRanges(); - selection?.addRange(range); + placeCaretAtEnd(editable); } function focusInput() { inputHandleRef.current?.focus(); diff --git a/patches/@astryxdesign+core+0.6.2.patch b/patches/@astryxdesign+core+0.6.2.patch index 45163cb1d7..fe292cec7e 100644 --- a/patches/@astryxdesign+core+0.6.2.patch +++ b/patches/@astryxdesign+core+0.6.2.patch @@ -184,10 +184,10 @@ index 3c457d1..7d5a11a 100644 }, getToolCallKey(call))) }) diff --git a/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js b/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js -index 97b1a3b..8581890 100644 +index 97b1a3b..e50f680 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js +++ b/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js -@@ -66,6 +66,41 @@ export function restoreSelectionRange(range) { +@@ -66,6 +66,51 @@ export function restoreSelectionRange(range) { selection.addRange(range); } @@ -202,8 +202,10 @@ index 97b1a3b..8581890 100644 + * programmatic caret move arrived as pinyin. The two boundaries are the same + * visual caret; only the text-node one composes. + * -+ * Tokens are `contenteditable="false"`, so a trailing token is not entered: the -+ * walk stops there and the element boundary stands. ++ * Tokens are `contenteditable="false"`, so a trailing token is not entered; ++ * and when nothing landable remains (a trailing token, a `
`, an empty ++ * editable) an empty text node appended at the boundary gives the composition ++ * a text anchor at the same visual caret. + */ +export function landInsideTrailingTextNode(range) { + // A DOM shim can implement `selectNodeContents` without exposing the @@ -220,16 +222,24 @@ index 97b1a3b..8581890 100644 + return; + } + if (!(node instanceof HTMLElement) || !node.isContentEditable) { -+ return; ++ break; + } + node = node.lastChild; + } ++ // An empty text node serializes to ''. Only at the end — a mid-content ++ // boundary keeps its element anchor rather than jump to the tail. ++ if (range.startOffset === container.childNodes.length) { ++ const text = document.createTextNode(''); ++ container.appendChild(text); ++ range.setStart(text, 0); ++ range.collapse(true); ++ } +} + /** * Collapse the Selection to the very end of `editable`'s content, * replacing whatever the Selection held before. -@@ -86,6 +121,7 @@ export function placeCaretAtEnd(editable) { +@@ -86,6 +131,7 @@ export function placeCaretAtEnd(editable) { const range = document.createRange(); range.selectNodeContents(editable); range.collapse(false); // collapse to end @@ -237,7 +247,7 @@ index 97b1a3b..8581890 100644 selection.removeAllRanges(); selection.addRange(range); return true; -@@ -118,6 +154,7 @@ export function ensureCaretInside(editable) { +@@ -118,6 +164,7 @@ export function ensureCaretInside(editable) { const range = document.createRange(); range.selectNodeContents(editable); range.collapse(false); // collapse to end @@ -245,7 +255,7 @@ index 97b1a3b..8581890 100644 selection.removeAllRanges(); selection.addRange(range); return selection; -@@ -220,7 +257,11 @@ export function insertTextAtCursor(editable, text) { +@@ -220,7 +267,11 @@ export function insertTextAtCursor(editable, text) { range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); @@ -258,6 +268,30 @@ index 97b1a3b..8581890 100644 range.collapse(true); selection.removeAllRanges(); selection.addRange(range); +diff --git a/node_modules/@astryxdesign/core/dist/Chat/index.d.ts b/node_modules/@astryxdesign/core/dist/Chat/index.d.ts +index a650ae4..aafef4b 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/index.d.ts ++++ b/node_modules/@astryxdesign/core/dist/Chat/index.d.ts +@@ -31,6 +31,7 @@ export { useChatPasteAsToken } from './useChatPasteAsToken'; + export type { UseChatPasteAsTokenOptions, UseChatPasteAsTokenReturn, } from './useChatPasteAsToken'; + export { useChatComposerTokens } from './useChatComposerTokens'; + export type { UseChatComposerTokensOptions, UseChatComposerTokensReturn, TokenPortal, } from './useChatComposerTokens'; ++export { placeCaretAtEnd } from './chatComposerSelection'; + export type { ChatMessageSender, ChatDensity } from './ChatContext'; + export { useChatLayoutContext, useChatComposerContext } from './ChatContext'; + export type { ChatComposerContextValue, ChatComposerInputControl, } from './ChatContext'; +diff --git a/node_modules/@astryxdesign/core/dist/Chat/index.js b/node_modules/@astryxdesign/core/dist/Chat/index.js +index 47cf976..8fb9241 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/index.js ++++ b/node_modules/@astryxdesign/core/dist/Chat/index.js +@@ -21,6 +21,7 @@ export { useChatStreamScroll } from "./useChatStreamScroll.js"; + export { useChatNewMessages } from "./useChatNewMessages.js"; + export { useChatPasteAsToken } from "./useChatPasteAsToken.js"; + export { useChatComposerTokens } from "./useChatComposerTokens.js"; ++export { placeCaretAtEnd } from "./chatComposerSelection.js"; + export { useChatLayoutContext, useChatComposerContext } from "./ChatContext.js"; + export { ChatToolCalls } from "./ChatToolCalls.js"; + export { ChatLayout } from "./ChatLayout.js"; diff --git a/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js b/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js index bee6075..378af1e 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useChatComposerTokens.js @@ -1222,10 +1256,10 @@ index 68a424e..d4f0c5e 100644 diff --git a/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts b/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts -index 0f31ca8..61ee6ff 100644 +index 0f31ca8..7fa3a89 100644 --- a/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts +++ b/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts -@@ -69,6 +69,42 @@ export function restoreSelectionRange(range: Range): void { +@@ -69,6 +69,52 @@ export function restoreSelectionRange(range: Range): void { selection.addRange(range); } @@ -1240,8 +1274,10 @@ index 0f31ca8..61ee6ff 100644 + * programmatic caret move arrived as pinyin. The two boundaries are the same + * visual caret; only the text-node one composes. + * -+ * Tokens are `contenteditable="false"`, so a trailing token is not entered: the -+ * walk stops there and the element boundary stands. ++ * Tokens are `contenteditable="false"`, so a trailing token is not entered; ++ * and when nothing landable remains (a trailing token, a `
`, an empty ++ * editable) an empty text node appended at the boundary gives the composition ++ * a text anchor at the same visual caret. + */ +export function landInsideTrailingTextNode(range: Range): void { + // A DOM shim can implement `selectNodeContents` without exposing the @@ -1259,16 +1295,24 @@ index 0f31ca8..61ee6ff 100644 + return; + } + if (!(node instanceof HTMLElement) || !node.isContentEditable) { -+ return; ++ break; + } + node = node.lastChild; + } ++ // An empty text node serializes to ''. Only at the end — a mid-content ++ // boundary keeps its element anchor rather than jump to the tail. ++ if (range.startOffset === container.childNodes.length) { ++ const text = document.createTextNode(''); ++ container.appendChild(text); ++ range.setStart(text, 0); ++ range.collapse(true); ++ } +} + /** * Collapse the Selection to the very end of `editable`'s content, * replacing whatever the Selection held before. -@@ -89,6 +125,7 @@ export function placeCaretAtEnd(editable: HTMLElement): boolean { +@@ -89,6 +135,7 @@ export function placeCaretAtEnd(editable: HTMLElement): boolean { const range = document.createRange(); range.selectNodeContents(editable); range.collapse(false); // collapse to end @@ -1276,7 +1320,7 @@ index 0f31ca8..61ee6ff 100644 selection.removeAllRanges(); selection.addRange(range); return true; -@@ -123,6 +160,7 @@ export function ensureCaretInside(editable: HTMLElement): Selection | null { +@@ -123,6 +170,7 @@ export function ensureCaretInside(editable: HTMLElement): Selection | null { const range = document.createRange(); range.selectNodeContents(editable); range.collapse(false); // collapse to end @@ -1284,7 +1328,7 @@ index 0f31ca8..61ee6ff 100644 selection.removeAllRanges(); selection.addRange(range); return selection; -@@ -262,7 +300,11 @@ export function insertTextAtCursor( +@@ -262,7 +310,11 @@ export function insertTextAtCursor( const textNode = document.createTextNode(text); range.insertNode(textNode); @@ -1297,6 +1341,18 @@ index 0f31ca8..61ee6ff 100644 range.collapse(true); selection.removeAllRanges(); selection.addRange(range); +diff --git a/node_modules/@astryxdesign/core/src/Chat/index.ts b/node_modules/@astryxdesign/core/src/Chat/index.ts +index 7239e18..db3ca04 100644 +--- a/node_modules/@astryxdesign/core/src/Chat/index.ts ++++ b/node_modules/@astryxdesign/core/src/Chat/index.ts +@@ -80,6 +80,7 @@ export type { + UseChatComposerTokensReturn, + TokenPortal, + } from './useChatComposerTokens'; ++export {placeCaretAtEnd} from './chatComposerSelection'; + export type {ChatMessageSender, ChatDensity} from './ChatContext'; + export {useChatLayoutContext, useChatComposerContext} from './ChatContext'; + export type { diff --git a/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts b/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts index 071303f..d28601a 100644 --- a/node_modules/@astryxdesign/core/src/Chat/useChatComposerTokens.ts diff --git a/patches/README.md b/patches/README.md index ccd89519bb..9cf25e46f8 100644 --- a/patches/README.md +++ b/patches/README.md @@ -243,4 +243,32 @@ alone — no `themeProps`, no `data-*`, no custom property — so the only handl is a structural selector that breaks the moment a caller passes `scrollButton={null}`. See #3446. +Three hunks fix composer caret anchoring and chip alignment rather than add +seams. Every programmatic caret move (`placeCaretAtEnd`, `ensureCaretInside`, +`insertTextAtCursor`, `insertToken`, `expandToken`, the token-paste path and +the controlled-write restore) anchored a collapsed range on a child offset of +the contenteditable; Chromium anchors an IME composition to the boundary it +starts from, and from an element boundary the first preedit commits as its raw +letters, so the first CJK word after a chip or a caret move arrived as pinyin. +`landInsideTrailingTextNode` moves the caret into the text node it visually +points at — appending an empty one when nothing landable remains — and the +`setStartAfter` sites anchor inside the node they just inserted. No product +code can reach these ranges: they are created inside the dependency's own +helpers. Tracked upstream as facebook/astryx#6411. + +The inline token chip used `vertical-align: middle`, which centres the box on +the parent's x-height midline and ignores its height, leaving a 20px chip +~1.8px low against CJK text on a 22px line. `insertToken`'s span and +`ChatTokenizedText`'s wrapper both carry `height: 1lh; align-items: center; +vertical-align: top` — they must stay equal or a token moves when the message +is sent. Neither is reachable by product CSS without `!important` against an +inline style and a stylex class. Tracked upstream as facebook/astryx#6412. + +The `./Chat` barrel re-export of `placeCaretAtEnd` is a widened surface, not a +behaviour change: `packages/ui`'s composer restores drafts with its own +caret-to-end and duplicated the boundary walk because the selection helpers +are not exported. Delete the re-export when upstream exports +`chatComposerSelection` or grows an equivalent caret primitive — raised in +#6411 — keeping the composer call site on whatever upstream ships. + Delete each hunk when the corresponding behavior ships in Astryx. From f02bedadf7bb596cbc509577cdd4a73d41e10cc0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 22:44:42 +0800 Subject: [PATCH 8/8] refactor(ui): drop the unreachable mentionMatchRank tier The slash-menu query never contains whitespace, so the every-word-in-name rank could only be reached by tests. Removing it also removes the duplicate of mentionQueryMatches' tokenize-every-includes loop, and the docblock now says what the code does: rank 0 is a prefix of primary, not 'a prefix of its id or name'. Generated-by: Devin Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ui/src/__tests__/chat-input-behavior.test.ts | 5 +---- packages/ui/src/chat-input-behavior.ts | 16 +++++----------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/__tests__/chat-input-behavior.test.ts b/packages/ui/src/__tests__/chat-input-behavior.test.ts index 151aa21de8..b435f567ff 100644 --- a/packages/ui/src/__tests__/chat-input-behavior.test.ts +++ b/packages/ui/src/__tests__/chat-input-behavior.test.ts @@ -149,12 +149,9 @@ describe('shared chat input behavior', () => { assert.equal(debug, 0); assert.equal(proseOnly, 3); assert.ok(debug < proseOnly); - // Prefix, then anywhere in the id/name, then every word of a multi-word - // query, then prose only. + // Prefix, then anywhere in the id/name, then prose only. assert.equal(mentionMatchRank('pro', 'project-only Project Only'), 0); assert.equal(mentionMatchRank('only', 'project-only Project Only'), 1); - // Words out of order: not a substring, but every word is in the name. - assert.equal(mentionMatchRank('logger debug', 'debug logger'), 2); assert.equal(mentionMatchRank(' ', 'project-only Project Only'), 0); assert.equal(mentionMatchRank('comp', 'compact'), 0); assert.equal(mentionMatchRank('pact', 'compact'), 1); diff --git a/packages/ui/src/chat-input-behavior.ts b/packages/ui/src/chat-input-behavior.ts index 5eb5f2c37d..b7203d257c 100644 --- a/packages/ui/src/chat-input-behavior.ts +++ b/packages/ui/src/chat-input-behavior.ts @@ -121,10 +121,9 @@ export function mentionQueryMatches(query: string, text: string): boolean { } /** - * How well one `/`-menu candidate answers the typed query, lower first: 0 for a - * prefix of its id or name, 1 for an id/name substring, 2 when every query word - * appears across id and name, 3 when only the description or the keywords - * explain the match. + * How well one `/`-menu candidate answers the typed query, lower first: 0 for + * a prefix of `primary`, 1 for a substring of it, 3 when the match lives only + * in the description the filter also searched. * * The menu orders by this ahead of its catalog order. `mentionQueryMatches` * alone treats a description as good as a name, so one or two typed letters of @@ -135,17 +134,12 @@ export function mentionQueryMatches(query: string, text: string): boolean { * `primary` is what a user is naming: a Skill's id and name, a command's id, * name and keywords. */ -export function mentionMatchRank(query: string, primary: string): 0 | 1 | 2 | 3 { +export function mentionMatchRank(query: string, primary: string): 0 | 1 | 3 { const normalized = query.trim().toLowerCase(); if (!normalized) return 0; const haystack = primary.toLowerCase(); if (haystack.startsWith(normalized)) return 0; - if (haystack.includes(normalized)) return 1; - const words = normalized.split(/\s+/); - // A multi-word query ("project only") rarely appears verbatim in an id/name, - // but every word being there still makes it a name match rather than prose. - if (words.every((word) => haystack.includes(word))) return 2; - return 3; + return haystack.includes(normalized) ? 1 : 3; } /** Normalize `/skill:` and bare `/` into the same Skill search query. */