fix(desktop): restore configured composer line breaks

This commit is contained in:
程序员阿江(Relakkes)
2026-08-04 17:10:27 +08:00
parent 30a55a1733
commit 614a0eae45
2 changed files with 79 additions and 7 deletions
+74 -6
View File
@@ -8,6 +8,8 @@ const viewportMocks = vi.hoisted(() => ({
}))
const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
const originalRangeGetClientRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
const originalRangeGetBoundingClientRect = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
const mocks = vi.hoisted(() => ({
create: vi.fn(),
@@ -239,8 +241,29 @@ describe('ChatInput file mentions', () => {
},
},
})
// jsdom does not implement it, and the branch list scrolls its active row
// into view whenever the selection moves.
// jsdom does not implement these layout APIs. ProseMirror reads Range
// geometry when a newline transaction scrolls the new selection into view.
Object.defineProperties(Range.prototype, {
getClientRects: {
configurable: true,
value: () => [],
},
getBoundingClientRect: {
configurable: true,
value: () => ({
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
width: 0,
height: 0,
toJSON: () => ({}),
}),
},
})
// The branch list also scrolls its active row whenever the selection moves.
Element.prototype.scrollIntoView = vi.fn()
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
@@ -260,6 +283,16 @@ describe('ChatInput file mentions', () => {
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'offsetWidth')
}
if (originalRangeGetClientRects) {
Object.defineProperty(Range.prototype, 'getClientRects', originalRangeGetClientRects)
} else {
Reflect.deleteProperty(Range.prototype, 'getClientRects')
}
if (originalRangeGetBoundingClientRect) {
Object.defineProperty(Range.prototype, 'getBoundingClientRect', originalRangeGetBoundingClientRect)
} else {
Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
}
})
// jsdom lays nothing out, so the composer column's width has to be stated.
@@ -1935,7 +1968,37 @@ describe('ChatInput file mentions', () => {
expect(screen.getByTestId('chat-input-toolbar')).toHaveClass('-mx-3')
})
it('uses Ctrl or Command Enter to send when that composer preference is selected', async () => {
it('uses Shift+Enter for a newline when Enter is the configured send shortcut', async () => {
useSettingsStore.setState({
chatSendBehavior: 'enter',
})
render(<ChatInput />)
await waitFor(() => {
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
})
const input = getComposerElement()
setComposerText('firstsecond', 5)
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(getComposerText()).toBe('first\nsecond')
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: 'first\nsecond',
attachments: [],
})
})
it.each([
['Ctrl+Enter', { ctrlKey: true }],
['Command+Enter', { metaKey: true }],
])('uses Enter or Shift+Enter for newlines and %s to send when configured', async (_shortcut, modifier) => {
useSettingsStore.setState({
chatSendBehavior: 'modifierEnter',
})
@@ -1947,15 +2010,20 @@ describe('ChatInput file mentions', () => {
})
const input = getComposerElement()
setComposerText('avoid accidental sends', 'avoid accidental sends'.length)
setComposerText('firstsecond', 5)
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(getComposerText()).toBe('first\nsecond')
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true })
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(getComposerText()).toBe('first\n\nsecond')
fireEvent.keyDown(input, { key: 'Enter', ...modifier })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: 'avoid accidental sends',
content: 'first\n\nsecond',
attachments: [],
})
})
@@ -20,7 +20,7 @@ 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 { baseKeymap, splitBlock } from 'prosemirror-commands'
import {
buildComposerDoc,
composerSchema,
@@ -139,6 +139,10 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
keymap({
'Backspace': deleteAdjacentMentionAtom('backward'),
'Delete': deleteAdjacentMentionAtom('forward'),
// Unlike a textarea, ProseMirror does not include Shift+Enter in
// its base keymap. Keep the composer's configured newline path
// explicit instead of relying on browser contenteditable behavior.
'Shift-Enter': splitBlock,
}),
keymap({ 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo }),
keymap(baseKeymap),