feat(ui): global Ctrl+K / Ctrl+N shortcuts with sidebar hint

This commit is contained in:
matevip 2026-05-15 10:19:32 +08:00
parent e10dd9d0a0
commit c20c066cbc
4 changed files with 87 additions and 2 deletions

View File

@ -400,6 +400,9 @@ export default {
appearance: 'Appearance & Language',
roleUser: 'User',
roleAdmin: 'Admin',
shortcutAgents: 'Agents',
shortcutNew: 'New',
shortcutsHint: 'Ctrl+K {agents} | Ctrl+N {newAction}',
},
notifications: {
pendingApprovals: '{n} tool call(s) pending approval',

View File

@ -400,6 +400,9 @@ export default {
appearance: '外观与语言',
roleUser: '用户',
roleAdmin: '管理员',
shortcutAgents: '智能体',
shortcutNew: '新建',
shortcutsHint: 'Ctrl+K {agents} | Ctrl+N {newAction}',
},
// Issue #81: provider-level hint / status text shared across chat popup and ModelSelector.
provider: {

View File

@ -1153,7 +1153,7 @@ onMounted(async () => {
mediumQuery.addEventListener('change', handleConvMediumChange)
await Promise.all([loadAgents(), loadModelState(), loadConversations()])
await hydrateStateFromRoute()
consumeRouteAction()
applyPendingRouteAction()
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
elapsedTickTimer = window.setInterval(() => {
if (activeCronRuns.value.length > 0) elapsedNow.value = Date.now()
@ -1186,8 +1186,11 @@ onBeforeUnmount(() => {
})
watch(() => route.query, () => {
// If a fresh action arrives (e.g. user re-fires Ctrl+K via the URL while
// the view is already alive), pick it up immediately.
captureRouteAction()
if (pendingRouteAction) applyPendingRouteAction()
void hydrateStateFromRoute()
consumeRouteAction()
})
watch([selectedAgentId, currentConversationId], () => {

View File

@ -126,6 +126,14 @@
<el-icon :size="16"><SwitchButton /></el-icon>
</button>
</div>
<div class="shortcuts-hint" :title="shortcutsHintText">
<kbd>Ctrl+K</kbd>
<span>{{ t('nav.shortcutAgents') }}</span>
<span class="shortcuts-hint__sep">|</span>
<kbd>Ctrl+N</kbd>
<span>{{ t('nav.shortcutNew') }}</span>
</div>
</template>
<template v-else>
@ -250,6 +258,41 @@ function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
}
}
type ChatShortcutAction = 'newChat' | 'selectAgent'
const shortcutsHintText = computed(() =>
`Ctrl+K ${t('nav.shortcutAgents')} | Ctrl+N ${t('nav.shortcutNew')}`,
)
function fireChatShortcut(action: ChatShortcutAction) {
if (route.path === '/chat') {
window.dispatchEvent(new CustomEvent('mc:chat-shortcut', { detail: action }))
} else {
router.push({ path: '/chat', query: { action } })
}
}
function isEditableTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
if (el.isContentEditable) return true
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
return false
}
function onGlobalKeydown(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey
if (!mod || e.altKey) return
const key = e.key.toLowerCase()
if (key !== 'k' && key !== 'n') return
// Ctrl+N within an editable field should keep its native behavior; Ctrl+K
// is rarely used by browsers (Firefox uses it for search-bar focus), but we
// still want to let the chat input handle native paste / undo unblocked.
if (key === 'n' && isEditableTarget(e.target)) return
e.preventDefault()
fireChatShortcut(key === 'k' ? 'selectAgent' : 'newChat')
}
onMounted(async () => {
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
@ -259,6 +302,8 @@ onMounted(async () => {
handleMediumChange(mediumQuery)
mediumQuery.addEventListener('change', handleMediumChange)
window.addEventListener('keydown', onGlobalKeydown)
// Check onboarding status
if (!localStorage.getItem('mc-onboarding-done')) {
try {
@ -280,6 +325,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
mediumQuery?.removeEventListener('change', handleMediumChange)
window.removeEventListener('keydown', onGlobalKeydown)
})
function onNavClick() {
@ -759,6 +805,36 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
.utility-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--mc-text-tertiary); margin: 0 0 8px; padding-left: 2px; }
.shortcuts-hint {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px 6px;
margin-top: 8px;
padding: 4px 6px;
font-size: 10px;
color: var(--mc-text-tertiary);
letter-spacing: 0.02em;
user-select: none;
}
.shortcuts-hint kbd {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
font-family: inherit;
font-size: 9.5px;
font-weight: 600;
line-height: 1.4;
}
.shortcuts-hint__sep {
opacity: 0.45;
}
/* 主题切换 */
.theme-toggle-row {
display: flex;