feat(desktop): inline @-mentions as ProseMirror pills in the composer

The composer was a plain textarea, so @-selected files and folders could
not render inline: they became attachment chips above the input and were
sent as a detached @"path" prefix. The composer now runs on ProseMirror
(ChatGPT desktop's architecture): @ selections insert an inline mention
pill (an atom node carrying label/path/isDirectory), and the pill is
serialized back to the @"absolute path" text the CLI already parses at
its exact position in the prompt.

- MentionComposer + composerEditor: schema, doc<->projection mapping,
  controlled bridge (doc changes project to text+mentions; external
  writes rebuild the editor state, resetting undo history), single-
  backspace whole-pill deletion, placeholder/disabled/aria plumbing
- composerMentions: tokenOrdinal keeps pills distinct from literal text
  that happens to spell the same token, so serialization and rebuilds
  never rewrite the wrong occurrence
- ChatInput and EmptySession composers both migrated; drafts persist
  mentions; drag-and-drop attachment chips unchanged
This commit is contained in:
程序员阿江(Relakkes)
2026-08-03 21:56:05 +08:00
parent 1084540fd2
commit 0c9dc0edd5
17 changed files with 1521 additions and 520 deletions
+26
View File
@@ -21,6 +21,12 @@
"mermaid": "^11.14.0",
"node-pty": "^1.1.0",
"prism-react-renderer": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-history": "^1.5.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.11",
"prosemirror-state": "^1.4.4",
"prosemirror-view": "^1.42.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-diff-viewer-continued": "^4.2.0",
@@ -1202,6 +1208,8 @@
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"orderedmap": ["orderedmap@2.1.1", "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
"p-cancelable": ["p-cancelable@2.1.1", "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -1276,6 +1284,20 @@
"property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"prosemirror-commands": ["prosemirror-commands@1.7.1", "https://registry.npmmirror.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="],
"prosemirror-history": ["prosemirror-history@1.5.0", "https://registry.npmmirror.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="],
"prosemirror-keymap": ["prosemirror-keymap@1.2.3", "https://registry.npmmirror.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="],
"prosemirror-model": ["prosemirror-model@1.25.11", "https://registry.npmmirror.com/prosemirror-model/-/prosemirror-model-1.25.11.tgz", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ=="],
"prosemirror-state": ["prosemirror-state@1.4.4", "https://registry.npmmirror.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="],
"prosemirror-transform": ["prosemirror-transform@1.12.0", "https://registry.npmmirror.com/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="],
"prosemirror-view": ["prosemirror-view@1.42.2", "https://registry.npmmirror.com/prosemirror-view/-/prosemirror-view-1.42.2.tgz", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ=="],
"pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -1330,6 +1352,8 @@
"rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"rope-sequence": ["rope-sequence@1.3.4", "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
"roughjs": ["roughjs@4.6.6", "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"rrweb-cssom": ["rrweb-cssom@0.7.1", "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", {}, "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="],
@@ -1520,6 +1544,8 @@
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
+6
View File
@@ -118,6 +118,12 @@
"mermaid": "^11.14.0",
"node-pty": "^1.1.0",
"prism-react-renderer": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-history": "^1.5.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.11",
"prosemirror-state": "^1.4.4",
"prosemirror-view": "^1.42.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-diff-viewer-continued": "^4.2.0",
+25 -29
View File
@@ -74,6 +74,7 @@ vi.mock('../api/sessions', async (importOriginal) => {
// Import all pages
import { EmptySession } from '../pages/EmptySession'
import { getComposerElement, setComposerText } from '../components/chat/composerTestUtils'
import { ActiveSession } from '../pages/ActiveSession'
import { ScheduledTasks } from '../pages/ScheduledTasks'
@@ -144,9 +145,7 @@ describe('Content-only pages render without errors', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/', selectionStart: 1 },
})
setComposerText('/', 1)
expect(await screen.findByText('lark-mail')).toBeInTheDocument()
expect(screen.getByText('mcp')).toBeInTheDocument()
@@ -163,9 +162,7 @@ describe('Content-only pages render without errors', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/goal', selectionStart: 5 },
})
setComposerText('/goal', 5)
expect(await screen.findByRole('option', { name: /^goal / })).toBeInTheDocument()
expect(screen.getByText('[<condition> | clear]')).toBeInTheDocument()
@@ -181,7 +178,7 @@ describe('Content-only pages render without errors', () => {
await Promise.resolve()
await Promise.resolve()
})
expect(container.querySelector('textarea')).toBeInTheDocument()
expect(container.querySelector('[data-composer-editor]')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session')
expect(container.innerHTML).toContain('Ask anything')
})
@@ -270,11 +267,10 @@ describe('Content-only pages render without errors', () => {
const { container } = render(<ActiveSession />)
// With empty messages, the hero is shown
expect(container.innerHTML).toContain('New session')
// ChatInput has a textarea
const textarea = container.querySelector('textarea')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveAttribute('placeholder', 'Ask anything...')
expect(textarea).toHaveAttribute('rows', '2')
// ChatInput renders the ProseMirror composer
const composer = container.querySelector('[data-composer-editor]')
expect(composer).toBeInTheDocument()
expect(composer).toHaveAttribute('data-placeholder', 'Ask anything...')
expect(container.innerHTML).not.toContain('Preview')
// Cleanup
resetPageStores()
@@ -329,8 +325,8 @@ describe('Content-only pages render without errors', () => {
render(<ActiveSession />)
const textarea = screen.getByPlaceholderText('Ask Claude to edit, debug or explain...')
expect(textarea).toHaveAttribute('rows', '1')
const composer = screen.getByRole('textbox')
expect(composer).toHaveAttribute('data-placeholder', 'Ask Claude to edit, debug or explain...')
resetPageStores()
})
@@ -432,9 +428,9 @@ describe('Content-only pages render without errors', () => {
render(<ActiveSession />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: '/mcp', selectionStart: 4 } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
const composer = getComposerElement()
setComposerText('/mcp', 4)
fireEvent.keyDown(composer, { key: 'Enter', code: 'Enter' })
expect(sendMessage).not.toHaveBeenCalled()
expect(await screen.findByText('Available MCP tools')).toBeInTheDocument()
@@ -503,9 +499,9 @@ describe('Content-only pages render without errors', () => {
render(<ActiveSession />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: '/skills', selectionStart: 7 } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
const composer = getComposerElement()
setComposerText('/skills', 7)
fireEvent.keyDown(composer, { key: 'Enter', code: 'Enter' })
expect(sendMessage).not.toHaveBeenCalled()
expect(await screen.findByText('Available skills')).toBeInTheDocument()
@@ -560,9 +556,9 @@ describe('Content-only pages render without errors', () => {
render(<ActiveSession />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: '/plugin', selectionStart: 7 } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
const composer = getComposerElement()
setComposerText('/plugin', 7)
fireEvent.keyDown(composer, { key: 'Enter', code: 'Enter' })
expect(sendMessage).not.toHaveBeenCalled()
expect(useTabStore.getState().activeTabId).toBe('__settings__')
@@ -623,9 +619,9 @@ describe('Content-only pages render without errors', () => {
render(<ActiveSession />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: '/help', selectionStart: 5 } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
const composer = getComposerElement()
setComposerText('/help', 5)
fireEvent.keyDown(composer, { key: 'Enter', code: 'Enter' })
expect(sendMessage).not.toHaveBeenCalled()
expect(screen.getByText('Slash commands')).toBeInTheDocument()
@@ -681,9 +677,9 @@ describe('Content-only pages render without errors', () => {
})
const { container } = render(<ActiveSession />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: '/status', selectionStart: 7 } })
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
const composer = getComposerElement()
setComposerText('/status', 7)
fireEvent.keyDown(composer, { key: 'Enter', code: 'Enter' })
expect(sendMessage).not.toHaveBeenCalled()
expect(await screen.findByText('Session inspector')).toBeInTheDocument()
+213 -201
View File
@@ -99,6 +99,7 @@ vi.mock('../controls/ModelSelector', async () => {
})
import { ChatInput } from './ChatInput'
import { getComposerElement, getComposerText, setComposerSelection, setComposerText } from './composerTestUtils'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
@@ -311,9 +312,12 @@ describe('ChatInput file mentions', () => {
render(<ChatInput />)
expect(screen.getByPlaceholderText(
const editor = screen.getByRole('textbox')
expect(editor).toHaveAttribute(
'data-placeholder',
'This temporary workspace was cleaned up. Start a new session in the original project to continue.',
)).toBeDisabled()
)
expect(editor).toHaveAttribute('contenteditable', 'false')
})
it('passes diff metadata to the composer card and clears the reference after send', async () => {
@@ -424,30 +428,25 @@ describe('ChatInput file mentions', () => {
render(<ChatInput variant="hero" />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'new tab draft', selectionStart: 13 },
})
expect(input.value).toBe('new tab draft')
setComposerText('new tab draft', 13)
expect(getComposerText()).toBe('new tab draft')
act(() => {
useTabStore.setState({ activeTabId: historySessionId })
})
await waitFor(() => {
expect(input.value).toBe('')
expect(getComposerText()).toBe('')
})
fireEvent.change(input, {
target: { value: 'history tab draft', selectionStart: 17 },
})
setComposerText('history tab draft', 17)
act(() => {
useTabStore.setState({ activeTabId: sessionId })
})
await waitFor(() => {
expect(input.value).toBe('new tab draft')
expect(getComposerText()).toBe('new tab draft')
})
act(() => {
@@ -455,7 +454,7 @@ describe('ChatInput file mentions', () => {
})
await waitFor(() => {
expect(input.value).toBe('history tab draft')
expect(getComposerText()).toBe('history tab draft')
})
})
@@ -508,10 +507,7 @@ describe('ChatInput file mentions', () => {
render(<ChatInput variant="hero" />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'draft before switching project', selectionStart: 30 },
})
setComposerText('draft before switching project', 30)
await openLocationMenu()
fireEvent.click(screen.getAllByTitle('/repo')[0]!)
@@ -525,34 +521,28 @@ describe('ChatInput file mentions', () => {
await waitFor(() => {
expect(useTabStore.getState().activeTabId).toBe('session-project-switch')
})
expect(input.value).toBe('draft before switching project')
expect(getComposerText()).toBe('draft before switching project')
})
it('restores an unsent composer draft after the composer unmounts', async () => {
const { unmount } = render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'keep this prompt while I inspect another tab', selectionStart: 43 },
})
expect(input.value).toBe('keep this prompt while I inspect another tab')
setComposerText('keep this prompt while I inspect another tab', 43)
expect(getComposerText()).toBe('keep this prompt while I inspect another tab')
unmount()
render(<ChatInput compact />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('keep this prompt while I inspect another tab')
expect(getComposerText()).toBe('keep this prompt while I inspect another tab')
})
})
it('appends a delayed browser screenshot without clearing an unsent draft after remount', async () => {
const { unmount } = render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'draft written while the agent is still running', selectionStart: 44 },
})
expect(input.value).toBe('draft written while the agent is still running')
setComposerText('draft written while the agent is still running', 44)
expect(getComposerText()).toBe('draft written while the agent is still running')
unmount()
@@ -572,7 +562,7 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('draft written while the agent is still running')
expect(getComposerText()).toBe('draft written while the agent is still running')
expect(screen.getByAltText('screenshot-full.png')).toBeInTheDocument()
})
})
@@ -638,16 +628,13 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'please adjust the current direction', selectionStart: 35 },
})
fireEvent.keyDown(input, { key: 'Enter' })
setComposerText('please adjust the current direction', 35)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalledWith(sessionId, expect.objectContaining({
type: 'user_message',
}))
expect(input.value).toBe('')
expect(getComposerText()).toBe('')
expect(screen.getByTestId('pending-user-message')).toHaveTextContent('please adjust the current direction')
fireEvent.click(screen.getByRole('button', { name: /Guide now/i }))
@@ -708,11 +695,8 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'first queued draft', selectionStart: 18 },
})
fireEvent.keyDown(input, { key: 'Enter' })
setComposerText('first queued draft', 18)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: /Edit queued message/i }))
const editInput = screen.getByLabelText('Queued message text')
@@ -758,11 +742,8 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'continue after completion', selectionStart: 25 },
})
fireEvent.keyDown(input, { key: 'Enter' })
setComposerText('continue after completion', 25)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(screen.getByTestId('pending-user-message')).toHaveTextContent('continue after completion')
expect(mocks.wsSend).not.toHaveBeenCalledWith(sessionId, expect.objectContaining({
@@ -955,10 +936,7 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox')
fireEvent.change(input, {
target: { value: 'continue while the agent runs', selectionStart: 29 },
})
setComposerText('continue while the agent runs', 29)
const run = screen.getByRole('button', { name: 'Run' })
const stop = screen.getByRole('button', { name: 'Stop' })
@@ -1146,9 +1124,8 @@ describe('ChatInput file mentions', () => {
await openBranchList()
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'run on feature branch', selectionStart: 21 } })
fireEvent.keyDown(input, { key: 'Enter' })
setComposerText('run on feature branch', 21)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
await waitFor(() => {
expect(mocks.create).toHaveBeenCalledWith({
@@ -1208,9 +1185,8 @@ describe('ChatInput file mentions', () => {
await openBranchList()
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'run with preserved permissions', selectionStart: 30 } })
fireEvent.keyDown(input, { key: 'Enter' })
setComposerText('run with preserved permissions', 30)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
await waitFor(() => {
expect(mocks.create).toHaveBeenCalledWith({
@@ -1277,9 +1253,8 @@ describe('ChatInput file mentions', () => {
// one click away — no second menu to open.
fireEvent.click(await screen.findByRole('menuitemradio', { name: /Isolated worktree/ }))
expect(await screen.findByText('Isolated')).toBeInTheDocument()
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'run in a worktree', selectionStart: 17 } })
fireEvent.keyDown(input, { key: 'Enter' })
setComposerText('run in a worktree', 17)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
await waitFor(() => {
expect(mocks.create).toHaveBeenCalledWith({
@@ -1297,7 +1272,63 @@ describe('ChatInput file mentions', () => {
.toBe('/repo/.claude/worktrees/desktop-feature-a-12345678')
})
it('turns a selected @ file into a chip without corrupting the typed path', async () => {
it('keeps mention pills in the unsent draft across session tab switches', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'backend',
entries: [
{ name: 'backend', path: '/repo/backend', relativePath: 'backend', isDirectory: true },
],
})
const historySessionId = 'history-session'
useTabStore.setState({
activeTabId: sessionId,
tabs: [
{ sessionId, title: 'New session', type: 'session', status: 'idle' },
{ sessionId: historySessionId, title: 'History session', type: 'session', status: 'idle' },
],
})
useChatStore.setState({
sessions: {
[sessionId]: {
...useChatStore.getState().sessions[sessionId]!,
},
[historySessionId]: {
...useChatStore.getState().sessions[sessionId]!,
},
},
})
render(<ChatInput compact />)
setComposerText('总结一下 @backend 这个目录', '总结一下 @backend'.length)
fireEvent.click(await screen.findByRole('option', { name: /backend/i }))
await waitFor(() => {
expect(getComposerText()).toBe('总结一下 @backend/ 这个目录')
})
act(() => {
useTabStore.setState({ activeTabId: historySessionId })
})
await waitFor(() => {
expect(getComposerText()).toBe('')
})
act(() => {
useTabStore.setState({ activeTabId: sessionId })
})
// The pill, not just its text: path and directory attrs survive the round
// trip through the composer draft.
await waitFor(() => {
expect(getComposerText()).toBe('总结一下 @backend/ 这个目录')
})
expect(document.querySelector('.composer-mention')).toHaveAttribute('data-mention-path', '/repo/backend')
})
it('inserts a selected @ file as an inline mention pill and sends its absolute path', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo/backend/src',
parentPath: '/repo/backend',
@@ -1309,59 +1340,38 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
const mention = '@backend/src/conditions.py'
fireEvent.change(input, {
target: {
value: `${mention} 记一下这个文件讲了什么东西。`,
selectionStart: mention.length,
},
})
setComposerText(`${mention} 记一下这个文件讲了什么东西。`, mention.length)
fireEvent.click(await screen.findByText('backend/src/conditions.py'))
// The trigger text becomes an inline pill — no attachment chip is added.
await waitFor(() => {
expect(input.value).toBe('记一下这个文件讲了什么东西。')
expect(getComposerText()).toBe('@conditions.py 记一下这个文件讲了什么东西。')
})
expect(screen.getByText('conditions.py')).toBeInTheDocument()
expect(document.querySelector('.composer-mention')).toHaveTextContent('@conditions.py')
expect(screen.queryByTestId('attachment-chip')).not.toBeInTheDocument()
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: '记一下这个文件讲了什么东西。',
attachments: [{
type: 'file',
name: 'conditions.py',
path: '/repo/backend/src/conditions.py',
isDirectory: false,
lineStart: undefined,
lineEnd: undefined,
note: undefined,
quote: undefined,
}],
content: '@"/repo/backend/src/conditions.py" 记一下这个文件讲了什么东西。',
attachments: [],
})
const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
expect(messages[messages.length - 1]).toMatchObject({
type: 'user_text',
content: '记一下这个文件讲了什么东西。',
content: '@conditions.py 记一下这个文件讲了什么东西。',
modelContent: '@"/repo/backend/src/conditions.py" 记一下这个文件讲了什么东西。',
attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }],
})
expect(getComposerText()).toBe('')
})
it('inserts queued inline workspace citations at the current cursor and keeps file context attached', async () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '请看实现',
selectionStart: 2,
selectionEnd: 2,
},
})
input.setSelectionRange(2, 2)
setComposerText('请看实现', 2)
act(() => {
useChatStore.getState().queueComposerInsertion(sessionId, {
@@ -1376,7 +1386,7 @@ describe('ChatInput file mentions', () => {
})
await waitFor(() => {
expect(input.value).toBe('请看 @"src/App.tsx" 实现')
expect(getComposerText()).toBe('请看 @"src/App.tsx" 实现')
})
expect(screen.getByText('App.tsx')).toBeInTheDocument()
expect(useWorkspaceChatContextStore.getState().referencesBySession[sessionId]).toMatchObject([
@@ -1388,7 +1398,7 @@ describe('ChatInput file mentions', () => {
},
])
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
@@ -1413,7 +1423,7 @@ describe('ChatInput file mentions', () => {
})
})
it('turns a selected @ directory into a workspace chip and model path reference', async () => {
it('inserts a selected @ directory as an inline mention pill and sends its absolute path', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
@@ -1425,44 +1435,97 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '@backend 讲一下这个目录。',
selectionStart: '@backend'.length,
},
})
setComposerText('@backend 讲一下这个目录。', '@backend'.length)
fireEvent.click(await screen.findByRole('option', { name: /backend/i }))
await waitFor(() => {
expect(input.value).toBe('讲一下这个目录。')
expect(getComposerText()).toBe('@backend/ 讲一下这个目录。')
})
expect(screen.getByText('backend/')).toBeInTheDocument()
expect(screen.getByText('folder')).toBeInTheDocument()
const pill = document.querySelector('.composer-mention')
expect(pill).toHaveTextContent('@backend/')
expect(pill).toHaveAttribute('data-mention-path', '/repo/backend')
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: '讲一下这个目录。',
attachments: [{
type: 'file',
name: 'backend/',
path: '/repo/backend',
isDirectory: true,
lineStart: undefined,
lineEnd: undefined,
note: undefined,
quote: undefined,
}],
content: '@"/repo/backend" 讲一下这个目录。',
attachments: [],
})
const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
expect(messages[messages.length - 1]).toMatchObject({
type: 'user_text',
content: '讲一下这个目录。',
content: '@backend/ 讲一下这个目录。',
modelContent: '@"/repo/backend" 讲一下这个目录。',
attachments: [{ name: 'backend/', path: '/repo/backend' }],
})
})
it('serializes only the pill when the same token also exists as literal text', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'main',
entries: [
{ name: 'main.ts', path: '/repo/src/main.ts', isDirectory: false },
],
})
render(<ChatInput compact />)
// The first `@main.ts` is literal text the user typed; only the second
// one goes through the picker and becomes a pill.
setComposerText('@main.ts 对比 @main', '@main.ts 对比 @main'.length)
fireEvent.click(await screen.findByRole('option', { name: /main\.ts/ }))
await waitFor(() => {
expect(getComposerText()).toBe('@main.ts 对比 @main.ts ')
})
expect(document.querySelectorAll('.composer-mention')).toHaveLength(1)
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
// The literal occurrence stays verbatim; the pill — and only the pill —
// becomes the @"absolute path" form.
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: '@main.ts 对比 @"/repo/src/main.ts"',
attachments: [],
})
})
it('deletes a mention pill atomically with Backspace', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'backend',
entries: [
{ name: 'backend', path: '/repo/backend', relativePath: 'backend', isDirectory: true },
],
})
render(<ChatInput compact />)
setComposerText('看下 @backend 这个目录', '看下 @backend'.length)
fireEvent.click(await screen.findByRole('option', { name: /backend/i }))
await waitFor(() => {
expect(getComposerText()).toBe('看下 @backend/ 这个目录')
})
// Caret directly after the atom: one Backspace removes the whole pill —
// it can never be half-deleted character by character.
setComposerSelection('看下 @backend/'.length)
fireEvent.keyDown(getComposerElement(), { key: 'Backspace' })
expect(getComposerText()).toBe('看下 这个目录')
expect(document.querySelector('.composer-mention')).not.toBeInTheDocument()
// Nothing to serialize any more: the path is gone from the model text too.
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: '看下 这个目录',
attachments: [],
})
})
@@ -1481,13 +1544,8 @@ describe('ChatInput file mentions', () => {
expect(await screen.findByText('large-a.log')).toBeInTheDocument()
expect(await screen.findByText('large-b.zip')).toBeInTheDocument()
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: 'analyze these',
selectionStart: 'analyze these'.length,
},
})
const input = getComposerElement()
setComposerText('analyze these', 'analyze these'.length)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
@@ -1555,13 +1613,8 @@ describe('ChatInput file mentions', () => {
expect(await screen.findByText('large-a.log')).toBeInTheDocument()
expect(screen.queryByTestId('chat-input-drop-overlay')).not.toBeInTheDocument()
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: 'analyze dropped file',
selectionStart: 'analyze dropped file'.length,
},
})
const input = getComposerElement()
setComposerText('analyze dropped file', 'analyze dropped file'.length)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
@@ -1588,10 +1641,13 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
const input = getComposerElement()
fireEvent.paste(input, {
clipboardData: {
files: [],
// ProseMirror reads text data before consulting our paste handler, so
// the stub has to answer like a real DataTransfer.
getData: () => '',
items: [{
kind: 'file',
type: 'text/markdown',
@@ -1602,12 +1658,7 @@ describe('ChatInput file mentions', () => {
expect(await screen.findByText('project-notes.md')).toBeInTheDocument()
fireEvent.change(input, {
target: {
value: 'review this document',
selectionStart: 'review this document'.length,
},
})
setComposerText('review this document', 'review this document'.length)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
@@ -1638,12 +1689,15 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
const input = getComposerElement()
const file = new File(['image'], 'late.png', { type: 'image/png' })
fireEvent.paste(input, {
clipboardData: {
files: [],
// ProseMirror reads text data before consulting our paste handler, so
// the stub has to answer like a real DataTransfer.
getData: () => '',
items: [{
kind: 'file',
type: 'image/png',
@@ -1653,12 +1707,7 @@ describe('ChatInput file mentions', () => {
})
expect(pendingReaders).toHaveLength(1)
fireEvent.change(input, {
target: {
value: 'send now',
selectionStart: 'send now'.length,
},
})
setComposerText('send now', 'send now'.length)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
@@ -1690,24 +1739,13 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
const panel = screen.getByTestId('chat-input-panel')
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/',
selectionStart: 1,
},
})
setComposerText('/', 1)
expect(await screen.findByText('mcp')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
fireEvent.change(input, {
target: {
value: '@readme',
selectionStart: 7,
},
})
setComposerText('@readme', 7)
expect(await screen.findByText('README.md')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
@@ -1730,9 +1768,7 @@ describe('ChatInput file mentions', () => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'ship it', selectionStart: 7 },
})
setComposerText('ship it', 7)
expect(screen.getByRole('button', { name: 'Open composer tools' })).toHaveClass('h-11', 'w-11')
expect(screen.getByRole('button', { name: 'Run' })).toHaveClass('h-11', 'w-11')
@@ -1750,9 +1786,7 @@ describe('ChatInput file mentions', () => {
expect(screen.getByTestId('chat-input-toolbar-trailing')).toHaveClass('min-w-0', 'flex-1', 'justify-end', 'gap-1')
expect(screen.getByTestId('model-selector-shell')).toHaveClass('min-w-0', 'flex-1')
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '@cond', selectionStart: 5 },
})
setComposerText('@cond', 5)
expect(await screen.findByText('conditions.py')).toBeInTheDocument()
const fileSearchMenu = document.getElementById('file-search-menu')
@@ -1815,13 +1849,8 @@ describe('ChatInput file mentions', () => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: 'avoid accidental sends',
selectionStart: 'avoid accidental sends'.length,
},
})
const input = getComposerElement()
setComposerText('avoid accidental sends', 'avoid accidental sends'.length)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalled()
@@ -1845,19 +1874,14 @@ describe('ChatInput file mentions', () => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/model',
selectionStart: 6,
},
})
const input = getComposerElement()
setComposerText('/model', 6)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(await screen.findByTestId('model-selector-dropdown')).toHaveTextContent('Model selector opened')
expect(input).toHaveValue('')
expect(getComposerText()).toBe('')
})
it('prioritizes active-session slash commands by command name when filtering', async () => {
@@ -1895,9 +1919,7 @@ describe('ChatInput file mentions', () => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/su', selectionStart: 3 },
})
setComposerText('/su', 3)
await waitFor(() => {
const commandButtons = screen
@@ -1938,10 +1960,7 @@ describe('ChatInput file mentions', () => {
render(<ChatInput />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: '/', selectionStart: 1 },
})
setComposerText('/', 1)
const systemCommand = await screen.findByText('mcp')
const futureNativeCommand = screen.getByText('future-native-command')
@@ -1989,15 +2008,12 @@ describe('ChatInput file mentions', () => {
expect(mocks.listAgents).toHaveBeenCalledWith('/repo')
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: '/debug', selectionStart: 6 },
})
setComposerText('/debug', 6)
const agentOption = await screen.findByText('agent debugger')
fireEvent.click(agentOption)
expect(input).toHaveValue('/agent debugger ')
expect(getComposerText()).toBe('/agent debugger ')
})
it('selects a highlighted agent entry from /agent without sending until the configured send shortcut is used', async () => {
@@ -2023,22 +2039,18 @@ describe('ChatInput file mentions', () => {
expect(mocks.listAgents).toHaveBeenCalledWith('/repo')
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: '/agent', selectionStart: 6 },
})
const input = getComposerElement()
setComposerText('/agent', 6)
await screen.findByText('agent debugger')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(input).toHaveValue('/agent debugger ')
expect(getComposerText()).toBe('/agent debugger ')
expect(mocks.wsSend).not.toHaveBeenCalled()
const prompt = '/agent debugger investigate this failure'
fireEvent.change(input, {
target: { value: prompt, selectionStart: prompt.length },
})
setComposerText(prompt, prompt.length)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalled()
+136 -127
View File
@@ -50,6 +50,12 @@ import {
} from '../../lib/composerAttachments'
import { useComposerFileDrop } from './useComposerFileDrop'
import { shouldSubmitOnEnter } from './sendShortcut'
import { MentionComposer, type MentionComposerHandle } from './MentionComposer'
import {
findMentionRanges,
insertMentionIntoText,
type ComposerMention,
} from '../../lib/composerMentions'
import type { PermissionMode } from '../../types/settings'
import { getSessionWorkspaceState } from '../../lib/sessionWorkspace'
import { hasRunningSubagentTasks } from '../../lib/backgroundTasks'
@@ -124,6 +130,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
// itself across the threshold. The shell just fills the chat column.
const [shellRef, shellWidth] = useElementWidth<HTMLDivElement>()
const [input, setInput] = useState('')
const [mentions, setMentions] = useState<ComposerMention[]>([])
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
@@ -142,7 +149,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const [editingQueuedMessageId, setEditingQueuedMessageId] = useState<string | null>(null)
const [editingQueuedMessageText, setEditingQueuedMessageText] = useState('')
const composingRef = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const composerRef = useRef<MentionComposerHandle>(null)
const composerContainerRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
@@ -153,11 +161,16 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const slashMenuId = useId()
const previousActiveTabIdRef = useRef<string | null>(null)
const inputRef = useRef(input)
const mentionsRef = useRef(mentions)
const attachmentsRef = useRef(attachments)
const pasteGenerationRef = useRef(0)
const setComposerInput = useCallback((value: string) => {
const setComposerInput = useCallback((value: string, nextMentions?: ComposerMention[]) => {
inputRef.current = value
setInput(value)
if (nextMentions !== undefined) {
mentionsRef.current = nextMentions
setMentions(nextMentions)
}
}, [])
const setComposerAttachments = useCallback((value: Attachment[] | ((previous: Attachment[]) => Attachment[])) => {
setAttachments((previous) => {
@@ -207,6 +220,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const draft = {
input: inputRef.current,
attachments: attachmentsRef.current,
mentions: mentionsRef.current,
}
const chatStore = useChatStore.getState()
if (draft.input.length === 0 && draft.attachments.length === 0) {
@@ -292,7 +306,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const nextDraft = activeTabId ? useChatStore.getState().sessions[activeTabId]?.composerDraft : undefined
invalidatePendingPastes()
setComposerInput(nextDraft?.input ?? '')
setComposerInput(nextDraft?.input ?? '', nextDraft?.mentions ?? [])
setComposerAttachments(nextDraft?.attachments ?? [])
setPlusMenuOpen(false)
setSlashMenuOpen(false)
@@ -314,7 +328,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}, [saveComposerDraft])
useEffect(() => {
textareaRef.current?.focus()
mentionsRef.current = mentions
}, [mentions])
useEffect(() => {
composerRef.current?.focus()
}, [isActive])
useEffect(() => {
@@ -334,7 +352,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (composerPrefill.mode === 'append') {
setComposerAttachments((previous) => [...previous, ...nextAttachments])
} else {
setComposerInput(composerPrefill.text)
setComposerInput(composerPrefill.text, [])
setComposerAttachments(nextAttachments)
}
setPlusMenuOpen(false)
@@ -345,11 +363,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setAtCursorPos(-1)
requestAnimationFrame(() => {
const el = textareaRef.current
el?.focus()
composerRef.current?.focus()
if (composerPrefill.mode !== 'append') {
const cursor = composerPrefill.text.length
el?.setSelectionRange(cursor, cursor)
composerRef.current?.setSelectionOffsets(composerPrefill.text.length)
}
})
clearComposerPrefill(activeTabId, composerPrefill.nonce)
@@ -364,10 +380,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
useEffect(() => {
if (!composerInsertion || !activeTabId || isMemberSession) return
const el = textareaRef.current
const currentInput = inputRef.current
const start = el?.selectionStart ?? currentInput.length
const end = el?.selectionEnd ?? start
const offsets = composerRef.current?.getSelectionOffsets()
const start = offsets?.start ?? currentInput.length
const end = offsets?.end ?? start
const next = insertComposerTokenAtRange(currentInput, start, end, composerInsertion.text)
if (composerInsertion.reference) {
@@ -381,8 +397,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
clearComposerInsertion(activeTabId, composerInsertion.nonce)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(next.cursorPos, next.cursorPos)
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(next.cursorPos)
})
}, [
activeTabId,
@@ -456,13 +472,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
})
}, [activeSession?.workDir, activeTabId, gitInfo?.workDir, showLaunchControls])
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
}, [input])
useDismissable({
open: plusMenuOpen,
refs: [plusMenuRef],
@@ -471,19 +480,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
useDismissable({
open: slashMenuOpen,
refs: [slashMenuRef, textareaRef],
refs: [slashMenuRef, composerContainerRef],
onDismiss: () => setSlashMenuOpen(false),
})
useDismissable({
open: !!localSlashPanel,
refs: [slashMenuRef, textareaRef],
refs: [slashMenuRef, composerContainerRef],
onDismiss: () => setLocalSlashPanel(null),
})
useDismissable({
open: fileSearchOpen,
refs: [textareaRef],
refs: [composerContainerRef],
onDismiss: () => setFileSearchOpen(false),
// This menu is looked up by id rather than held in a ref. Returning true
// when it is absent preserves the original behavior: with no menu in the
@@ -539,8 +548,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setSlashMenuOpen(true)
}, [])
// Detect @ trigger (file search)
const detectAtTrigger = useCallback((value: string, cursorPos: number) => {
// Detect @ trigger (file search). The scan runs on the projected text; an
// `@` that belongs to an existing mention pill never counts as a trigger.
const detectAtTrigger = useCallback((value: string, cursorPos: number, currentMentions: ComposerMention[]) => {
const textBeforeCursor = value.slice(0, cursorPos)
let pos = -1
@@ -558,6 +568,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}
}
if (pos >= 0 && findMentionRanges(value, currentMentions).some((range) => pos >= range.start && pos < range.end)) {
pos = -1
}
if (pos < 0) {
setFileSearchOpen(false)
setAtFilter('')
@@ -573,28 +587,24 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setFileSearchOpen(true)
}, [])
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
const handleComposerChange = (text: string, nextMentions: ComposerMention[]) => {
setComposerInput(text, nextMentions)
if (isMemberSession) {
setComposerInput(value)
return
}
const cursorPos = event.target.selectionStart ?? value.length
setComposerInput(value)
detectSlashTrigger(value, cursorPos)
detectAtTrigger(value, cursorPos)
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? text.length
detectSlashTrigger(text, cursorPos)
detectAtTrigger(text, cursorPos, nextMentions)
}
const selectSlashCommand = useCallback((command: string) => {
const el = textareaRef.current
if (!el) return
const cursorPos = el.selectionStart ?? input.length
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const replacement = replaceSlashToken(input, cursorPos, command)
setComposerInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
})
}, [input])
@@ -660,7 +670,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (pendingSlashUiAction?.type === 'panel') {
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
setComposerInput('')
setComposerInput('', [])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -670,7 +680,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (pendingSlashUiAction?.type === 'settings') {
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
setComposerInput('')
setComposerInput('', [])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -679,7 +689,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (pendingSlashUiAction?.type === 'model') {
modelSelectorRef.current?.open()
setComposerInput('')
setComposerInput('', [])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -691,7 +701,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const workspaceReferencePrompt = !isMemberSession
? formatWorkspaceReferencePrompt(workspaceReferences)
: ''
const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n')
// Inline @-mentions travel as the `@"absolute path"` text the CLI already
// parses. Serialized from the live document — only the doc knows which
// `@label` is a pill and which is literal text the user typed.
const serializedText = (composerRef.current?.getModelContent() ?? input).trim()
const contentForModel = [workspaceReferencePrompt, serializedText].filter(Boolean).join('\n\n')
const displayContent = text || (
workspaceReferences.length > 0
? t('chat.contextReferencesOnly', { count: workspaceReferences.length })
@@ -777,7 +791,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
})
}
invalidatePendingPastes()
setComposerInput('')
setComposerInput('', [])
setComposerAttachments([])
useChatStore.getState().clearComposerDraft(activeTabId!)
if (targetSessionId !== activeTabId) useChatStore.getState().clearComposerDraft(targetSessionId)
@@ -791,9 +805,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setLocalSlashPanel(null)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
const handleComposerKeyDown = (event: KeyboardEvent): boolean => {
// Ignore key events during IME composition (e.g. Chinese input method)
if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return
if (composingRef.current || event.isComposing || event.keyCode === 229) return false
// Route file search navigation keys to FileSearchMenu
if (fileSearchOpen) {
@@ -804,20 +818,20 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
return
return true
}
fileSearchRef.current?.handleKeyDown(event.nativeEvent)
return
fileSearchRef.current?.handleKeyDown(event)
return true
}
// Other keys (typing) should go to the textarea - let it propagate
return
// Other keys (typing) should go to the editor - let ProseMirror handle them
return false
}
if (localSlashPanel) {
if (event.key === 'Escape') {
event.preventDefault()
setLocalSlashPanel(null)
return
return true
}
}
@@ -825,12 +839,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
return
return true
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
return
return true
}
if (event.key === 'Enter') {
const selected = filteredCommands[slashSelectedIndex]
@@ -841,36 +855,38 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
shouldSubmitOnEnter(event, chatSendBehavior)
) {
event.preventDefault()
handleSubmit()
return
void handleSubmit()
return true
}
event.preventDefault()
if (selected) selectSlashCommand(selected.name)
return
return true
}
if (event.key === 'Tab') {
event.preventDefault()
const selected = filteredCommands[slashSelectedIndex]
if (selected) selectSlashCommand(selected.name)
return
return true
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashMenuOpen(false)
return
return true
}
}
if (shouldSubmitOnEnter(event, chatSendBehavior)) {
event.preventDefault()
handleSubmit()
void handleSubmit()
return true
}
return false
}
const handlePaste = (event: React.ClipboardEvent) => {
if (isMemberSession) return
const files = getDataTransferFiles(event.clipboardData)
if (files.length === 0) return
const handleComposerPaste = (event: ClipboardEvent): boolean => {
if (isMemberSession) return false
const files = event.clipboardData ? getDataTransferFiles(event.clipboardData) : []
if (files.length === 0) return false
event.preventDefault()
const pasteGeneration = pasteGenerationRef.current
@@ -885,6 +901,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
.catch((error) => {
console.warn('[attachments] Failed to read pasted files', error)
})
return true
}
const appendFiles = useCallback((files: FileList | File[]) => {
@@ -966,16 +983,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const insertSlashCommand = () => {
if (isMemberSession) return
const el = textareaRef.current
const cursorPos = el?.selectionStart ?? input.length
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
setComposerInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
})
}
@@ -1058,37 +1074,27 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setComposerInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(newCursorPos)
})
}}
onSelect={(path, name, isDirectory) => {
if (atCursorPos >= 0) {
const referenceName = name.split('/').filter(Boolean).pop() ?? name
const tokenEnd = atCursorPos + 1 + atFilter.length
const beforeToken = input.slice(0, atCursorPos)
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
const newValue = `${beforeToken}${spacer}${afterToken}`
const newCursorPos = atCursorPos + spacer.length
if (activeTabId) {
addWorkspaceReference(activeTabId, {
kind: 'file',
path,
absolutePath: path,
name: isDirectory ? `${referenceName}/` : referenceName,
isDirectory,
})
}
setComposerInput(newValue)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
void textareaRef.current?.focus()
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}
if (atCursorPos < 0) return
const referenceName = name.split('/').filter(Boolean).pop() ?? name
const tokenEnd = atCursorPos + 1 + atFilter.length
const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, {
label: isDirectory ? `${referenceName}/` : referenceName,
path,
isDirectory,
})
setComposerInput(inserted.text, inserted.mentions)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
void composerRef.current?.focus()
requestAnimationFrame(() => {
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}}
/>
)}
@@ -1236,53 +1242,56 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{isHeroComposer ? (
<div className="flex items-start gap-3">
<textarea
ref={textareaRef}
<MentionComposer
ref={composerRef}
rootRef={composerContainerRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
mentions={mentions}
onChange={handleComposerChange}
onKeyDown={handleComposerKeyDown}
onPaste={handleComposerPaste}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
role={isSlashMenuVisible ? 'combobox' : undefined}
aria-autocomplete={isSlashMenuVisible ? 'list' : undefined}
aria-expanded={isSlashMenuVisible ? true : undefined}
aria-controls={isSlashMenuVisible ? slashMenuId : undefined}
aria-activedescendant={isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined}
rows={2}
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
className="flex-1"
editorClassName="max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
aria={{
role: isSlashMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible ? 'list' : undefined,
'aria-expanded': isSlashMenuVisible ? 'true' : undefined,
'aria-controls': isSlashMenuVisible ? slashMenuId : undefined,
'aria-activedescendant': isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined,
}}
/>
</div>
) : (
<textarea
ref={textareaRef}
<MentionComposer
ref={composerRef}
rootRef={composerContainerRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
mentions={mentions}
onChange={handleComposerChange}
onKeyDown={handleComposerKeyDown}
onPaste={handleComposerPaste}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
role={isSlashMenuVisible ? 'combobox' : undefined}
aria-autocomplete={isSlashMenuVisible ? 'list' : undefined}
aria-expanded={isSlashMenuVisible ? true : undefined}
aria-controls={isSlashMenuVisible ? slashMenuId : undefined}
aria-activedescendant={isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined}
rows={1}
// `block`: a textarea is inline-block by default, so it carries a
// ~6px descender gap under it. The hero branch escapes it through
// its flex row; this one is a plain block child and was sitting
// 18px above the divider where the hero sits 12px.
className={`block w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${
editorClassName={`max-h-[200px] overflow-y-auto text-sm leading-relaxed text-[var(--color-text-primary)] ${
useCompactChrome ? 'py-1.5' : 'py-2'
}`}
aria={{
role: isSlashMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible ? 'list' : undefined,
'aria-expanded': isSlashMenuVisible ? 'true' : undefined,
'aria-controls': isSlashMenuVisible ? slashMenuId : undefined,
'aria-activedescendant': isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined,
}}
/>
)}
@@ -0,0 +1,266 @@
/**
* ProseMirror-backed composer editor.
*
* Replaces the plain <textarea> so @-mentions can render as inline pills
* (atom nodes) while everything upstream still speaks plain text: the parent
* owns a projected `value` string plus the `mentions` array, and this
* component keeps the PM document in sync with both directions.
*
* - User edits flow out through `onChange(projectedText, mentions)`.
* - Programmatic writes flow in through the `value`/`mentions` props; when
* they diverge from the last projection the doc is rebuilt (caret kept at
* the same projected offset).
*
* Keyboard and paste handling are delegated to the parent: `onKeyDown` /
* `onPaste` return `true` to mark the event handled (same contract as the old
* textarea handlers calling preventDefault).
*/
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { keymap } from 'prosemirror-keymap'
import { history, redo, undo } from 'prosemirror-history'
import { baseKeymap } from 'prosemirror-commands'
import {
buildComposerDoc,
composerSchema,
deleteAdjacentMentionAtom,
pmPosToTextOffset,
projectComposerDoc,
projectedDocLength,
serializeComposerDoc,
textOffsetToPmPos,
} from './composerEditor'
import type { ComposerMention } from '../../lib/composerMentions'
import { mentionsEqual } from '../../lib/composerMentions'
export type MentionComposerHandle = {
focus: () => void
/** Caret/selection as offsets in the projected plain text. */
getSelectionOffsets: () => { start: number; end: number }
setSelectionOffsets: (start: number, end?: number) => void
/**
* Content for the model: text with each mention pill serialized to
* `@"absolute path"`. Read from the live document, so literal text that
* merely looks like a pill's token is never rewritten.
*/
getModelContent: () => string
}
export type MentionComposerProps = {
value: string
mentions: ComposerMention[]
onChange: (text: string, mentions: ComposerMention[]) => void
onKeyDown?: (event: KeyboardEvent) => boolean
onPaste?: (event: ClipboardEvent) => boolean
onCompositionStart?: () => void
onCompositionEnd?: () => void
placeholder?: string
disabled?: boolean
/** Class for the outer wrapper div (layout concerns: flex-1, etc). */
className?: string
/** Class for the editable element itself (padding, font size, max-height). */
editorClassName?: string
/** Extra aria-* attributes applied to the editable element. */
aria?: Record<string, string | undefined>
/** Exposes the wrapper div for outside-press dismissal checks. */
rootRef?: React.Ref<HTMLDivElement>
}
/**
* Lets tests reach the live EditorView from the rendered DOM. Keyed by the
* editable element (`[data-composer-editor]`).
*/
const composerViewRegistry = new WeakMap<HTMLElement, EditorView>()
export function getComposerViewForTesting(element: HTMLElement | null): EditorView | undefined {
return element ? composerViewRegistry.get(element) : undefined
}
function syncEmptyState(view: EditorView, placeholder?: string) {
const doc = view.state.doc
const isEmpty = doc.childCount === 1 && doc.firstChild!.content.size === 0
if (isEmpty) {
view.dom.setAttribute('data-empty', 'true')
view.dom.setAttribute('data-placeholder', placeholder ?? '')
view.dom.setAttribute('aria-placeholder', placeholder ?? '')
} else {
view.dom.removeAttribute('data-empty')
view.dom.removeAttribute('data-placeholder')
view.dom.removeAttribute('aria-placeholder')
}
}
export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposerProps>(
function MentionComposer(props, ref) {
const {
value,
mentions,
onCompositionStart,
onCompositionEnd,
placeholder,
className,
editorClassName,
aria,
rootRef,
} = props
const containerRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
const propsRef = useRef(props)
propsRef.current = props
const lastProjectedRef = useRef({ text: value, mentions })
useEffect(() => {
const container = containerRef.current
if (!container) return
const view = new EditorView(container, {
state: EditorState.create({
schema: composerSchema,
doc: buildComposerDoc(propsRef.current.value, propsRef.current.mentions),
plugins: [
new Plugin({
key: new PluginKey('mention-composer-props'),
props: {
handleKeyDown: (_view, event) => propsRef.current.onKeyDown?.(event) ?? false,
handlePaste: (_view, event, _slice) => propsRef.current.onPaste?.(event) ?? false,
handleDOMEvents: {
// File drops belong to the composer's outer drop zone, which
// turns them into attachments. Left to ProseMirror, a drop
// that also carries text (some platforms attach the file
// name) would insert that text alongside the attachment.
drop: () => true,
},
},
}),
history(),
// Whole-pill deletion, ahead of baseKeymap's structural commands.
keymap({
'Backspace': deleteAdjacentMentionAtom('backward'),
'Delete': deleteAdjacentMentionAtom('forward'),
}),
keymap({ 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo }),
keymap(baseKeymap),
],
}),
dispatchTransaction: (tr) => {
const currentView = viewRef.current
if (!currentView) return
currentView.updateState(currentView.state.apply(tr))
if (tr.docChanged) {
const projected = projectComposerDoc(currentView.state.doc)
lastProjectedRef.current = projected
propsRef.current.onChange(projected.text, projected.mentions)
}
syncEmptyState(currentView, propsRef.current.placeholder)
},
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'data-composer-editor': 'true',
class: 'composer-pm',
},
editable: () => !propsRef.current.disabled,
})
viewRef.current = view
composerViewRegistry.set(view.dom, view)
syncEmptyState(view, propsRef.current.placeholder)
return () => {
composerViewRegistry.delete(view.dom)
viewRef.current = null
view.destroy()
}
}, [])
// External writes: the parent replaced the text (slash replacement,
// draft restore, submit, ...) — rebuild the doc, keeping the caret at the
// same projected offset. History starts over with the new state: mapping
// the old undo stack across a full replacement would let Cmd+Z resurrect
// already-sent text or apply another tab's steps to this draft.
useEffect(() => {
const view = viewRef.current
if (!view) return
const last = lastProjectedRef.current
if (value === last.text && mentionsEqual(mentions, last.mentions)) return
const previousAnchor = pmPosToTextOffset(view.state.doc, view.state.selection.anchor)
const doc = buildComposerDoc(value, mentions)
const nextAnchor = textOffsetToPmPos(doc, Math.min(previousAnchor, projectedDocLength(doc)))
lastProjectedRef.current = { text: value, mentions }
view.updateState(EditorState.create({
schema: composerSchema,
doc,
plugins: view.state.plugins,
selection: TextSelection.near(doc.resolve(nextAnchor)),
}))
syncEmptyState(view, propsRef.current.placeholder)
}, [value, mentions])
// Dynamic editor chrome: classes and aria attributes are not part of the
// document, so they are applied straight to the editable element.
useEffect(() => {
const view = viewRef.current
if (!view) return
view.dom.className = `composer-pm${editorClassName ? ` ${editorClassName}` : ''}`
}, [editorClassName])
useEffect(() => {
const view = viewRef.current
if (!view) return
syncEmptyState(view, placeholder)
}, [placeholder])
useEffect(() => {
const view = viewRef.current
if (!view || !aria) return
for (const [name, attributeValue] of Object.entries(aria)) {
if (attributeValue === undefined) view.dom.removeAttribute(name)
else view.dom.setAttribute(name, attributeValue)
}
}, [aria])
useImperativeHandle(ref, () => ({
focus: () => viewRef.current?.focus(),
getSelectionOffsets: () => {
const view = viewRef.current
if (!view) return { start: 0, end: 0 }
const { from, to } = view.state.selection
return {
start: pmPosToTextOffset(view.state.doc, from),
end: pmPosToTextOffset(view.state.doc, to),
}
},
setSelectionOffsets: (start, end = start) => {
const view = viewRef.current
if (!view) return
const docLength = projectedDocLength(view.state.doc)
const from = textOffsetToPmPos(view.state.doc, Math.min(start, docLength))
const to = textOffsetToPmPos(view.state.doc, Math.min(end, docLength))
const selection = from === to
? TextSelection.near(view.state.doc.resolve(from))
: TextSelection.create(view.state.doc, from, to)
view.dispatch(view.state.tr.setSelection(selection))
},
getModelContent: () => {
const view = viewRef.current
return view ? serializeComposerDoc(view.state.doc) : ''
},
}), [])
return (
<div
ref={(node) => {
containerRef.current = node
if (typeof rootRef === 'function') rootRef(node)
else if (rootRef) (rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node
}}
className={className}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
)
},
)
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
AT_MENTION_NODE,
buildComposerDoc,
pmPosToTextOffset,
projectComposerDoc,
projectedDocLength,
serializeComposerDoc,
textOffsetToPmPos,
} from './composerEditor'
import { findMentionRanges, mentionToken, type ComposerMention } from '../../lib/composerMentions'
const dirMention: ComposerMention = {
label: 'MediaCrawlerPro-Python/',
path: '/repo/MediaCrawlerPro-Python',
isDirectory: true,
tokenOrdinal: 0,
}
const fileMention: ComposerMention = {
label: 'main.ts',
path: '/repo/src/main.ts',
isDirectory: false,
tokenOrdinal: 0,
}
describe('buildComposerDoc / projectComposerDoc round trip', () => {
it('round-trips plain text without mentions', () => {
const projected = projectComposerDoc(buildComposerDoc('hello\nworld', []))
expect(projected).toEqual({ text: 'hello\nworld', mentions: [] })
})
it('round-trips mentions with their attrs intact', () => {
const text = `read ${mentionToken(dirMention)} and ${mentionToken(fileMention)}`
const projected = projectComposerDoc(buildComposerDoc(text, [dirMention, fileMention]))
expect(projected.text).toBe(text)
expect(projected.mentions).toEqual([dirMention, fileMention])
})
it('builds one paragraph per line', () => {
const doc = buildComposerDoc('a\nb\nc', [])
expect(doc.childCount).toBe(3)
})
it('creates an atom node per mention', () => {
const doc = buildComposerDoc(mentionToken(dirMention), [dirMention])
let atomCount = 0
doc.descendants((node) => {
if (node.type.name === AT_MENTION_NODE) atomCount += 1
return true
})
expect(atomCount).toBe(1)
})
it('drops mentions whose token is not in the text', () => {
const projected = projectComposerDoc(buildComposerDoc('no token here', [dirMention]))
expect(projected).toEqual({ text: 'no token here', mentions: [] })
})
})
describe('serializeComposerDoc', () => {
it('serializes atoms to @"path" and keeps text verbatim', () => {
const text = `read ${mentionToken(dirMention)}\nthen edit ${mentionToken(fileMention)}`
const doc = buildComposerDoc(text, [dirMention, fileMention])
expect(serializeComposerDoc(doc)).toBe(
'read @"/repo/MediaCrawlerPro-Python"\nthen edit @"/repo/src/main.ts"',
)
})
it('never rewrites literal text that merely looks like a pill token', () => {
// The user typed `@main.ts` by hand, then inserted a real pill for the
// same file after it. Only the pill may be serialized — the doc, unlike
// an in-order indexOf scan, knows which occurrence is the atom.
const text = `compare @main.ts with ${mentionToken(fileMention)}`
const doc = buildComposerDoc(text, [{ ...fileMention, tokenOrdinal: 1 }])
expect(serializeComposerDoc(doc)).toBe('compare @main.ts with @"/repo/src/main.ts"')
// …and the projection remembers which occurrence is the pill.
expect(projectComposerDoc(doc).mentions[0]!.tokenOrdinal).toBe(1)
})
it('returns plain text when the doc has no atoms', () => {
expect(serializeComposerDoc(buildComposerDoc('just text', []))).toBe('just text')
})
})
describe('pmPosToTextOffset / textOffsetToPmPos', () => {
const text = `ab ${mentionToken(dirMention)} cd\nef ${mentionToken(fileMention)} gh`
const doc = buildComposerDoc(text, [dirMention, fileMention])
it('round-trips every projected offset outside mention tokens', () => {
// Offsets strictly inside a token have no PM position (the atom is
// indivisible) and snap to the atom's end edge instead.
const tokenRanges = findMentionRanges(text, [dirMention, fileMention])
const insideToken = (offset: number) =>
tokenRanges.some((range) => offset > range.start && offset < range.end)
for (let offset = 0; offset <= text.length; offset += 1) {
if (insideToken(offset)) continue
const pmPos = textOffsetToPmPos(doc, offset)
expect(pmPosToTextOffset(doc, pmPos)).toBe(offset)
}
})
it('snaps offsets inside a token to the atom edge', () => {
const tokenStart = 3 // after "ab "
const tokenEnd = tokenStart + mentionToken(dirMention).length
expect(pmPosToTextOffset(doc, textOffsetToPmPos(doc, tokenStart + 2))).toBe(tokenEnd)
})
it('maps the full doc length both ways', () => {
expect(projectedDocLength(doc)).toBe(text.length)
expect(pmPosToTextOffset(doc, doc.content.size)).toBe(text.length)
})
it('counts mention tokens as their projected length', () => {
// Position right after the first atom ↔ offset right after its token.
const tokenEnd = 3 + mentionToken(dirMention).length
const pmPos = textOffsetToPmPos(doc, tokenEnd)
expect(pmPosToTextOffset(doc, pmPos)).toBe(tokenEnd)
})
it('clamps offsets outside the document', () => {
expect(textOffsetToPmPos(doc, 9999)).toBe(doc.content.size)
expect(pmPosToTextOffset(doc, 9999)).toBe(text.length)
})
})
@@ -0,0 +1,240 @@
/**
* ProseMirror document plumbing for the chat composer.
*
* The document is a flat list of paragraphs (newline = new paragraph) with
* `atMention` inline atoms for @-mentions. Everything outside this file works
* on the plain-text projection from composerMentions.ts — these functions are
* the only place that knows how the projection maps onto PM positions.
*/
import { Node as PMNode, Schema } from 'prosemirror-model'
import type { Command } from 'prosemirror-state'
import type { ComposerMention } from '../../lib/composerMentions'
import { findMentionRanges, mentionToken, tokenOccurrences } from '../../lib/composerMentions'
export const AT_MENTION_NODE = 'atMention'
export const composerSchema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: {
content: 'inline*',
group: 'block',
parseDOM: [{ tag: 'p' }],
toDOM: () => ['p', 0],
},
text: { group: 'inline' },
[AT_MENTION_NODE]: {
inline: true,
group: 'inline',
// Atom: the pill is one indivisible unit — no cursor inside it, and
// Backspace/Delete removes the whole mention rather than a character.
atom: true,
selectable: true,
draggable: false,
attrs: {
label: { default: '' },
path: { default: '' },
isDirectory: { default: false },
},
toDOM: (node) => [
'span',
{
class: node.attrs.isDirectory
? 'composer-mention composer-mention--directory'
: 'composer-mention',
'data-mention-path': node.attrs.path as string,
'data-mention-label': node.attrs.label as string,
title: node.attrs.path as string,
},
['span', {
class: 'material-symbols-outlined composer-mention-icon',
// The icon is decoration; the label already carries the meaning.
'aria-hidden': 'true',
}, node.attrs.isDirectory ? 'folder' : 'draft'],
`@${node.attrs.label as string}`,
],
parseDOM: [
{
tag: 'span.composer-mention',
getAttrs: (dom) => ({
// The ligature icon text pollutes textContent, so the label
// travels in its own attribute.
label: dom.getAttribute('data-mention-label') ?? '',
path: dom.getAttribute('data-mention-path') ?? '',
isDirectory: dom.classList.contains('composer-mention--directory'),
}),
},
],
},
},
})
/** Build a PM document from the plain-text projection. */
export function buildComposerDoc(text: string, mentions: ComposerMention[]): PMNode {
const ranges = findMentionRanges(text, mentions)
const mentionType = composerSchema.nodes[AT_MENTION_NODE]!
const paragraphType = composerSchema.nodes.paragraph!
const paragraphs: PMNode[] = []
let rangeIndex = 0
let lineStart = 0
for (const line of text.split('\n')) {
const lineEnd = lineStart + line.length
const content: PMNode[] = []
let cursor = 0
while (rangeIndex < ranges.length) {
const range = ranges[rangeIndex]!
// A token never spans a newline (filesystem names can't contain one), so
// a range outside this line just means we've caught up with it.
if (range.start >= lineEnd || range.end > lineEnd) break
const relativeStart = range.start - lineStart
if (relativeStart > cursor) {
content.push(composerSchema.text(line.slice(cursor, relativeStart)))
}
content.push(mentionType.create({
label: range.mention.label,
path: range.mention.path,
isDirectory: range.mention.isDirectory,
}))
cursor = range.end - lineStart
rangeIndex += 1
}
if (cursor < line.length) {
content.push(composerSchema.text(line.slice(cursor)))
}
paragraphs.push(paragraphType.create(null, content))
lineStart = lineEnd + 1
}
return composerSchema.node('doc', null, paragraphs)
}
/** Project a PM document back to plain text plus the mention list, in document order. */
export function projectComposerDoc(doc: PMNode): { text: string; mentions: ComposerMention[] } {
// The doc knows exactly where the atoms are; the ordinals let the
// plain-text projection keep that knowledge when literal text happens to
// spell out the same token.
const atoms: Array<{ projectedStart: number; mention: ComposerMention }> = []
let text = ''
doc.forEach((block, _offset, index) => {
if (index > 0) text += '\n'
block.forEach((inline) => {
if (inline.type.name === AT_MENTION_NODE) {
const mention: ComposerMention = {
label: inline.attrs.label as string,
path: inline.attrs.path as string,
isDirectory: inline.attrs.isDirectory as boolean,
tokenOrdinal: 0, // resolved below
}
atoms.push({ projectedStart: text.length, mention })
text += mentionToken(mention)
} else if (inline.isText) {
text += inline.text ?? ''
}
})
})
for (const atom of atoms) {
const token = mentionToken(atom.mention)
atom.mention.tokenOrdinal = tokenOccurrences(text, token)
.filter((position) => position < atom.projectedStart).length
}
return { text, mentions: atoms.map((atom) => atom.mention) }
}
/**
* Serialize a PM document for the model: text stays text, each mention atom
* becomes the `@"absolute path"` form the CLI already parses. Done from the
* document rather than the plain-text projection on purpose — only the doc
* knows which `@label` is a pill and which is literal text the user typed.
*/
export function serializeComposerDoc(doc: PMNode): string {
let text = ''
doc.forEach((block, _offset, index) => {
if (index > 0) text += '\n'
block.forEach((inline) => {
if (inline.type.name === AT_MENTION_NODE) {
text += `@"${inline.attrs.path as string}"`
} else if (inline.isText) {
text += inline.text ?? ''
}
})
})
return text
}
/** Projected text length of the whole document. */
export function projectedDocLength(doc: PMNode): number {
return pmPosToTextOffset(doc, doc.content.size)
}
/** Map a PM position to its offset in the plain-text projection. */
export function pmPosToTextOffset(doc: PMNode, pos: number): number {
const bounded = Math.max(0, Math.min(pos, doc.content.size))
return doc.textBetween(0, bounded, '\n', (node) =>
node.type.name === AT_MENTION_NODE
? mentionToken(node.attrs as ComposerMention)
: '',
).length
}
/** Map a projection offset back to a PM position. */
export function textOffsetToPmPos(doc: PMNode, textOffset: number): number {
let remaining = Math.max(0, textOffset)
let result = -1
doc.forEach((block, blockPos, index) => {
if (result >= 0) return
if (index > 0) {
// The '\n' separator belongs to the boundary between blocks.
if (remaining === 0) {
result = blockPos
return
}
remaining -= 1
}
const contentStart = blockPos + 1
block.forEach((inline, inlineOffset) => {
if (result >= 0) return
const inlinePos = contentStart + inlineOffset
const projectedLength = inline.isText
? (inline.text ?? '').length
: inline.type.name === AT_MENTION_NODE
? mentionToken(inline.attrs as ComposerMention).length
: 0
if (remaining <= projectedLength) {
if (inline.isText) {
result = inlinePos + remaining
} else {
// Inside a token: snap to the nearest atom edge.
result = remaining === 0 ? inlinePos : inlinePos + inline.nodeSize
}
return
}
remaining -= projectedLength
})
if (result < 0 && remaining === 0) {
result = contentStart + block.content.size
}
})
return result >= 0 ? result : doc.content.size
}
/**
* Delete the atMention atom directly adjacent to the caret. Without this the
* browser's native contenteditable behavior decides what Backspace does with
* an uneditable inline span — one deterministic transaction beats hoping, and
* it keeps the pill single-press deletable in every environment.
*/
export function deleteAdjacentMentionAtom(direction: 'backward' | 'forward'): Command {
return (state, dispatch) => {
const { empty, $from } = state.selection
if (!empty || !$from.parent.isTextblock) return false
const node = direction === 'backward' ? $from.nodeBefore : $from.nodeAfter
if (!node || node.type.name !== AT_MENTION_NODE) return false
if (dispatch) {
const from = direction === 'backward' ? $from.pos - node.nodeSize : $from.pos
dispatch(state.tr.delete(from, from + node.nodeSize))
}
return true
}
}
@@ -0,0 +1,53 @@
/**
* Test helpers for driving the ProseMirror composer (MentionComposer) in
* jsdom. `fireEvent.change` cannot type into a contenteditable editor, so
* tests set the text by dispatching a transaction on the live EditorView —
* the same code path real typing takes (dispatchTransaction → onChange →
* parent state), just without simulating raw DOM mutations.
*/
import { act } from 'react'
import { TextSelection } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view'
import { getComposerViewForTesting } from './MentionComposer'
import { projectComposerDoc, textOffsetToPmPos } from './composerEditor'
export function getComposerElement(): HTMLElement {
const element = document.querySelector('[data-composer-editor]')
if (!element) throw new Error('composer editor element not found')
return element as HTMLElement
}
export function getComposerView(): EditorView {
const view = getComposerViewForTesting(getComposerElement())
if (!view) throw new Error('composer view not found — is a MentionComposer mounted?')
return view
}
/** Replace the whole composer content, optionally placing the caret at a projected offset. */
export function setComposerText(text: string, selectionOffset?: number): void {
const view = getComposerView()
act(() => {
// Insert and place the caret in one transaction, so the component's
// onChange (trigger detection for / and @) sees the final caret position.
const tr = view.state.tr.insertText(text, 0, view.state.doc.content.size)
if (selectionOffset !== undefined) {
const pos = textOffsetToPmPos(tr.doc, selectionOffset)
tr.setSelection(TextSelection.near(tr.doc.resolve(pos)))
}
view.dispatch(tr)
})
}
/** Current projected plain-text content of the composer. */
export function getComposerText(): string {
return projectComposerDoc(getComposerView().state.doc).text
}
/** Place the caret at a projected text offset. */
export function setComposerSelection(offset: number): void {
const view = getComposerView()
act(() => {
const pos = textOffsetToPmPos(view.state.doc, offset)
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(pos))))
})
}
@@ -2579,13 +2579,18 @@ describe('WorkspacePanel', () => {
const otherComposerShell = document.createElement('div')
otherComposerShell.dataset.testid = 'chat-input-shell'
otherComposerShell.dataset.sessionId = 'another-session'
otherComposerShell.append(document.createElement('textarea'))
const otherComposer = document.createElement('div')
otherComposer.setAttribute('data-composer-editor', 'true')
otherComposer.tabIndex = -1
otherComposerShell.append(otherComposer)
document.body.append(otherComposerShell)
const composerShell = document.createElement('div')
composerShell.dataset.testid = 'chat-input-shell'
composerShell.dataset.sessionId = 'session-diff-comment'
const composer = document.createElement('textarea')
const composer = document.createElement('div')
composer.setAttribute('data-composer-editor', 'true')
composer.tabIndex = -1
composerShell.append(composer)
document.body.append(composerShell)
@@ -1528,7 +1528,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
const composer = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="chat-input-shell"]'),
).find((element) => element.dataset.sessionId === sessionId)
composer?.querySelector<HTMLTextAreaElement>('textarea:not([disabled])')?.focus()
composer?.querySelector<HTMLElement>('[data-composer-editor]')?.focus()
})
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
findMentionRanges,
insertMentionIntoText,
mentionToken,
type ComposerMention,
type NewComposerMention,
} from './composerMentions'
const dirMention: ComposerMention = {
label: 'MediaCrawlerPro-Python/',
path: '/repo/MediaCrawlerPro-Python',
isDirectory: true,
tokenOrdinal: 0,
}
const fileMention: ComposerMention = {
label: 'main.ts',
path: '/repo/src/main.ts',
isDirectory: false,
tokenOrdinal: 0,
}
describe('findMentionRanges', () => {
it('locates tokens in document order', () => {
const text = `look at ${mentionToken(dirMention)} and ${mentionToken(fileMention)} please`
const ranges = findMentionRanges(text, [dirMention, fileMention])
expect(ranges).toHaveLength(2)
expect(ranges[0]!.start).toBe(text.indexOf(mentionToken(dirMention)))
expect(ranges[1]!.start).toBe(text.indexOf(mentionToken(fileMention)))
})
it('disambiguates repeated labels by ordinal', () => {
const text = `${mentionToken(dirMention)} vs ${mentionToken(dirMention)}`
const ranges = findMentionRanges(text, [dirMention, { ...dirMention, tokenOrdinal: 1 }])
expect(ranges).toHaveLength(2)
expect(ranges[0]!.end).toBeLessThanOrEqual(ranges[1]!.start)
})
it('lets a literal duplicate keep its slot instead of stealing the pill', () => {
// The first `@main.ts` is literal text the user typed; the pill for the
// same label sits at the second occurrence.
const text = `compare @main.ts with ${mentionToken(fileMention)}`
const ranges = findMentionRanges(text, [{ ...fileMention, tokenOrdinal: 1 }])
expect(ranges).toHaveLength(1)
expect(ranges[0]!.start).toBe(text.lastIndexOf(mentionToken(fileMention)))
})
it('stops matching once a token went missing', () => {
const text = `only ${mentionToken(fileMention)} survived`
const ranges = findMentionRanges(text, [dirMention, fileMention])
expect(ranges).toHaveLength(0)
})
})
describe('insertMentionIntoText', () => {
it('replaces the trigger range with the token and a trailing space', () => {
const result = insertMentionIntoText('check @Medi out', [], 6, 11, dirMention)
expect(result.text).toBe(`check ${mentionToken(dirMention)} out`)
expect(result.mentions).toEqual([dirMention])
expect(result.cursorPos).toBe('check '.length + mentionToken(dirMention).length + 1)
})
it('keeps existing mentions in document order around the insertion point', () => {
const base = `${mentionToken(dirMention)} mid ${mentionToken(fileMention)}`
const next: NewComposerMention = { label: 'new/', path: '/repo/new', isDirectory: true }
const insertAt = mentionToken(dirMention).length + 1 // before "mid"
const result = insertMentionIntoText(base, [dirMention, fileMention], insertAt, insertAt + 3, next)
expect(result.text).toBe(`${mentionToken(dirMention)} ${mentionToken(next)} ${mentionToken(fileMention)}`)
expect(result.mentions.map((mention) => mention.label)).toEqual([
dirMention.label,
next.label,
fileMention.label,
])
})
it('counts literal duplicates before the insertion point into the ordinal', () => {
const base = `see @main.ts and `
const next: NewComposerMention = { label: 'main.ts', path: '/repo/src/main.ts', isDirectory: false }
const result = insertMentionIntoText(base, [], base.length, base.length, next)
expect(result.mentions[0]!.tokenOrdinal).toBe(1)
})
it('clamps out-of-range bounds instead of throwing', () => {
const result = insertMentionIntoText('abc', [], -5, 99, fileMention)
expect(result.text).toBe(`${mentionToken(fileMention)} `)
expect(result.cursorPos).toBe(result.text.length)
})
})
+126
View File
@@ -0,0 +1,126 @@
/**
* Inline @-mentions in the composer.
*
* The composer holds mentions as structured data (this type), but most of the
* surrounding machinery — trigger detection, slash replacement, drafts —
* works on a plain-text projection where each mention shows up as the token
* `@${label}`. These helpers convert between the two and keep them honest:
* mentions are matched against the text in document order, so a mention whose
* token no longer exists simply stops matching (and is treated as literal
* text) instead of corrupting the rest of the serialization.
*/
export type ComposerMention = {
/** Display label inside the token, e.g. `MediaCrawlerPro-Python/` for a directory. */
label: string
/** Absolute filesystem path sent to the model. */
path: string
isDirectory: boolean
/**
* Which occurrence of this mention's token in the projected text is the
* pill, counting literal occurrences too (0-based). The plain-text
* projection cannot tell a pill apart from identical text the user typed
* by hand — this ordinal, always derived from the live document, is what
* keeps the round trip exact in that case.
*/
tokenOrdinal: number
}
/** A mention before its position in the text is known (e.g. at insertion time). */
export type NewComposerMention = Omit<ComposerMention, 'tokenOrdinal'>
export type MentionRange = {
start: number
end: number
mention: ComposerMention
}
export function mentionToken(mention: Pick<ComposerMention, 'label'>): string {
return `@${mention.label}`
}
export function mentionsEqual(a: ComposerMention[], b: ComposerMention[]): boolean {
if (a.length !== b.length) return false
return a.every((mention, index) => {
const other = b[index]!
return mention.label === other.label &&
mention.path === other.path &&
mention.isDirectory === other.isDirectory &&
mention.tokenOrdinal === other.tokenOrdinal
})
}
/** Every start offset of `token` in `text`, in order. */
export function tokenOccurrences(text: string, token: string): number[] {
const positions: number[] = []
let from = 0
for (;;) {
const index = text.indexOf(token, from)
if (index < 0) return positions
positions.push(index)
from = index + token.length
}
}
/**
* Locate each mention's token occurrence in the projected text, in document
* order, honoring each mention's tokenOrdinal so literal duplicate text does
* not steal a pill's slot. Matching stops at the first mention whose token
* occurrence can no longer be found — callers only ever see a consistent
* prefix.
*/
export function findMentionRanges(text: string, mentions: ComposerMention[]): MentionRange[] {
const ranges: MentionRange[] = []
const occurrenceCache = new Map<string, number[]>()
let from = 0
for (const mention of mentions) {
const token = mentionToken(mention)
let positions = occurrenceCache.get(token)
if (!positions) {
positions = tokenOccurrences(text, token)
occurrenceCache.set(token, positions)
}
const start = positions[mention.tokenOrdinal]
if (start === undefined || start < from) break
ranges.push({ start, end: start + token.length, mention })
from = start + token.length
}
return ranges
}
/**
* Insert a mention token over `[start, end)` in the projected text and return
* the updated projection. A trailing space always follows the token so the
* caret lands clear of the pill and trigger detection does not immediately
* re-open on the mention's own `@`.
*/
export function insertMentionIntoText(
text: string,
mentions: ComposerMention[],
start: number,
end: number,
mention: NewComposerMention,
): { text: string; mentions: ComposerMention[]; cursorPos: number } {
const boundedStart = Math.max(0, Math.min(start, text.length))
const boundedEnd = Math.max(boundedStart, Math.min(end, text.length))
const before = text.slice(0, boundedStart)
// The token supplies its own trailing space; any whitespace the trigger
// text had after it collapses into that one.
const after = text.slice(boundedEnd).replace(/^\s+/, '')
const token = mentionToken(mention)
const insertion = `${token} `
const positioned: ComposerMention = {
...mention,
tokenOrdinal: tokenOccurrences(before, token).length,
}
const insertIndex = findMentionRanges(text, mentions).filter((range) => range.start < boundedStart).length
return {
text: `${before}${insertion}${after}`,
mentions: [
...mentions.slice(0, insertIndex),
positioned,
...mentions.slice(insertIndex),
],
cursorPos: before.length + insertion.length,
}
}
+64 -81
View File
@@ -157,6 +157,7 @@ import { useUIStore } from '../stores/uiStore'
import { usePluginStore } from '../stores/pluginStore'
import type { RepositoryContextResult } from '../api/sessions'
import { browserHost } from '../lib/desktopHost/browserHost'
import { getComposerElement, getComposerText, setComposerText } from '../components/chat/composerTestUtils'
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
return {
@@ -369,9 +370,7 @@ describe('EmptySession', () => {
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
})
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/su', selectionStart: 3 },
})
setComposerText('/su', 3)
await waitFor(() => {
const commandOptions = screen.getAllByRole('option')
@@ -403,10 +402,7 @@ describe('EmptySession', () => {
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: '/', selectionStart: 1 },
})
setComposerText('/', 1)
const listbox = await screen.findByRole('listbox', { name: 'Slash commands' })
const combobox = screen.getByRole('combobox')
@@ -450,15 +446,12 @@ describe('EmptySession', () => {
expect(mocks.listAgents).toHaveBeenCalledWith(undefined)
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: '/debug', selectionStart: 6 },
})
setComposerText('/debug', 6)
const agentOption = await screen.findByText('agent debugger')
fireEvent.click(agentOption)
expect(input).toHaveValue('/agent debugger ')
expect(getComposerText()).toBe('/agent debugger ')
})
it('opens the draft model selector for /model without creating or sending a session', async () => {
@@ -468,20 +461,15 @@ describe('EmptySession', () => {
render(<EmptySession />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/model',
selectionStart: 6,
},
})
const input = getComposerElement()
setComposerText('/model', 6)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.createSession).not.toHaveBeenCalled()
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(await screen.findByTestId('model-selector-dropdown')).toHaveTextContent('Model selector opened')
expect(input).toHaveValue('')
expect(getComposerText()).toBe('')
})
it('selects a highlighted agent entry from /agent without creating a session', async () => {
@@ -507,16 +495,14 @@ describe('EmptySession', () => {
expect(mocks.listAgents).toHaveBeenCalledWith(undefined)
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: '/agent', selectionStart: 6 },
})
const input = getComposerElement()
setComposerText('/agent', 6)
await screen.findByText('agent debugger')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(input).toHaveValue('/agent debugger ')
expect(getComposerText()).toBe('/agent debugger ')
expect(mocks.createSession).not.toHaveBeenCalled()
expect(mocks.wsSend).not.toHaveBeenCalled()
})
@@ -549,9 +535,7 @@ describe('EmptySession', () => {
it('creates a session with the selected project and branch when submitted', async () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
await pickProject()
expect(mocks.createSession).not.toHaveBeenCalled()
@@ -605,9 +589,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
@@ -637,9 +619,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.click(screen.getByRole('button', { name: 'Permission mode: default' }))
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'run automatically', selectionStart: 17 },
})
setComposerText('run automatically', 17)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
@@ -672,9 +652,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
@@ -712,9 +690,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
@@ -762,9 +738,7 @@ describe('EmptySession', () => {
expect(await screen.findByText('huge-a.log')).toBeInTheDocument()
expect(await screen.findByText('huge-b.zip')).toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'check these files', selectionStart: 'check these files'.length },
})
setComposerText('check these files', 'check these files'.length)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
@@ -814,9 +788,7 @@ describe('EmptySession', () => {
expect(await screen.findByText('session-context.log')).toBeInTheDocument()
expect(screen.queryByTestId('empty-session-drop-overlay')).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'use this context', selectionStart: 'use this context'.length },
})
setComposerText('use this context', 'use this context'.length)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
@@ -857,9 +829,12 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.paste(screen.getByRole('textbox'), {
fireEvent.paste(getComposerElement(), {
clipboardData: {
files: [],
// ProseMirror reads text data before consulting our paste handler, so
// the stub has to answer like a real DataTransfer.
getData: () => '',
items: [{
kind: 'file',
type: 'application/json',
@@ -870,9 +845,7 @@ describe('EmptySession', () => {
expect(await screen.findByText('project-context.json')).toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'use this context', selectionStart: 'use this context'.length },
})
setComposerText('use this context', 'use this context'.length)
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
@@ -892,6 +865,39 @@ describe('EmptySession', () => {
})
})
it('sends a selected @ directory as an inline @"path" in the first draft message', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/workspace/project',
parentPath: null,
query: 'backend',
entries: [
{ name: 'backend', path: '/workspace/project/backend', relativePath: 'backend', isDirectory: true },
],
})
render(<EmptySession />)
setComposerText('@backend 讲一下这个目录。', '@backend'.length)
fireEvent.click(await screen.findByRole('option', { name: /backend/i }))
await waitFor(() => {
expect(getComposerText()).toBe('@backend/ 讲一下这个目录。')
})
expect(document.querySelector('.composer-mention')).toHaveTextContent('@backend/')
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalled()
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: '@"/workspace/project/backend" 讲一下这个目录。',
attachments: [],
})
expect(getComposerText()).toBe('')
})
it('keeps slash and @ popovers visible above the empty-session drop target', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/workspace/project',
@@ -905,24 +911,13 @@ describe('EmptySession', () => {
render(<EmptySession />)
const panel = screen.getByTestId('empty-session-composer-panel')
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/',
selectionStart: 1,
},
})
setComposerText('/', 1)
expect(await screen.findByText('mcp')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
fireEvent.change(input, {
target: {
value: '@readme',
selectionStart: 7,
},
})
setComposerText('@readme', 7)
expect(await screen.findByText('README.md')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
@@ -933,9 +928,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
@@ -973,9 +966,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
@@ -1001,9 +992,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
@@ -1049,9 +1038,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
@@ -1093,9 +1080,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
await pickProject()
const pill = await screen.findByRole('button', { name: `Location: project / ${longBranch}` })
@@ -1141,9 +1126,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'draft question', selectionStart: 14 },
})
setComposerText('draft question', 14)
await pickProject()
await waitFor(() => {
+94 -79
View File
@@ -39,6 +39,12 @@ import {
} from '../lib/composerAttachments'
import { useComposerFileDrop } from '../components/chat/useComposerFileDrop'
import { shouldSubmitOnEnter } from '../components/chat/sendShortcut'
import { MentionComposer, type MentionComposerHandle } from '../components/chat/MentionComposer'
import {
findMentionRanges,
insertMentionIntoText,
type ComposerMention,
} from '../lib/composerMentions'
import {
appendAgentSlashCommands,
buildAgentSlashCommands,
@@ -98,6 +104,7 @@ function resolveCreateSessionErrorMessage(error: unknown, t: Translate): string
export function EmptySession() {
const t = useTranslation()
const [input, setInput] = useState('')
const [mentions, setMentions] = useState<ComposerMention[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [workDir, setWorkDir] = useState('')
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
@@ -114,7 +121,8 @@ export function EmptySession() {
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>([])
const [agentSlashCommands, setAgentSlashCommands] = useState<SlashCommandOption[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const composerRef = useRef<MentionComposerHandle>(null)
const composerContainerRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
@@ -144,7 +152,7 @@ export function EmptySession() {
const isMobileComposer = useMobileViewport() && !isDesktopRuntime()
useEffect(() => {
textareaRef.current?.focus()
composerRef.current?.focus()
}, [])
useDismissable({
@@ -155,19 +163,19 @@ export function EmptySession() {
useDismissable({
open: slashMenuOpen,
refs: [slashMenuRef, textareaRef],
refs: [slashMenuRef, composerContainerRef],
onDismiss: () => setSlashMenuOpen(false),
})
useDismissable({
open: !!localSlashPanel,
refs: [slashMenuRef, textareaRef],
refs: [slashMenuRef, composerContainerRef],
onDismiss: () => setLocalSlashPanel(null),
})
useDismissable({
open: fileSearchOpen,
refs: [textareaRef],
refs: [composerContainerRef],
onDismiss: () => setFileSearchOpen(false),
// See ChatInput: this menu is found by id, and its absence used to mean
// "ignore the press".
@@ -281,6 +289,7 @@ export function EmptySession() {
if (slashUiAction?.type === 'panel') {
setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName)
setInput('')
setMentions([])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -291,6 +300,7 @@ export function EmptySession() {
useUIStore.getState().setPendingSettingsTab(slashUiAction.tab)
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
setInput('')
setMentions([])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -300,6 +310,7 @@ export function EmptySession() {
if (slashUiAction?.type === 'model') {
modelSelectorRef.current?.open()
setInput('')
setMentions([])
setSlashMenuOpen(false)
setFileSearchOpen(false)
setPlusMenuOpen(false)
@@ -351,10 +362,14 @@ export function EmptySession() {
data: attachment.data,
mimeType: attachment.mimeType,
}))
if (text || attachmentPayload.length > 0) {
sendMessage(sessionId, text, attachmentPayload)
// Inline @-mentions go out as the `@"absolute path"` text the CLI parses,
// serialized from the live document; the bubble keeps the pill text.
const serializedText = (composerRef.current?.getModelContent() ?? input).trim()
if (serializedText || attachmentPayload.length > 0) {
sendMessage(sessionId, serializedText, attachmentPayload, { displayContent: text })
}
setInput('')
setMentions([])
setAttachments([])
} catch (error) {
addToast({
@@ -366,8 +381,10 @@ export function EmptySession() {
}
}
const handleInputChange = (value: string, cursorPos: number) => {
const handleComposerChange = (value: string, nextMentions: ComposerMention[]) => {
setInput(value)
setMentions(nextMentions)
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? value.length
const token = findSlashToken(value, cursorPos)
if (!token) {
setSlashMenuOpen(false)
@@ -376,7 +393,8 @@ export function EmptySession() {
setSlashMenuOpen(true)
}
// Detect @ trigger for file search
// Detect @ trigger for file search, skipping an `@` that belongs to an
// existing mention pill.
const textBeforeCursor = value.slice(0, cursorPos)
let pos = -1
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
@@ -392,6 +410,9 @@ export function EmptySession() {
break
}
}
if (pos >= 0 && findMentionRanges(value, nextMentions).some((range) => pos >= range.start && pos < range.end)) {
pos = -1
}
if (pos < 0) {
setFileSearchOpen(false)
setAtFilter('')
@@ -404,9 +425,9 @@ export function EmptySession() {
}
}
const handleKeyDown = (event: React.KeyboardEvent) => {
const handleComposerKeyDown = (event: KeyboardEvent): boolean => {
// Ignore key events during IME composition (e.g. Chinese input method)
if (event.nativeEvent.isComposing) return
if (event.isComposing || event.keyCode === 229) return false
// Route file search navigation keys to FileSearchMenu
if (fileSearchOpen) {
@@ -417,24 +438,24 @@ export function EmptySession() {
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
return
return true
}
fileSearchRef.current?.handleKeyDown(event.nativeEvent)
return
fileSearchRef.current?.handleKeyDown(event)
return true
}
return
return false
}
if (slashMenuOpen && filteredCommands.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
return
return true
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
return
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
const selected = filteredCommands[slashSelectedIndex]
@@ -447,28 +468,30 @@ export function EmptySession() {
) {
event.preventDefault()
void handleSubmit()
return
return true
}
event.preventDefault()
if (selected) selectSlashCommand(selected.name)
return
return true
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashMenuOpen(false)
return
return true
}
}
if (shouldSubmitOnEnter(event, chatSendBehavior)) {
event.preventDefault()
handleSubmit()
void handleSubmit()
return true
}
return false
}
const handlePaste = (event: React.ClipboardEvent) => {
const files = getDataTransferFiles(event.clipboardData)
if (files.length === 0) return
const handleComposerPaste = (event: ClipboardEvent): boolean => {
const files = event.clipboardData ? getDataTransferFiles(event.clipboardData) : []
if (files.length === 0) return false
event.preventDefault()
void filesToComposerAttachments(files)
@@ -479,6 +502,7 @@ export function EmptySession() {
.catch((error) => {
console.warn('[attachments] Failed to read pasted files', error)
})
return true
}
const appendFiles = useCallback((files: FileList | File[]) => {
@@ -537,30 +561,27 @@ export function EmptySession() {
}
const selectSlashCommand = (command: string) => {
const el = textareaRef.current
if (!el) return
const cursorPos = el.selectionStart ?? input.length
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const replacement = replaceSlashCommand(input, cursorPos, command)
if (!replacement) return
setInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
})
}
const insertSlashCommand = () => {
const el = textareaRef.current
const cursorPos = el?.selectionStart ?? input.length
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const replacement = insertSlashTrigger(input, cursorPos)
setInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
})
}
@@ -632,37 +653,28 @@ export function EmptySession() {
setInput(newValue)
setAtFilter(relativePath)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(newCursorPos)
})
}}
onSelect={(path, name) => {
if (atCursorPos >= 0) {
const attachmentName = name.split('/').filter(Boolean).pop() ?? name
const tokenEnd = atCursorPos + 1 + atFilter.length
const beforeToken = input.slice(0, atCursorPos)
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
const newValue = `${beforeToken}${spacer}${afterToken}`
const newCursorPos = atCursorPos + spacer.length
setAttachments((prev) => [
...prev,
{
id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: attachmentName,
type: 'file',
path,
},
])
setInput(newValue)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
void textareaRef.current?.focus()
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
})
}
onSelect={(path, name, isDirectory) => {
if (atCursorPos < 0) return
const referenceName = name.split('/').filter(Boolean).pop() ?? name
const tokenEnd = atCursorPos + 1 + atFilter.length
const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, {
label: isDirectory ? `${referenceName}/` : referenceName,
path,
isDirectory,
})
setInput(inserted.text)
setMentions(inserted.mentions)
setFileSearchOpen(false)
setAtFilter('')
setAtCursorPos(-1)
void composerRef.current?.focus()
requestAnimationFrame(() => {
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}}
/>
)}
@@ -696,25 +708,28 @@ export function EmptySession() {
)}
<div className="flex items-start gap-3">
<textarea
ref={textareaRef}
<MentionComposer
ref={composerRef}
rootRef={composerContainerRef}
value={input}
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
role={isSlashMenuVisible ? 'combobox' : undefined}
aria-autocomplete={isSlashMenuVisible ? 'list' : undefined}
aria-expanded={isSlashMenuVisible ? true : undefined}
aria-controls={isSlashMenuVisible ? slashMenuId : undefined}
aria-activedescendant={isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined}
className={`flex-1 resize-none border-none bg-transparent leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${
isMobileComposer ? 'max-h-[132px] min-h-[72px] py-1.5 text-base' : 'py-2'
}`}
style={{ fontFamily: 'var(--font-body)' }}
mentions={mentions}
onChange={handleComposerChange}
onKeyDown={handleComposerKeyDown}
onPaste={handleComposerPaste}
placeholder={t('empty.placeholder')}
rows={2}
className="flex-1"
editorClassName={`overflow-y-auto leading-relaxed text-[var(--color-text-primary)] ${
isMobileComposer ? 'max-h-[132px] min-h-[72px] py-1.5 text-base' : 'max-h-[200px] py-2'
}`}
aria={{
role: isSlashMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible ? 'list' : undefined,
'aria-expanded': isSlashMenuVisible ? 'true' : undefined,
'aria-controls': isSlashMenuVisible ? slashMenuId : undefined,
'aria-activedescendant': isSlashMenuVisible
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
: undefined,
}}
/>
</div>
+3
View File
@@ -17,6 +17,7 @@ import {
import { hasRunningBackgroundTasks, hasRunningSubagentTasks } from '../lib/backgroundTasks'
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import type { ComposerAttachment } from '../lib/composerAttachments'
import type { ComposerMention } from '../lib/composerMentions'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { RuntimeSelection } from '../types/runtime'
@@ -52,6 +53,8 @@ type CompactSummaryMessage = Extract<UIMessage, { type: 'compact_summary' }>
export type ComposerDraftState = {
input: string
attachments: ComposerAttachment[]
/** Inline @-mention pills in the draft, in document order. */
mentions?: ComposerMention[]
}
export type QueuedUserMessage = {
+49
View File
@@ -1502,6 +1502,55 @@ button, input, textarea, select, a, [role="button"] {
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
}
/* ProseMirror composer (MentionComposer). Paragraphs carry the line breaks,
so only space preservation needs `pre-wrap`. */
.composer-pm {
outline: none;
font-family: var(--font-body);
}
.composer-pm p {
margin: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
}
/* Floated zero-height placeholder: the empty paragraph keeps its own caret,
the hint text just borrows its line. */
.composer-pm[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--color-text-tertiary);
float: left;
height: 0;
pointer-events: none;
}
/* Inline @-mention pill (an atom node in the document). */
.composer-mention {
color: var(--color-success);
background: var(--color-success-container);
border-radius: var(--radius-md);
padding: 0 4px;
margin: 0 1px;
overflow-wrap: anywhere;
}
.composer-mention-icon {
font-size: 15px;
vertical-align: -3px;
margin-right: 1px;
}
/* The editor reports `contenteditable="false"` when the workspace is gone;
keep the old textarea's dimmed disabled look. */
.composer-pm[contenteditable="false"] {
opacity: 0.5;
}
.composer-pm .ProseMirror-selectednode.composer-mention {
outline: 2px solid var(--color-border-focus);
}
.composer-drop-overlay {
animation: composer-drop-fade 140ms ease-out, composer-drop-pulse 1.1s ease-in-out infinite;
}