diff --git a/apps/desktop/e2e/composer-inline-completion.spec.ts b/apps/desktop/e2e/composer-inline-completion.spec.ts deleted file mode 100644 index 96f48e84d6..0000000000 --- a/apps/desktop/e2e/composer-inline-completion.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend'; -import { test, expect, COMPOSER_INPUT } from './fixtures'; -import type { Page } from '@playwright/test'; - -/** - * The active-offer lifecycle for the composer's inline completion. - * - * This lives here rather than in the Storybook play function on purpose: the - * render smoke opens stories in embedded mode, which disables autoplay - * (FIDELITY.md), so a story can only prove that the composer mounts. Every - * assertion below is about a keystroke, a caret, a focus change or a layout — - * none of which that lane executes. - * - * History is seeded by sending real messages rather than by writing - * `maka-input-history` directly: the entries are owned by `useComposerHistory`, - * which reads storage once at mount and thereafter follows the module's own - * writes, so a test that poked at the key would be seeding a list nobody holds. - */ - -const OFFER = '[data-astryx-inline-completion]'; - -/** The draft as the composer sees it — the offer is in the editable but not in the value. */ -async function draft(page: Page): Promise { - return page.locator(COMPOSER_INPUT).evaluate((editable) => { - const clone = editable.cloneNode(true) as HTMLElement; - for (const offer of Array.from(clone.querySelectorAll('[data-astryx-inline-completion]'))) { - offer.remove(); - } - return clone.textContent ?? ''; - }); -} - -/** - * Type into the composer once it is actually ready to receive it. - * - * The first send creates a session and the shell re-keys the composer, so a - * click-and-type issued straight after a settled turn can land on the element - * being replaced and be dropped. Asserting focus first, and the draft after, - * turns that race into a legible failure instead of a mysterious missing offer. - */ -async function typeDraft(page: Page, text: string): Promise { - const composer = page.locator(COMPOSER_INPUT); - await composer.click(); - await expect(composer).toBeFocused(); - await composer.pressSequentially(text); - await expect.poll(() => draft(page)).toBe(text); -} - -/** Send `text` so it enters prompt history through the path the product uses. */ -async function sendAndSettle(page: Page, text: string): Promise { - const composer = page.locator(COMPOSER_INPUT); - await composer.click(); - await composer.fill(text); - await composer.press('Enter'); - // Settle on the reply landing in the transcript, not on the Stop button - // going away: the first send also creates the session and re-keys the - // composer, and the button disappears before that has finished — typing into - // the gap is dropped on the element being replaced. - await expect(page.getByText(`Fake backend received: ${text}`.slice(0, 40))).toBeVisible({ - timeout: 30_000, - }); - await expect(page.getByRole('button', { name: '停止' })).toHaveCount(0, { timeout: 30_000 }); -} - -test('a visible offer completes on Tab and never reaches the value before it', async ({ - window: page, -}) => { - const recalled = '帮我把 composer 的样式再收紧一点'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '帮我把'); - - const offer = page.locator(OFFER); - await expect(offer).toHaveCount(1); - // Offered, not committed: together they read as the recalled prompt, but the - // value still holds only what was typed. - await expect(offer).toHaveText(recalled.slice('帮我把'.length)); - expect(await draft(page)).toBe('帮我把'); - - await composer.press('Tab'); - await expect(offer).toHaveCount(0); - expect(await draft(page)).toBe(recalled); - // Focus stays in the field so the next keystroke keeps typing. - await expect(composer).toBeFocused(); -}); - -test('an offer that does not fit the field is withdrawn and leaves Tab alone', async ({ - window: page, -}) => { - // Long enough to lay out past `maxRows`, which is exactly the case where the - // preview and the insertion used to disagree: the tail is unreadable while - // Tab would still commit all of it. - const long = `请审查这个实现,逐条说明:${'键盘路由、输入法、换行、滚动、焦点与撤销栈各自的边界条件;'.repeat(40)}`; - await sendAndSettle(page, long); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '请审查这个'); - - // Withdrawn rather than clipped: nothing is offered at all. - await expect(page.locator(OFFER)).toHaveCount(0); - await composer.press('Tab'); - expect(await draft(page)).toBe('请审查这个'); -}); - -test('the offer follows focus and the caret without a render', async ({ window: page }) => { - const recalled = '帮我把 composer 的样式再收紧一点'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '帮我把'); - await expect(page.locator(OFFER)).toHaveCount(1); - - // Blur withdraws, refocus reconciles. Neither fires a render, so an offer - // that only reappeared on the next keystroke would fail here. - await page.locator('body').click({ position: { x: 5, y: 5 } }); - await expect(page.locator(OFFER)).toHaveCount(0); - await composer.click(); - await expect(page.locator(OFFER)).toHaveCount(1); - - // A caret that leaves the end takes the offer with it, and Tab is ordinary - // again — the case where a stale offer used to splice itself mid-draft. - await composer.press('ArrowLeft'); - await expect(page.locator(OFFER)).toHaveCount(0); - await composer.press('Tab'); - expect(await draft(page)).toBe('帮我把'); -}); - -test('a composing Tab reaches the IME instead of committing the offer', async ({ - window: page, -}) => { - const recalled = '帮我把 composer 的样式再收紧一点'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '帮我把'); - await expect(page.locator(OFFER)).toHaveCount(1); - - const prevented = await composer.evaluate((editable) => { - editable.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); - const tab = new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - cancelable: true, - isComposing: true, - }); - editable.dispatchEvent(tab); - return tab.defaultPrevented; - }); - - // The offer goes at `compositionstart`, and the key is left for the IME. - await expect(page.locator(OFFER)).toHaveCount(0); - expect(prevented).toBe(false); - expect(await draft(page)).toBe('帮我把'); -}); - -test('an open trigger menu keeps Tab for itself', async ({ window: page }) => { - await sendAndSettle(page, '/review 这段实现有没有问题'); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '/rev'); - // The menu owns the caret and Tab while it is open, so nothing is offered - // beside it even though history would otherwise complete this draft. - await expect(composer).toHaveAttribute('aria-expanded', 'true'); - await expect(page.locator(OFFER)).toHaveCount(0); - - await composer.press('Escape'); - await expect(composer).toHaveAttribute('aria-expanded', 'false'); - // With the menu gone the same draft completes, which is what makes the - // absence above about ownership rather than about the matcher. - await expect(page.locator(OFFER)).toHaveCount(1); -}); - -test('Escape stops a streaming turn on the first press even with an offer up', async ({ - window: page, -}) => { - const recalled = '帮我把 composer 的样式再收紧一点'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await composer.click(); - await composer.fill(FAKE_HOLD_OPEN_PROMPT); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '停止' })).toBeVisible({ timeout: 30_000 }); - - // An offer is up while the turn runs — the case where the input used to - // swallow Escape and leave the interrupt to a second press. - await typeDraft(page, '帮我把'); - await expect(page.locator(OFFER)).toHaveCount(1); - - await composer.press('Escape'); - await expect(page.locator(OFFER)).toHaveCount(0); - await expect(page.getByRole('button', { name: '停止' })).toHaveCount(0, { timeout: 30_000 }); -}); - -test('only a bare Tab commits: ordinary typing and Shift+Tab leave the offer uncommitted', async ({ - window: page, -}) => { - const recalled = '帮我把 composer 的样式再收紧一点'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '帮我把'); - await expect(page.locator(OFFER)).toHaveCount(1); - - // Shift+Tab is back-tab, never an acceptance: the draft is untouched and the - // key does its ordinary job of leaving the field. - await composer.press('Shift+Tab'); - expect(await draft(page)).toBe('帮我把'); - await expect(composer).not.toBeFocused(); - - // A printable key keeps typing. The offer may re-derive against the longer - // draft, but nothing of it may have entered the value. - await composer.click(); - await expect(composer).toBeFocused(); - await composer.pressSequentially('这'); - expect(await draft(page)).toBe('帮我把这'); - expect(await draft(page)).not.toContain('composer'); -}); - -test('accepting is one undoable transaction: Tab, Undo, Redo', async ({ window: page }) => { - const recalled = '帮我把 composer 的样式再收紧一点'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '帮我把'); - await expect(page.locator(OFFER)).toHaveCount(1); - await composer.press('Tab'); - expect(await draft(page)).toBe(recalled); - - // Undo must take back the completion and nothing else. A scripted range - // mutation is not on the contentEditable undo stack, so it used to leave the - // suffix in place and eat the character typed before it instead. - await composer.press('ControlOrMeta+z'); - await expect.poll(() => draft(page)).toBe('帮我把'); - - await composer.press('ControlOrMeta+Shift+z'); - await expect.poll(() => draft(page)).toBe(recalled); -}); - -test('a programmatic draft replacement invalidates a live offer', async ({ window: page }) => { - const recalled = '帮我把 composer 的样式再收紧一点'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '帮我把'); - await expect(page.locator(OFFER)).toHaveCount(1); - - // The host replaces the draft out from under the offer — a session switch or - // a prompt insertion. The offer was derived from text the editor no longer - // holds, so it must not survive, and Tab must not splice it into what - // replaced it. - await composer.fill('完全不同的另一段草稿'); - await expect.poll(() => draft(page)).toBe('完全不同的另一段草稿'); - await expect(page.locator(OFFER)).toHaveCount(0); - - await composer.press('Tab'); - expect(await draft(page)).toBe('完全不同的另一段草稿'); -}); - -test('a multi-line completion keeps its line breaks through Tab, Undo and Redo', async ({ - window: page, -}) => { - // Two lines, because a `\n` handed to `insertText` becomes a wrapping div in - // Chromium that the serializer reads without restoring the break — the - // prompt used to come back joined into one line. - const recalled = '第一行的要求\n第二行的补充说明'; - await sendAndSettle(page, recalled); - - const composer = page.locator(COMPOSER_INPUT); - await typeDraft(page, '第一行的'); - await expect(page.locator(OFFER)).toHaveCount(1); - - await composer.press('Tab'); - // Exact value, newline included: the contract is that Tab commits precisely - // what the offer showed. - await expect.poll(() => draft(page)).toBe(recalled); - - // And still one undo entry, even though it took several editing commands. - await composer.press('ControlOrMeta+z'); - await expect.poll(() => draft(page)).toBe('第一行的'); - await composer.press('ControlOrMeta+Shift+z'); - await expect.poll(() => draft(page)).toBe(recalled); -}); diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index f94ede2f16..a347b01ed5 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { userEvent } from 'storybook/test'; import { useState, type CSSProperties, type ReactNode } from 'react'; import type { ComponentProps } from 'react'; import type { ProjectRecord } from '@maka/core/project'; @@ -7,10 +6,8 @@ import type { SessionSummary, StoredMessage } from '@maka/core/session'; import { ChatSurfaceLayout, ChatView, - clearGlobalInputHistory, Composer, deriveTitlebarProjectName, - saveGlobalInputHistoryEntry, SessionListPanel, TitlebarSessionIdentity, } from '@maka/ui'; @@ -1032,39 +1029,3 @@ export const ModeOnWithPendingAttachments: Story = { /> ), }; - -const RECALLED_PROMPT = '帮我把 composer 的样式再收紧一点'; - -// Real path: the user has sent this prompt before and starts retyping it, so -// the editor offers the rest inside the field. -// -// A review driver, not coverage: the render smoke opens stories in embedded -// mode, which disables autoplay (FIDELITY.md), so nothing below is executed by -// CI. The active-offer lifecycle — Tab, caret, focus, composition, trigger-menu -// priority, streaming Escape — is pinned in -// `apps/desktop/e2e/composer-inline-completion.spec.ts`, which does run. -export const ComposerInlineSuggestion: Story = { - // Seeded through the module's own write path, before the story mounts: - // `useComposerHistory` reads storage once at mount and thereafter follows - // that module's writes, so poking the key from `play` would seed a list - // nobody holds. - loaders: [ - async () => { - clearGlobalInputHistory(); - saveGlobalInputHistoryEntry(RECALLED_PROMPT); - return {}; - }, - ], - render: () => , - play: async ({ canvasElement }) => { - // Scoped to this canvas, not the document: Storybook can have other - // stories mounted, and a document-wide lookup would drive whichever - // composer happened to be first. - const editable = canvasElement.querySelector( - '.maka-composer-editor [contenteditable="true"]', - ); - if (!editable) return; - await userEvent.click(editable); - await userEvent.keyboard(RECALLED_PROMPT.slice(0, 3)); - }, -}; diff --git a/packages/ui/src/__tests__/astryx-inline-completion.test.tsx b/packages/ui/src/__tests__/astryx-inline-completion.test.tsx deleted file mode 100644 index c03f51b3f2..0000000000 --- a/packages/ui/src/__tests__/astryx-inline-completion.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { ChatComposerInput } from '@astryxdesign/core'; -import { renderToStaticMarkup } from 'react-dom/server'; - -/** - * Guard for the `inlineCompletion` half of - * `patches/@astryxdesign+core+0.4.0.patch`. - * - * The behavior itself — Tab commits, Escape dismisses, the offer wraps and - * scrolls with the field, the announcement — needs a caret and a focused - * editor, so it is pinned by the `composer-inline-suggestion` play story in - * the Storybook smoke run. What is checkable without a DOM is the seam: an - * unpatched `ChatComposerInput` does not destructure `inlineCompletion`, so it - * falls into `...rest`, reaches the root element, and React renders it as a - * stray `inlinecompletion` attribute. That is the failure this catches, and it - * is the same failure that would silently ship the feature as a no-op. - * - * Delete the patch when both assertions hold against an unpatched package. - */ -describe('Astryx ChatComposerInput inline completion', () => { - it('consumes the offered completion instead of leaking it onto the DOM', () => { - const markup = renderToStaticMarkup( - , - ); - - assert.doesNotMatch(markup, /inlinecompletion/i); - }); - - it('keeps an unaccepted completion out of the rendered value', () => { - const markup = renderToStaticMarkup( - , - ); - - // Offered text is ephemeral: it exists only once the editor is focused - // with the caret at the end, and it is never part of the value. A server - // render has neither focus nor a caret, so nothing of it may appear. - assert.doesNotMatch(markup, /绝不能出现在草稿里/); - assert.match(markup, /contenteditable/i); - }); -}); diff --git a/packages/ui/src/__tests__/prompt-history-match.test.ts b/packages/ui/src/__tests__/prompt-history-match.test.ts deleted file mode 100644 index 9dee49b5af..0000000000 --- a/packages/ui/src/__tests__/prompt-history-match.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - canonicalizePromptText, - matchPromptHistory, - PROMPT_HISTORY_MATCH_MIN_DRAFT, -} from '../prompt-history-match.js'; - -/** Entries are stored oldest-first, so the last one is the newest. */ -const HISTORY = ['帮我写一个测试', '帮我写一个 README', '帮我改一下样式']; - -describe('matchPromptHistory', () => { - it('completes the draft with the remainder of the newest matching entry', () => { - assert.equal(matchPromptHistory('帮我改', HISTORY), '一下样式'); - // Two entries share this prefix; the newer of the two wins. - assert.equal(matchPromptHistory('帮我写一个 ', HISTORY), 'README'); - }); - - it('returns only the remainder so the typed prefix is never rewritten', () => { - const rest = matchPromptHistory('帮我写一个测', HISTORY); - assert.equal(rest, '试'); - assert.equal(`帮我写一个测${rest}`, '帮我写一个测试'); - }); - - it('ignores case on the second pass while preserving what the user typed', () => { - const rest = matchPromptHistory('fix the', ['Fix the failing test']); - assert.equal(rest, ' failing test'); - // The stored capital F is not forced back onto the draft. - assert.equal(`fix the${rest}`, 'fix the failing test'); - }); - - it('prefers an exact-case match over a newer case-insensitive one', () => { - const entries = ['fix the exact one', 'Fix the newer one']; - assert.equal(matchPromptHistory('fix the', entries), ' exact one'); - }); - - it('stays quiet when there is nothing left to complete', () => { - assert.equal(matchPromptHistory('帮我改一下样式', HISTORY), null); - assert.equal(matchPromptHistory('完全没见过的输入', HISTORY), null); - assert.equal(matchPromptHistory('帮我改', []), null); - }); - - it('stays quiet until the draft is long enough to distinguish prompts', () => { - assert.equal(PROMPT_HISTORY_MATCH_MIN_DRAFT, 2); - assert.equal(matchPromptHistory('帮', ['帮我改一下样式']), null); - assert.equal(matchPromptHistory(' ', [' 留白开头的历史']), null); - assert.equal(matchPromptHistory('帮我', ['帮我改一下样式']), '改一下样式'); - }); - - it('matches history and nothing else, including drafts that open with a trigger', () => { - // Whether a trigger menu owns the caret and Tab is the editor's question, - // asked of the editor. A composer without mention sources must still - // complete its own `/review …` history. - const entries = ['/review 这段实现', '@src/app.tsx 看一下']; - assert.equal(matchPromptHistory('/review', entries), ' 这段实现'); - assert.equal(matchPromptHistory('@src/', entries), 'app.tsx 看一下'); - }); - - it('completes a multi-line draft from the end of its last line', () => { - assert.equal(matchPromptHistory('第一行\n第二行', ['第一行\n第二行的内容']), '的内容'); - }); -}); - -describe('canonicalizePromptText', () => { - it('lets a draft carrying token anchors match the history it was stored as', () => { - // The editor anchors an inline token with U+00A0; `composerWireText` stores - // an ordinary space. Without one shared spelling the same prompt never - // matches itself. - const stored = '看 src/app.tsx 这段实现'; - const draftWithAnchor = '看 src/app.tsx'; - assert.equal(canonicalizePromptText(draftWithAnchor), '看 src/app.tsx'); - assert.equal(matchPromptHistory(draftWithAnchor, [stored]), ' 这段实现'); - }); - - it('slices the suffix from the stored entry, not the canonical form', () => { - const stored = '写 一个测试用例'; - // The offer is what the entry actually holds, so accepting reproduces it. - assert.equal(matchPromptHistory('写 一个', [stored]), '测试用例'); - }); -}); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index a5c2695ff3..0e676e64b9 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -607,7 +607,7 @@ export const Composer = forwardRef< onDraftKeyChange: resetPromptHistoryNavigation, persistence: props.draftPersistence, }); - const { resetNavigation, rememberSentEntry, handleArrowKey, matchCompletion } = useComposerHistory({ + const { resetNavigation, rememberSentEntry, handleArrowKey } = useComposerHistory({ text: textPort, saveCurrentDraft, }); @@ -1546,14 +1546,6 @@ export const Composer = forwardRef< // surfaces, and clearable from Settings · 数据 (see // use-composer-history.ts). hasHistory={false} - // The rest of the newest past prompt this draft is a prefix - // of, offered as dim text after the caret. What is offered is - // ours to decide; whether it can be shown — caret at the end, - // no trigger menu, not mid-composition — and how it wraps, - // scrolls, announces and commits are the input's, which is the - // only thing that knows those states. - inlineCompletion={matchCompletion(text) ?? undefined} - inlineCompletionLabel={copy.inlineCompletionHint} triggers={triggers} pasteAsToken={pasteAsToken} onFiles={onInputFiles} diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 6991e2d1fd..f2f579ea6a 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -82,8 +82,6 @@ export interface ConversationCopy { composer: { placeholder: string; textareaAriaLabel: string; - /** Instruction announced after an inline completion, for screen readers. */ - inlineCompletionHint: string; pastedQuoteLabel: string; selectedSkillsAriaLabel: string; removeSkillAriaLabel(name: string): string; @@ -384,7 +382,7 @@ const CONVERSATION_COPY = { startersAriaLabel: '深度研究起手式', starters: DEEP_RESEARCH_STARTER_PROMPTS, }, composer: { - placeholder: '描述任务,@ 引用文件,/ 选择技能…', textareaAriaLabel: '消息输入框', inlineCompletionHint: '按 Tab 键补全,按 Esc 键忽略', pastedQuoteLabel: '粘贴的文本', selectedSkillsAriaLabel: '已选择的 Skill', removeSkillAriaLabel: (name) => `移除 Skill:${name}`, awaitingPermission: '等待你确认权限…', + placeholder: '描述任务,@ 引用文件,/ 选择技能…', textareaAriaLabel: '消息输入框', pastedQuoteLabel: '粘贴的文本', selectedSkillsAriaLabel: '已选择的 Skill', removeSkillAriaLabel: (name) => `移除 Skill:${name}`, awaitingPermission: '等待你确认权限…', sending: '正在发送…', importing: '正在导入…', sendLabel: '发送', steerLabel: '插入消息', stopLabel: '停止', stopping: '停止中…', streaming: 'Maka 正在回答…', processing: 'Maka 正在处理…', continuing: 'Maka 继续中…', interruptHint: '或点停止中断', addContext: '添加上下文', stagedContext: '附加内容', @@ -525,7 +523,7 @@ const CONVERSATION_COPY = { ], }, composer: { - placeholder: 'Describe a task, @ to reference files, / for skills…', textareaAriaLabel: 'Message input', inlineCompletionHint: 'Press Tab to complete, Esc to dismiss', pastedQuoteLabel: 'Pasted text', selectedSkillsAriaLabel: 'Selected Skills', removeSkillAriaLabel: (name) => `Remove Skill: ${name}`, awaitingPermission: 'Waiting for your permission decision…', + placeholder: 'Describe a task, @ to reference files, / for skills…', textareaAriaLabel: 'Message input', pastedQuoteLabel: 'Pasted text', selectedSkillsAriaLabel: 'Selected Skills', removeSkillAriaLabel: (name) => `Remove Skill: ${name}`, awaitingPermission: 'Waiting for your permission decision…', sending: 'Sending…', importing: 'Importing…', sendLabel: 'Send', steerLabel: 'Steer', stopLabel: 'Stop', stopping: 'Stopping…', streaming: 'Maka is responding…', processing: 'Maka is working…', continuing: 'Maka is continuing…', interruptHint: 'or click Stop to interrupt', addContext: 'Add context', stagedContext: 'staged items', diff --git a/packages/ui/src/prompt-history-match.ts b/packages/ui/src/prompt-history-match.ts deleted file mode 100644 index 143a82c0f4..0000000000 --- a/packages/ui/src/prompt-history-match.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Prompt-history matching — the decision behind the composer's inline - * completion. - * - * This retrieves text the user has already sent. It does not generate one, and - * it is deliberately not a "suggestion" abstraction: a future model completion - * would be asynchronous, cancellable and metered, and a prompt recommendation - * need not extend a typed prefix at all. Those are different decision - * contracts and belong in their own modules; only the visual seam - * (`ChatComposerInput`'s `inlineCompletion`) is worth sharing. - * - * Pure, and pure of the DOM too: the caret, the selection, wrapping and - * whether anything else already owns Tab are the editor's business, and it - * answers all of them itself. What is left here is the one question the - * composer can answer — given this draft and this history, what would finish - * it — plus the two guards that keep the answer honest: - * - * - a match must be a true prefix extension, so what is offered is exactly - * what appending it produces; - * - a draft too short to distinguish prompts is not matched, because at one - * or two characters nearly every entry qualifies and the offer would - * flicker through unrelated old prompts. - */ - -/** - * Shortest draft that earns a match. One character matches nearly every - * history entry. - */ -export const PROMPT_HISTORY_MATCH_MIN_DRAFT = 2; - -/** - * The one spelling both sides of a comparison are held to. - * - * A draft carries U+00A0 where an inline token anchors itself, while history - * is stored after `composerWireText` has normalized those to ordinary spaces — - * so the same prompt could be spelled two ways and simply never match itself. - * Canonicalizing here, on both the draft and the entries, means neither side - * has to know what the other stores. It is a one-for-one character swap, so - * offsets are preserved and a suffix can still be sliced from the original - * entry rather than from the canonical form. - */ -export function canonicalizePromptText(text: string): string { - return text.replace(/\u00a0/g, ' '); -} - -/** - * The remainder of the newest history entry that `draft` is a prefix of, or - * null when nothing matches. - * - * Returns the remainder rather than the entry so the caller appends instead of - * replacing: whatever the user typed is never rewritten, which matters for the - * case-insensitive pass below — typing `fix ` must not be recased to a stored - * `Fix `. - */ -export function matchPromptHistory(draft: string, entries: readonly string[]): string | null { - if (draft.trim().length < PROMPT_HISTORY_MATCH_MIN_DRAFT) return null; - const canonicalDraft = canonicalizePromptText(draft); - - // Newest first: the most recent matching prompt is the one the user is most - // likely retyping. - const exact = newestMatch((entry) => entry.startsWith(canonicalDraft)); - if (exact !== null) return exact.slice(canonicalDraft.length); - // Second pass ignores case, so a draft that starts lowercase still finds the - // capitalized prompt it is a prefix of. - const lowered = canonicalDraft.toLowerCase(); - const insensitive = newestMatch((entry) => entry.toLowerCase().startsWith(lowered)); - return insensitive === null ? null : insensitive.slice(canonicalDraft.length); - - function newestMatch(matches: (canonicalEntry: string) => boolean): string | null { - for (let index = entries.length - 1; index >= 0; index--) { - const entry = entries[index]; - // Equal length means the draft already *is* that entry — nothing is left - // to complete. - if (entry === undefined || entry.length <= canonicalDraft.length) continue; - if (matches(canonicalizePromptText(entry))) return entry; - } - return null; - } -} diff --git a/packages/ui/src/use-composer-history.ts b/packages/ui/src/use-composer-history.ts index 9b8929407e..a72955029e 100644 --- a/packages/ui/src/use-composer-history.ts +++ b/packages/ui/src/use-composer-history.ts @@ -18,7 +18,7 @@ * and keeps this. */ -import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { useEffect, useRef, type KeyboardEvent } from 'react'; import type { ComposerTextPort } from './chat-input-behavior.js'; import { type ComposerHistoryState, @@ -31,7 +31,6 @@ import { saveGlobalInputHistoryEntry, subscribeGlobalInputHistory, } from './input-history.js'; -import { matchPromptHistory } from './prompt-history-match.js'; export interface ComposerHistoryApi { /** @@ -58,15 +57,6 @@ export interface ComposerHistoryApi { * stop further key handling. */ handleArrowKey(event: KeyboardEvent): boolean; - /** - * What would finish `draft` if it were taken from history, or null. - * - * Lives here because this hook is the history's only owner: a second holder - * would need its own copy of the entries, and a copy is exactly what lets a - * prompt cleared from Settings · 数据 be completed back into a draft. The - * decision itself is `matchPromptHistory`, pure and tested on its own. - */ - matchCompletion(draft: string): string | null; } export function useComposerHistory(input: { @@ -75,10 +65,6 @@ export function useComposerHistory(input: { saveCurrentDraft(value?: string): void; }): ComposerHistoryApi { const promptHistoryRef = useRef({ entries: readGlobalInputHistory() ?? [], index: -1, savedDraft: '' }); - // Re-render on a write, so an offer drawn from an entry that has just been - // cleared from Settings · 数据 leaves the screen with it rather than waiting - // for the next keystroke to recompute. - const [, setHistoryRevision] = useState(0); // The subscription is registered once, so anything it calls must be reached // through the latest render rather than captured from the first. Today the // pieces that matter happen to be ref-backed — the text port is created once @@ -107,13 +93,8 @@ export function useComposerHistory(input: { ); promptHistoryRef.current = state; if (restoreDraft) applyValueRef.current(state.savedDraft); - setHistoryRevision((revision) => revision + 1); }), []); - function matchCompletion(draft: string): string | null { - return matchPromptHistory(draft, promptHistoryRef.current.entries); - } - function resetNavigation() { promptHistoryRef.current = { entries: promptHistoryRef.current.entries, @@ -175,5 +156,5 @@ export function useComposerHistory(input: { return true; } - return { resetNavigation, rememberSentEntry, handleArrowKey, matchCompletion }; + return { resetNavigation, rememberSentEntry, handleArrowKey }; } diff --git a/patches/@astryxdesign+core+0.4.0.patch b/patches/@astryxdesign+core+0.4.0.patch index a818460aee..6cb20dd531 100644 --- a/patches/@astryxdesign+core+0.4.0.patch +++ b/patches/@astryxdesign+core+0.4.0.patch @@ -1,413 +1,3 @@ -diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts -index 90022b3..f9c7b8d 100644 ---- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts -+++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts -@@ -154,6 +154,28 @@ export interface ChatComposerInputProps extends Omit, - * never submits while a composition is in progress. - */ - onKeyDown?: (event: KeyboardEvent) => void; -+ /** -+ * Text offered as a continuation of the current value, rendered dim after -+ * the caret and committed with Tab. -+ * -+ * The consumer decides *what* to offer; this component decides whether it -+ * can be shown, because only it knows the selection, the composition -+ * state, whether a trigger menu already owns Tab, and how the text wraps -+ * and scrolls. The offer is drawn inside the editable and excluded from -+ * the value, so what the user sees and what Tab commits are the same text -+ * by construction — never a clipped preview of a longer insertion. -+ * -+ * Shown only while the editable is focused with the caret collapsed at the -+ * end of the content and no trigger menu open. Escape dismisses the -+ * current offer until a different one arrives. -+ */ -+ inlineCompletion?: string; -+ /** -+ * Instruction appended after the completion in its accessible -+ * announcement, e.g. "press Tab to accept". Product copy, so it has no -+ * default; without it the completion text is announced on its own. -+ */ -+ inlineCompletionLabel?: string; - } - export declare function ChatComposerInput(props: ChatComposerInputProps): import("react").JSX.Element; - export declare namespace ChatComposerInput { -diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js -index edd9080..227efd7 100644 ---- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js -+++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js -@@ -93,12 +93,61 @@ function selectAll(el) { - selection.removeAllRanges(); - selection.addRange(range); - } -+/** -+ * True when the selection is collapsed with nothing after it but the editor's -+ * own trailing filler. An inline completion is only ever offered here, because -+ * that is the only caret position at which appending it is what Tab does. -+ * -+ * An offer already on screen does not count as content after the caret: this -+ * has to answer the same question before the offer is painted AND while it is -+ * standing, because the caret can move without a render and the answer is what -+ * decides whether Tab may still commit. -+ */ -+function caretAtContentEnd(editable) { -+ const selection = window.getSelection(); -+ if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) { -+ return false; -+ } -+ const range = selection.getRangeAt(0); -+ if (!editable.contains(range.startContainer)) { -+ return false; -+ } -+ const after = document.createRange(); -+ after.selectNodeContents(editable); -+ after.setEnd(editable, editable.childNodes.length); -+ after.setStart(range.endContainer, range.endOffset); -+ const remainder = after.cloneContents(); -+ for (const offer of Array.from(remainder.querySelectorAll('[data-astryx-inline-completion]'))) { -+ offer.remove(); -+ } -+ return (remainder.textContent ?? '').replace(/\n+$/, '').length === 0; -+} -+/** -+ * True when every line of `span` lies inside the editable's visible box. -+ * -+ * Moving the offer into the editor made the preview and the insertion one -+ * layout, but the editor is still capped by `maxRows` and scrolls: a long -+ * candidate lays out past the bottom of the field, where the user cannot read -+ * it, while Tab would still commit all of it. So laying it out is not the same -+ * as offering it — this is the question asked between the two. -+ */ -+function offerFullyVisible(editable, span) { -+ const field = editable.getBoundingClientRect(); -+ const offer = span.getBoundingClientRect(); -+ return offer.bottom <= field.bottom + 0.5 && offer.top >= field.top - 0.5; -+} - function serialize(node) { - let result = ''; - for (const child of Array.from(node.childNodes)) { - if (child.nodeType === Node.TEXT_NODE) { - result += child.textContent ?? ''; - } else if (child instanceof HTMLElement) { -+ if (child.hasAttribute('data-astryx-inline-completion')) { -+ // Ephemeral: offered text the user has not accepted. It lives in the -+ // editable so it participates in real layout (wrapping, growth, -+ // scrolling), but it is not part of the value until Tab commits it. -+ continue; -+ } - if (child.hasAttribute('data-astryx-token')) { - result += child.getAttribute('data-astryx-token-value') ?? ''; - } else if (child.tagName === 'BR') { -@@ -136,6 +185,8 @@ export function ChatComposerInput(props) { - onFiles, - onSubmit = composerCtx?.onSubmit, - onKeyDown: onKeyDownProp, -+ inlineCompletion, -+ inlineCompletionLabel, - xstyle, - className, - style, -@@ -171,6 +222,23 @@ export function ChatComposerInput(props) { - // incorrectly skipped. - const pendingEchoValueRef = useRef(undefined); - -+ // --- Inline completion (ephemeral offered text) --- -+ // The candidate the consumer supplies is only *offered*; this component owns -+ // whether it can be shown at all, because only it knows the selection, the -+ // composition state, whether a trigger menu already owns Tab, and how the -+ // text will wrap and scroll. Showing it inside the editable is what makes -+ // the preview and the committed value the same thing by construction. -+ // `{ base, suffix }` — the candidate and the exact draft it was derived -+ // from. Null when nothing is offered. -+ const activeOfferRef = useRef(null); -+ const inlineCompletionDismissedRef = useRef(null); -+ // An IME composition is the editor's own state, so the guard belongs here -+ // rather than in whatever a consumer happens to track: a composing Tab must -+ // never commit an offer into the half-built character, and an offer must not -+ // stand beside a composition it is not part of. -+ const inlineCompletionComposingRef = useRef(false); -+ const [inlineCompletionAnnouncement, setInlineCompletionAnnouncement] = useState(''); -+ - // Stable refs for imperative handle callbacks (avoid re-creating handle on every render) - const insertTokenRef = useRef(() => undefined); - const insertTextRef = useRef(() => {}); -@@ -302,12 +370,241 @@ export function ChatComposerInput(props) { - emitChange(); - triggerMenu.handleInput(); - }, [emitChange, triggerMenu]); -+ const triggerMenuOpen = triggerMenu.ariaProps['aria-expanded'] === true; -+ -+ const withdrawOffer = useCallback(() => { -+ const editable = editableRef.current; -+ if (editable) { -+ for (const stale of Array.from(editable.querySelectorAll('[data-astryx-inline-completion]'))) { -+ stale.remove(); -+ } -+ } -+ activeOfferRef.current = null; -+ setInlineCompletionAnnouncement(''); -+ }, []); -+ -+ const reconcileOffer = useCallback(() => { -+ const editable = editableRef.current; -+ if (!editable) { -+ return; -+ } -+ const suffix = inlineCompletion ?? ''; -+ // The draft as the value sees it — `serialize` skips the offer, so this is -+ // the base a candidate is bound to whether or not one is standing. -+ const base = serialize(editable); -+ const active = activeOfferRef.current; -+ const standing = editable.querySelector('[data-astryx-inline-completion]'); -+ const eligible = suffix.length > 0 -+ && !isDisabled -+ && !triggerMenuOpen -+ && !inlineCompletionComposingRef.current -+ && document.activeElement === editable -+ && caretAtContentEnd(editable) -+ && !(inlineCompletionDismissedRef.current?.base === base -+ && inlineCompletionDismissedRef.current?.suffix === suffix); -+ -+ // Already correct: same candidate, same base, still on screen and still -+ // fully visible. Returning here is what keeps a controlled input from -+ // rebuilding the node once per character typed. -+ if (eligible -+ && active !== null -+ && active.base === base -+ && active.suffix === suffix -+ && standing !== null -+ && offerFullyVisible(editable, standing)) { -+ return; -+ } -+ -+ withdrawOffer(); -+ if (base.length === 0) { -+ // A cleared field cannot have dismissed anything. -+ inlineCompletionDismissedRef.current = null; -+ } -+ if (!eligible) { -+ return; -+ } -+ -+ const span = document.createElement('span'); -+ span.setAttribute('data-astryx-inline-completion', ''); -+ span.setAttribute('aria-hidden', 'true'); -+ span.contentEditable = 'false'; -+ span.style.opacity = '0.45'; -+ span.style.pointerEvents = 'none'; -+ span.style.userSelect = 'none'; -+ span.textContent = suffix; -+ // Before a trailing filler `
`: that filler contributes no text, so the -+ // caret still counts as at the end, and appending past it would draw the -+ // offer on the next line instead of continuing the one being typed. -+ const filler = editable.lastChild; -+ if (filler instanceof HTMLBRElement) { -+ editable.insertBefore(span, filler); -+ } else { -+ editable.appendChild(span); -+ } -+ // Laid out, now judged: a candidate whose tail falls past the bottom of the -+ // field is withdrawn rather than offered, so Tab keeps its ordinary meaning -+ // instead of committing text the user could not read. -+ if (!offerFullyVisible(editable, span)) { -+ span.remove(); -+ return; -+ } -+ activeOfferRef.current = { base, suffix }; -+ setInlineCompletionAnnouncement( -+ inlineCompletionLabel ? `${suffix} ${inlineCompletionLabel}` : suffix, -+ ); -+ }, [inlineCompletion, inlineCompletionLabel, isDisabled, triggerMenuOpen, withdrawOffer]); -+ -+ // A passive effect, and deliberately not a layout one: the controlled-value -+ // sync above rewrites `editable.textContent` from its own passive effect, and -+ // reconciling before that ran promoted an offer against a draft the editor -+ // was about to replace — leaving the span removed by the rewrite while the -+ // active record and the announcement still claimed it. Effects run in -+ // declaration order, so reconciling here is reconciling against the DOM the -+ // value actually produced. No dependency array: the decision reads the live -+ // selection, which a render does not announce. -+ useEffect(reconcileOffer); -+ -+ /** -+ * The caret can leave the end of the content without React hearing about it: -+ * a click, an arrow key, a drag-select and a blur all move the selection -+ * with no state change and therefore no commit, so the layout effect above -+ * never re-runs and a stale offer would still be standing when Tab arrives. -+ * -+ * `selectionchange` is document-scoped because that is the only event that -+ * fires for every one of those, and composition is tracked here for the same -+ * reason it is enforced here: the editor is what knows. -+ */ -+ useEffect(() => { -+ const editable = editableRef.current; -+ if (!editable) { -+ return undefined; -+ } -+ // Re-decide, rather than only withdraw: the caret leaving the end has to -+ // take the offer with it, and the caret coming back has to bring it back — -+ // neither is a render, so the layout effect sees neither. -+ const onSelectionChange = () => { -+ if (document.activeElement === editable) { -+ reconcileOffer(); -+ } else if (activeOfferRef.current !== null) { -+ withdrawOffer(); -+ } -+ }; -+ const onCompositionStart = () => { -+ inlineCompletionComposingRef.current = true; -+ withdrawOffer(); -+ }; -+ const onCompositionEnd = () => { -+ inlineCompletionComposingRef.current = false; -+ reconcileOffer(); -+ }; -+ document.addEventListener('selectionchange', onSelectionChange); -+ // Focus leaving withdraws; focus returning re-asks. Both go through the -+ // one reconciliation owner, because focus, selection and composition are -+ // inputs to the same decision rather than three states to keep in step. -+ // Refocusing fires neither `selectionchange` nor a render, so without this -+ // a valid candidate stayed withdrawn for as long as the caret sat still. -+ editable.addEventListener('focusout', withdrawOffer); -+ editable.addEventListener('focusin', reconcileOffer); -+ editable.addEventListener('compositionstart', onCompositionStart); -+ editable.addEventListener('compositionend', onCompositionEnd); -+ return () => { -+ document.removeEventListener('selectionchange', onSelectionChange); -+ editable.removeEventListener('focusout', withdrawOffer); -+ editable.removeEventListener('focusin', reconcileOffer); -+ editable.removeEventListener('compositionstart', onCompositionStart); -+ editable.removeEventListener('compositionend', onCompositionEnd); -+ }; -+ }, [reconcileOffer, withdrawOffer]); -+ -+ /** -+ * Commit the offer as one undoable editing transaction. -+ * -+ * Through `execCommand('insertText')`, not `Range.insertNode`: Chromium does -+ * not put a scripted range mutation on the contentEditable undo stack, so the -+ * accepted suffix survived a Ctrl/Cmd+Z that instead ate the character the -+ * user had typed before it. `insertText` is the browser's own editing -+ * command, which undo and redo already understand. -+ */ -+ const acceptOffer = useCallback(() => { -+ const editable = editableRef.current; -+ const active = activeOfferRef.current; -+ if (!editable || active === null) { -+ return false; -+ } -+ const standing = editable.querySelector('[data-astryx-inline-completion]'); -+ // Revalidated here, synchronously, against the editor as it stands at this -+ // keystroke rather than as it stood at the last commit — and against the -+ // candidate's own base draft, so an offer derived from text the editor no -+ // longer holds cannot be spliced into whatever replaced it. Refusing -+ // returns the keystroke to its ordinary meaning. -+ if (inlineCompletionComposingRef.current -+ || document.activeElement !== editable -+ || serialize(editable) !== active.base -+ || !caretAtContentEnd(editable) -+ || standing === null -+ || !offerFullyVisible(editable, standing)) { -+ withdrawOffer(); -+ return false; -+ } -+ // The decoration goes first so the command inserts into the draft rather -+ // than around a node the value never contained. -+ withdrawOffer(); -+ // One transaction, several commands. A `\n` handed to `insertText` becomes -+ // a wrapping `
` in Chromium, which `serialize` walks without -+ // restoring the break — a two-line prompt came back joined into one — and -+ // the command emits no `beforeinput`, so a consumer's own multi-line -+ // replay never sees it either. Issuing the break as the browser's own -+ // line-break command produces the `
` the serializer does understand, -+ // and Chromium keeps the whole sequence on a single undo entry. -+ const lines = active.suffix.split('\n'); -+ for (const [index, line] of lines.entries()) { -+ if (index > 0) { -+ document.execCommand('insertLineBreak'); -+ } -+ if (line) { -+ document.execCommand('insertText', false, line); -+ } -+ } -+ emitChange(); -+ return true; -+ }, [emitChange, withdrawOffer]); - const handleKeyDown = useCallback(e => { - // Let trigger menu consume the event first - if (triggerMenu.handleKeyDown(e)) { - return; - } - -+ // Inline completion, after the menu has had its turn and before the -+ // consumer's: Tab commits what is shown, Escape dismisses it. Both only -+ // when something is actually on screen, so an editor with no completion -+ // keeps Tab's ordinary focus move and Escape's ordinary meaning for the -+ // consumer. -+ // A key the IME is still using is not ours to read at all: the offer is -+ // already gone by `compositionstart`, and this keeps the Tab that commits -+ // a candidate from being swallowed on the way to it. -+ const composing = inlineCompletionComposingRef.current -+ || e.nativeEvent?.isComposing === true -+ || e.nativeEvent?.keyCode === 229 -+ || e.key === 'Process'; -+ if (!composing && activeOfferRef.current !== null) { -+ if (e.key === 'Tab' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { -+ // preventDefault only if it really committed — a revalidation that -+ // fails hands Tab back to its ordinary focus move. -+ if (acceptOffer()) { -+ e.preventDefault(); -+ return; -+ } -+ } else if (e.key === 'Escape') { -+ // Withdraw the offer and remember it as dismissed, then let the key -+ // continue: neither `preventDefault` nor an early return. An input -+ // primitive can own what it drew without owning what Escape means to -+ // its host — a consumer that stops a streaming turn on Escape must -+ // still do it on the first press, not the second. -+ inlineCompletionDismissedRef.current = activeOfferRef.current; -+ withdrawOffer(); -+ } -+ } -+ - // Consumer passthrough — runs before built-in Enter/history handling. - // A consumer can preventDefault() to fully own the keystroke. - onKeyDownProp?.(e); -@@ -411,7 +708,7 @@ export function ChatComposerInput(props) { - e.preventDefault(); - } - } -- }, [hasHistory, onSubmit, onChange, emitChange, triggerMenu, onKeyDownProp]); -+ }, [hasHistory, onSubmit, onChange, emitChange, triggerMenu, onKeyDownProp, acceptOffer, withdrawOffer]); - const handlePaste = useCallback(e => { - const editable = editableRef.current; - if (!editable) { -@@ -479,6 +776,26 @@ export function ChatComposerInput(props) { - maxHeight: `${maxHeight}px` - } - }) -+ }), /*#__PURE__*/_jsx("div", { -+ // Mounted unconditionally, and empty when there is nothing to say. A -+ // live region that arrives in the same commit as its text is usually -+ // not announced at all — the region has to be in the accessibility -+ // tree first, and only its text may change. -+ role: "status", -+ "aria-live": "polite", -+ style: { -+ position: 'absolute', -+ width: '1px', -+ height: '1px', -+ margin: '-1px', -+ padding: 0, -+ border: 0, -+ overflow: 'hidden', -+ clip: 'rect(0 0 0 0)', -+ clipPath: 'inset(50%)', -+ whiteSpace: 'nowrap' -+ }, -+ children: inlineCompletionAnnouncement - }), triggerMenu.renderMenu(), tokens.tokenPortals.filter(({ - span - }) => span.isConnected).map(({ diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts index d1bfeeb..b4a0e62 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts diff --git a/patches/README.md b/patches/README.md index 09a3cb2e5a..3fbea4918e 100644 --- a/patches/README.md +++ b/patches/README.md @@ -28,7 +28,7 @@ Delete when that guard passes against an unpatched package. ## `@astryxdesign/core@0.4.0` -Six published component seams drop host-owned state or semantics: +Five published component seams drop host-owned state or semantics: - `ChatLayout` needs a conversation identity that resets scroll/unread state without remounting its composer slot and discarding the live draft. @@ -43,13 +43,6 @@ Six published component seams drop host-owned state or semantics: `ChatLayoutContextValue` publishes the hook's existing `unlock`. - `ChatToolCalls` needs a stable row slot for product styling and E2E geometry. - `List` must forward its published `aria-label` to the rendered list element. -- `ChatComposerInput` publishes no seam for an inline completion, and one drawn - beside the editable cannot agree with it about wrapping, the caret, the - composition state or an open trigger menu: measured, a one-row field offering - a 116-character completion showed 57 and Tab committed all 116. - `inlineCompletion` / `inlineCompletionLabel` draw the offer inside the editor, - excluded from `serialize`, so the preview and the insertion are one layout. - Upstream ask: [facebook/astryx#4822](https://github.com/facebook/astryx/issues/4822). - `SideNavItem` needs an interactive `trailingAction` sibling between its navigation control and nested items. `endContent` renders inside the primary control, while a sibling outside `SideNavItem` can only come before the