Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions web/src/lib/chat/__tests__/composer-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,30 +86,25 @@ 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,
shiftKey: false,
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,
shiftKey: true,
ctrlKey: false,
metaKey: false,
isComposing: false,
isMobile: true,
})
).toBe(false);
).toBe(true);
});
});

Expand Down
3 changes: 0 additions & 3 deletions web/src/lib/chat/composer-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 0 additions & 1 deletion web/src/lib/components/chat/PromptComposer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
isComposing: event.isComposing,
isMobile: appShell.isMobile,
})
) return;

Expand Down
99 changes: 98 additions & 1 deletion web/src/lib/components/chat/__tests__/NewChatForm.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
Expand Down Expand Up @@ -49,7 +66,46 @@ function makeSnapshot(overrides: Partial<RemoteSettingsSnapshot> = {}): 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<Awaited<ReturnType<typeof settingsApi.getRemoteSettings>>>();
vi.mocked(settingsApi.getRemoteSettings).mockReturnValueOnce(pending.promise);
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -15,6 +30,9 @@

setAppShell({
projectBasePath: '/workspace',
get isMobile() {
return isMobile;
},
onNewChatDialogSeed() {
return () => {};
}
Expand Down Expand Up @@ -86,4 +104,4 @@
} as never);
</script>

<NewChatForm onStartChat={() => {}} />
<NewChatForm {onStartChat} />
Loading