Skip to content

feat(prompt-input): add PromptInput with token highlighting - #115

Open
pratikbuilds wants to merge 9 commits into
conversation/05-conversationfrom
conversation/08-prompt-input
Open

pratikbuilds wants to merge 9 commits into
conversation/05-conversationfrom
conversation/08-prompt-input

Conversation

@pratikbuilds

Copy link
Copy Markdown
Collaborator

PromptInput is a fully controlled form. PromptInputTextarea draws @mention and /command tokens on an aria-hidden mirror beneath transparent native text, grows through field-sizing with a measured fallback, and never submits during IME composition. usePromptInput drives PromptInputSuggestions from the keyboard, and PromptInputSubmit turns Send into Stop while a reply is running.

Stacked on #114: review only this commit. Part of #94.

Notes for review

  • status lives on PromptInput (root), not PromptInputSubmit: Enter must see the status so it never sends mid-reply; PromptInputSubmit takes only onStop.
  • Fixed while building: Stop could also send the draft when the host flipped status to ready inside onStop; the Stop click now cancels that (e2e covers it).
  • Sizing fallback is covered by e2e only (happy-dom reports field-sizing as supported and has no layout).
  • Not shared with ChatInput/Textarea sizing to keep existing modules untouched; a follow-up could consolidate after the old family is removed.
  • canSubmit on PromptInput (default value.trim() !== "") lets a host send attachments with no text, e.g. canSubmit={value.trim() !== "" || files.length > 0}; Enter and Send both honour it; disabled, busy status and IME composition still block.

Verification

  • This commit passes bun run typecheck and bun run lint on its own (the stack is checked commit by commit).
  • At the top of the stack: bun run check, bun run build, bun run dep-guard, bun run contrast-test and bun run test:e2e (Playwright specs and the Storybook axe sweep in light and dark) pass.

Closes #102

@TheGreatAxios TheGreatAxios 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.

Automated multi-agent review: independent review + second-agent validation; only confirmed items listed (all re-verified against branch head 225a189).

  • minor (process) the plan specifies four commits (core input / mirror highlighting / measured sizing fallback / suggestions, with "the fallback is its own commit") — this PR is one commit. Split before merge or record the exemption in the PR body.
  • minor a11y src/ui/prompt-input.tsx:317 — aria-autocomplete="list" is hardcoded while aria-controls/aria-activedescendant are gated on listOpen: with no suggestions part mounted, the field still claims list completion. Gate it: aria-autocomplete={listOpen ? "list" : undefined}.
  • should-fix a11y prompt-input.tsx:316-319 — the suggestion listbox's open/closed state is never exposed (no aria-expanded/aria-haspopup anywhere; observation confirmed). Careful with the fix: aria-expanded on a textarea is invalid per ARIA 1.2 / ARIA-in-HTML (axe aria-allowed-attr), and role="combobox" is not permitted on textarea either. Smallest valid fix: aria-haspopup="listbox" (valid on textbox) plus a visually-hidden live region announcing suggestions shown/hidden — aria-activedescendant already carries the current option while navigating.
  • should-fix prompt-input.tsx:507 — if a host sets status="submitted" without onStop, a focused Send becomes disabled and focus drops to <body> (Chrome). Keep the button focusable with aria-disabled + guarded click (the pattern exists in tool-approval.tsx:205-207); add the activeElement regression test.
  • should-fix prompt-input.tsx:399-405 vs :439 — the render-callback signature never exposes optionId(listId, token), so a host rendering its own element structurally cannot match the aria-activedescendant id: a dangling descendant is guaranteed whenever that option is current. Either wrap render-prop children in the id-bearing <li role="option"> inside PromptInputSuggestions, or pass optionId(...) to the callback.
  • minor src/hooks/use-prompt-input.ts:100, 214-221 — caret refreshes only on textarea events; a host-driven onValueChange with focus retained leaves a stale caret, so suggestions can pop for the wrong position. Re-read selectionStart in the value-sync layout effect when no pick is pending.
  • test gap — the Safari post-composition keyCode === 229 guard (use-prompt-input.ts:176) is real code but has zero test coverage in either unit or e2e layers (no keyCode occurrence in any test file). Add the unit case { key: "Enter", keyCode: 229, isComposing: false } after compositionend, or an e2e Safari-style sequence.
  • nit src/hooks/use-scroll-current-into-view.ts:31 — inline: "center" contradicts the hook's own docstring ("only the rail's own scroll position moves") on the horizontal axis; a wide option in a narrow viewport can scroll a page ancestor. inline: "nearest" (or make the hook take options).

@pratikbuilds

pratikbuilds commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Here's what happened to each point.

Fixed

1. A focused Send button that turned disabled dropped focus to the page (should-fix)

  • The problem: this happens in normal use, not only with status and no onStop. A host that clears the draft on submit makes Send unsendable the moment the reply ends or Stop is pressed, and a disabled button loses focus.
  • The fix: "nothing to send" now uses aria-disabled instead of disabled, as you suggested. The button keeps focus, and pressing it sends nothing. If the host passes disabled itself, it's still a real disabled.
  • Test: Playwright checks that focus never lands on <body>.

2. Screen readers couldn't tell when the suggestion list opened or closed (should-fix)

  • The fix: as you suggested, the textarea gets aria-haspopup="listbox". A hidden polite status says "3 suggestions" while the list is open and empties when it closes. An announce prop lets a host change the wording, for example to translate it.
  • Test: axe runs on the open list in Playwright, plus a unit test of the announcements.

3. A custom suggestion renderer could leave aria-activedescendant pointing at nothing (should-fix)

  • The fix: the docs now say the render callback has to return a PromptInputSuggestion, which carries the id. In development, a console error fires if the current option's id is missing from the page.

4. A value the host set left the caret position stale (minor)

  • The fix: when the host changes the value while the textarea has focus, the caret is read again, so suggestions open at the right place.

5. aria-autocomplete="list" was set even with no suggestions (minor)

  • The fix: it's now set only when a suggestions list is part of the input.

6. Safari's Enter right after typing with an IME was untested (test gap)

  • The fix: added the test. It fails if the guard is removed.

Not changed, and why

  • Splitting the PR into four commits: this is a process question, so I've left it for the maintainers.
  • inline: "center" in useScrollCurrentIntoView: that hook is shared with an earlier component, so changing it belongs in its own PR.

Commit: fix(prompt-input): keep send focusable and expose suggestion state

@pratikbuilds
pratikbuilds force-pushed the conversation/08-prompt-input branch from e83902b to 606dab6 Compare October 27, 2026 13:54
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

One more commit on this PR, style(prompt-input): the active suggestion drops the accent stripe and keeps the neutral fill alone, and the rows are tighter. This matches the toolbar popups restyled in #118.

@TheGreatAxios TheGreatAxios 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.

Review · Request changes

Adds PromptInput, with @mention and /command tokens drawn on a mirror under the textarea.

Findings

  • src/hooks/use-prompt-input.ts:158 — Accepting a suggestion when whitespace already follows the token leaves the caret inside the token on the execCommand path, so the list stays open and Enter will not send. insertPromptToken reuses that space and places the caret after it (prompt-tokens.ts:100-105). pick inserts only the token text and returns before pendingCaret can move the caret. With ask @ad now and the caret on the space after @ad, the text becomes ask @Ada now and selectionStart stays on that space. findActivePromptToken still sees @Ada, so a second Enter calls pick again. The end-of-draft case hides this because the inserted text includes a new trailing space, which is what the e2e covers. After a successful insertText, set the caret to next.caret.

  • src/lib/prompt-tokens.ts:101 — A reused newline is treated like a space, so the fallback caret jumps to the next line. WHITESPACE matches \n, spaced is true, and caret is token.length + 1, which skips the line break (insertPromptToken("ask @ad\nnext", …) lands on n). Keep the caret before a reused newline. The execCommand fix above should use that caret, or it will jump lines in browsers too.

  • Commit style(prompt-input): match the suggestion rows to the toolbar popups uses style, which CONTRIBUTING.md does not list. refactor matches that change.

@pratikbuilds
pratikbuilds force-pushed the conversation/08-prompt-input branch from 606dab6 to aef6a93 Compare October 28, 2026 08:41
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks, all three addressed in aef6a93 (fix) and f849185 (reworded to refactor).

  • Caret after an execCommand pick: pick now moves the caret to next.caret after a successful insertText. The test DOM has no execCommand, so the hook tests add a stand-in that behaves like the browser and run every pick test both ways. A new e2e case covers a mid-draft pick in Chromium; before the fix it failed with the caret at 8 instead of 9.
  • Reused newline: I went a little differently from the suggestion. If the caret stays right before the newline, it is still inside @Ada, so the list stays open and Enter picks again, which is the first bug over again. Instead insertPromptToken reuses only a space on the same line and adds a space before a line break. ask @ad\nnext becomes ask @Ada \nnext, with the caret on that line and outside the token.
  • Commit type: reworded to refactor. The rest of the stack is restacked on top.

@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

One more commit on this PR, found while fixing the toolbar's Escape handling in #118.

Escape on the open suggestion list also closed a dialog around the prompt

  • The problem: Radix layers such as Dialog close on an Escape they see in the document's capture phase, unless it's already handled. That runs before React's handlers, so the list's stopPropagation came too late, and one Escape closed both the list and the dialog.
  • The fix: while the list is open, it marks Escape handled from the window's capture phase, which runs first. PromptInputTextarea skips its own key handling when a consumer's onKeyDown has already handled a key, so it now lets through an Escape the list claimed.
  • Tests: the unit test "an open list marks Escape handled before a Radix layer sees it", and a Playwright case in a real dialog, "Escape closes the suggestion list, not the dialog around it".

Commit: fix(prompt-input): keep Escape on an open list from closing a dialog

PromptInput is a fully controlled form. PromptInputTextarea draws
@mention and /command tokens on an aria-hidden mirror beneath
transparent native text, grows through field-sizing with a measured
fallback, and never submits during IME composition. usePromptInput
drives PromptInputSuggestions from the keyboard, and PromptInputSubmit
turns Send into Stop while a reply is running.
A focused Send that turned disabled dropped focus to the page. That
happened in the normal flow: a host that clears the draft on submit
makes Send unsendable when the reply ends or Stop is pressed. Send now
uses aria-disabled for "nothing to send", and the form's guarded submit
keeps it inert. A host's own disabled is still a real one.

The suggestion list's state was invisible to assistive tech, and a
textarea may not carry aria-expanded. The textarea now has
aria-haspopup="listbox", and aria-autocomplete is set only while a
suggestions part is mounted. A polite status announces how many
suggestions are open and empties when the list closes.

A value set by the host moved the caret without a select event, so
suggestions opened for a stale position. The value-sync effect now
reads the caret again while the textarea has focus.

Document that a suggestion render callback must return a
PromptInputSuggestion, warn in development when the current option's
id is missing, and cover Safari's keyCode 229 Enter after composition.
Drop the accent stripe from the active suggestion, keeping the neutral
fill alone, and tighten the rows to match the toolbar's popups.
Picking a suggestion through the browser's editing left the caret after
the inserted text. When a following space was reused that is still
inside the token, so the list stayed open and Enter picked again instead
of sending. Move the caret to where insertPromptToken puts it.

insertPromptToken reused a following line break like a space, putting
the caret on the next line. Reuse only a space within the line; before a
line break, add one so the caret stays on its line and outside the
token.
Radix layers such as Dialog dismiss on an Escape they see in the
document's capture phase, unless it is already prevented. That runs
before React's handlers, so the list's stopPropagation came too late
and Escape closed both the list and the dialog around the prompt.

While the list is open, prevent Escape from the window's capture phase,
which runs first. PromptInputTextarea skips the hook when a consumer's
onKeyDown prevents a key, so let an Escape the list claimed through.
The key Escape dismissed and the row the arrows chose outlived their
token. Escape was keyed on the token's start and text, so retyping the
same token, or typing it again in the next draft, opened the list and
closed it at once. The arrow choice survived until a pick, so a later
token's list opened on that row and Enter picked it.

Clear both whenever the caret is in no token: on change, select and
blur, and when the host sets the value. Narrowing within one token keeps
the caret inside it, so the choice still holds there.
Only each option cancelled mousedown. A press on the list's padding,
the gaps between rows or its scrollbar moved focus out of the textarea,
whose blur closed the list, so a long list could not be scrolled with
its scrollbar.

Cancel mousedown once on the listbox instead. Scrollbar dragging and
wheel scrolling still work, and custom option content is covered too.
usePromptInput prevents Escape from the window's capture phase while
its list is open, before any React handler runs. A host that builds its
own field and skips textareaProps.onKeyDown for prevented keys would
never close the list, and nothing said so. Document it on the hook and
on textareaProps.
PromptInputTextarea read `ref` from its props, which React 19 passes
but React 18 strips from a function component's props, so a host ref
never reached the textarea on React 18. Wrap it in forwardRef; the ref
still merges with the one the prompt keeps for itself.
@pratikbuilds
pratikbuilds force-pushed the conversation/08-prompt-input branch from f0075c9 to 68923bf Compare October 28, 2026 14:47
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Round 3 fixes

A fresh review pass over the stack turned up the points below; each is fixed in a new commit at the end of this branch, and the stack is rebased onto it.

  • 94b07e2: An Escape or arrow choice outlived its token, so a retyped @a stayed dismissed and a new list opened on the old row. Both are forgotten once the caret leaves the token.
  • da7128e: Only the options cancelled mousedown, so a press on the list's padding, gaps or scrollbar blurred the textarea and closed the list. mousedown is now cancelled on the whole listbox.
  • 826e4a5: The usePromptInput docs now say that an Escape the open list claims reaches onKeyDown already defaultPrevented, so a wrapper that skips prevented keys must still pass it on.
  • 68923bf: React 18 refs: components that read ref from props only receive it on React 19, while the peer range allows ^18.2.0. They are now wrapped in forwardRef, so a host ref reaches the element on both; the peer range is unchanged. Covers PromptInputTextarea.

bun run check and dep-guard pass on every branch of the stack, and the full e2e suite passes at the top of the stack (163/163, including the stories axe sweep). Each behaviour fix comes with a test that fails without it; the forwardRef tests can only guard, since the suite runs on React 19.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants