mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Merge eddf467df6 into 53fb175855
This commit is contained in:
commit
61d7c46812
34 changed files with 525 additions and 22 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -4499,6 +4499,7 @@ export function ProjectView({
|
|||
githubConnected={githubConnected}
|
||||
onConnectRepo={handleConnectRepo}
|
||||
composerDraftSignal={composerDraftSignal}
|
||||
enterToSend={config.enterToSend ?? true}
|
||||
petConfig={config.pet}
|
||||
onAdoptPet={onAdoptPetInline}
|
||||
onTogglePet={onTogglePet}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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': 'داكن',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'تاریک',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'ダーク',
|
||||
|
|
|
|||
|
|
@ -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': '다크',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Тёмная',
|
||||
|
|
|
|||
|
|
@ -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': 'มืด',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Темна',
|
||||
|
|
|
|||
|
|
@ -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': '深色',
|
||||
|
|
|
|||
|
|
@ -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': '深色',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
28
apps/web/src/state/useEnterToSend.ts
Normal file
28
apps/web/src/state/useEnterToSend.ts
Normal 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;
|
||||
}
|
||||
45
apps/web/src/styles/workspace/settings.css
Normal file
45
apps/web/src/styles/workspace/settings.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
145
apps/web/tests/components/ChatComposer.send-key.test.tsx
Normal file
145
apps/web/tests/components/ChatComposer.send-key.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in a new issue