From a9fdf2920f595f18867e88b6ede99f2bfd5e88c0 Mon Sep 17 00:00:00 2001 From: cfal Date: Sun, 10 May 2026 11:41:54 +0800 Subject: [PATCH] web: Unify Enter/Shift+Enter submit behavior across devices Removes mobile-specific logic, so the "Send by Shift+Enter" setting now applies equally on mobile and desktop. Enter key behavior in the prompt composer now consistently follows the user preference regardless of device. Updates related tests and removes unused isMobile handling. --- .../chat/__tests__/composer-shortcuts.test.ts | 11 +-- web/src/lib/chat/composer-shortcuts.ts | 3 - .../lib/components/chat/PromptComposer.svelte | 1 - .../chat/__tests__/NewChatForm.test.ts | 99 ++++++++++++++++++- .../__tests__/NewChatFormTestHarness.svelte | 22 ++++- 5 files changed, 121 insertions(+), 15 deletions(-) diff --git a/web/src/lib/chat/__tests__/composer-shortcuts.test.ts b/web/src/lib/chat/__tests__/composer-shortcuts.test.ts index 4c6e88095..7523d15f2 100644 --- a/web/src/lib/chat/__tests__/composer-shortcuts.test.ts +++ b/web/src/lib/chat/__tests__/composer-shortcuts.test.ts @@ -86,7 +86,7 @@ describe('shouldSubmitOnEnter', () => { ).toBe(false); }); - it('never submits on mobile (Enter inserts newline)', () => { + it('uses the setting as the only Enter/Shift+Enter mode switch', () => { expect( shouldSubmitOnEnter({ sendByShiftEnter: false, @@ -94,12 +94,8 @@ describe('shouldSubmitOnEnter', () => { ctrlKey: false, metaKey: false, isComposing: false, - isMobile: true, }) - ).toBe(false); - }); - - it('never submits on mobile even with Shift+Enter', () => { + ).toBe(true); expect( shouldSubmitOnEnter({ sendByShiftEnter: true, @@ -107,9 +103,8 @@ describe('shouldSubmitOnEnter', () => { ctrlKey: false, metaKey: false, isComposing: false, - isMobile: true, }) - ).toBe(false); + ).toBe(true); }); }); diff --git a/web/src/lib/chat/composer-shortcuts.ts b/web/src/lib/chat/composer-shortcuts.ts index 21c1cbbfd..56d389945 100644 --- a/web/src/lib/chat/composer-shortcuts.ts +++ b/web/src/lib/chat/composer-shortcuts.ts @@ -4,16 +4,13 @@ interface EnterSubmissionParams { ctrlKey: boolean; metaKey: boolean; isComposing: boolean; - isMobile?: boolean; } /** * Returns true when an Enter keypress should submit the composer. - * On mobile, Enter always inserts a newline (submit via send button). * Ctrl/Cmd+Enter stays unbound regardless of preference. */ export function shouldSubmitOnEnter(params: EnterSubmissionParams): boolean { - if (params.isMobile) return false; if (params.isComposing) return false; if (params.ctrlKey || params.metaKey) return false; return params.sendByShiftEnter ? params.shiftKey : !params.shiftKey; diff --git a/web/src/lib/components/chat/PromptComposer.svelte b/web/src/lib/components/chat/PromptComposer.svelte index 640174c97..ca9a745ec 100644 --- a/web/src/lib/components/chat/PromptComposer.svelte +++ b/web/src/lib/components/chat/PromptComposer.svelte @@ -107,7 +107,6 @@ ctrlKey: event.ctrlKey, metaKey: event.metaKey, isComposing: event.isComposing, - isMobile: appShell.isMobile, }) ) return; diff --git a/web/src/lib/components/chat/__tests__/NewChatForm.test.ts b/web/src/lib/components/chat/__tests__/NewChatForm.test.ts index 412d0d7b3..52a30e6f2 100644 --- a/web/src/lib/components/chat/__tests__/NewChatForm.test.ts +++ b/web/src/lib/components/chat/__tests__/NewChatForm.test.ts @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import NewChatFormTestHarness from './NewChatFormTestHarness.svelte'; +import * as chatsApi from '$lib/api/chats'; import * as settingsApi from '$lib/api/settings'; import * as gitApi from '$lib/api/git'; import type { RemoteSettingsSnapshot } from '$shared/settings'; @@ -18,6 +19,22 @@ vi.mock('$lib/api/settings', () => ({ updateRemoteSettings: vi.fn(), })); +function installMatchMedia(matches: boolean): void { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -49,7 +66,46 @@ function makeSnapshot(overrides: Partial = {}): RemoteSe }; } +async function renderReadyNewChatForm(options: { + sendByShiftEnter?: boolean; + isMobile?: boolean; +} = {}) { + const onStartChat = vi.fn(); + vi.mocked(settingsApi.getRemoteSettings).mockResolvedValueOnce(makeSnapshot({ + lastProjectPath: '/workspace/project', + })); + vi.mocked(chatsApi.validateStart).mockResolvedValue({ + valid: true, + isGitRepo: false + }); + + render(NewChatFormTestHarness, { + props: { + ...options, + onStartChat, + } + }); + + const messageInput = await screen.findByPlaceholderText('How can I help you today?') as HTMLTextAreaElement; + await waitFor(() => { + expect(screen.queryByRole('status', { name: 'Loading chat defaults...' })).toBeNull(); + }); + await fireEvent.input(messageInput, { target: { value: 'Start this session' } }); + + const sendButton = screen.getByTitle('Start session') as HTMLButtonElement; + await waitFor(() => { + expect(sendButton.disabled).toBe(false); + }); + + return { messageInput, onStartChat }; +} + describe('NewChatForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + installMatchMedia(false); + }); + it('shows a centered spinner and hides the composer until settings load', async () => { const pending = deferred>>(); vi.mocked(settingsApi.getRemoteSettings).mockReturnValueOnce(pending.promise); @@ -109,4 +165,45 @@ describe('NewChatForm', () => { expect(worktreeDialog.textContent).toContain('New worktree'); }); + it('starts on Enter when send by Shift+Enter is disabled', async () => { + const { messageInput, onStartChat } = await renderReadyNewChatForm({ + sendByShiftEnter: false, + }); + + await fireEvent.keyDown(messageInput, { key: 'Enter' }); + + expect(onStartChat).toHaveBeenCalledTimes(1); + }); + + it('keeps Enter as a newline when send by Shift+Enter is enabled', async () => { + const { messageInput, onStartChat } = await renderReadyNewChatForm({ + sendByShiftEnter: true, + }); + + await fireEvent.keyDown(messageInput, { key: 'Enter' }); + + expect(onStartChat).not.toHaveBeenCalled(); + }); + + it('starts on Shift+Enter when send by Shift+Enter is enabled', async () => { + const { messageInput, onStartChat } = await renderReadyNewChatForm({ + sendByShiftEnter: true, + }); + + await fireEvent.keyDown(messageInput, { key: 'Enter', shiftKey: true }); + + expect(onStartChat).toHaveBeenCalledTimes(1); + }); + + it('does not let the mobile viewport override the Enter setting', async () => { + installMatchMedia(true); + const { messageInput, onStartChat } = await renderReadyNewChatForm({ + sendByShiftEnter: false, + isMobile: true, + }); + + await fireEvent.keyDown(messageInput, { key: 'Enter' }); + + expect(onStartChat).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/src/lib/components/chat/__tests__/NewChatFormTestHarness.svelte b/web/src/lib/components/chat/__tests__/NewChatFormTestHarness.svelte index 2c73e9f74..97f9e4e84 100644 --- a/web/src/lib/components/chat/__tests__/NewChatFormTestHarness.svelte +++ b/web/src/lib/components/chat/__tests__/NewChatFormTestHarness.svelte @@ -2,9 +2,24 @@ import NewChatForm from '../NewChatForm.svelte'; import { setAppShell, setModelCatalog, setLocalSettings, setRemoteSettings, setChatSessions } from '$lib/context'; import { createRemoteSettingsStore } from '$lib/stores/remote-settings.svelte'; + import type { NewChatConfig } from '$lib/types/app'; + + interface Props { + sendByShiftEnter?: boolean; + isMobile?: boolean; + onStartChat?: (config: NewChatConfig) => void; + } + + let { + sendByShiftEnter = false, + isMobile = false, + onStartChat = () => {}, + }: Props = $props(); setLocalSettings({ - sendByShiftEnter: false + get sendByShiftEnter() { + return sendByShiftEnter; + } } as never); setRemoteSettings(createRemoteSettingsStore()); @@ -15,6 +30,9 @@ setAppShell({ projectBasePath: '/workspace', + get isMobile() { + return isMobile; + }, onNewChatDialogSeed() { return () => {}; } @@ -86,4 +104,4 @@ } as never); - {}} /> +