Skip to content

feat: PromptInput @-mention chips on a ProseMirror editor - #870

Open
rohanchkrabrty wants to merge 2 commits into
mainfrom
worktree-promptiinput-improvements
Open

feat: PromptInput @-mention chips on a ProseMirror editor#870
rohanchkrabrty wants to merge 2 commits into
mainfrom
worktree-promptiinput-improvements

Conversation

@rohanchkrabrty

Copy link
Copy Markdown
Contributor

Summary

  • Adds an internal ProseMirror editor core (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.
  • Adds PromptInput.Editor as a sibling to PromptInput.Textarea and a PromptInput.Mentions config part, so the ~55 kB engine is opt-in and plain composers are untouched.
  • Reworks the value model: both input parts report markup, derived text and mentions through one channel, onSubmit gets a message object, and emptiness is derived from the document so a chip-only message sends.
  • Chips render their icon and label only — the trigger stays in the submitted text — and are sized to the line box, so inserting one never changes the composer's height or nudges the page.
  • Docs gain a mentions section, the markup dialect and four demos; tests cover the markup round-trips, the suggestion state machine, key routing, async search and hydration, plus a parity suite that runs the shared contract against both input parts.

rohanchkrabrty and others added 2 commits July 31, 2026 13:24
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>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview Jul 31, 2026 8:00am

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Added a ProseMirror-backed PromptInput.Editor with markup serialization, mention chips, draft restoration, keyboard behavior, accessibility support, and maximum-length enforcement. Added PromptInput.Mentions with grouped results, asynchronous cancellation, mention resolution, and programmatic insertion. Updated callbacks to return structured text, markup, and mention metadata. Added public types, styles, tests, runtime dependencies, and documentation demos.

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
Loading

Suggested reviewers: rsbh, paansinghcoder

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding @-mention chips to the PromptInput ProseMirror editor.
Description check ✅ Passed The description accurately covers the editor core, PromptInput integration, value model, chip behavior, documentation, and tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (7)
packages/raystack/components/editor/__tests__/markup.test.ts (1)

77-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The LCG loses precision, so the corpus is narrower than intended.

seed * 1103515245 reaches about 2.4e18. That exceeds Number.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.imul to 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 win

Don’t call ../editor ProseMirror-free.

../editor re-exports ProseMirror-backed modules such as schema, suggestion UI/plugins, styles, and use-editor. PromptInputEditor imports from ../editor and imports editorStyles; use-mention-menu.ts only 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 value

Assert the submit call before reading it.

Line 280 indexes onSubmit.mock.calls[0] directly. If onSubmit never fires, the test fails with a property-access error on undefined instead 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 value

Move the trigger warning out of the render body.

This console.warn runs 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 value

Consider reference counting for duplicate triggers.

register is idempotent for creation, but the returned cleanup deletes the config unconditionally. If two Mentions instances 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 calls setData, 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 value

Latch 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 useEffectEvent on the app package, or assign these refs from useEffect.

🤖 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 win

Add 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.tsx at lines 177-185 exists: it releases apiRef, partKindRef, inputRef, and editorMounted only when the unmounting part still owns the slot. Without a test, a regression there stays silent, and getMessage could 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5e38ff6 and da3e94c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (31)
  • apps/www/src/content/docs/ai-elements/prompt-input/demo.ts
  • apps/www/src/content/docs/ai-elements/prompt-input/index.mdx
  • apps/www/src/content/docs/ai-elements/prompt-input/props.ts
  • packages/raystack/components/editor/__tests__/markup.test.ts
  • packages/raystack/components/editor/editor.module.css
  • packages/raystack/components/editor/index.ts
  • packages/raystack/components/editor/markup.ts
  • packages/raystack/components/editor/mention-node-view.ts
  • packages/raystack/components/editor/mention.ts
  • packages/raystack/components/editor/schema.ts
  • packages/raystack/components/editor/suggestion-menu.tsx
  • packages/raystack/components/editor/suggestion-plugin.ts
  • packages/raystack/components/editor/use-editor.ts
  • packages/raystack/components/prompt-input/__tests__/prompt-input-editor.test.tsx
  • packages/raystack/components/prompt-input/__tests__/prompt-input-mentions.test.tsx
  • packages/raystack/components/prompt-input/__tests__/prompt-input-parity.test.tsx
  • packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx
  • packages/raystack/components/prompt-input/index.tsx
  • packages/raystack/components/prompt-input/prompt-input-context.tsx
  • packages/raystack/components/prompt-input/prompt-input-editor.tsx
  • packages/raystack/components/prompt-input/prompt-input-mention-registry.ts
  • packages/raystack/components/prompt-input/prompt-input-mentions.tsx
  • packages/raystack/components/prompt-input/prompt-input-root.tsx
  • packages/raystack/components/prompt-input/prompt-input-submit.tsx
  • packages/raystack/components/prompt-input/prompt-input-textarea.tsx
  • packages/raystack/components/prompt-input/prompt-input.module.css
  • packages/raystack/components/prompt-input/prompt-input.tsx
  • packages/raystack/components/prompt-input/use-mention-menu.ts
  • packages/raystack/index.tsx
  • packages/raystack/package.json
  • packages/raystack/vitest.setup.ts

Comment on lines +141 to +181
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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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 -40

Repository: 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.

Comment on lines +44 to +57
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') ?? '@'
};
}
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Suggested change
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.

Comment on lines +131 to +159
{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>
);
})}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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/editor

Repository: 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.

Comment on lines +284 to +295
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment on lines +115 to +127
// 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
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 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.

Comment on lines +3 to +13
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 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' || true

Repository: 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' || true

Repository: 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.

Comment on lines +275 to +292
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]
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant