mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user