fix: unify window titlebar controls and distinguish conversation settings

Move preferences and shortcuts into a global titlebar 12px from the right edge. Remove hidden duplicate controls and preserve shortcut dismissal. Replace conversation settings gear with sliders.

Verified: frontend tests 343/343, web and desktop builds, Electron position and interaction checks.
This commit is contained in:
Ryder Sun committed 2026-09-14 15:21:04 +08:00
1 parent 5465609d54
commit 956ddd0dd9
12 files changed
+121 -120

No files matched your search

+7 -1
View File
@@ -22,6 +22,10 @@
</section>
<template v-else>
<WindowTitlebar ref="windowTitlebar" v-model:shortcut-menu-open="shortcutMenuOpen"
:t="t" :theme="theme" :active-view="activeView" :blocked="contentInteractionBlocked"
:open-system-settings="openSystemSettings" :toggle-locale="toggleLocale" :toggle-theme="toggleTheme"
:shortcut-definitions="shortcutDefinitions" />
<transition name="agent-discovery" appear>
<section
v-if="agentDiscoveryPending"
@@ -188,6 +192,7 @@ import {
WarningOutline,
} from '@vicons/ionicons5'
import ConversationComposer from './components/ConversationComposer.vue'
import WindowTitlebar from './components/WindowTitlebar.vue'
import ConversationHeader from './components/ConversationHeader.vue'
import ConversationTimelineView from './components/ConversationTimelineView.vue'
import HomeDashboard from './components/HomeDashboard.vue'
@@ -270,6 +275,7 @@ const installConfirmKind = ref('')
const focusedAgentKind = ref('')
const shortcutMenuOpen = ref(false)
const conversationHeader = ref(null)
const windowTitlebar = ref(null)
const workspaceModalContent = ref(null)
const onboardingDialog = ref(null)
const modalDialog = ref(null)
@@ -912,7 +918,7 @@ useAppWindowInteractions({
collapsedGroupMenuButton,
collapsedGroupMenuOpen,
completeOnboarding,
conversationHeader,
windowTitlebar,
customAgentDeleteArmed,
deleteArmed,
messageDeleteArmedId,
@@ -926,7 +926,7 @@ describe('Meldwork workbench', () => {
expect(runningStep.text()).toBe('Running')
expect(runningStep.classes()).toContain('running')
await wrapper.findAll('.sidebar-footer-actions button')[0].trigger('click')
await wrapper.findAll('.titlebar-preferences button')[0].trigger('click')
const detailsText = wrapper.findAll('.execution-details').map(details => details.text()).join(' ')
expect(detailsText).toContain('运行进程')
expect(detailsText).toContain('写入文件')
+11 -7
View File
@@ -92,7 +92,7 @@ describe('Meldwork workbench', () => {
expect(wrapper.get('.sidebar-settings-entry').attributes('aria-current')).toBe('page')
expect(wrapper.get('.brand-button').attributes()).not.toHaveProperty('aria-current')
const controls = wrapper.findAll('.sidebar-footer-actions button')
const controls = wrapper.findAll('.titlebar-preferences button')
await controls[0].trigger('click')
expect(wrapper.get('.system-settings-header h1').text()).toBe('设置')
expect(wrapper.get('.system-settings-header p').text()).toContain('知识库')
@@ -307,7 +307,7 @@ describe('Meldwork workbench', () => {
await flushPromises()
expect(wrapper.get('.conversation-empty-copy').text()).toContain('再把 Agent 们叫到一起')
await wrapper.findAll('.sidebar-footer-actions button')[1].trigger('click')
await wrapper.findAll('.titlebar-preferences button')[1].trigger('click')
expect(wrapper.get('.conversation-empty-wordmark').attributes('src')).toBe('./logos/meldwork-wordmark-v3-dark.svg')
await wrapper.get('.conversation-link').trigger('click')
@@ -478,8 +478,8 @@ describe('Meldwork workbench', () => {
const { wrapper } = await mountApp()
expect(wrapper.find('.brand-actions').exists()).toBe(false)
expect(wrapper.findAll('.sidebar-footer-actions button')).toHaveLength(2)
expect(wrapper.findAll('.sidebar-footer-actions .preference-icon-frame')).toHaveLength(2)
expect(wrapper.findAll('.titlebar-preferences button')).toHaveLength(2)
expect(wrapper.findAll('.titlebar-preferences .preference-icon-frame')).toHaveLength(2)
expect(wrapper.findAll('.nav-heading svg')).toHaveLength(0)
expect(wrapper.findAll('.sidebar-agent-main img')).toHaveLength(AGENTS.length)
const [agentsToggle, groupsToggle] = wrapper.findAll('.nav-heading')
@@ -736,7 +736,7 @@ describe('Meldwork workbench', () => {
wrapper.unmount()
})
it('lists core shortcuts beside conversation settings and handles sidebar toggle', async () => {
it('lists core shortcuts in the global titlebar and handles sidebar toggle', async () => {
const { wrapper } = await mountApp(({ state }) => {
state.groups.push({
id: 'group-shortcuts',
@@ -752,7 +752,11 @@ describe('Meldwork workbench', () => {
})
await wrapper.get('.conversation-link').trigger('click')
const shortcutButton = wrapper.get('[aria-label="Keyboard shortcuts"]')
expect(wrapper.find('.sidebar .titlebar-actions').exists()).toBe(false)
expect(wrapper.find('.conversation-header .shortcut-menu-anchor').exists()).toBe(false)
expect(wrapper.findAll('.titlebar-actions button')).toHaveLength(4)
expect(wrapper.get('[aria-label="Conversation settings"] svg').html()).not.toBe(wrapper.get('.titlebar-actions .sidebar-settings-entry svg').html())
const shortcutButton = wrapper.get('.titlebar-actions [aria-label="Keyboard shortcuts"]')
expect(shortcutButton.find('.keyboard-shortcut-icon').exists()).toBe(true)
await wrapper.get('.shortcut-menu-anchor').trigger('mouseenter')
expect(wrapper.get('#keyboard-shortcut-menu').attributes('role')).toBe('tooltip')
@@ -1379,7 +1383,7 @@ describe('Meldwork workbench', () => {
expect(wrapper.get('.system-message .markdown-body').text()).toBe('Recovered conclusion before timeout.')
expect(wrapper.get('.system-message').text()).not.toContain('Hermes failed: LOCAL_AGENT_TIMEOUT')
await wrapper.findAll('.sidebar-footer-actions button')[0].trigger('click')
await wrapper.findAll('.titlebar-preferences button')[0].trigger('click')
expect(wrapper.get('.conversation-link').text()).toContain('Agent 群聊')
expect(wrapper.get('.system-message').text()).toContain('Hermes 调用失败:该 Agent 响应超时')
expect(wrapper.get('.system-message .markdown-body').text()).toBe('Recovered conclusion before timeout.')
@@ -23,7 +23,7 @@ function mountInteractions(overrides = {}) {
collapsedGroupMenuButton: ref(null),
collapsedGroupMenuOpen,
completeOnboarding: vi.fn(() => { onboardingVisible.value = false }),
conversationHeader: ref({ containsShortcutTarget: () => false }),
windowTitlebar: ref({ containsShortcutTarget: () => false }),
customAgentDeleteArmed: ref(false),
deleteArmed: ref(false),
messageDeleteArmedId: ref(''),
@@ -176,7 +176,7 @@ describe('App window interactions', () => {
const { deps } = mountInteractions()
deps.collapsedGroupMenu.value = collapsedMenu
deps.conversationHeader.value = { containsShortcutTarget: target => target === inside }
deps.windowTitlebar.value = { containsShortcutTarget: target => target === inside }
deps.roundSettingsControl.value = roundControl
deps.messageDeleteArmedId.value = 'message-1'
deps.sidebarDeleteGroupId.value = 'group-1'
+3 -68
View File
@@ -83,40 +83,6 @@
<FolderOpenOutline />
<span>{{ compactPath(activeGroup.workdir) }}</span>
</button>
<div
ref="shortcutMenu"
class="shortcut-menu-anchor"
@mouseenter="openShortcutMenu"
@mouseleave="scheduleShortcutMenuClose"
@focusin="openShortcutMenu"
@focusout="closeShortcutMenuAfterFocus"
>
<button
class="icon-button"
type="button"
:title="t('shortcut.title')"
:aria-label="t('shortcut.title')"
:aria-expanded="String(shortcutMenuOpen)"
aria-controls="keyboard-shortcut-menu"
>
<span class="keyboard-shortcut-icon" aria-hidden="true"><span /></span>
</button>
<section
v-if="shortcutMenuOpen"
id="keyboard-shortcut-menu"
class="shortcut-menu"
role="tooltip"
:aria-label="t('shortcut.title')"
>
<header>{{ t('shortcut.title') }}</header>
<ul>
<li v-for="shortcut in shortcutDefinitions" :key="shortcut.labelKey">
<span>{{ t(shortcut.labelKey) }}</span>
<kbd>{{ shortcut.keys }}</kbd>
</li>
</ul>
</section>
</div>
<button
class="icon-button"
type="button"
@@ -125,20 +91,20 @@
:disabled="Boolean(activeRun) || sending"
@click="openGroupSettings"
>
<SettingsOutline />
<OptionsOutline />
</button>
</div>
</header>
</template>
<script setup>
import { onBeforeUnmount, ref } from 'vue'
import { ref } from 'vue'
import {
CheckmarkCircleOutline,
CloseOutline,
FolderOpenOutline,
PencilOutline,
SettingsOutline,
OptionsOutline,
} from '@vicons/ionicons5'
import { agentLogo } from '../catalog.js'
@@ -164,8 +130,6 @@ const {
saveInlineTitle,
saving,
sending,
shortcutDefinitions,
shortcutMenuOpen,
t,
theme,
} = props.controller
@@ -173,23 +137,6 @@ const {
const titleBlock = ref(null)
const titleInput = ref(null)
const titleButton = ref(null)
const shortcutMenu = ref(null)
let shortcutMenuCloseTimer = null
function openShortcutMenu() {
clearTimeout(shortcutMenuCloseTimer)
shortcutMenuCloseTimer = null
shortcutMenuOpen.value = true
}
function scheduleShortcutMenuClose() {
clearTimeout(shortcutMenuCloseTimer)
shortcutMenuCloseTimer = setTimeout(() => {
shortcutMenuOpen.value = false
shortcutMenuCloseTimer = null
}, 90)
}
function focusTitleBlock() {
titleBlock.value?.focus?.()
}
@@ -203,19 +150,7 @@ function focusTitleButton() {
titleButton.value?.focus?.()
}
function containsShortcutTarget(target) {
return shortcutMenu.value?.contains(target) === true
}
function closeShortcutMenuAfterFocus(event) {
if (shortcutMenu.value?.contains(event.relatedTarget)) return
scheduleShortcutMenuClose()
}
onBeforeUnmount(() => clearTimeout(shortcutMenuCloseTimer))
defineExpose({
containsShortcutTarget,
focusTitleBlock,
focusTitleButton,
focusTitleInput,
@@ -0,0 +1,84 @@
<template>
<div class="titlebar-actions" :aria-label="t('nav.settings')" :inert="blocked ? '' : undefined">
<button class="icon-button sidebar-settings-entry" :aria-current="activeView === 'settings' ? 'page' : undefined" type="button" :title="t('nav.settings')" :aria-label="t('nav.settings')" @click="openSystemSettings('agents')">
<SettingsOutline />
</button>
<div class="titlebar-preferences">
<button class="icon-button" type="button" :title="t('common.languageTarget')" :aria-label="t('common.language')" @click="toggleLocale">
<span class="preference-icon-frame"><LanguageOutline /></span>
</button>
<button class="icon-button" type="button" :title="theme === 'dark' ? t('common.themeLight') : t('common.themeDark')" :aria-label="theme === 'dark' ? t('common.themeLight') : t('common.themeDark')" @click="toggleTheme">
<span class="preference-icon-frame"><SunnyOutline v-if="theme === 'dark'" /><MoonOutline v-else /></span>
</button>
</div>
<div
ref="shortcutMenu"
class="shortcut-menu-anchor"
@mouseenter="openShortcutMenu"
@mouseleave="scheduleShortcutMenuClose"
@focusin="openShortcutMenu"
@focusout="closeShortcutMenuAfterFocus"
>
<button
class="icon-button"
type="button"
:title="t('shortcut.title')"
:aria-label="t('shortcut.title')"
:aria-expanded="String(shortcutMenuOpen)"
aria-controls="keyboard-shortcut-menu"
>
<span class="keyboard-shortcut-icon" aria-hidden="true"><span /></span>
</button>
<section
v-if="shortcutMenuOpen"
id="keyboard-shortcut-menu"
class="shortcut-menu"
role="tooltip"
:aria-label="t('shortcut.title')"
>
<header>{{ t('shortcut.title') }}</header>
<ul>
<li v-for="shortcut in shortcutDefinitions" :key="shortcut.labelKey">
<span>{{ t(shortcut.labelKey) }}</span>
<kbd>{{ shortcut.keys }}</kbd>
</li>
</ul>
</section>
</div>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { SettingsOutline, LanguageOutline, SunnyOutline, MoonOutline } from '@vicons/ionicons5'
defineProps(['t', 'theme', 'activeView', 'blocked', 'openSystemSettings', 'toggleLocale', 'toggleTheme', 'shortcutDefinitions'])
const shortcutMenuOpen = defineModel('shortcutMenuOpen', { type: Boolean, default: false })
const shortcutMenu = ref(null)
let shortcutMenuCloseTimer = null
function openShortcutMenu() {
clearTimeout(shortcutMenuCloseTimer)
shortcutMenuCloseTimer = null
shortcutMenuOpen.value = true
}
function scheduleShortcutMenuClose() {
clearTimeout(shortcutMenuCloseTimer)
shortcutMenuCloseTimer = setTimeout(() => {
shortcutMenuOpen.value = false
shortcutMenuCloseTimer = null
}, 90)
}
function containsShortcutTarget(target) {
return shortcutMenu.value?.contains(target) === true
}
function closeShortcutMenuAfterFocus(event) {
if (shortcutMenu.value?.contains(event.relatedTarget)) return
scheduleShortcutMenuClose()
}
onBeforeUnmount(() => clearTimeout(shortcutMenuCloseTimer))
defineExpose({ containsShortcutTarget })
</script>
+1 -26
View File
@@ -32,18 +32,6 @@
<ChevronForwardOutline v-if="sidebarCollapsed" />
<ChevronBackOutline v-else />
</button>
<div class="brand-preference-actions" aria-label="Workspace preferences">
<button class="icon-button" type="button" :title="t('nav.settings')" :aria-label="t('nav.settings')" @click="openSystemSettings('agents')">
<SettingsOutline />
</button>
<button class="icon-button" type="button" :title="t('common.languageTarget')" :aria-label="t('common.language')" @click="toggleLocale">
<LanguageOutline />
</button>
<button class="icon-button" type="button" :title="theme === 'dark' ? t('common.themeLight') : t('common.themeDark')" :aria-label="theme === 'dark' ? t('common.themeLight') : t('common.themeDark')" @click="toggleTheme">
<SunnyOutline v-if="theme === 'dark'" />
<MoonOutline v-else />
</button>
</div>
</header>
<button class="new-group-button" type="button" :title="t('nav.newGroup')" @click="openNewGroup">
@@ -288,13 +276,7 @@
</button>
</div>
<footer class="sidebar-footer legacy-preference-hooks" aria-hidden="true">
<button class="sidebar-settings-entry" type="button" @click="openSystemSettings('agents')"><SettingsOutline /><span>{{ t('nav.settings') }}</span></button>
<div class="sidebar-footer-actions">
<button class="icon-button" type="button" @click="toggleLocale"><LanguageOutline /></button>
<button class="icon-button" type="button" @click="toggleTheme"><SunnyOutline v-if="theme === 'dark'" /><MoonOutline v-else /></button>
</div>
</footer>
</aside>
<Teleport to="body">
@@ -381,12 +363,8 @@ import {
ChevronBackOutline,
ChevronForwardOutline,
CloudOutline,
LanguageOutline,
MoonOutline,
PencilOutline,
PeopleOutline,
SettingsOutline,
SunnyOutline,
TrashOutline,
} from '@vicons/ionicons5'
import { agentLogo } from '../catalog.js'
@@ -424,7 +402,6 @@ const {
openConversationRename,
openNewGroup,
openSidebarConversationDelete,
openSystemSettings,
productMark,
remainingDirectGroupsCount,
remainingGroupGroupsCount,
@@ -443,8 +420,6 @@ const {
toggleCollapsedGroupMenu,
toggleDirectSessionListExpanded,
toggleGroupSessionListExpanded,
toggleLocale,
toggleTheme,
visibleDirectGroupsFor,
visibleGroupGroups,
} = props.controller
@@ -8,7 +8,7 @@ export function useAppWindowInteractions({
collapsedGroupMenuButton,
collapsedGroupMenuOpen,
completeOnboarding,
conversationHeader,
windowTitlebar,
customAgentDeleteArmed,
deleteArmed,
messageDeleteArmedId,
@@ -139,7 +139,7 @@ export function useAppWindowInteractions({
if (roundSettingsOpen.value && !roundSettingsControl.value?.contains(target)) {
roundSettingsOpen.value = false
}
if (shortcutMenuOpen.value && !conversationHeader.value?.containsShortcutTarget(target)) {
if (shortcutMenuOpen.value && !windowTitlebar.value?.containsShortcutTarget(target)) {
shortcutMenuOpen.value = false
}
if (
-2
View File
@@ -126,8 +126,6 @@ export function createConversationControllers({
saveInlineTitle: conversationActions.saveInlineTitle,
saving: app.saving,
sending: app.sending,
shortcutDefinitions: app.shortcutDefinitions,
shortcutMenuOpen: app.shortcutMenuOpen,
t: app.t,
theme: app.theme,
}
@@ -206,8 +206,6 @@
padding: 9px 8px 12px;
}
.legacy-preference-hooks { display: none; }
.sidebar-footer button {
height: 38px;
display: grid;
+10 -6
View File
@@ -19,15 +19,15 @@
.brand-button,
.sidebar-toggle,
.brand-preference-actions {
.titlebar-actions {
-webkit-app-region: no-drag;
}
.brand-preference-actions {
.titlebar-actions {
display: inline-flex;
position: fixed;
top: 4px;
right: 76px;
top: 6px;
right: 12px;
z-index: 20;
align-items: center;
gap: 2px;
@@ -37,7 +37,7 @@
-webkit-app-region: no-drag;
}
.brand-preference-actions .icon-button {
.titlebar-actions .icon-button {
width: 28px;
height: 28px;
padding: 0;
@@ -46,7 +46,7 @@
color: var(--muted);
}
.brand-preference-actions .icon-button:hover {
.titlebar-actions .icon-button:hover {
border-color: var(--border);
background: var(--surface-hover);
color: var(--text);
@@ -751,3 +751,7 @@
.group-avatar.stack img:nth-child(1) { top: 0; left: 0; }
.group-avatar.stack img:nth-child(2) { right: 0; bottom: 0; }
.group-avatar.stack img:nth-child(3) { left: 0; bottom: 0; }
.titlebar-preferences { display: flex; gap: 2px; }
.app-shell:not([data-platform="darwin"]) { --desktop-titlebar-height: 32px; }
.titlebar-actions .shortcut-menu { top: calc(100% + 8px); right: 0; }
-3
View File
@@ -40,7 +40,6 @@ export function createWorkspaceControllers({
openConversationRename: conversationActions.openConversationRename,
openNewGroup: conversationActions.openNewGroup,
openSidebarConversationDelete: conversationActions.openSidebarConversationDelete,
openSystemSettings: app.openSystemSettings,
productMark: app.productMark,
remainingDirectGroupsCount: conversationNavigation.remainingDirectGroupsCount,
remainingGroupGroupsCount: conversationNavigation.remainingGroupGroupsCount,
@@ -59,8 +58,6 @@ export function createWorkspaceControllers({
toggleCollapsedGroupMenu: collapsedGroupMenu.toggleCollapsedGroupMenu,
toggleDirectSessionListExpanded: conversationNavigation.toggleDirectSessionListExpanded,
toggleGroupSessionListExpanded: conversationNavigation.toggleGroupSessionListExpanded,
toggleLocale: app.toggleLocale,
toggleTheme: app.toggleTheme,
visibleDirectGroupsFor: conversationNavigation.visibleDirectGroupsFor,
visibleGroupGroups: conversationNavigation.visibleGroupGroups,
}