diff --git a/frontend/src/App.css b/frontend/src/App.css index ae1fdfc..94e5679 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2356,6 +2356,11 @@ body.image-preview-open { overflow: hidden; } +.composer-card:focus-visible { + outline: 2px solid rgba(77, 168, 255, 0.78); + outline-offset: -2px; +} + .composer-shell { display: flex; flex-direction: column; @@ -3185,3 +3190,102 @@ body[data-route='/'] .social-link-button:focus-visible::before { .browser-assist-form { max-width: 620px; } + +.keyboard-shortcuts-settings { + max-width: 780px; +} + +.keyboard-shortcuts-heading h4 { + margin: 24px 0 4px; +} + +.keyboard-shortcuts-heading .ant-typography { + margin-bottom: 16px; +} + +.keyboard-shortcut-list { + overflow: hidden; + border: 1px solid var(--app-border); + border-radius: 10px; + background: var(--app-bg-panel-strong); +} + +.keyboard-shortcut-row { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(120px, auto) auto; + align-items: center; + gap: 16px; + padding: 14px 16px; + border-bottom: 1px solid var(--app-border-soft); +} + +.keyboard-shortcut-row:last-child { + border-bottom: 0; +} + +.keyboard-shortcut-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.keyboard-shortcut-key { + min-width: 108px; + padding: 5px 9px; + border: 1px solid var(--app-border); + border-bottom-color: var(--app-border-strong); + border-radius: 6px; + background: var(--app-bg-muted); + color: var(--app-text); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + font-weight: 700; + line-height: 1.4; + text-align: center; + white-space: nowrap; +} + +.keyboard-shortcut-key.is-empty { + color: var(--app-text-muted); + font-weight: 500; +} + +.keyboard-shortcut-actions { + justify-self: end; +} + +.keyboard-shortcuts-error { + margin-top: 12px; +} + +.keyboard-shortcuts-footer { + margin-top: 16px; +} + +.keyboard-shortcut-live { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 720px) { + .keyboard-shortcut-row { + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + } + + .keyboard-shortcut-key { + min-width: 96px; + } + + .keyboard-shortcut-actions { + grid-column: 1 / -1; + justify-self: start; + } +} diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx index 2fce863..6067246 100644 --- a/frontend/src/components/ChatPane.tsx +++ b/frontend/src/components/ChatPane.tsx @@ -38,6 +38,8 @@ import { ToolField } from './ToolField' import { ChatMessageList } from './ChatMessageList' import { ToolCallAssistPopover } from '../features/tool-call-assist/ToolCallAssistPopover' import { normalizeChatText } from '../lib/chat-format' +import { formatKeyboardShortcutBinding, type KeyboardShortcutConfig } from '../features/keyboard-shortcuts/shortcuts' +import type { ComposerKeyboardAction } from '../hooks/chatWorkspace/composerKeyboard' import type { ComposerMode, BuiltinToolOption, @@ -65,10 +67,11 @@ type ChatPaneProps = { composer: string composerMode: ComposerMode draftBuffer: string - handleComposerKeyDown: (event: KeyboardEvent) => void + handleComposerKeyDown: (event: KeyboardEvent) => ComposerKeyboardAction['type'] isMobile: boolean isWaitingForUser: boolean keyboardOffset: number + keyboardShortcuts: KeyboardShortcutConfig messagesLoading: boolean onDraft: () => void | Promise onAutomationRecording: (action: 'start' | 'stop' | 'cancel') => void | Promise @@ -119,6 +122,7 @@ export function ChatPane(props: ChatPaneProps) { isMobile, isWaitingForUser, keyboardOffset, + keyboardShortcuts, messagesLoading, onDraft, onAutomationRecording, @@ -264,6 +268,38 @@ export function ChatPane(props: ChatPaneProps) { { label: 'summery 模式', value: 'summery' }, { label: 'reasoning 模式', value: 'reasoning' }, ] + const shortcutCopy = (action: keyof KeyboardShortcutConfig['bindings'], label: string) => { + const binding = keyboardShortcuts.bindings[action] + return binding ? `${formatKeyboardShortcutBinding(binding)} ${label}` : '' + } + const modeCycleShortcut = shortcutCopy('cycle_mode', '切换类型') + const answerShortcutHint = [ + shortcutCopy('stream', '流式输出'), + shortcutCopy('newline', '换行'), + shortcutCopy('complete', '结束输出'), + shortcutCopy('restore_draft', '继续编辑'), + modeCycleShortcut, + ].filter(Boolean).join(',') + const thinkingShortcutHint = [ + shortcutCopy('stream', '流式输出'), + shortcutCopy('newline', '换行'), + modeCycleShortcut, + ].filter(Boolean).join(',') + + function handleComposerAreaKeyDown(event: KeyboardEvent) { + const action = handleComposerKeyDown(event) + if (action !== 'cycle_mode') return + window.requestAnimationFrame(() => { + const editor = composerCardRef.current?.querySelector( + 'textarea[data-composer-editor="true"]:not(:disabled)', + ) + if (editor) { + editor.focus() + } else { + composerCardRef.current?.focus() + } + }) + } return (
@@ -299,7 +335,15 @@ export function ChatPane(props: ChatPaneProps) { />
- +
{draftBuffer && ( @@ -539,16 +583,16 @@ export function ChatPane(props: ChatPaneProps) { 当前会以{' '} {reasoningStreamMode === 'reasoning' ? 'reasoning' : 'summery'} - 输出给调用方 · Enter 流式输出,Shift+Enter 换行 + 输出给调用方{thinkingShortcutHint ? ` · ${thinkingShortcutHint}` : ''}