feat(site): replace Agent chat textarea with Lexical editor (#22449)

## Summary

Replaces the plain `<TextareaAutosize>` in the Agent chat input
(`AgentChatInput`) with a Lexical-based editor component, matching the
pattern used in [coder/blink](https://github.com/coder/blink).

## What changed

### New component: `ChatMessageInput`
`site/src/components/ChatMessageInput/ChatMessageInput.tsx`

A Lexical-powered text input that behaves as a plain-text editor with:
- **Enter** submits, **Shift+Enter** inserts newline
- Rich-text formatting disabled (Cmd+B/I/U blocked)
- Paste sanitization (strips formatting, inserts plain text)
- Undo/redo via HistoryPlugin
- Imperative ref API: `insertText()`, `clear()`, `focus()`, `getValue()`

### Updated components
- **`AgentChatInput.tsx`** — Swapped `<TextareaAutosize>` for
`<ChatMessageInput>`. Moved from controlled `value`/`onChange` to
ref-based pattern with `initialValue`/`onContentChange`.
- **`AgentDetail.tsx`** — Updated to use `useRef` for input value
tracking and `editorInitialValue` state for editor resets (edit/cancel
flows).
- **`AgentsPage.tsx`** — Updated to use `useRef` + `initialValue`
pattern.
- **`AgentChatInput.stories.tsx`** — Updated prop names.

### Why Lexical?
This lays the groundwork for features that a native `<textarea>` can't
support:
- Ghost text / inline autocomplete suggestions
- @-mentions and slash commands
- Programmatic text insertion (e.g. from speech-to-text)
- Custom inline decorators (chips, pills, badges)
- Syntax-highlighted code blocks

No adornments are added in this PR — it's a drop-in replacement that
matches existing behavior.

---------

Co-authored-by: Coder <coder@coder.com>
This commit is contained in:
Kyle Carberry
2026-02-28 22:18:36 -05:00
committed by GitHub
co-authored by Coder
parent a6d2462076
commit 897f178a5c
7 changed files with 874 additions and 76 deletions
+3
View File
@@ -46,6 +46,8 @@
"@fontsource/ibm-plex-mono": "5.2.7",
"@fontsource/jetbrains-mono": "5.2.8",
"@fontsource/source-code-pro": "5.2.7",
"@lexical/react": "0.41.0",
"@lexical/utils": "0.41.0",
"@monaco-editor/react": "4.7.0",
"@mui/material": "5.18.0",
"@mui/system": "5.18.0",
@@ -90,6 +92,7 @@
"front-matter": "4.0.2",
"humanize-duration": "3.33.1",
"jszip": "3.10.1",
"lexical": "0.41.0",
"lodash": "4.17.21",
"lucide-react": "0.555.0",
"monaco-editor": "0.55.1",
+329
View File
@@ -52,6 +52,12 @@ importers:
'@fontsource/source-code-pro':
specifier: 5.2.7
version: 5.2.7
'@lexical/react':
specifier: 0.41.0
version: 0.41.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(yjs@13.6.29)
'@lexical/utils':
specifier: 0.41.0
version: 0.41.0
'@monaco-editor/react':
specifier: 4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
@@ -184,6 +190,9 @@ importers:
jszip:
specifier: 3.10.1
version: 3.10.1
lexical:
specifier: 0.41.0
version: 0.41.0
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -1037,15 +1046,33 @@ packages:
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==, tarball: https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz}
'@floating-ui/core@1.7.4':
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==, tarball: https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==, tarball: https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz}
'@floating-ui/dom@1.7.5':
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==, tarball: https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==, tarball: https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react-dom@2.1.7':
resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==, tarball: https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.27.18':
resolution: {integrity: sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q==, tarball: https://registry.npmjs.org/@floating-ui/react/-/react-0.27.18.tgz}
peerDependencies:
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==, tarball: https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz}
@@ -1233,6 +1260,80 @@ packages:
'@leeoniya/ufuzzy@1.0.10':
resolution: {integrity: sha512-OR1yiyN8cKBn5UiHjKHUl0LcrTQt4vZPUpIf96qIIZVLxgd4xyASuRvTZ3tjbWvuyQAMgvKsq61Nwu131YyHnA==, tarball: https://registry.npmjs.org/@leeoniya/ufuzzy/-/ufuzzy-1.0.10.tgz}
'@lexical/clipboard@0.41.0':
resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==, tarball: https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.41.0.tgz}
'@lexical/code@0.41.0':
resolution: {integrity: sha512-0hoNi1KC9/N3SBOGcOcFqnT0OpwmcRRAhfxTKMGqfCtCvAMzULVwZ8RWc9/NV9bKYESgBTW5D9xkDANP2mspHg==, tarball: https://registry.npmjs.org/@lexical/code/-/code-0.41.0.tgz}
'@lexical/devtools-core@0.41.0':
resolution: {integrity: sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==, tarball: https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.41.0.tgz}
peerDependencies:
react: '>=17.x'
react-dom: '>=17.x'
'@lexical/dragon@0.41.0':
resolution: {integrity: sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==, tarball: https://registry.npmjs.org/@lexical/dragon/-/dragon-0.41.0.tgz}
'@lexical/extension@0.41.0':
resolution: {integrity: sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==, tarball: https://registry.npmjs.org/@lexical/extension/-/extension-0.41.0.tgz}
'@lexical/hashtag@0.41.0':
resolution: {integrity: sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==, tarball: https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.41.0.tgz}
'@lexical/history@0.41.0':
resolution: {integrity: sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==, tarball: https://registry.npmjs.org/@lexical/history/-/history-0.41.0.tgz}
'@lexical/html@0.41.0':
resolution: {integrity: sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==, tarball: https://registry.npmjs.org/@lexical/html/-/html-0.41.0.tgz}
'@lexical/link@0.41.0':
resolution: {integrity: sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==, tarball: https://registry.npmjs.org/@lexical/link/-/link-0.41.0.tgz}
'@lexical/list@0.41.0':
resolution: {integrity: sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==, tarball: https://registry.npmjs.org/@lexical/list/-/list-0.41.0.tgz}
'@lexical/mark@0.41.0':
resolution: {integrity: sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==, tarball: https://registry.npmjs.org/@lexical/mark/-/mark-0.41.0.tgz}
'@lexical/markdown@0.41.0':
resolution: {integrity: sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==, tarball: https://registry.npmjs.org/@lexical/markdown/-/markdown-0.41.0.tgz}
'@lexical/offset@0.41.0':
resolution: {integrity: sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==, tarball: https://registry.npmjs.org/@lexical/offset/-/offset-0.41.0.tgz}
'@lexical/overflow@0.41.0':
resolution: {integrity: sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==, tarball: https://registry.npmjs.org/@lexical/overflow/-/overflow-0.41.0.tgz}
'@lexical/plain-text@0.41.0':
resolution: {integrity: sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==, tarball: https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.41.0.tgz}
'@lexical/react@0.41.0':
resolution: {integrity: sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==, tarball: https://registry.npmjs.org/@lexical/react/-/react-0.41.0.tgz}
peerDependencies:
react: '>=17.x'
react-dom: '>=17.x'
'@lexical/rich-text@0.41.0':
resolution: {integrity: sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==, tarball: https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.41.0.tgz}
'@lexical/selection@0.41.0':
resolution: {integrity: sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==, tarball: https://registry.npmjs.org/@lexical/selection/-/selection-0.41.0.tgz}
'@lexical/table@0.41.0':
resolution: {integrity: sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==, tarball: https://registry.npmjs.org/@lexical/table/-/table-0.41.0.tgz}
'@lexical/text@0.41.0':
resolution: {integrity: sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==, tarball: https://registry.npmjs.org/@lexical/text/-/text-0.41.0.tgz}
'@lexical/utils@0.41.0':
resolution: {integrity: sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==, tarball: https://registry.npmjs.org/@lexical/utils/-/utils-0.41.0.tgz}
'@lexical/yjs@0.41.0':
resolution: {integrity: sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==, tarball: https://registry.npmjs.org/@lexical/yjs/-/yjs-0.41.0.tgz}
peerDependencies:
yjs: '>=13.5.22'
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==, tarball: https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz}
peerDependencies:
@@ -1507,6 +1608,9 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==, tarball: https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz}
'@preact/signals-core@1.13.0':
resolution: {integrity: sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==, tarball: https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==, tarball: https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz}
@@ -4266,6 +4370,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==, tarball: https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, tarball: https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz}
engines: {node: '>=8'}
@@ -4568,6 +4675,14 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, tarball: https://registry.npmjs.org/levn/-/levn-0.4.1.tgz}
engines: {node: '>= 0.8.0'}
lexical@0.41.0:
resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==, tarball: https://registry.npmjs.org/lexical/-/lexical-0.41.0.tgz}
lib0@0.2.117:
resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==, tarball: https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz}
engines: {node: '>=16'}
hasBin: true
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==, tarball: https://registry.npmjs.org/lie/-/lie-3.3.0.tgz}
@@ -5329,6 +5444,11 @@ packages:
peerDependencies:
react: ^19.2.2
react-error-boundary@6.1.1:
resolution: {integrity: sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==, tarball: https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-fast-compare@2.0.4:
resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==, tarball: https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz}
@@ -5891,6 +6011,9 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, tarball: https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==, tarball: https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz}
@@ -6499,6 +6622,10 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, tarball: https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz}
engines: {node: '>=12'}
yjs@13.6.29:
resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==, tarball: https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==, tarball: https://registry.npmjs.org/yn/-/yn-3.1.1.tgz}
engines: {node: '>=6'}
@@ -7066,17 +7193,40 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/core@1.7.4':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.5':
dependencies:
'@floating-ui/core': 1.7.4
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.6(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
'@floating-ui/react-dom@2.1.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@floating-ui/dom': 1.7.5
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
'@floating-ui/react@0.27.18(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@floating-ui/utils': 0.2.10
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
tabbable: 6.4.0
'@floating-ui/utils@0.2.10': {}
'@fontsource-variable/geist@5.2.8': {}
@@ -7390,6 +7540,165 @@ snapshots:
'@leeoniya/ufuzzy@1.0.10': {}
'@lexical/clipboard@0.41.0':
dependencies:
'@lexical/html': 0.41.0
'@lexical/list': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/code@0.41.0':
dependencies:
'@lexical/utils': 0.41.0
lexical: 0.41.0
prismjs: 1.30.0
'@lexical/devtools-core@0.41.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@lexical/html': 0.41.0
'@lexical/link': 0.41.0
'@lexical/mark': 0.41.0
'@lexical/table': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
'@lexical/dragon@0.41.0':
dependencies:
'@lexical/extension': 0.41.0
lexical: 0.41.0
'@lexical/extension@0.41.0':
dependencies:
'@lexical/utils': 0.41.0
'@preact/signals-core': 1.13.0
lexical: 0.41.0
'@lexical/hashtag@0.41.0':
dependencies:
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/history@0.41.0':
dependencies:
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/html@0.41.0':
dependencies:
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/link@0.41.0':
dependencies:
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/list@0.41.0':
dependencies:
'@lexical/extension': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/mark@0.41.0':
dependencies:
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/markdown@0.41.0':
dependencies:
'@lexical/code': 0.41.0
'@lexical/link': 0.41.0
'@lexical/list': 0.41.0
'@lexical/rich-text': 0.41.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/offset@0.41.0':
dependencies:
lexical: 0.41.0
'@lexical/overflow@0.41.0':
dependencies:
lexical: 0.41.0
'@lexical/plain-text@0.41.0':
dependencies:
'@lexical/clipboard': 0.41.0
'@lexical/dragon': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/react@0.41.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(yjs@13.6.29)':
dependencies:
'@floating-ui/react': 0.27.18(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@lexical/devtools-core': 0.41.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@lexical/dragon': 0.41.0
'@lexical/extension': 0.41.0
'@lexical/hashtag': 0.41.0
'@lexical/history': 0.41.0
'@lexical/link': 0.41.0
'@lexical/list': 0.41.0
'@lexical/mark': 0.41.0
'@lexical/markdown': 0.41.0
'@lexical/overflow': 0.41.0
'@lexical/plain-text': 0.41.0
'@lexical/rich-text': 0.41.0
'@lexical/table': 0.41.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
'@lexical/yjs': 0.41.0(yjs@13.6.29)
lexical: 0.41.0
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
react-error-boundary: 6.1.1(react@19.2.2)
transitivePeerDependencies:
- yjs
'@lexical/rich-text@0.41.0':
dependencies:
'@lexical/clipboard': 0.41.0
'@lexical/dragon': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/selection@0.41.0':
dependencies:
lexical: 0.41.0
'@lexical/table@0.41.0':
dependencies:
'@lexical/clipboard': 0.41.0
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/text@0.41.0':
dependencies:
lexical: 0.41.0
'@lexical/utils@0.41.0':
dependencies:
'@lexical/selection': 0.41.0
lexical: 0.41.0
'@lexical/yjs@0.41.0(yjs@13.6.29)':
dependencies:
'@lexical/offset': 0.41.0
'@lexical/selection': 0.41.0
lexical: 0.41.0
yjs: 13.6.29
'@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.2)':
dependencies:
'@types/mdx': 2.0.13
@@ -7645,6 +7954,8 @@ snapshots:
'@popperjs/core@2.11.8': {}
'@preact/signals-core@1.13.0': {}
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
@@ -10555,6 +10866,8 @@ snapshots:
isexe@2.0.0: {}
isomorphic.js@0.2.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-instrument@5.2.1:
@@ -11128,6 +11441,12 @@ snapshots:
type-check: 0.4.0
optional: true
lexical@0.41.0: {}
lib0@0.2.117:
dependencies:
isomorphic.js: 0.2.5
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -12094,6 +12413,10 @@ snapshots:
react: 19.2.2
scheduler: 0.27.0
react-error-boundary@6.1.1(react@19.2.2):
dependencies:
react: 19.2.2
react-fast-compare@2.0.4: {}
react-inspector@6.0.2(react@19.2.2):
@@ -12762,6 +13085,8 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.4.0: {}
tailwind-merge@2.6.0: {}
tailwind-merge@3.4.1: {}
@@ -13330,6 +13655,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yjs@13.6.29:
dependencies:
lib0: 0.2.117
yn@3.1.1:
optional: true
@@ -0,0 +1,391 @@
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { mergeRegister } from "@lexical/utils";
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$insertNodes,
$isRangeSelection,
COMMAND_PRIORITY_HIGH,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
KEY_ENTER_COMMAND,
type LexicalEditor,
PASTE_COMMAND,
type ParagraphNode,
} from "lexical";
import {
type FC,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { cn } from "utils/cn";
// Blocks Cmd+B/I/U and element formatting shortcuts so the editor
// stays plain-text only.
const DisableFormattingPlugin: FC = memo(function DisableFormattingPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return mergeRegister(
editor.registerCommand(
FORMAT_TEXT_COMMAND,
() => true,
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
FORMAT_ELEMENT_COMMAND,
() => true,
COMMAND_PRIORITY_HIGH,
),
);
}, [editor]);
return null;
});
// Intercepts paste events and inserts clipboard content as plain text,
// stripping any rich-text formatting.
const PasteSanitizationPlugin: FC = memo(function PasteSanitizationPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event: ClipboardEvent | null) => {
if (!event) return false;
const clipboardData = event.clipboardData;
if (!clipboardData) return false;
const text = clipboardData.getData("text/plain");
if (!text) return false;
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(text);
} else {
const root = $getRoot();
const lastChild = root.getLastChild();
if (lastChild) {
if (lastChild.getType() === "paragraph") {
const paragraph = lastChild as ParagraphNode;
const textNode = $createTextNode(text);
paragraph.append(textNode);
textNode.selectEnd();
} else {
const textNode = $createTextNode(text);
lastChild.insertAfter(textNode);
textNode.selectEnd();
}
} else {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(text);
paragraph.append(textNode);
root.append(paragraph);
textNode.selectEnd();
}
}
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);
return null;
});
// Handles Enter key behavior: plain Enter submits via the onEnter
// callback, Shift+Enter inserts a newline.
const EnterKeyPlugin: FC<{ onEnter?: () => void }> = memo(
function EnterKeyPlugin({ onEnter }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (event?.shiftKey) {
return false;
}
if (onEnter) {
event?.preventDefault();
onEnter();
}
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, onEnter]);
return null;
},
);
// Fires the onChange callback with the editor's plain-text content
// on every update.
const ContentChangePlugin: FC<{
onChange?: (content: string) => void;
}> = memo(function ContentChangePlugin({ onChange }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!onChange) return;
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const root = $getRoot();
const content = root.getTextContent();
onChange(content);
});
});
}, [editor, onChange]);
return null;
});
// Seeds the editor with an initial value on first mount.
const ValueSyncPlugin: FC<{ initialValue?: string }> = memo(
function ValueSyncPlugin({ initialValue }) {
const [editor] = useLexicalComposerContext();
const hasInitialized = useRef(false);
useEffect(() => {
if (!hasInitialized.current && initialValue !== undefined) {
hasInitialized.current = true;
if (initialValue === "") {
return;
}
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
const textNode = $createTextNode(initialValue);
paragraph.append(textNode);
root.append(paragraph);
});
}
}, [editor, initialValue]);
return null;
},
);
// Exposes the LexicalEditor instance to the parent via a callback
// so it can be stored in a ref for imperative access.
const InsertTextPlugin: FC<{
onEditorReady: (editor: LexicalEditor) => void;
}> = memo(function InsertTextPlugin({ onEditorReady }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
onEditorReady(editor);
}, [editor, onEditorReady]);
return null;
});
export interface ChatMessageInputRef {
insertText: (text: string) => void;
clear: () => void;
focus: () => void;
getValue: () => string;
}
interface ChatMessageInputProps
extends Omit<React.ComponentProps<"div">, "onChange" | "role" | "ref"> {
placeholder?: string;
initialValue?: string;
onChange?: (content: string) => void;
rows?: number;
onEnter?: () => void;
disabled?: boolean;
autoFocus?: boolean;
"aria-label"?: string;
}
const ChatMessageInput = memo(
({
className,
placeholder,
initialValue,
onChange,
rows,
onEnter,
disabled,
autoFocus,
"aria-label": ariaLabel,
ref,
...props
}: ChatMessageInputProps & { ref?: React.Ref<ChatMessageInputRef> }) => {
const initialConfig = useMemo(
() => ({
namespace: "ChatMessageInput",
theme: {
paragraph: "m-0",
},
onError: (error: Error) => console.error("Lexical error:", error),
nodes: [],
}),
[],
);
const style = useMemo(
() => ({
minHeight: rows ? `${rows * 1.5}rem` : undefined,
}),
[rows],
);
const editorRef = useRef<LexicalEditor | null>(null);
const handleEditorReady = useCallback((editor: LexicalEditor) => {
editorRef.current = editor;
}, []);
const handleContentChange = useCallback(
(content: string) => {
onChange?.(content);
},
[onChange],
);
useImperativeHandle(
ref,
() => ({
insertText: (text: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const textNode = $createTextNode(text);
$insertNodes([textNode]);
textNode.selectEnd();
} else {
const root = $getRoot();
const lastChild = root.getLastChild();
if (lastChild) {
if (lastChild.getType() === "paragraph") {
const paragraph = lastChild as ParagraphNode;
const textNode = $createTextNode(text);
paragraph.append(textNode);
textNode.selectEnd();
} else {
const textNode = $createTextNode(text);
lastChild.insertAfter(textNode);
textNode.selectEnd();
}
} else {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(text);
paragraph.append(textNode);
root.append(paragraph);
textNode.selectEnd();
}
}
});
},
clear: () => {
const editor = editorRef.current;
if (!editor) return;
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.select();
});
},
focus: () => {
const editor = editorRef.current;
if (!editor) return;
editor.focus(() => {
editor.update(() => {
const root = $getRoot();
const last = root.getLastChild();
if (!last) {
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.select();
return;
}
last.selectEnd();
});
});
},
getValue: () => {
const editor = editorRef.current;
if (!editor) return "";
let content = "";
editor.getEditorState().read(() => {
content = $getRoot().getTextContent();
});
return content;
},
}),
[],
);
return (
<LexicalComposer initialConfig={initialConfig} key={initialValue}>
<div
className={cn(
"grid w-full rounded-md bg-transparent text-base placeholder:text-content-secondary focus-visible:outline-none whitespace-pre-wrap break-words [&>*]:col-start-1 [&>*]:row-start-1",
disabled && "cursor-not-allowed opacity-50",
className,
)}
style={style}
{...props}
>
<RichTextPlugin
contentEditable={
<ContentEditable
className="outline-none w-full whitespace-pre-wrap [&_p]:leading-normal [&_p:first-child]:mt-0 [&_p:last-child]:mb-0"
data-testid="chat-message-input"
style={{ minHeight: "inherit" }}
aria-label={ariaLabel}
aria-disabled={disabled}
/>
}
placeholder={
<div className="pointer-events-none text-content-secondary [&_p]:leading-normal">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<DisableFormattingPlugin />
<PasteSanitizationPlugin />
<EnterKeyPlugin onEnter={disabled ? undefined : onEnter} />
<ContentChangePlugin onChange={handleContentChange} />
<ValueSyncPlugin initialValue={initialValue} />
<InsertTextPlugin onEditorReady={handleEditorReady} />
{autoFocus && <AutoFocusPlugin />}
</div>
</LexicalComposer>
);
},
);
ChatMessageInput.displayName = "ChatMessageInput";
export { ChatMessageInput };
@@ -16,9 +16,9 @@ const meta: Meta<typeof AgentChatInput> = {
component: AgentChatInput,
args: {
onSend: fn(),
onChange: fn(),
onContentChange: fn(),
onModelChange: fn(),
value: "",
initialValue: "",
isDisabled: false,
isLoading: false,
selectedModel: defaultModelOptions[0].id,
@@ -47,12 +47,24 @@ export const DisablesSendUntilInput: Story = {
export const SendsAndClearsInput: Story = {
args: {
onSend: fn(),
value: "Run focused tests",
initialValue: "Run focused tests",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
// Wait for the Lexical editor to initialize and render the
// initial value text into the DOM before interacting.
const editor = canvas.getByTestId("chat-message-input");
await waitFor(() => {
expect(editor.textContent).toBe("Run focused tests");
});
const sendButton = canvas.getByRole("button", { name: "Send" });
await waitFor(() => {
expect(sendButton).toBeEnabled();
});
await userEvent.click(sendButton);
await waitFor(() => {
expect(args.onSend).toHaveBeenCalledWith("Run focused tests");
@@ -63,7 +75,7 @@ export const SendsAndClearsInput: Story = {
export const DisabledInput: Story = {
args: {
isDisabled: true,
value: "Should not send",
initialValue: "Should not send",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -75,7 +87,7 @@ export const NoModelOptions: Story = {
args: {
isDisabled: false,
hasModelOptions: false,
value: "Model required",
initialValue: "Model required",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -87,7 +99,7 @@ export const LoadingSpinner: Story = {
args: {
isDisabled: true,
isLoading: true,
value: "Sending...",
initialValue: "Sending...",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -103,7 +115,7 @@ export const LoadingDisablesSend: Story = {
args: {
isDisabled: false,
isLoading: true,
value: "Another message",
initialValue: "Another message",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+89 -40
View File
@@ -4,6 +4,10 @@ import {
type ModelSelectorOption,
} from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
ChatMessageInput,
type ChatMessageInputRef,
} from "components/ChatMessageInput/ChatMessageInput";
import {
Tooltip,
TooltipContent,
@@ -16,12 +20,13 @@ import {
Square,
XIcon,
} from "lucide-react";
import { memo, type ReactNode, useRef } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { memo, type ReactNode, useCallback, useRef, useState } from "react";
import { cn } from "utils/cn";
import { formatProviderLabel } from "./modelOptions";
import { QueuedMessagesList } from "./QueuedMessagesList";
export type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput";
export interface AgentContextUsage {
readonly usedTokens?: number;
readonly contextLimitTokens?: number;
@@ -39,9 +44,12 @@ interface AgentChatInputProps {
placeholder?: string;
isDisabled: boolean;
isLoading: boolean;
// Controlled input value. The parent owns the text state.
value: string;
onChange: (value: string) => void;
// Ref for the Lexical editor, exposed for imperative access.
inputRef?: React.Ref<ChatMessageInputRef>;
// Initial text to seed the editor with.
initialValue?: string;
// Called on every text change inside the editor.
onContentChange?: (content: string) => void;
// Model selector.
selectedModel: string;
onModelChange: (value: string) => void;
@@ -208,8 +216,9 @@ export const AgentChatInput = memo<AgentChatInputProps>(
placeholder = "Type a message...",
isDisabled,
isLoading,
value,
onChange,
inputRef,
initialValue,
onContentChange,
selectedModel,
onModelChange,
modelOptions,
@@ -232,18 +241,71 @@ export const AgentChatInput = memo<AgentChatInputProps>(
contextUsage,
sticky = false,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const internalRef = useRef<ChatMessageInputRef>(null);
const canSend =
!isDisabled && !isLoading && hasModelOptions && !!value.trim();
// Merge the external inputRef with our internal ref so both
// point to the same ChatMessageInputRef instance.
const setRef = useCallback(
(instance: ChatMessageInputRef | null) => {
(
internalRef as React.MutableRefObject<ChatMessageInputRef | null>
).current = instance;
if (typeof inputRef === "function") {
inputRef(instance);
} else if (inputRef && typeof inputRef === "object") {
(
inputRef as React.MutableRefObject<ChatMessageInputRef | null>
).current = instance;
}
},
[inputRef],
);
const handleSubmit = () => {
if (!canSend) {
// Track whether the editor has content so we can gate the
// send button without a controlled value prop.
const [hasContent, setHasContent] = useState(() => !!initialValue?.trim());
const handleContentChange = useCallback(
(content: string) => {
setHasContent(!!content.trim());
onContentChange?.(content);
},
[onContentChange],
);
const canSend = !isDisabled && !isLoading && hasModelOptions && hasContent;
const handleSubmit = useCallback(() => {
const text = internalRef.current?.getValue()?.trim() ?? "";
// If the input is empty and there are queued messages,
// promote the first one instead of submitting.
if (
!text &&
!isDisabled &&
!isLoading &&
queuedMessages.length > 0 &&
onPromoteQueuedMessage
) {
void onPromoteQueuedMessage(queuedMessages[0].id);
return;
}
onSend(value.trim());
textareaRef.current?.focus();
};
if (!text || isDisabled || isLoading || !hasModelOptions) {
return;
}
onSend(text);
internalRef.current?.clear();
internalRef.current?.focus();
}, [
isDisabled,
isLoading,
hasModelOptions,
onSend,
queuedMessages,
onPromoteQueuedMessage,
]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
@@ -257,23 +319,6 @@ export const AgentChatInput = memo<AgentChatInputProps>(
e.preventDefault();
onInterrupt();
}
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// If the input is empty and there are queued messages,
// promote the first one instead of submitting.
if (
!value.trim() &&
!isDisabled &&
!isLoading &&
queuedMessages.length > 0 &&
onPromoteQueuedMessage
) {
void onPromoteQueuedMessage(queuedMessages[0].id);
return;
}
handleSubmit();
}
};
@@ -302,7 +347,10 @@ export const AgentChatInput = memo<AgentChatInputProps>(
className="mb-2"
/>
)}
<div className="rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40">
<div
className="rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40"
onKeyDown={handleKeyDown}
>
{editingQueuedMessageID !== null && (
<div className="flex items-center justify-between border-b border-border-default/70 bg-surface-primary/25 px-3 py-1.5">
<span className="text-sm text-content-secondary">
@@ -340,16 +388,17 @@ export const AgentChatInput = memo<AgentChatInputProps>(
</Button>
</div>
)}
<TextareaAutosize
ref={textareaRef}
<ChatMessageInput
ref={setRef}
aria-label="Chat message"
className="min-h-[120px] w-full resize-none border-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary outline-none placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
className="min-h-[120px] w-full resize-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
initialValue={initialValue}
onChange={handleContentChange}
onEnter={handleSubmit}
disabled={isDisabled}
minRows={4}
rows={4}
autoFocus
/>
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-2">
+35 -22
View File
@@ -29,7 +29,7 @@ import { useNavigate, useOutletContext, useParams } from "react-router";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { AgentChatInput } from "./AgentChatInput";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
import {
selectChatStatus,
selectHasStreamState,
@@ -204,8 +204,9 @@ interface AgentDetailInputProps {
modelCatalogStatusMessage: string | null;
// Controlled input value and editing state, owned by the
// conversation component.
inputValue: string;
onInputChange: (value: string) => void;
inputRef?: React.Ref<ChatMessageInputRef>;
initialValue?: string;
onContentChange?: (content: string) => void;
editingQueuedMessageID: number | null;
onStartQueueEdit: (id: number, text: string) => void;
onCancelQueueEdit: () => void;
@@ -230,8 +231,9 @@ const AgentDetailInput: FC<AgentDetailInputProps> = ({
modelSelectorPlaceholder,
inputStatusText,
modelCatalogStatusMessage,
inputValue,
onInputChange,
inputRef,
initialValue,
onContentChange,
editingQueuedMessageID,
onStartQueueEdit,
onCancelQueueEdit,
@@ -264,8 +266,9 @@ const AgentDetailInput: FC<AgentDetailInputProps> = ({
return (
<AgentChatInput
onSend={onSend}
value={inputValue}
onChange={onInputChange}
inputRef={inputRef}
initialValue={initialValue}
onContentChange={onContentChange}
queuedMessages={queuedMessages}
onDeleteQueuedMessage={onDeleteQueuedMessage}
onPromoteQueuedMessage={onPromoteQueuedMessage}
@@ -335,7 +338,9 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
modelCatalogStatusMessage,
savingMessageId,
}) => {
const [inputValue, setInputValue] = useState("");
const inputValueRef = useRef("");
const chatInputRef = useRef<ChatMessageInputRef>(null);
const [editorInitialValue, setEditorInitialValue] = useState("");
// -- History editing state --
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
@@ -346,16 +351,18 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
const handleEditUserMessage = useCallback(
(messageId: number, text: string) => {
setDraftBeforeHistoryEdit((prev) =>
editingMessageId !== null ? prev : inputValue,
editingMessageId !== null ? prev : inputValueRef.current,
);
setEditingMessageId(messageId);
setInputValue(text);
setEditorInitialValue(text);
inputValueRef.current = text;
},
[editingMessageId, inputValue],
[editingMessageId],
);
const handleCancelHistoryEdit = useCallback(() => {
setInputValue(draftBeforeHistoryEdit ?? "");
setEditorInitialValue(draftBeforeHistoryEdit ?? "");
inputValueRef.current = draftBeforeHistoryEdit ?? "";
setEditingMessageId(null);
setDraftBeforeHistoryEdit(null);
}, [draftBeforeHistoryEdit]);
@@ -371,16 +378,18 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
const handleStartQueueEdit = useCallback(
(id: number, text: string) => {
setDraftBeforeQueueEdit((prev) =>
editingQueuedMessageID === null ? inputValue : prev,
editingQueuedMessageID === null ? inputValueRef.current : prev,
);
setEditingQueuedMessageID(id);
setInputValue(text);
setEditorInitialValue(text);
inputValueRef.current = text;
},
[editingQueuedMessageID, inputValue],
[editingQueuedMessageID],
);
const handleCancelQueueEdit = useCallback(() => {
setInputValue(draftBeforeQueueEdit ?? "");
setEditorInitialValue(draftBeforeQueueEdit ?? "");
inputValueRef.current = draftBeforeQueueEdit ?? "";
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
}, [draftBeforeQueueEdit]);
@@ -394,7 +403,8 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
const queueEditID = editingQueuedMessageID;
// Clear input and editing state optimistically.
setInputValue("");
setEditorInitialValue("");
inputValueRef.current = "";
if (editingMessageId !== null) {
setEditingMessageId(null);
setDraftBeforeHistoryEdit(null);
@@ -412,7 +422,8 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
})
.catch(() => {
// Restore input so the user can retry.
setInputValue(message);
setEditorInitialValue(message);
inputValueRef.current = message;
});
},
[editingMessageId, editingQueuedMessageID, onDeleteQueuedMessage, onSend],
@@ -445,8 +456,11 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
inputValue={inputValue}
onInputChange={setInputValue}
inputRef={chatInputRef}
initialValue={editorInitialValue}
onContentChange={(content) => {
inputValueRef.current = content;
}}
editingQueuedMessageID={editingQueuedMessageID}
onStartQueueEdit={handleStartQueueEdit}
onCancelQueueEdit={handleCancelQueueEdit}
@@ -896,8 +910,7 @@ const AgentDetail: FC = () => {
</div>
<AgentChatInput
onSend={() => {}}
value=""
onChange={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={selectedModel}
+7 -6
View File
@@ -511,12 +511,13 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
isConfigureAgentsDialogOpen,
onConfigureAgentsDialogOpenChange,
}) => {
const [inputValue, setInputValue] = useState(() => {
const [initialInputValue] = useState(() => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(emptyInputStorageKey) ?? "";
});
const inputValueRef = useRef(initialInputValue);
const initialSystemPrompt = () => {
if (typeof window === "undefined") {
return "";
@@ -659,10 +660,10 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
}
};
const handleInputChange = useCallback((value: string) => {
setInputValue(value);
const handleContentChange = useCallback((content: string) => {
inputValueRef.current = content;
if (typeof window !== "undefined") {
localStorage.setItem(emptyInputStorageKey, value);
localStorage.setItem(emptyInputStorageKey, content);
}
}, []);
const handleModelChange = useCallback((value: string) => {
@@ -720,8 +721,8 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
placeholder="Ask Coder to build, fix bugs, or explore your project..."
isDisabled={isCreating}
isLoading={isCreating}
value={inputValue}
onChange={handleInputChange}
initialValue={initialInputValue}
onContentChange={handleContentChange}
selectedModel={selectedModel}
onModelChange={handleModelChange}
modelOptions={modelOptions}