This commit is contained in:
leessju 2026-05-31 10:13:42 +00:00 committed by GitHub
commit 61d7c46812
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 525 additions and 22 deletions

View file

@ -1,8 +1,8 @@
import type { CSSProperties } from 'react';
import { useRef } from 'react';
import { useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { PreviewCommentSnapshot } from '../comments';
import type { Dict } from '../i18n/types';
import { useEnterToSend } from '../state/useEnterToSend';
import type { PreviewComment, PreviewCommentMember } from '../types';
import { isImeComposing } from '../utils/imeComposing';
@ -264,8 +264,24 @@ export function BoardComposerPopover({
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const hasCommentChange = !existing || draft.trim() !== existing.note.trim();
const podMembers = target.podMembers ?? [];
const enterToSend = useEnterToSend();
const composingRef = useRef(false);
const sendDisabled = pendingCount === 0 || sending;
// Send the comment (current draft + any queued notes) on the configured
// key: bare Enter when "Enter to send" is on, ⌘/Ctrl + Enter when off.
// Shift / Alt always insert a newline, and an in-progress IME composition
// (e.g. a Korean syllable) is never treated as a send.
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Enter') return;
if (isImeComposing(event, composingRef.current)) return;
const sends = enterToSend
? !event.shiftKey && !event.altKey && !event.metaKey && !event.ctrlKey
: (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
if (!sends) return;
event.preventDefault();
if (sendDisabled) return;
void onSendBatch();
};
return (
<div
className={`comment-popover${docked ? ' comment-popover-docked' : ''}`}
@ -345,19 +361,7 @@ export function BoardComposerPopover({
onCompositionEnd={() => {
composingRef.current = false;
}}
onKeyDown={(event) => {
if (isImeComposing(event, composingRef.current)) return;
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
if (sendDisabled) return;
void onSendBatch();
}
}}
onKeyDown={handleInputKeyDown}
/>
<div className="comment-popover-actions">
<div className="comment-popover-actions-start">

View file

@ -161,6 +161,10 @@ interface Props {
// ChatPane). Pass `null` (or omit) to render the full rail.
pinnedPluginId?: string | null;
footerAccessory?: ReactNode;
// Composer send-key preference (Settings → General). When true, a bare
// Enter sends and ⌘/Ctrl + Enter inserts a newline; when false/undefined
// the original ⌘/Ctrl + Enter-sends behavior applies.
enterToSend?: boolean;
}
// Imperative handle so ancestors (e.g. example chips in ChatPane) can
@ -224,6 +228,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
onProjectSkillChange,
pinnedPluginId = null,
footerAccessory,
enterToSend = true,
},
ref
) {
@ -1880,6 +1885,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
composingRef.current = false;
}}
onKeyDown={(e) => {
// IME guard (macOS Korean/Japanese/… composition): Enter that
// commits an in-progress syllable must not send or pick.
if (isImeComposing(e, composingRef.current)) return;
if (slash && filteredSlash.length > 0) {
if (e.key === 'ArrowDown') {
@ -1910,14 +1917,51 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
setMention(null);
return;
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.altKey &&
(e.metaKey || e.ctrlKey || !mention)
) {
// Escape stops an in-flight run, mirroring the Stop button.
// The slash / mention popovers consume Escape above, so this
// only fires when neither is open.
if (e.key === "Escape" && streaming) {
e.preventDefault();
void submit();
onStop();
return;
}
if (e.key === "Enter") {
// Send-key resolution honors the Settings → General
// "Enter to send" preference. On (default): bare Enter sends
// and ⌘/Ctrl+Enter inserts a newline. Off (legacy):
// ⌘/Ctrl+Enter sends and bare Enter inserts a newline.
// Shift+Enter always newlines. The slash palette already
// consumed bare Enter above when it was open.
const sends = enterToSend
? !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey
: (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey;
// Don't fire a send out from under an open @-mention
// popover the user is mid-selecting.
if (sends && !(enterToSend && mention)) {
e.preventDefault();
void submit();
return;
}
// Enter-to-send mode: the former send combo now inserts a
// newline at the caret (browsers don't newline on the
// modifier combo by themselves). Skip while sending is
// disabled so the keystroke stays inert, matching the
// blocked-submit behavior.
if (enterToSend && (e.metaKey || e.ctrlKey) && !sendDisabled) {
e.preventDefault();
const ta = e.currentTarget;
const value = ta.value;
const start = ta.selectionStart ?? value.length;
const end = ta.selectionEnd ?? value.length;
setDraft(value.slice(0, start) + "\n" + value.slice(end));
requestAnimationFrame(() => {
try {
ta.selectionStart = ta.selectionEnd = start + 1;
} catch {
/* textarea detached before caret restore */
}
});
}
}
}}
/>

View file

@ -304,6 +304,9 @@ interface Props {
// Bumped by the parent to push a draft into the composer (used by the
// "Import repo" CTA). The nonce lets the same text fire more than once.
composerDraftSignal?: { text: string; nonce: number };
// Composer send-key preference (Settings → General), forwarded to
// ChatComposer. Omitted → defaults to the ⌘/Ctrl+Enter-sends behavior.
enterToSend?: boolean;
// Optional pet wiring forwarded straight through to ChatComposer's
// /pet button. When omitted the composer hides the button entirely.
petConfig?: AppConfig['pet'];
@ -389,6 +392,7 @@ export function ChatPane({
githubConnected,
onConnectRepo,
composerDraftSignal,
enterToSend,
petConfig,
onAdoptPet,
onTogglePet,
@ -1306,6 +1310,7 @@ export function ChatPane({
onStop={onStop}
onOpenSettings={onOpenSettings}
onOpenMcpSettings={onOpenMcpSettings}
enterToSend={enterToSend}
petConfig={petConfig}
onAdoptPet={onAdoptPet}
onTogglePet={onTogglePet}

View file

@ -1774,6 +1774,7 @@ export function DesignSystemDetailView({
void sendProjectChatMessage(prompt, attachments, commentAttachments);
}}
onStop={stopProjectChat}
enterToSend={config?.enterToSend ?? true}
initialDraft={chatSeed?.text}
conversations={conversations}
activeConversationId={activeConversationId}

View file

@ -4499,6 +4499,7 @@ export function ProjectView({
githubConnected={githubConnected}
onConnectRepo={handleConnectRepo}
composerDraftSignal={composerDraftSignal}
enterToSend={config.enterToSend ?? true}
petConfig={config.pet}
onAdoptPet={onAdoptPetInline}
onTogglePet={onTogglePet}

View file

@ -122,6 +122,7 @@ import {
export type SettingsSection =
| 'execution'
| 'general'
| 'instructions'
| 'media'
| 'composio'
@ -2024,6 +2025,7 @@ export function SettingsDialog({
},
integrations: { title: t('settings.mcpServerTitle'), subtitle: t('settings.mcpServerHint') },
mcpClient: { title: t('settings.externalMcpTitle'), subtitle: t('settings.externalMcpHint') },
general: { title: t('settings.general'), subtitle: t('settings.generalHint') },
language: { title: t('settings.language'), subtitle: t('settings.languageHint') },
appearance: { title: t('settings.appearance'), subtitle: t('settings.appearanceHint') },
critiqueTheater: {
@ -2407,6 +2409,17 @@ export function SettingsDialog({
<small>{t('settings.mcpServerHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'general' ? ' active' : ''}`}
onClick={() => setActiveSection('general')}
>
<Icon name="sliders" size={18} />
<span>
<strong>{t('settings.general')}</strong>
<small>{t('settings.generalHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'language' ? ' active' : ''}`}
@ -3650,6 +3663,10 @@ export function SettingsDialog({
</section>
) : null}
{activeSection === 'general' ? (
<GeneralSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'appearance' ? (
<AppearanceSection cfg={cfg} setCfg={setCfg} />
) : null}
@ -6184,6 +6201,41 @@ const THEMES: Array<{ value: AppTheme; labelKey: 'settings.themeSystem' | 'setti
{ value: 'dark', labelKey: 'settings.themeDark', icon: 'moon' },
];
function GeneralSection({
cfg,
setCfg,
}: {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}) {
const { t } = useI18n();
const enterToSend = cfg.enterToSend ?? true;
return (
<section className="settings-section">
<div className="settings-toggle-card">
<span className="settings-toggle-card-icon" aria-hidden>
<Icon name="send" size={14} />
</span>
<span className="settings-toggle-card-body">
<span className="settings-toggle-card-title">{t('settings.enterToSend')}</span>
<span className="settings-toggle-card-desc">{t('settings.enterToSendHint')}</span>
</span>
<label className="toggle-switch toggle-switch-sm" title={t('settings.enterToSend')}>
<input
type="checkbox"
checked={enterToSend}
onChange={(e) =>
setCfg((c) => ({ ...c, enterToSend: e.target.checked }))
}
aria-label={t('settings.enterToSend')}
/>
<span className="toggle-slider" />
</label>
</div>
</section>
);
}
function AppearanceSection({
cfg,
setCfg,

View file

@ -308,6 +308,10 @@ export const ar: Dict = {
'settings.languageHint': 'تغيير لغة الواجهة. تحفظ في المتصفح.',
'settings.appearance': 'المظهر',
'settings.appearanceHint': 'اختر الفاتح، الداكن، أو اتبع إعدادات النظام.',
'settings.general': 'عام',
'settings.generalHint': 'تفضيلات على مستوى التطبيق.',
'settings.enterToSend': 'اضغط Enter للإرسال',
'settings.enterToSendHint': 'عند التفعيل، يرسل Enter الرسالة ويُدرج ⌘/Ctrl + Enter سطرًا جديدًا. عند الإيقاف، يرسل ⌘/Ctrl + Enter ويُدرج Enter سطرًا جديدًا.',
'settings.themeSystem': 'النظام',
'settings.themeLight': 'فاتح',
'settings.themeDark': 'داكن',

View file

@ -308,6 +308,10 @@ export const de: Dict = {
'settings.languageHint': 'Wechseln Sie die Sprache der Oberfläche. Wird in diesem Browser gespeichert.',
'settings.appearance': 'Erscheinungsbild',
'settings.appearanceHint': 'Hell, dunkel oder Systemeinstellung übernehmen.',
'settings.general': 'Allgemein',
'settings.generalHint': 'App-weite Einstellungen.',
'settings.enterToSend': 'Mit Enter senden',
'settings.enterToSendHint': 'Wenn aktiviert, sendet Enter die Nachricht und ⌘/Strg + Enter fügt einen Zeilenumbruch ein. Wenn deaktiviert, sendet ⌘/Strg + Enter und Enter fügt einen Zeilenumbruch ein.',
'settings.themeSystem': 'System',
'settings.themeLight': 'Hell',
'settings.themeDark': 'Dunkel',

View file

@ -319,6 +319,10 @@ export const en: Dict = {
'settings.languageHint': 'Switch the interface language. Saved to this browser.',
'settings.appearance': 'Appearance',
'settings.appearanceHint': 'Choose light, dark, or follow your system setting.',
'settings.general': 'General',
'settings.generalHint': 'App-wide preferences.',
'settings.enterToSend': 'Press Enter to send',
'settings.enterToSendHint': 'When on, Enter sends your message and ⌘/Ctrl + Enter inserts a newline. When off, ⌘/Ctrl + Enter sends and Enter inserts a newline.',
'settings.themeSystem': 'System',
'settings.themeLight': 'Light',
'settings.themeDark': 'Dark',

View file

@ -308,6 +308,10 @@ export const esES: Dict = {
'settings.languageHint': 'Cambia el idioma de la interfaz. Se guarda en este navegador.',
'settings.appearance': 'Apariencia',
'settings.appearanceHint': 'Elige claro, oscuro o seguir la configuración del sistema.',
'settings.general': 'General',
'settings.generalHint': 'Preferencias de toda la aplicación.',
'settings.enterToSend': 'Pulsar Enter para enviar',
'settings.enterToSendHint': 'Cuando está activado, Enter envía el mensaje y ⌘/Ctrl + Enter inserta un salto de línea. Cuando está desactivado, ⌘/Ctrl + Enter envía y Enter inserta un salto de línea.',
'settings.themeSystem': 'Sistema',
'settings.themeLight': 'Claro',
'settings.themeDark': 'Oscuro',

View file

@ -308,6 +308,10 @@ export const fa: Dict = {
'settings.languageHint': 'زبان رابط را تغییر دهید. در این مرورگر ذخیره می‌شود.',
'settings.appearance': 'ظاهر',
'settings.appearanceHint': 'روشن، تاریک یا پیروی از تنظیمات سیستم.',
'settings.general': 'عمومی',
'settings.generalHint': 'تنظیمات کلی برنامه.',
'settings.enterToSend': 'برای ارسال Enter را بزنید',
'settings.enterToSendHint': 'وقتی روشن است، Enter پیام را ارسال می‌کند و ⌘/Ctrl + Enter خط جدید درج می‌کند. وقتی خاموش است، ⌘/Ctrl + Enter ارسال می‌کند و Enter خط جدید درج می‌کند.',
'settings.themeSystem': 'سیستم',
'settings.themeLight': 'روشن',
'settings.themeDark': 'تاریک',

View file

@ -304,6 +304,10 @@ export const fr: Dict = {
'settings.languageHint': 'Changer la langue de l\'interface. Enregistré dans ce navigateur.',
'settings.appearance': 'Apparence',
'settings.appearanceHint': 'Choisissez clair, sombre, ou suivez le paramètre système.',
'settings.general': 'Général',
'settings.generalHint': 'Préférences de l\'application.',
'settings.enterToSend': 'Appuyer sur Entrée pour envoyer',
'settings.enterToSendHint': 'Activé, Entrée envoie le message et ⌘/Ctrl + Entrée insère un saut de ligne. Désactivé, ⌘/Ctrl + Entrée envoie et Entrée insère un saut de ligne.',
'settings.themeSystem': 'Système',
'settings.themeLight': 'Clair',
'settings.themeDark': 'Sombre',

View file

@ -308,6 +308,10 @@ export const hu: Dict = {
'settings.languageHint': 'A felület nyelvének váltása. Ebben a böngészőben mentve.',
'settings.appearance': 'Megjelenés',
'settings.appearanceHint': 'Válassz világos, sötét, vagy kövesd a rendszer beállítását.',
'settings.general': 'Általános',
'settings.generalHint': 'Alkalmazásszintű beállítások.',
'settings.enterToSend': 'Küldés Enterrel',
'settings.enterToSendHint': 'Bekapcsolva az Enter elküldi az üzenetet, a ⌘/Ctrl + Enter pedig új sort szúr be. Kikapcsolva a ⌘/Ctrl + Enter küld, az Enter pedig új sort szúr be.',
'settings.themeSystem': 'Rendszer',
'settings.themeLight': 'Világos',
'settings.themeDark': 'Sötét',

View file

@ -305,6 +305,10 @@ export const id: Dict = {
'settings.languageHint': 'Ganti bahasa antarmuka. Disimpan di browser ini.',
'settings.appearance': 'Tampilan',
'settings.appearanceHint': 'Pilih terang, gelap atau ikuti pengaturan sistem.',
'settings.general': 'Umum',
'settings.generalHint': 'Preferensi seluruh aplikasi.',
'settings.enterToSend': 'Tekan Enter untuk mengirim',
'settings.enterToSendHint': 'Saat aktif, Enter mengirim pesan dan ⌘/Ctrl + Enter menyisipkan baris baru. Saat nonaktif, ⌘/Ctrl + Enter mengirim dan Enter menyisipkan baris baru.',
'settings.themeSystem': 'Sistem',
'settings.themeLight': 'Terang',
'settings.themeDark': 'Gelap',

View file

@ -305,6 +305,10 @@ export const it: Dict = {
'settings.languageHint': 'Cambia la lingua dell\'interfaccia. Salvato in questo browser.',
'settings.appearance': 'Aspetto',
'settings.appearanceHint': 'Scegli chiaro, scuro o segui l\'impostazione di sistema.',
'settings.general': 'Generale',
'settings.generalHint': 'Preferenze dell\'app.',
'settings.enterToSend': 'Premi Invio per inviare',
'settings.enterToSendHint': 'Se attivo, Invio invia il messaggio e ⌘/Ctrl + Invio inserisce un a capo. Se disattivato, ⌘/Ctrl + Invio invia e Invio inserisce un a capo.',
'settings.themeSystem': 'Sistema',
'settings.themeLight': 'Chiaro',
'settings.themeDark': 'Scuro',

View file

@ -308,6 +308,10 @@ export const ja: Dict = {
'settings.languageHint': 'インターフェースの言語を切り替えます。このブラウザに保存されます。',
'settings.appearance': '外観',
'settings.appearanceHint': 'ライト、ダーク、またはシステム設定に従う。',
'settings.general': '一般',
'settings.generalHint': 'アプリ全体の設定。',
'settings.enterToSend': 'Enter で送信',
'settings.enterToSendHint': 'オンの場合、Enter でメッセージを送信し、⌘/Ctrl + Enter で改行します。オフの場合、⌘/Ctrl + Enter で送信し、Enter で改行します。',
'settings.themeSystem': 'システム',
'settings.themeLight': 'ライト',
'settings.themeDark': 'ダーク',

View file

@ -308,6 +308,10 @@ export const ko: Dict = {
'settings.languageHint': '인터페이스 언어를 변경합니다. 이 브라우저에 저장됩니다.',
'settings.appearance': '화면 모드',
'settings.appearanceHint': '라이트, 다크 또는 시스템 설정에 따릅니다.',
'settings.general': '일반',
'settings.generalHint': '앱 전반 환경설정.',
'settings.enterToSend': 'Enter로 전송',
'settings.enterToSendHint': '켜면 Enter로 메시지를 전송하고 ⌘/Ctrl + Enter로 줄을 바꿉니다. 끄면 ⌘/Ctrl + Enter로 전송하고 Enter로 줄을 바꿉니다.',
'settings.themeSystem': '시스템',
'settings.themeLight': '라이트',
'settings.themeDark': '다크',

View file

@ -308,6 +308,10 @@ export const pl: Dict = {
'settings.languageHint': 'Zmień język interfejsu. Zapisano w tej przeglądarce.',
'settings.appearance': 'Wygląd',
'settings.appearanceHint': 'Jasny, ciemny lub zgodny z ustawieniami systemu.',
'settings.general': 'Ogólne',
'settings.generalHint': 'Preferencje całej aplikacji.',
'settings.enterToSend': 'Enter wysyła wiadomość',
'settings.enterToSendHint': 'Gdy włączone, Enter wysyła wiadomość, a ⌘/Ctrl + Enter wstawia nowy wiersz. Gdy wyłączone, ⌘/Ctrl + Enter wysyła, a Enter wstawia nowy wiersz.',
'settings.themeSystem': 'Systemowy',
'settings.themeLight': 'Jasny',
'settings.themeDark': 'Ciemny',

View file

@ -308,6 +308,10 @@ export const ptBR: Dict = {
'settings.languageHint': 'Altere o idioma da interface. Salvo neste navegador.',
'settings.appearance': 'Aparência',
'settings.appearanceHint': 'Escolha claro, escuro ou seguir as configurações do sistema.',
'settings.general': 'Geral',
'settings.generalHint': 'Preferências do aplicativo.',
'settings.enterToSend': 'Pressione Enter para enviar',
'settings.enterToSendHint': 'Quando ativado, Enter envia a mensagem e ⌘/Ctrl + Enter insere uma quebra de linha. Quando desativado, ⌘/Ctrl + Enter envia e Enter insere uma quebra de linha.',
'settings.themeSystem': 'Sistema',
'settings.themeLight': 'Claro',
'settings.themeDark': 'Escuro',

View file

@ -308,6 +308,10 @@ export const ru: Dict = {
'settings.languageHint': 'Переключить язык интерфейса. Сохраняется в этом браузере.',
'settings.appearance': 'Внешний вид',
'settings.appearanceHint': 'Выберите светлую, тёмную или системную тему.',
'settings.general': 'Общие',
'settings.generalHint': 'Настройки всего приложения.',
'settings.enterToSend': 'Отправка по Enter',
'settings.enterToSendHint': 'Когда включено, Enter отправляет сообщение, а ⌘/Ctrl + Enter вставляет перенос строки. Когда выключено, ⌘/Ctrl + Enter отправляет, а Enter вставляет перенос строки.',
'settings.themeSystem': 'Системная',
'settings.themeLight': 'Светлая',
'settings.themeDark': 'Тёмная',

View file

@ -303,6 +303,10 @@ export const th: Dict = {
'settings.languageHint': 'เปลี่ยนภาษาอินเทอร์เฟซ บันทึกไว้ในเบราว์เซอร์นี้',
'settings.appearance': 'ลักษณะที่ปรากฏ',
'settings.appearanceHint': 'เลือกธีมสว่าง มืด หรือตามระบบ',
'settings.general': 'ทั่วไป',
'settings.generalHint': 'การตั้งค่าทั้งแอป',
'settings.enterToSend': 'กด Enter เพื่อส่ง',
'settings.enterToSendHint': 'เมื่อเปิด Enter จะส่งข้อความ และ ⌘/Ctrl + Enter จะขึ้นบรรทัดใหม่ เมื่อปิด ⌘/Ctrl + Enter จะส่ง และ Enter จะขึ้นบรรทัดใหม่',
'settings.themeSystem': 'ระบบ',
'settings.themeLight': 'สว่าง',
'settings.themeDark': 'มืด',

View file

@ -308,6 +308,10 @@ export const tr: Dict = {
'settings.languageHint': 'Arayüz dilini değiştirin. Bu tarayıcıya kaydedilir.',
'settings.appearance': 'Görünüm',
'settings.appearanceHint': 'Açık, koyu veya sistem ayarını takip et.',
'settings.general': 'Genel',
'settings.generalHint': 'Uygulama geneli tercihler.',
'settings.enterToSend': 'Göndermek için Enter\'a basın',
'settings.enterToSendHint': 'Açıkken Enter mesajı gönderir, ⌘/Ctrl + Enter satır ekler. Kapalıyken ⌘/Ctrl + Enter gönderir, Enter satır ekler.',
'settings.themeSystem': 'Sistem',
'settings.themeLight': 'Açık',
'settings.themeDark': 'Koyu',

View file

@ -309,6 +309,10 @@ export const uk: Dict = {
'settings.languageHint': 'Перемкніть мову інтерфейсу. Зберігається в цьому браузері.',
'settings.appearance': 'Зовнішній вигляд',
'settings.appearanceHint': 'Виберіть світлу, темну тему або дотримуйтеся системного параметра.',
'settings.general': 'Загальні',
'settings.generalHint': 'Налаштування всього застосунку.',
'settings.enterToSend': 'Надсилати через Enter',
'settings.enterToSendHint': 'Коли увімкнено, Enter надсилає повідомлення, а ⌘/Ctrl + Enter додає новий рядок. Коли вимкнено, ⌘/Ctrl + Enter надсилає, а Enter додає новий рядок.',
'settings.themeSystem': 'Системна',
'settings.themeLight': 'Світла',
'settings.themeDark': 'Темна',

View file

@ -319,6 +319,10 @@ export const zhCN: Dict = {
'settings.languageHint': '切换界面语言,设置仅保存在当前浏览器。',
'settings.appearance': '外观',
'settings.appearanceHint': '选择浅色、深色或跟随系统设置。',
'settings.general': '通用',
'settings.generalHint': '应用级偏好设置。',
'settings.enterToSend': '按 Enter 发送',
'settings.enterToSendHint': '开启时Enter 发送消息,⌘/Ctrl + Enter 换行。关闭时,⌘/Ctrl + Enter 发送Enter 换行。',
'settings.themeSystem': '系统',
'settings.themeLight': '浅色',
'settings.themeDark': '深色',

View file

@ -311,6 +311,10 @@ export const zhTW: Dict = {
'settings.languageHint': '切換介面語言,設定僅儲存在當前瀏覽器。',
'settings.appearance': '外觀',
'settings.appearanceHint': '選擇淺色、深色或跟隨系統設定。',
'settings.general': '一般',
'settings.generalHint': '應用程式層級偏好設定。',
'settings.enterToSend': '按 Enter 傳送',
'settings.enterToSendHint': '開啟時Enter 傳送訊息,⌘/Ctrl + Enter 換行。關閉時,⌘/Ctrl + Enter 傳送Enter 換行。',
'settings.themeSystem': '系統',
'settings.themeLight': '淺色',
'settings.themeDark': '深色',

View file

@ -325,6 +325,10 @@ export interface Dict {
'settings.languageHint': string;
'settings.appearance': string;
'settings.appearanceHint': string;
'settings.general': string;
'settings.generalHint': string;
'settings.enterToSend': string;
'settings.enterToSendHint': string;
'settings.themeSystem': string;
'settings.themeLight': string;
'settings.themeDark': string;

View file

@ -11,6 +11,7 @@
@import './styles/workspace/connectors.css';
@import './styles/workspace/drawer.css';
@import './styles/workspace/design-files.css';
@import './styles/workspace/settings.css';
@import './styles/viewer/core.css';
@import './styles/viewer/code.css';
@import './styles/viewer/tools.css';

View file

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { loadConfig } from './config';
const STORAGE_KEY = 'open-design:config';
/**
* Reads the Settings General "Enter to send" preference.
*
* Defaults to `true` (Enter sends) so it matches the composer's default. The
* value is read from the persisted `open-design:config` blob at mount and kept
* in sync across tabs via the platform `storage` event. Same-tab consumers that
* mount fresh after a Settings change (e.g. a comment popover opened after the
* dialog closes) pick up the latest value on their next mount.
*/
export function useEnterToSend(): boolean {
const [value, setValue] = useState<boolean>(() => loadConfig().enterToSend ?? true);
useEffect(() => {
if (typeof window === 'undefined') return;
const onStorage = (event: StorageEvent): void => {
if (event.key !== null && event.key !== STORAGE_KEY) return;
setValue(loadConfig().enterToSend ?? true);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
return value;
}

View file

@ -0,0 +1,45 @@
/* Settings toggle card a bordered row with an icon, title + description and
* a trailing switch. Mirrors the skills-row card look for standalone
* preference toggles (first used by Settings General). */
.settings-toggle-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.settings-toggle-card:hover {
border-color: var(--border-strong, var(--border));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.settings-toggle-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
background: var(--bg-subtle);
color: var(--text-muted);
}
.settings-toggle-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-toggle-card-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.settings-toggle-card-desc {
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}

View file

@ -373,6 +373,11 @@ export interface AppConfig {
customInstructions?: string;
projectLocations?: ProjectLocationPrefs[];
defaultProjectLocationId?: string | null;
// Composer send-key preference. When true (default), a bare Enter sends the
// message and ⌘/Ctrl + Enter inserts a newline; when false, the legacy
// ⌘/Ctrl + Enter sends / Enter newlines behavior applies. Unset is treated
// as true so the default matches the composer's Enter-to-send default.
enterToSend?: boolean;
}
export interface TelemetryConfig {

View file

@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardComposerPopover } from '../../src/components/BoardComposerPopover';
import type { PreviewCommentSnapshot } from '../../src/comments';
const STORAGE_KEY = 'open-design:config';
afterEach(() => {
cleanup();
window.localStorage.clear();
});
function target(): PreviewCommentSnapshot {
return {
filePath: 'index.html',
elementId: 'el-1',
selector: '#el-1',
label: 'Button',
text: '',
position: { x: 0, y: 0, width: 100, height: 40 },
htmlHint: '',
selectionKind: 'element',
memberCount: 1,
podMembers: [],
};
}
function renderPopover(onSendBatch: () => void) {
return render(
<BoardComposerPopover
target={target()}
existing={null}
draft="looks good"
notes={[]}
onDraft={() => {}}
onAddDraft={() => {}}
onRemoveQueuedNote={() => {}}
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={onSendBatch}
onRemoveMember={() => {}}
sending={false}
t={((key: string) => String(key)) as never}
/>,
);
}
describe('BoardComposerPopover send key (honors Settings → General)', () => {
it('default (Enter to send): bare Enter sends, Shift+Enter does not', () => {
const onSendBatch = vi.fn();
renderPopover(onSendBatch);
const input = screen.getByTestId('comment-popover-input');
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
expect(onSendBatch).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
it('does not send while an IME composition is in progress', () => {
const onSendBatch = vi.fn();
renderPopover(onSendBatch);
const input = screen.getByTestId('comment-popover-input');
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
expect(onSendBatch).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
it('legacy (enterToSend=false): ⌘/Ctrl+Enter sends, bare Enter does not', () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ enterToSend: false }));
const onSendBatch = vi.fn();
renderPopover(onSendBatch);
const input = screen.getByTestId('comment-popover-input');
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSendBatch).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter', metaKey: true });
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
});

View file

@ -97,6 +97,7 @@ describe('ChatComposer infinite re-render regression (#2097)', () => {
renderComposer({
draftStorageKey: key,
onSend,
enterToSend: false,
});
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
fireEvent.change(textarea, {

View file

@ -0,0 +1,145 @@
// @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();
});
});

View file

@ -1370,6 +1370,7 @@ export type TrackingSettingsArea =
| 'configure_execution_mode'
| 'configure_execution_mode_local_cli'
| 'configure_execution_mode_byok'
| 'general'
| 'instructions'
| 'memory'
| 'media_providers'
@ -2194,6 +2195,8 @@ export function settingsSectionToTracking(
section: string,
): TrackingSettingsArea {
switch (section) {
case 'general':
return 'general';
case 'execution':
return 'configure_execution_mode';
case 'instructions':