diff --git a/desktop/bun.lock b/desktop/bun.lock index 001bacb5..801ebb8c 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -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=="], diff --git a/desktop/package.json b/desktop/package.json index ffc39b19..c74ba9c1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index bdc1478f..47aa7d4d 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -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() - 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() - fireEvent.change(screen.getByRole('textbox'), { - target: { value: '/goal', selectionStart: 5 }, - }) + setComposerText('/goal', 5) expect(await screen.findByRole('option', { name: /^goal / })).toBeInTheDocument() expect(screen.getByText('[ | 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() // 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() - 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() - 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() - 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() - 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() - 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() - 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() diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index d54a3353..4af4cd63 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() - 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() - 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() - 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() - 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() 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() - 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() 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() - 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() - 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() - 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() - 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() + + 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() - 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() - 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() - 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() + + // 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() + + 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() - 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() - 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() 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() - 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() diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 716569aa..eb409df6 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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() const [input, setInput] = useState('') + const [mentions, setMentions] = useState([]) const [attachments, setAttachments] = useState([]) 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(null) const [editingQueuedMessageText, setEditingQueuedMessageText] = useState('') const composingRef = useRef(false) - const textareaRef = useRef(null) + const composerRef = useRef(null) + const composerContainerRef = useRef(null) const panelRef = useRef(null) const fileInputRef = useRef(null) const modelSelectorRef = useRef(null) @@ -153,11 +161,16 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const slashMenuId = useId() const previousActiveTabIdRef = useRef(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) => { - 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 ? (
-