ソースを参照

发布 v0.3.11:精简 Bot 侧栏并调整设置与模式入口

LSamien 1 ヶ月 前
親
コミット
0a0dade0d4

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.10**,配套 iOS **1.3(173)**。详见 [发布说明](docs/releases/v0.3.10.md)。
+当前发布版本:**v0.3.11**,配套 iOS **1.3(173)**。详见 [发布说明](docs/releases/v0.3.11.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 15 - 0
docs/releases/v0.3.11.md

@@ -0,0 +1,15 @@
+# v0.3.11
+
+调整 Bot 模式侧栏与普通聊天的底部入口。
+
+- Bot 模式去掉侧栏的“聊天列表”标题和普通聊天分组的“聊天”标签,保留置顶标识。
+- Bot 模式桌面侧栏由 264px 加宽至 300px;折叠宽度和手机网页抽屉延续原样式。
+- 普通聊天移除侧栏“聊天”功能入口,底部齿轮打开“进入设置”和“进入 Bot 模式”菜单。
+- 保留原有账号区域行为与 Agent 切换,并支持菜单键盘操作和关闭设置后的焦点恢复。
+
+本次仅更新 Web,继续配套 iOS 1.3(173)。
+
+## 验证
+
+- 85 个测试文件、498 项测试,以及类型检查、生产构建通过。
+- 浏览器验证侧栏宽度、标题移除、设置菜单、进入 Bot 模式,以及原有群聊创建、成员管理、归档搜索和移动端抽屉流程。

+ 3 - 3
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.10",
+  "version": "0.3.11",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.10",
+      "version": "0.3.11",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",
@@ -6215,7 +6215,7 @@
       }
     },
     "node_modules/seemly": {
-      "version": "0.3.10",
+      "version": "0.3.11",
       "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
       "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
       "license": "MIT"

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.10",
+  "version": "0.3.11",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.10",
-  "webVersion": "0.3.10",
-  "gitTag": "v0.3.10"
+  "releaseVersion": "0.3.11",
+  "webVersion": "0.3.11",
+  "gitTag": "v0.3.11"
 }

+ 46 - 9
src/client/components/app/WorkspaceShell.vue

@@ -103,6 +103,10 @@ const createMenuOpen = ref(false)
 const createMenu = ref<HTMLElement | null>(null)
 let createTrigger: HTMLElement | null = null
 const createPosition = ref({ left: '8px', top: '58px' })
+const settingsMenuOpen = ref(false)
+const settingsMenu = ref<HTMLElement | null>(null)
+let settingsTrigger: HTMLButtonElement | null = null
+const settingsMenuPosition = ref({ left: '8px', top: '8px' })
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
@@ -119,7 +123,7 @@ const navItems: NavItem[] = [
 
 // The active workspace is already represented by its main canvas and sidebar
 // context. Keep this strip focused on destinations the user can switch to.
-const featureNavItems = computed(() => navItems.filter(item => item.key !== activeNav.value.key
+const featureNavItems = computed(() => navItems.filter(item => item.key !== 'groups' && item.key !== activeNav.value.key
  ))
 
 const applicationWorkspace = computed(() => route.path.startsWith('/conversations'))
@@ -156,6 +160,7 @@ function closeMenus(event: MouseEvent) {
   const target = event.target as HTMLElement
   if (!target.closest('.sidebar-account-switcher')) profileMenuOpen.value = false
   if (!target.closest('.workspace-create-menu, .sidebar-create-trigger')) createMenuOpen.value = false
+  if (!target.closest('.workspace-settings-menu, .sidebar-settings-trigger')) settingsMenuOpen.value = false
 }
 function closeCreateMenu() {
   createMenuOpen.value = false
@@ -177,13 +182,37 @@ function chooseCreate(kind: 'agent' | 'group') {
   if (kind === 'agent') emit('createAgent')
   else emit('createGroup')
 }
-function createMenuKeydown(event: KeyboardEvent) {
+function actionMenuKeydown(event: KeyboardEvent) {
   if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
   event.preventDefault()
-  const buttons = [...(createMenu.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])]
+  const buttons = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
   const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
   buttons[event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length]?.focus()
 }
+
+function closeSettingsMenu() {
+  settingsMenuOpen.value = false
+  void nextTick(() => { if (settingsTrigger?.isConnected) settingsTrigger.focus() })
+}
+async function openSettingsMenu(event: MouseEvent) {
+  if (applicationWorkspace.value) { openSettings('account-security', event); return }
+  if (settingsMenuOpen.value) { closeSettingsMenu(); return }
+  settingsTrigger = event.currentTarget as HTMLButtonElement
+  const rect = settingsTrigger.getBoundingClientRect()
+  settingsMenuPosition.value = { left: `${Math.max(8, Math.min(rect.right - 180, window.innerWidth - 188))}px`, top: `${Math.max(8, rect.top - 92)}px` }
+  profileMenuOpen.value = false
+  createMenuOpen.value = false
+  settingsMenuOpen.value = true
+  await nextTick()
+  settingsMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
+}
+function chooseSettingsAction(action: 'settings' | 'bots') {
+  settingsMenuOpen.value = false
+  if (action === 'bots') { void navigate('/conversations'); return }
+  settingsReturnFocus.value = settingsTrigger?.closest('.mobile-drawer')
+    ? mobileNavigationTrigger.value : settingsTrigger ?? undefined
+  openSettings('agent-identity')
+}
 function workspaceKeydown(event: KeyboardEvent) {
   if (!applicationWorkspace.value || !(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'k' || settingsOpen.value || document.querySelector('dialog[open]')) return
   event.preventDefault()
@@ -248,6 +277,7 @@ function closeSettings() {
 
 function toggleSidebar() {
   createMenuOpen.value = false
+  settingsMenuOpen.value = false
   sidebarCollapsed.value = !sidebarCollapsed.value
   try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0') } catch { /* optional persistence */ }
 }
@@ -301,7 +331,7 @@ function handleSidebarFocusout(event: FocusEvent) {
 
 function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
 
-watch(() => route.fullPath, () => { mobileDrawerOpen.value = false; createMenuOpen.value = false })
+watch(() => route.fullPath, () => { mobileDrawerOpen.value = false; createMenuOpen.value = false; settingsMenuOpen.value = false })
 watch(() => activeNav.value.key, () => {
   sidebarSearchOpen.value = false
   profileMenuOpen.value = false
@@ -402,7 +432,7 @@ onBeforeUnmount(() => {
         @focusout.capture="handleSidebarFocusout"
       >
         <slot name="sidebar-before-heading" />
-        <header class="sidebar-context__heading">
+        <header v-if="!applicationWorkspace" class="sidebar-context__heading">
           <strong>{{ sidebarContextTitle || contextHeading }}</strong>
           <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
         </header>
@@ -421,7 +451,7 @@ onBeforeUnmount(() => {
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
-          <button class="sidebar-settings-trigger" type="button" title="设置中心" aria-label="打开设置中心" @click="openSettings(applicationWorkspace ? 'account-security' : 'agent-identity', $event)">
+          <button class="sidebar-settings-trigger" type="button" :title="applicationWorkspace ? '设置中心' : '设置与模式'" :aria-label="applicationWorkspace ? '打开设置中心' : '设置与模式'" :aria-haspopup="applicationWorkspace ? undefined : 'menu'" :aria-expanded="applicationWorkspace ? undefined : settingsMenuOpen" @click="openSettingsMenu">
             <AppIcon name="settings" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -508,7 +538,7 @@ onBeforeUnmount(() => {
         @focusout.capture="handleSidebarFocusout"
       >
         <slot name="sidebar-before-heading" />
-        <header class="sidebar-context__heading">
+        <header v-if="!applicationWorkspace" class="sidebar-context__heading">
           <strong>{{ sidebarContextTitle || contextHeading }}</strong>
           <span v-if="sidebarSubtitle">{{ sidebarSubtitle }}</span>
         </header>
@@ -527,7 +557,7 @@ onBeforeUnmount(() => {
             </span>
             <AppIcon v-if="!applicationWorkspace" class="sidebar-account-switcher__chevron" name="chevron-down" :size="14" />
           </button>
-          <button class="sidebar-settings-trigger" type="button" title="设置中心" aria-label="打开设置中心" @click="openSettings(applicationWorkspace ? 'account-security' : 'agent-identity', $event)">
+          <button class="sidebar-settings-trigger" type="button" :title="applicationWorkspace ? '设置中心' : '设置与模式'" :aria-label="applicationWorkspace ? '打开设置中心' : '设置与模式'" :aria-haspopup="applicationWorkspace ? undefined : 'menu'" :aria-expanded="applicationWorkspace ? undefined : settingsMenuOpen" @click="openSettingsMenu">
             <AppIcon name="settings" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -590,8 +620,14 @@ onBeforeUnmount(() => {
       @set-theme="emit('setTheme', $event)"
     />
     <Teleport to="body">
+      <div v-if="settingsMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeSettingsMenu" @keydown.esc.prevent.stop="closeSettingsMenu">
+        <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :style="settingsMenuPosition" role="menu" aria-label="设置与模式" @keydown="actionMenuKeydown">
+          <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />进入设置</button>
+          <button type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon name="users" :size="17" />进入 Bot 模式</button>
+        </div>
+      </div>
       <div v-if="createMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeCreateMenu" @keydown.esc.prevent.stop="closeCreateMenu">
-        <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="createMenuKeydown">
+        <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseCreate('agent')"><AppIcon name="users" :size="17" />新建 Bot</button>
           <button type="button" role="menuitem" @click="chooseCreate('group')"><AppIcon name="groups" :size="17" />新建群聊</button>
         </div>
@@ -612,6 +648,7 @@ onBeforeUnmount(() => {
   transition: grid-template-columns 180ms var(--ease-out);
 }
 
+.workspace-shell--conversations { grid-template-columns: 300px minmax(0, 1fr) auto; }
 .workspace-shell--collapsed { grid-template-columns: 68px minmax(0, 1fr) auto; }
 .mobile-header, .mobile-drawer, .drawer-scrim { display: none; }
 

+ 4 - 1
src/client/components/workspace/ConversationList.vue

@@ -8,7 +8,10 @@ const emit = defineEmits<{ select: [id: string]; pin: [id: string]; archive: [id
 const menuId = ref('')
 const menuPosition = ref({ left: '8px', top: '8px' })
 const menuConversation = computed(() => props.conversations.find(c => c.id === menuId.value))
-const rows = computed(() => props.conversations.filter(c => !c.archived).map(c => workspaceConversationItem(c, props.agents)))
+const rows = computed(() => props.conversations.filter(c => !c.archived).map(c => ({
+  ...workspaceConversationItem(c, props.agents),
+  section: c.pinned ? '已置顶' : '',
+})))
 function openMenu(id: string, event: MouseEvent) {
   menuId.value = id
   menuPosition.value = { left: `${Math.max(8, Math.min(event.clientX, window.innerWidth - 170))}px`, top: `${Math.max(8, Math.min(event.clientY, window.innerHeight - 100))}px` }

+ 18 - 1
tests/client/workspaceShell.test.ts

@@ -109,8 +109,12 @@ describe('Workspace shell account controls', () => {
 
     const settingsTrigger = desktop.get<HTMLButtonElement>('.sidebar-settings-trigger')
     expect(settingsTrigger.text()).toBe('')
-    expect(settingsTrigger.attributes('aria-label')).toBe('打开设置中心')
+    expect(settingsTrigger.attributes('aria-label')).toBe('设置与模式')
     await settingsTrigger.trigger('click')
+    const actions = document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')
+    expect([...actions].map(button => button.textContent?.trim())).toEqual(['进入设置', '进入 Bot 模式'])
+    actions[0]!.click()
+    await wrapper.vm.$nextTick()
     const settings = wrapper.get('[data-testid="settings-center"]')
     expect(settings.attributes('data-page')).toBe('agent-identity')
     expect(desktop.find('.profile-menu').exists()).toBe(false)
@@ -125,10 +129,17 @@ describe('Workspace shell account controls', () => {
     expect(wrapper.get('.mobile-header').attributes()).toHaveProperty('inert')
     expect(document.activeElement).toBe(wrapper.get('.mobile-drawer button[aria-label="关闭导航"]').element)
     await wrapper.get('.mobile-drawer .sidebar-settings-trigger').trigger('click')
+    document.querySelector<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')!.click()
+    await wrapper.vm.$nextTick()
     await wrapper.get('[data-testid="close-settings"]').trigger('click')
     await wrapper.vm.$nextTick()
     expect(document.activeElement).toBe(mobileNavigation.element)
 
+    expect(desktop.findAll('.sidebar-feature-nav button').map(button => button.text())).not.toContain('聊天')
+    await settingsTrigger.trigger('click')
+    document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')[1]!.click()
+    await vi.waitFor(() => expect(wrapper.classes()).toContain('workspace-shell--conversations'))
+    expect(document.querySelector('.workspace-settings-menu')).toBeNull()
     wrapper.unmount()
   })
 })
@@ -139,6 +150,12 @@ it('keeps the conversation sidebar focused with a two-item create menu and modal
   expect(rail.find('.sidebar-feature-nav').exists()).toBe(false)
   expect(rail.find('.sidebar-primary-action').exists()).toBe(false)
   expect(rail.find('.sidebar-footer').exists()).toBe(true)
+  expect(rail.find('.sidebar-context__heading').exists()).toBe(false)
+  expect(wrapper.find('.mobile-drawer .sidebar-context__heading').exists()).toBe(false)
+  await rail.get('.sidebar-account-switcher__main').trigger('click')
+  expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('account-security')
+  expect(document.querySelector('.workspace-settings-menu')).toBeNull()
+  await wrapper.get('[data-testid="close-settings"]').trigger('click')
   await rail.get('.sidebar-create-trigger').trigger('click')
   const items = document.querySelectorAll<HTMLButtonElement>('.workspace-create-menu [role="menuitem"]')
   expect([...items].map(item => item.textContent?.trim())).toEqual(['新建 Bot', '新建群聊'])

+ 2 - 1
tests/e2e/offlineUpdate.spec.ts

@@ -55,7 +55,8 @@ test('opens settings and schedules an independent Web update while all 9119 requ
     expect(changed.ok()).toBe(true)
     expect((await page.request.get(origin + '/readyz')).status()).toBe(503)
     await page.goto(origin + '/chat')
-    await page.getByRole('button', { name: '打开设置中心' }).click()
+    await page.getByRole('button', { name: /^(打开设置中心|设置与模式)$/ }).click()
+    if (await page.getByRole('menuitem', { name: '进入设置', exact: true }).isVisible()) await page.getByRole('menuitem', { name: '进入设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置中心' })
     await dialog.getByRole('button', { name: '系统概览', exact: true }).click()
     await expect(dialog.getByText(`Web ${current.webVersion} · 可独立升级与回滚`)).toBeVisible()

+ 19 - 0
tests/e2e/workspace-chat.spec.ts

@@ -35,6 +35,8 @@ test('created roles and editable teams share a durable chat list without plugin
   const rail = page.locator('.desktop-sidebar')
   await expect(rail.locator('.sidebar-feature-nav')).toHaveCount(0)
   await expect(rail.locator('.sidebar-primary-action')).toHaveCount(0)
+  await expect(rail).toHaveCSS('width', '300px')
+  await expect(rail.locator('.sidebar-context__heading')).toHaveCount(0)
   await expect(rail.getByRole('checkbox', { name: '显示已归档' })).toHaveCount(0)
   const plus = rail.getByRole('button', { name: '新建', exact: true })
   const collapse = rail.getByRole('button', { name: '折叠侧边栏', exact: true })
@@ -54,9 +56,26 @@ test('created roles and editable teams share a durable chat list without plugin
   await page.keyboard.press('Escape')
   await expect(rail.getByRole('button', { name: '搜索', exact: true })).toBeFocused()
   await rail.getByRole('button', { name: '展开侧边栏', exact: true }).click()
+  await page.goto('/chat')
+  await expect(rail).toHaveCSS('width', '264px')
+  await expect(rail.getByRole('button', { name: '聊天', exact: true })).toHaveCount(0)
+  await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
+  const settingsMenu = page.getByRole('menu', { name: '设置与模式', exact: true })
+  await expect(settingsMenu.getByRole('menuitem')).toHaveText(['进入设置', '进入 Bot 模式'])
+  await page.screenshot({ path: testInfo.outputPath('chat-settings-mode-menu.png') })
+  await settingsMenu.getByRole('menuitem', { name: '进入设置', exact: true }).click()
+  await expect(page.getByRole('dialog', { name: '设置中心', exact: true })).toBeVisible()
+  await page.getByRole('button', { name: '关闭设置中心', exact: true }).click()
+  await expect(rail.getByRole('button', { name: '设置与模式', exact: true })).toBeFocused()
+  await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
+  await settingsMenu.getByRole('menuitem', { name: '进入 Bot 模式', exact: true }).click()
+  await expect(page).toHaveURL(/\/conversations$/)
+  await expect(rail).toHaveCSS('width', '300px')
   await page.addInitScript(() => { Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }) })
   await page.reload()
   await createAgent(page, '产品经理')
+  await expect(rail.getByText('聊天', { exact: true })).toHaveCount(0)
+  await expect(rail.getByText('聊天列表', { exact: true })).toHaveCount(0)
   await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('请说明角色职责')
   await page.getByRole('button', { name: '发送消息', exact: true }).click()
   await expect(page.locator('.message--assistant')).toContainText('我是产品经理')

+ 2 - 1
tests/e2e/workspace.spec.ts

@@ -37,7 +37,8 @@ test.beforeEach(async ({ page }) => {
 })
 
 test('explains optional upstream credentials without blocking a ready connection', async ({ page }, testInfo) => {
-  await page.getByRole('button', { name: '打开设置中心' }).first().click()
+  await page.getByRole('button', { name: /^(打开设置中心|设置与模式)$/ }).first().click()
+  if (await page.getByRole('menuitem', { name: '进入设置', exact: true }).isVisible()) await page.getByRole('menuitem', { name: '进入设置', exact: true }).click()
   const dialog = page.getByRole('dialog', { name: '设置中心' })
   await dialog.getByRole('button', { name: 'Hermes 连接', exact: true }).click()
   await expect(dialog.locator('[aria-label="Hermes 连接状态"]')).toBeVisible()