mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Adds a General section to Settings holding a "Press Enter to send" preference, plus two related composer fixes. - General section: a reusable settings toggle card (icon + title + description + switch) wired to a new AppConfig.enterToSend flag, threaded through ProjectView / DesignSystemFlow → ChatPane → ChatComposer and persisted to localStorage. New i18n keys across all 19 locales. - Composer send key: when enterToSend is on, bare Enter sends and ⌘/Ctrl + Enter inserts a newline; off keeps the original ⌘/Ctrl + Enter sends behavior. The slash palette and @-mention popover keep priority on Enter/Escape. - Escape stops an in-flight run (mirrors the Stop button) when no popover is open. - IME fix: Enter arriving mid-composition (macOS Korean/Japanese/…) is the IME's commit key, so it no longer sends — which previously shipped the text without the composing syllable and stranded that character in the box. The next Enter (composition finished) sends and clears cleanly. Unit coverage in ChatComposer.send-key.test.tsx for every branch.
145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ChatComposer } from '../../src/components/ChatComposer';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function typeDraft(value: string): HTMLTextAreaElement {
|
|
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
fireEvent.change(textarea, {
|
|
target: { value, selectionStart: value.length, selectionEnd: value.length },
|
|
});
|
|
return textarea;
|
|
}
|
|
|
|
describe('ChatComposer send key (Settings → General: Enter to send)', () => {
|
|
it('default (Enter to send): bare Enter sends, Shift+Enter does not', async () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming={false}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
/>,
|
|
);
|
|
const textarea = typeDraft('hi there');
|
|
|
|
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
|
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
|
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
|
expect(onSend).toHaveBeenCalledWith('hi there', [], [], undefined);
|
|
});
|
|
|
|
it('default: ⌘/Ctrl + Enter inserts a newline instead of sending', async () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming={false}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
/>,
|
|
);
|
|
const textarea = typeDraft('line one');
|
|
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
|
|
|
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
await waitFor(() => expect(textarea.value).toBe('line one\n'));
|
|
});
|
|
|
|
it('legacy (enterToSend=false): ⌘/Ctrl + Enter sends, bare Enter does not', async () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming={false}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
enterToSend={false}
|
|
/>,
|
|
);
|
|
const textarea = typeDraft('hello');
|
|
|
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
|
|
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
|
|
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
|
expect(onSend).toHaveBeenCalledWith('hello', [], [], undefined);
|
|
});
|
|
|
|
it('does not send while an IME composition is in progress, sends once it finishes', async () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming={false}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
/>,
|
|
);
|
|
const textarea = typeDraft('안녕');
|
|
|
|
// macOS Korean: the Enter that commits the composing syllable arrives with
|
|
// isComposing=true. We must not send (that would strand the last char).
|
|
fireEvent.keyDown(textarea, { key: 'Enter', isComposing: true });
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
|
|
// Composition finished — the next Enter sends cleanly.
|
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
|
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
|
expect(onSend).toHaveBeenCalledWith('안녕', [], [], undefined);
|
|
});
|
|
|
|
it('Escape stops an in-flight run', () => {
|
|
const onStop = vi.fn();
|
|
render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={vi.fn()}
|
|
onStop={onStop}
|
|
/>,
|
|
);
|
|
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
fireEvent.keyDown(textarea, { key: 'Escape' });
|
|
expect(onStop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('Escape does nothing when no run is in flight', () => {
|
|
const onStop = vi.fn();
|
|
render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming={false}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={vi.fn()}
|
|
onStop={onStop}
|
|
/>,
|
|
);
|
|
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
fireEvent.keyDown(textarea, { key: 'Escape' });
|
|
expect(onStop).not.toHaveBeenCalled();
|
|
});
|
|
});
|