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); - {}} /> +