feat: PromptInput @-mention chips on a ProseMirror editor - #870
feat: PromptInput @-mention chips on a ProseMirror editor#870rohanchkrabrty wants to merge 2 commits into
Conversation
The row-signature join used a raw NUL byte, which made git treat use-mention-menu.ts as a binary file and hid it from diffs. The escape produces the same separator, and the file is plain text again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdded a ProseMirror-backed Sequence Diagram(s)sequenceDiagram
participant User
participant PromptInputEditor
participant PromptInputMentionRegistry
participant useMentionMenu
participant SuggestionMenu
User->>PromptInputEditor: type trigger and query
PromptInputEditor->>useMentionMenu: update suggestion state
useMentionMenu->>PromptInputMentionRegistry: search or resolve items
useMentionMenu->>SuggestionMenu: render results
User->>SuggestionMenu: select item
SuggestionMenu->>PromptInputEditor: insert mention chip
PromptInputEditor->>PromptInputRoot: submit structured message
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (7)
packages/raystack/components/editor/__tests__/markup.test.ts (1)
77-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe LCG loses precision, so the corpus is narrower than intended.
seed * 1103515245reaches about 2.4e18. That exceedsNumber.MAX_SAFE_INTEGER, so low-order bits are dropped before the modulo. The sequence stays deterministic but its distribution is quantized, which weakens the generated corpus.Use
Math.imulto keep the arithmetic exact in 32 bits.♻️ Proposed fix
let seed = 987654321; const next = (max: number) => { - seed = (seed * 1103515245 + 12345) % 2147483648; + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; return seed % max; };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/raystack/components/editor/__tests__/markup.test.ts` around lines 77 - 104, Update the deterministic generator in the `survives a generated corpus` test so the LCG multiplication uses `Math.imul` and remains exact in 32-bit arithmetic before applying the modulus. Preserve the existing seed, constants, reproducibility, and corpus-generation behavior.packages/raystack/components/editor/index.ts (1)
11-20: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winDon’t call
../editorProseMirror-free.
../editorre-exports ProseMirror-backed modules such as schema, suggestion UI/plugins, styles, anduse-editor.PromptInputEditorimports from../editorand importseditorStyles;use-mention-menu.tsonly imports types but still goes through the same barrel. Import the ProseMirror-free mention API directly from./mention, or expose a dedicated ProseMirror-free entry point.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/raystack/components/editor/index.ts` around lines 11 - 20, Remove the claim that ../editor is ProseMirror-free and update PromptInputEditor and use-mention-menu.ts to import the mention API directly from ./mention (or a dedicated ProseMirror-free entry point), avoiding the ../editor barrel while preserving their existing runtime and type imports.packages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsx (1)
273-283: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAssert the submit call before reading it.
Line 280 indexes
onSubmit.mock.calls[0]directly. IfonSubmitnever fires, the test fails with a property-access error onundefinedinstead of naming the real cause. The other tests in this file, for example Line 231, assert the call count first.♻️ Add the call-count assertion
substrate.type(input, 'ship @[x](y:z) today'); fireEvent.keyDown(input, { key: 'Enter' }); + expect(onSubmit).toHaveBeenCalledTimes(1); const message: PromptInputMessage = onSubmit.mock.calls[0][0];🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsx` around lines 273 - 283, In the literal markup-shaped string test, assert that onSubmit was called once before accessing onSubmit.mock.calls[0]. Keep the existing message text and mentions assertions unchanged.packages/raystack/components/prompt-input/prompt-input-mentions.tsx (1)
70-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMove the trigger warning out of the render body.
This
console.warnruns on every render, and twice per commit under StrictMode. The other two warnings in this file already sit in effects. Move this one into the registration effect at Line 78, so the message appears once per trigger.♻️ Warn from the registration effect
- if (process.env.NODE_ENV !== 'production' && !isTriggerCharacter(trigger)) { - console.warn( - `[Apsara] PromptInput.Mentions trigger ${JSON.stringify(trigger)} is not ` + - 'a single punctuation character, so a chip picked from it cannot ' + - 'round-trip through the markup dialect. Use "@", "/", "#" or similar.' - ); - } - useEffect(() => { + if (process.env.NODE_ENV !== 'production' && !isTriggerCharacter(trigger)) { + console.warn( + `[Apsara] PromptInput.Mentions trigger ${JSON.stringify(trigger)} is not ` + + 'a single punctuation character, so a chip picked from it cannot ' + + 'round-trip through the markup dialect. Use "@", "/", "#" or similar.' + ); + } if (🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/raystack/components/prompt-input/prompt-input-mentions.tsx` around lines 70 - 76, Move the development-only invalid-trigger console.warn from the render body into the registration effect beginning near the trigger registration logic. Keep the existing isTriggerCharacter check and warning message, and ensure the effect dependencies cause the warning to appear once per trigger rather than on every render.packages/raystack/components/prompt-input/prompt-input-mention-registry.ts (1)
85-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider reference counting for duplicate triggers.
registeris idempotent for creation, but the returned cleanup deletes the config unconditionally. If twoMentionsinstances declare the same trigger, the unmount of one removes the trigger the other still owns. The menu then stops opening for that trigger until a re-render callssetData, which itself no-ops because the config is gone.The current release documents one
Mentions, so this is a latent case only.♻️ Reference-counted registration
export class PromptInputMentionRegistry { private configs = new Map<string, PromptInputMentionsConfig>(); + private owners = new Map<string, number>(); private items = new Map<string, PromptInputMentionItem>(); private listeners = new Set<() => void>(); private revision = 0; register(trigger: string): () => void { + this.owners.set(trigger, (this.owners.get(trigger) ?? 0) + 1); if (!this.configs.has(trigger)) { this.configs.set(trigger, { trigger }); this.emit(); } return () => { - if (this.configs.delete(trigger)) this.emit(); + const count = (this.owners.get(trigger) ?? 1) - 1; + if (count > 0) { + this.owners.set(trigger, count); + return; + } + this.owners.delete(trigger); + if (this.configs.delete(trigger)) this.emit(); }; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/raystack/components/prompt-input/prompt-input-mention-registry.ts` around lines 85 - 93, Update register in the mention registry to reference-count duplicate trigger registrations, incrementing the count for existing triggers and decrementing it during cleanup. Only delete the trigger configuration and emit the change when its count reaches zero, while ensuring each returned cleanup decrements at most once.packages/raystack/components/prompt-input/prompt-input-editor.tsx (1)
76-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLatch these refs outside the render body.
Lines 76-79 and line 114 write refs while rendering. React treats renders as pure, so an interrupted concurrent pass could leave stale refs; use
useEffectEventon the app package, or assign these refs fromuseEffect.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/raystack/components/prompt-input/prompt-input-editor.tsx` around lines 76 - 79, Update the ref synchronization around setValueRef and requestSubmitRef so refs are not assigned during render. Use the app package’s useEffectEvent when available, or synchronize both refs inside a useEffect, while preserving the existing setValue and requestSubmit behavior.packages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsx (1)
579-599: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a test for swapping one input part for the other.
This block covers two input parts mounted together. It does not cover replacing one with the other after mount. That path is the reason the unregister branch in
prompt-input-root.tsxat lines 177-185 exists: it releasesapiRef,partKindRef,inputRef, andeditorMountedonly when the unmounting part still owns the slot. Without a test, a regression there stays silent, andgetMessagecould later read off a destroyed ProseMirror view.💚 Suggested test
it('hands the slot to a replacement part', async () => { const Swap = () => { const [editor, setEditor] = useState(false); return ( <> <button type='button' onClick={() => setEditor(true)}> swap </button> <PromptInput defaultValue='draft'> {editor ? ( <PromptInput.Editor /> ) : ( <PromptInput.Textarea placeholder='Reply…' /> )} <PromptInput.Footer> <PromptInput.Submit /> </PromptInput.Footer> </PromptInput> </> ); }; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const { container } = render(<Swap />); fireEvent.click(screen.getByRole('button', { name: 'swap' })); await flush(); // The replacement owns the slot, and a swap is not a double mount. expect(warn).not.toHaveBeenCalled(); fireEvent.mouseDown(container.querySelector('form') as HTMLFormElement); expect(editorOf(container)).toHaveFocus(); warn.mockRestore(); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsx` around lines 579 - 599, Add a test in the “Mutually exclusive input parts” suite that conditionally swaps PromptInput.Textarea for PromptInput.Editor after mounting, then flushes the update and verifies no mutual-exclusion warning occurs and the replacement editor receives focus on form mouse down. This should exercise the unregister ownership cleanup in the PromptInput root while retaining the existing default value and footer setup needed for the replacement flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/www/src/content/docs/ai-elements/prompt-input/demo.ts`:
- Around line 141-181: Add Avatar and Badge to the demo component scope exports
before the editorCapabilitiesDemo definitions, matching the existing FileIcon
and Component1Icon bindings so the user entries can resolve both components.
In `@packages/raystack/components/editor/schema.ts`:
- Around line 44-57: Update the mention span getAttrs parser to return false
when data-mention-id is missing or empty, or when data-mention-trigger is absent
or not a valid trigger accepted by readMention. Preserve the existing attribute
mapping for valid spans so only non-round-trippable mentions remain plain text.
In `@packages/raystack/components/editor/suggestion-menu.tsx`:
- Around line 131-159: Update the suggestion row rendering in the group.items
map to attach a ref to the row whose index matches highlightedIndex, and use an
effect keyed to highlightedIndex to call that row’s scrollIntoView({ block:
'nearest' }). Preserve the existing highlighting, pointer, and selection
behavior for all rows.
In `@packages/raystack/components/editor/suggestion-plugin.ts`:
- Around line 284-295: Update the caret calculation in the mention insertion
flow around isWhitespaceAt and TextSelection.create so it is positioned past the
space in both spaced and non-spaced cases. Preserve the existing mention
replacement and spacing behavior, changing only the caret offset to account for
the existing trailing space when spaced is true.
In `@packages/raystack/components/prompt-input/prompt-input-mentions.tsx`:
- Around line 115-127: Prevent inline onSearch and resolveMentions callbacks
from restarting an in-flight search by latching their latest implementations in
registry state without treating callback identity changes as data revisions.
Update the prompt-input registry flow around setData and sameData so the latest
callbacks are invoked while config changes only emit for meaningful fields,
preserving debounce and abort behavior in use-mention-menu.ts.
In `@packages/raystack/components/prompt-input/prompt-input.tsx`:
- Around line 3-13: Update the PromptInput export boundary around
PromptInputRoot and the PromptInput barrel so importing the base PromptInput
does not import PromptInputEditor or PromptInputMentions and therefore remains
textarea-only. Move Editor/Mentions to a separate opt-in subpath or lazy entry
point, while preserving their existing API for consumers that explicitly import
the editor-enabled entry.
In `@packages/raystack/components/prompt-input/use-mention-menu.ts`:
- Around line 275-292: Update UseMentionMenuResult.handleKeyDown and its
implementation around handleKeyDown to accept and pass through the
plugin-supplied SuggestionState. Modify select to prefer that provided state for
keyboard selection while retaining suggestionRef.current as the fallback for
pointer selection, and use the selected state’s from/to range when calling
actions.insertMention.
---
Nitpick comments:
In `@packages/raystack/components/editor/__tests__/markup.test.ts`:
- Around line 77-104: Update the deterministic generator in the `survives a
generated corpus` test so the LCG multiplication uses `Math.imul` and remains
exact in 32-bit arithmetic before applying the modulus. Preserve the existing
seed, constants, reproducibility, and corpus-generation behavior.
In `@packages/raystack/components/editor/index.ts`:
- Around line 11-20: Remove the claim that ../editor is ProseMirror-free and
update PromptInputEditor and use-mention-menu.ts to import the mention API
directly from ./mention (or a dedicated ProseMirror-free entry point), avoiding
the ../editor barrel while preserving their existing runtime and type imports.
In
`@packages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsx`:
- Around line 579-599: Add a test in the “Mutually exclusive input parts” suite
that conditionally swaps PromptInput.Textarea for PromptInput.Editor after
mounting, then flushes the update and verifies no mutual-exclusion warning
occurs and the replacement editor receives focus on form mouse down. This should
exercise the unregister ownership cleanup in the PromptInput root while
retaining the existing default value and footer setup needed for the replacement
flow.
In
`@packages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsx`:
- Around line 273-283: In the literal markup-shaped string test, assert that
onSubmit was called once before accessing onSubmit.mock.calls[0]. Keep the
existing message text and mentions assertions unchanged.
In `@packages/raystack/components/prompt-input/prompt-input-editor.tsx`:
- Around line 76-79: Update the ref synchronization around setValueRef and
requestSubmitRef so refs are not assigned during render. Use the app package’s
useEffectEvent when available, or synchronize both refs inside a useEffect,
while preserving the existing setValue and requestSubmit behavior.
In `@packages/raystack/components/prompt-input/prompt-input-mention-registry.ts`:
- Around line 85-93: Update register in the mention registry to reference-count
duplicate trigger registrations, incrementing the count for existing triggers
and decrementing it during cleanup. Only delete the trigger configuration and
emit the change when its count reaches zero, while ensuring each returned
cleanup decrements at most once.
In `@packages/raystack/components/prompt-input/prompt-input-mentions.tsx`:
- Around line 70-76: Move the development-only invalid-trigger console.warn from
the render body into the registration effect beginning near the trigger
registration logic. Keep the existing isTriggerCharacter check and warning
message, and ensure the effect dependencies cause the warning to appear once per
trigger rather than on every render.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8ebd1fe3-ff2d-45a7-b3c8-ff7925f33585
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (31)
apps/www/src/content/docs/ai-elements/prompt-input/demo.tsapps/www/src/content/docs/ai-elements/prompt-input/index.mdxapps/www/src/content/docs/ai-elements/prompt-input/props.tspackages/raystack/components/editor/__tests__/markup.test.tspackages/raystack/components/editor/editor.module.csspackages/raystack/components/editor/index.tspackages/raystack/components/editor/markup.tspackages/raystack/components/editor/mention-node-view.tspackages/raystack/components/editor/mention.tspackages/raystack/components/editor/schema.tspackages/raystack/components/editor/suggestion-menu.tsxpackages/raystack/components/editor/suggestion-plugin.tspackages/raystack/components/editor/use-editor.tspackages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsxpackages/raystack/components/prompt-input/__tests__/prompt-input-mentions.test.tsxpackages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsxpackages/raystack/components/prompt-input/__tests__/prompt-input.test.tsxpackages/raystack/components/prompt-input/index.tsxpackages/raystack/components/prompt-input/prompt-input-context.tsxpackages/raystack/components/prompt-input/prompt-input-editor.tsxpackages/raystack/components/prompt-input/prompt-input-mention-registry.tspackages/raystack/components/prompt-input/prompt-input-mentions.tsxpackages/raystack/components/prompt-input/prompt-input-root.tsxpackages/raystack/components/prompt-input/prompt-input-submit.tsxpackages/raystack/components/prompt-input/prompt-input-textarea.tsxpackages/raystack/components/prompt-input/prompt-input.module.csspackages/raystack/components/prompt-input/prompt-input.tsxpackages/raystack/components/prompt-input/use-mention-menu.tspackages/raystack/index.tsxpackages/raystack/package.jsonpackages/raystack/vitest.setup.ts
| icon: <FileIcon />, | ||
| data: { path: '/docs/ai-elements/prompt-input' } | ||
| }, | ||
| { | ||
| id: 'button', | ||
| label: 'Button', | ||
| type: 'component', | ||
| group: 'Components', | ||
| icon: <Component1Icon />, | ||
| data: { status: 'stable' } | ||
| }, | ||
| { | ||
| id: 'data-table', | ||
| label: 'DataTable', | ||
| type: 'component', | ||
| group: 'Components', | ||
| icon: <Component1Icon />, | ||
| data: { status: 'stable' } | ||
| }, | ||
| { | ||
| id: 'prompt-input', | ||
| label: 'PromptInput', | ||
| type: 'component', | ||
| group: 'Components', | ||
| icon: <Component1Icon />, | ||
| data: { status: 'beta' } | ||
| }, | ||
| { | ||
| id: 'u1', | ||
| label: 'Maya Chen', | ||
| type: 'user', | ||
| group: 'Users', | ||
| icon: <Avatar size={1} fallback="M" /> | ||
| }, | ||
| { | ||
| id: 'u2', | ||
| label: 'Apsara Assistant', | ||
| type: 'user', | ||
| group: 'Users', | ||
| icon: <Avatar size={1} fallback="A" />, | ||
| trailing: <Badge size="micro" variant="neutral">Agent</Badge> |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the demo/preview renderer and inspect the identifiers it injects into scope.
fd -t f -e ts -e tsx -e js -e jsx --full-path apps/www | xargs rg -n -l 'Demo' -g '*.tsx' | head -50
rg -n -C 10 'scope' --iglob '*demo*' --iglob '*preview*' apps/www/src | head -120
rg -n 'Component1Icon|FileIcon' apps/www/src | head -40Repository: raystack/apsara
Length of output: 13088
Add the missing preview scope bindings.
FileIcon and Component1Icon are already exported from the demo component scope, but Avatar and Badge are not. Add those bindings before using them in the editorCapabilitiesDemo code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/www/src/content/docs/ai-elements/prompt-input/demo.ts` around lines 141
- 181, Add Avatar and Badge to the demo component scope exports before the
editorCapabilitiesDemo definitions, matching the existing FileIcon and
Component1Icon bindings so the user entries can resolve both components.
| parseDOM: [ | ||
| { | ||
| tag: 'span[data-mention-id]', | ||
| getAttrs: dom => { | ||
| const el = dom as HTMLElement; | ||
| return { | ||
| id: el.getAttribute('data-mention-id') ?? '', | ||
| label: el.getAttribute('data-mention-label') ?? el.textContent, | ||
| type: el.getAttribute('data-mention-type') ?? 'mention', | ||
| trigger: el.getAttribute('data-mention-trigger') ?? '@' | ||
| }; | ||
| } | ||
| } | ||
| ], |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Reject mention spans that cannot round-trip.
getAttrs accepts an empty data-mention-id and any data-mention-trigger. Both produce a chip whose markup does not parse back. serializeMention emits @[label](type:) for an empty id, and readMention rejects an empty id (markup.ts line 80) and a non-trigger character (line 38), so the chip degrades to literal text on the next value round trip.
Return false when the attributes are not serializable, so the paste stays plain text instead.
🐛 Proposed fix
-import { Schema } from 'prosemirror-model';
-import type { MentionAttrs } from './mention';
+import { Schema } from 'prosemirror-model';
+import { isTriggerCharacter, type MentionAttrs } from './mention'; tag: 'span[data-mention-id]',
getAttrs: dom => {
const el = dom as HTMLElement;
+ const id = el.getAttribute('data-mention-id') ?? '';
+ const label =
+ el.getAttribute('data-mention-label') ?? el.textContent ?? '';
+ const type = el.getAttribute('data-mention-type') || 'mention';
+ const trigger = el.getAttribute('data-mention-trigger') ?? '@';
+ // A mention that cannot be serialized round-trips as literal
+ // text, so the rule declines the match instead.
+ if (!id || !label || !isTriggerCharacter(trigger)) return false;
- return {
- id: el.getAttribute('data-mention-id') ?? '',
- label: el.getAttribute('data-mention-label') ?? el.textContent,
- type: el.getAttribute('data-mention-type') ?? 'mention',
- trigger: el.getAttribute('data-mention-trigger') ?? '@'
- };
+ return { id, label, type, trigger };
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| parseDOM: [ | |
| { | |
| tag: 'span[data-mention-id]', | |
| getAttrs: dom => { | |
| const el = dom as HTMLElement; | |
| return { | |
| id: el.getAttribute('data-mention-id') ?? '', | |
| label: el.getAttribute('data-mention-label') ?? el.textContent, | |
| type: el.getAttribute('data-mention-type') ?? 'mention', | |
| trigger: el.getAttribute('data-mention-trigger') ?? '@' | |
| }; | |
| } | |
| } | |
| ], | |
| parseDOM: [ | |
| { | |
| tag: 'span[data-mention-id]', | |
| getAttrs: dom => { | |
| const el = dom as HTMLElement; | |
| const id = el.getAttribute('data-mention-id') ?? ''; | |
| const label = | |
| el.getAttribute('data-mention-label') ?? el.textContent ?? ''; | |
| const type = el.getAttribute('data-mention-type') || 'mention'; | |
| const trigger = el.getAttribute('data-mention-trigger') ?? '@'; | |
| // A mention that cannot be serialized round-trips as literal | |
| // text, so the rule declines the match instead. | |
| if (!id || !label || !isTriggerCharacter(trigger)) return false; | |
| return { id, label, type, trigger }; | |
| } | |
| } | |
| ], |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/editor/schema.ts` around lines 44 - 57, Update
the mention span getAttrs parser to return false when data-mention-id is missing
or empty, or when data-mention-trigger is absent or not a valid trigger accepted
by readMention. Preserve the existing attribute mapping for valid spans so only
non-round-trippable mentions remain plain text.
| {group.items.map(item => { | ||
| cursor += 1; | ||
| const index = cursor; | ||
| const highlighted = index === highlightedIndex; | ||
| return ( | ||
| <Cell | ||
| key={`${item.type ?? ''}:${item.id}`} | ||
| id={suggestionOptionId(id, index)} | ||
| role='option' | ||
| aria-selected={highlighted} | ||
| aria-disabled={item.disabled || undefined} | ||
| data-highlighted={highlighted ? '' : undefined} | ||
| className={cx(styles.suggestionRow)} | ||
| leadingIcon={item.icon} | ||
| trailingIcon={item.trailing} | ||
| onPointerMove={() => { | ||
| if (!item.disabled) onHighlightChange(index); | ||
| }} | ||
| // Keep the caret: a press inside the menu must not move | ||
| // focus out of the editor. | ||
| onPointerDown={event => event.preventDefault()} | ||
| onClick={() => { | ||
| if (!item.disabled) onSelect(item, index); | ||
| }} | ||
| > | ||
| {item.label} | ||
| </Cell> | ||
| ); | ||
| })} |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Confirm no consumer already scrolls the highlighted suggestion row.
rg -n 'scrollIntoView|data-highlighted|highlightedIndex' packages/raystack/components/prompt-input packages/raystack/components/editorRepository: raystack/apsara
Length of output: 1755
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== suggestion-menu outline =="
ast-grep outline packages/raystack/components/editor/suggestion-menu.tsx || true
echo
echo "== suggestion-menu lines 1-190 =="
cat -n packages/raystack/components/editor/suggestion-menu.tsx | sed -n '1,190p'
echo
echo "== use-mention-menu relevant lines =="
cat -n packages/raystack/components/prompt-input/use-mention-menu.ts | sed -n '120,420p'
echo
echo "== prompt-input-editor relevant lines =="
cat -n packages/raystack/components/prompt-input/prompt-input-editor.tsx | sed -n '200,240p'
echo
echo "== editor css popup/scroll classes =="
rg -n "popup|scroll|height|max-height|overflow|Cell|suggestionRow|suggestionOptionId" packages/raystack/components/editor/editor.module.css packages/raystack/components/editor -g '*.css' -g '*.tsx' -g '*.ts' | sed -n '1,220p'Repository: raystack/apsara
Length of output: 26572
Scroll the highlighted suggestion row into view.
The popup is capped at 320px with overflow-y: auto, while keyboard navigation only updates highlightedIndex. Highlighted rows can move out of the popup viewport once the list is long enough; attach a ref to the highlighted row and call scrollIntoView({ block: 'nearest' }) when highlightedIndex changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/editor/suggestion-menu.tsx` around lines 131 -
159, Update the suggestion row rendering in the group.items map to attach a ref
to the row whose index matches highlightedIndex, and use an effect keyed to
highlightedIndex to call that row’s scrollIntoView({ block: 'nearest' }).
Preserve the existing highlighting, pointer, and selection behavior for all
rows.
| const tr = state.tr; | ||
| const nodes = [mentionType.create(attrs)]; | ||
| // Skip the trailing space when the caret already sits in front of one, so | ||
| // picking a mention mid-sentence does not leave a gap. | ||
| const spaced = isWhitespaceAt(state, target.to); | ||
| if (!spaced) nodes.push(editorSchema.text(' ')); | ||
|
|
||
| tr.replaceWith(target.from, target.to, nodes); | ||
| const caret = Math.min(target.from + (spaced ? 1 : 2), tr.doc.content.size); | ||
| tr.setSelection(TextSelection.create(tr.doc, caret)); | ||
| tr.scrollIntoView(); | ||
| view.dispatch(tr); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The caret lands before the existing space when the chip is inserted mid-sentence.
A mention node is one position wide. When spaced is false the caret goes to from + 2, which is after the inserted space. When spaced is true the caret goes to from + 1, which is between the chip and the existing space. The next typed character then attaches directly to the chip, and the space stays to its right. This contradicts the doc comment on line 270.
Place the caret past the space in both branches.
🐛 Proposed fix
tr.replaceWith(target.from, target.to, nodes);
- const caret = Math.min(target.from + (spaced ? 1 : 2), tr.doc.content.size);
+ // Chip (1) plus the space that now follows it, inserted or pre-existing.
+ const caret = Math.min(target.from + 2, tr.doc.content.size);
tr.setSelection(TextSelection.create(tr.doc, caret));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const tr = state.tr; | |
| const nodes = [mentionType.create(attrs)]; | |
| // Skip the trailing space when the caret already sits in front of one, so | |
| // picking a mention mid-sentence does not leave a gap. | |
| const spaced = isWhitespaceAt(state, target.to); | |
| if (!spaced) nodes.push(editorSchema.text(' ')); | |
| tr.replaceWith(target.from, target.to, nodes); | |
| const caret = Math.min(target.from + (spaced ? 1 : 2), tr.doc.content.size); | |
| tr.setSelection(TextSelection.create(tr.doc, caret)); | |
| tr.scrollIntoView(); | |
| view.dispatch(tr); | |
| const tr = state.tr; | |
| const nodes = [mentionType.create(attrs)]; | |
| // Skip the trailing space when the caret already sits in front of one, so | |
| // picking a mention mid-sentence does not leave a gap. | |
| const spaced = isWhitespaceAt(state, target.to); | |
| if (!spaced) nodes.push(editorSchema.text(' ')); | |
| tr.replaceWith(target.from, target.to, nodes); | |
| // Chip (1) plus the space that now follows it, inserted or pre-existing. | |
| const caret = Math.min(target.from + 2, tr.doc.content.size); | |
| tr.setSelection(TextSelection.create(tr.doc, caret)); | |
| tr.scrollIntoView(); | |
| view.dispatch(tr); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/editor/suggestion-plugin.ts` around lines 284 -
295, Update the caret calculation in the mention insertion flow around
isWhitespaceAt and TextSelection.create so it is positioned past the space in
both spaced and non-spaced cases. Preserve the existing mention replacement and
spacing behavior, changing only the caret offset to account for the existing
trailing space when spaced is true.
| // Pushed after every render and compared field by field, so an inline `items` | ||
| // array or an inline `onSearch` stays live without churning the config | ||
| // identity — which would restart an in-flight debounce on every keystroke. | ||
| useEffect(() => { | ||
| registry.setData(trigger, { | ||
| items, | ||
| onSearch, | ||
| resolveMentions, | ||
| onOpenChange, | ||
| emptyMessage, | ||
| loadingRowCount | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
An inline onSearch still restarts the in-flight search.
sameData compares onSearch by identity. An inline onSearch={q => …} is a new function on every consumer render, so setData emits, config.onSearch changes, and the search effect in use-mention-menu.ts at Line 224 tears down its timer and aborts its controller. The debounce then restarts.
The comment on Lines 115-117 promises that field-by-field comparison prevents this. It prevents churn from the config object only, not from an inline callback.
Either document that onSearch and resolveMentions must be stable, for example memoized with useCallback, or latch them in a ref inside the registry so identity changes do not bump the revision.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/prompt-input/prompt-input-mentions.tsx` around
lines 115 - 127, Prevent inline onSearch and resolveMentions callbacks from
restarting an in-flight search by latching their latest implementations in
registry state without treating callback identity changes as data revisions.
Update the prompt-input registry flow around setData and sameData so the latest
callbacks are invoked while config changes only emit for meaningful fields,
preserving debounce and abort behavior in use-mention-menu.ts.
| import { PromptInputEditor } from './prompt-input-editor'; | ||
| import { PromptInputMentions } from './prompt-input-mentions'; | ||
| import { PromptInputFooter, PromptInputHeader } from './prompt-input-parts'; | ||
| import { PromptInputRoot } from './prompt-input-root'; | ||
| import { PromptInputSubmit } from './prompt-input-submit'; | ||
| import { PromptInputTextarea } from './prompt-input-textarea'; | ||
|
|
||
| export const PromptInput = Object.assign(PromptInputRoot, { | ||
| Textarea: PromptInputTextarea, | ||
| Editor: PromptInputEditor, | ||
| Mentions: PromptInputMentions, |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm the editor entry point is reachable only through the barrel, and size the ProseMirror surface.
set -uo pipefail
echo '== Editor imports pulled in by the barrel =='
rg -n "^import" packages/raystack/components/prompt-input/prompt-input-editor.tsx
echo '== ProseMirror imports across the editor module =='
rg -n "from 'prosemirror" packages/raystack/components/editor
echo '== Existing subpath exports, if any =='
fd -t f 'package.json' packages/raystack --max-depth 1 --exec jq '{exports, sideEffects}'Repository: raystack/apsara
Length of output: 2571
🏁 Script executed:
#!/bin/bash
set -uo pipefail
echo '== prompt-input editor imports =='
cat -n packages/raystack/components/prompt-input/prompt-input-editor.tsx | sed -n '1,80p'
echo '== prompt-input root relevant imports and usage =='
cat -n packages/raystack/components/prompt-input/prompt-input-root.tsx | sed -n '1,220p'
echo '== mention editor module outline/imports =='
ls -la packages/raystack/components/editor/mention.* packages/raystack/components/editor use-editor.tsx 2>/dev/null || true
for f in packages/raystack/components/editor/mention.* packages/raystack/components/editor use-editor.tsx; do
[ -f "$f" ] && { echo "--- $f"; cat -n "$f" | sed -n '1,80p'; }
done
echo '== prompt-input files mentioning Editor/Mentions/prosemirror/use-editor =='
rg -n "(PromptInputEditor|use-editor|prosemirror|PromptInput.Mentions|PromptInput.Editor|./prompt-input-editor)" packages/raystack/components/prompt-input packages/raystack/components/editor -g '*.ts' -g '*.tsx' -g '!*.d.ts'
echo '== package manifests likely importing prosemirror or prompt-input entries =='
fd -t f 'package.json' . -g 'package.json' | sed -n '1,80p' | xargs -r -I{} sh -c 'echo "--- {}"; jq ".exports // .main // \".\" as $top | {pkg: (path(.) | join(\"/\")), exports: $top, dependencies: (.dependencies // {} | with_entries(select(.key|test(\"prosemirror|raystack|`@raystack`\")))), devDependencies: (.devDependencies // {} | with_entries(select(.key|test(\"prosemirror|raystack|`@raystack`\"))))}" "{}"'
echo '== transitive ProseMirror references in package files, excluding lockfiles =='
rg -n "\"(prosemirror|prompt-input)\"" packages --glob 'package.json' || trueRepository: raystack/apsara
Length of output: 24875
🏁 Script executed:
#!/bin/bash
set -uo pipefail
echo '== raystack package.json exports/core fields =='
jq '{package, name, version, type, main, module, exports, dependencies, devDependencies}' packages/raystack/package.json
echo '== editor index exports =='
cat -n packages/raystack/components/editor/index.ts | sed -n '1,120p'
echo '== suggestion menu imports relevant to ProseMirror edge cases =='
cat -n packages/raystack/components/editor/suggestion-menu.tsx | sed -n '1,120p'
echo '== lockfile ProseMirror references under relevant packages =='
rg -n "`@raystack`|prosemirror|prompt-input" packages/raycast* packages/raystack package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null | sed -n '1,200p' || trueRepository: raystack/apsara
Length of output: 24729
Make PromptInput.Editor opt-in at the import boundary.
PromptInputEditor imports from ../editor, which re-exports ProseMirror-backed modules such as use-editor, schema, and suggestion-plugin. This makes importing PromptInput pull in the editor engine even when only PromptInput.Textarea is rendered. Keep the base barrel textarea-only and expose Editor/Mentions through a separate subpath or a lazy boundary if editor rendering must remain opt-in.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/prompt-input/prompt-input.tsx` around lines 3 -
13, Update the PromptInput export boundary around PromptInputRoot and the
PromptInput barrel so importing the base PromptInput does not import
PromptInputEditor or PromptInputMentions and therefore remains textarea-only.
Move Editor/Mentions to a separate opt-in subpath or lazy entry point, while
preserving their existing API for consumers that explicitly import the
editor-enabled entry.
| const select = useCallback( | ||
| (item: PromptInputMentionItem) => { | ||
| const state = suggestionRef.current; | ||
| if (!state) return; | ||
| const type = item.type ?? 'mention'; | ||
| registry.remember(state.trigger, { ...item, type }); | ||
| actions.insertMention( | ||
| { | ||
| id: item.id, | ||
| label: item.label, | ||
| type, | ||
| trigger: state.trigger | ||
| }, | ||
| { from: state.from, to: state.to } | ||
| ); | ||
| }, | ||
| [actions, registry] | ||
| ); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use the plugin-supplied SuggestionState in handleKeyDown instead of the render-time ref.
UseMentionMenuResult.handleKeyDown is declared as (event: KeyboardEvent, state: SuggestionState) => boolean at Line 139, and the ProseMirror plugin passes the live state. The implementation at Line 337 accepts only event and drops state. select then falls back to suggestionRef.current, which holds the value from the last committed render.
setSuggestion is called from the ProseMirror update, so the ref only catches up after React commits. If a keydown reaches the plugin before that commit, select inserts the chip over a stale [from, to] range. The character typed last then survives next to the new chip.
Thread the state through instead of reading the ref.
🐛 Route selection through the plugin-supplied state
const select = useCallback(
- (item: PromptInputMentionItem) => {
- const state = suggestionRef.current;
+ (item: PromptInputMentionItem, at?: SuggestionState) => {
+ const state = at ?? suggestionRef.current;
if (!state) return; const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
+ (event: KeyboardEvent, state: SuggestionState) => {
if (!openRef.current) return false; case 'Enter':
case 'Tab': {
const item = items[current];
if (!item || item.disabled) {
close();
return false;
}
consume();
- select(item);
+ select(item, state);
return true;
}select is also exposed for pointer selection, where the ref is the correct source, so keep the fallback.
Also applies to: 336-375
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/prompt-input/use-mention-menu.ts` around lines
275 - 292, Update UseMentionMenuResult.handleKeyDown and its implementation
around handleKeyDown to accept and pass through the plugin-supplied
SuggestionState. Modify select to prefer that provided state for keyboard
selection while retaining suggestionRef.current as the fallback for pointer
selection, and use the selected state’s from/to range when calling
actions.insertMention.
Summary
components/editor) — plain-text schema with an atomic mention node, a caret-anchored suggestion menu, a trigger/query plugin, and a@[label](type:id)markup serializer.PromptInput.Editoras a sibling toPromptInput.Textareaand aPromptInput.Mentionsconfig part, so the ~55 kB engine is opt-in and plain composers are untouched.onSubmitgets a message object, and emptiness is derived from the document so a chip-only message sends.