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/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-input-behavior.test.ts b/packages/ui/src/__tests__/chat-input-behavior.test.ts index 0d9a01adee..b435f567ff 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,23 @@ 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 prose only. + assert.equal(mentionMatchRank('pro', 'project-only Project Only'), 0); + assert.equal(mentionMatchRank('only', 'project-only Project Only'), 1); + 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/__tests__/chat-turn-inline-references.test.tsx b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx new file mode 100644 index 0000000000..2a104244d2 --- /dev/null +++ b/packages/ui/src/__tests__/chat-turn-inline-references.test.tsx @@ -0,0 +1,197 @@ +/* + * 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 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'; + +let dom: TranscriptDom | undefined; + +afterEach(async () => { + await dom?.cleanup(); + dom = undefined; +}); + +function userTurn(user: ChatItem): TurnViewModel { + return { + turnId: 'turn-1', + status: 'completed', + user, + tools: [], + notes: [], + startedAt: 1, + timeline: [], + }; +} + +async function renderUserRow(user: ChatItem): Promise { + dom = installTranscriptDom(); + await dom.render( + + + , + ); + 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; +} + +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'); +}); + +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/chat-input-behavior.ts b/packages/ui/src/chat-input-behavior.ts index 657f9dfbb4..b7203d257c 100644 --- a/packages/ui/src/chat-input-behavior.ts +++ b/packages/ui/src/chat-input-behavior.ts @@ -120,6 +120,28 @@ 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 `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 + * 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 | 3 { + const normalized = query.trim().toLowerCase(); + if (!normalized) return 0; + const haystack = primary.toLowerCase(); + if (haystack.startsWith(normalized)) return 0; + return haystack.includes(normalized) ? 1 : 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/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/composer.tsx b/packages/ui/src/composer.tsx index 380f238c48..ab8216eba3 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, @@ -102,6 +103,7 @@ import { ChatComposerInput, IconButton, Lightbox, + placeCaretAtEnd, Token, Tooltip, useChatPasteAsToken, @@ -194,6 +196,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 @@ -671,12 +678,7 @@ export const Composer = forwardRef< return; } caretPendingRef.current = false; - const selection = document.getSelection(); - const range = document.createRange(); - range.selectNodeContents(editable); - range.collapse(false); - selection?.removeAllRanges(); - selection?.addRange(range); + placeCaretAtEnd(editable); } function focusInput() { inputHandleRef.current?.focus(); @@ -1032,6 +1034,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 +1046,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 +1064,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, diff --git a/packages/ui/src/inline-reference.tsx b/packages/ui/src/inline-reference.tsx index 8213010cee..43bb25f2fb 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(...skillTokenizedParts(props.text, cursor, reference.start)); + } parts.push( {parts}; } + +/** + * `/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; +} + +/** + * 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', + ); + }, +}; diff --git a/patches/@astryxdesign+core+0.6.2.patch b/patches/@astryxdesign+core+0.6.2.patch index 291ccf8e33..fe292cec7e 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 @@ -111,6 +128,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 +183,178 @@ 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..e50f680 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js ++++ b/node_modules/@astryxdesign/core/dist/Chat/chatComposerSelection.js +@@ -66,6 +66,51 @@ 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; ++ * 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 ++ // 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) { ++ 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 +131,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 +164,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 +267,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/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 ++++ 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); + +@@ -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 @@ -859,7 +1071,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 { @@ -870,7 +1082,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; @@ -879,7 +1099,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]); @@ -888,6 +1108,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 @@ -968,6 +1196,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 +1255,159 @@ 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..7fa3a89 100644 +--- a/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts ++++ b/node_modules/@astryxdesign/core/src/Chat/chatComposerSelection.ts +@@ -69,6 +69,52 @@ 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; ++ * 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 ++ // 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) { ++ 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 +135,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 +170,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 +310,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/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 ++++ 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); +@@ -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 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.