Răsfoiți Sursa

WIP: 将会话和群聊搜索改为浮动框

samien 2 luni în urmă
părinte
comite
84e949602e

+ 12 - 2
src/client/components/app/ResourceSidebar.vue

@@ -5,12 +5,14 @@ import type { SidebarItem } from './types'
 
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
 const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
+const SIDEBAR_SEARCH_INPUT_EVENT = 'hermes-yaoyao:sidebar-search-input'
 
 const props = withDefaults(defineProps<{
   items: SidebarItem[]
   activeId?: string
   loading?: boolean
   searchable?: boolean
+  externalSearch?: boolean
   search?: string
   searchPlaceholder?: string
   emptyTitle?: string
@@ -22,6 +24,7 @@ const props = withDefaults(defineProps<{
   activeId: '',
   loading: false,
   searchable: true,
+  externalSearch: false,
   search: '',
   searchPlaceholder: '搜索',
   emptyTitle: '暂无内容',
@@ -73,8 +76,13 @@ function closeSearch(clear = false) {
   document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_CLOSE_EVENT))
 }
 
-function handleSearchRequest() { void focusSearch() }
+function handleSearchRequest() { if (!props.externalSearch) void focusSearch() }
 function handleSearchCloseRequest() { searchOpen.value = false }
+function handleExternalSearch(event: Event) {
+  if (!props.externalSearch) return
+  const detail = (event as CustomEvent<{ value?: unknown }>).detail
+  if (typeof detail?.value === 'string') emit('search', detail.value)
+}
 function handleListScroll(event: Event) {
   const element = event.currentTarget as HTMLElement
   if (props.hasMore && !props.loadingMore && element.scrollHeight - element.scrollTop - element.clientHeight < 96) {
@@ -89,10 +97,12 @@ watch(() => props.search, value => {
 onMounted(() => {
   document.addEventListener(SIDEBAR_SEARCH_EVENT, handleSearchRequest)
   document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSearchCloseRequest)
+  document.addEventListener(SIDEBAR_SEARCH_INPUT_EVENT, handleExternalSearch)
 })
 onBeforeUnmount(() => {
   document.removeEventListener(SIDEBAR_SEARCH_EVENT, handleSearchRequest)
   document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSearchCloseRequest)
+  document.removeEventListener(SIDEBAR_SEARCH_INPUT_EVENT, handleExternalSearch)
 })
 
 defineExpose({ focusSearch })
@@ -101,7 +111,7 @@ defineExpose({ focusSearch })
 <template>
   <div class="resource-sidebar">
     <Transition name="search-reveal">
-      <label v-if="searchable && searchOpen" class="sidebar-search">
+      <label v-if="searchable && !externalSearch && searchOpen" class="sidebar-search">
         <AppIcon name="search" :size="15" />
         <input
           ref="searchInput"

+ 55 - 2
src/client/components/app/WorkspaceShell.vue

@@ -15,6 +15,7 @@ type NavItem = {
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
 const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
 const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
+const SIDEBAR_SEARCH_INPUT_EVENT = 'hermes-yaoyao:sidebar-search-input'
 
 const props = withDefaults(defineProps<{
   userName?: string
@@ -51,8 +52,10 @@ const mobileDrawerOpen = ref(false)
 const profileMenuOpen = ref(false)
 const sidebarCollapsed = ref(false)
 const sidebarSearchOpen = ref(false)
+const sidebarSearchQuery = ref('')
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
+const floatingSearchInput = ref<HTMLInputElement | null>(null)
 
 const navItems: NavItem[] = [
   { key: 'chat', label: '对话', path: '/chat', icon: 'chat' },
@@ -76,6 +79,9 @@ const contextHeading = computed(() => ({
   files: '历史记录',
 })[activeNav.value.key])
 
+const searchUsesFloatingDialog = computed(() => ['chat', 'groups'].includes(activeNav.value.key))
+const floatingSearchLabel = computed(() => activeNav.value.key === 'groups' ? '搜索群聊' : '搜索会话')
+
 const hasPrimaryAction = computed(() => ['chat', 'groups', 'files'].includes(activeNav.value.key))
 
 async function navigate(path: string) {
@@ -103,6 +109,14 @@ async function openSidebarSearch(host: HTMLElement | null) {
     try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, '0') } catch { /* optional persistence */ }
     await nextTick()
   }
+  if (searchUsesFloatingDialog.value) {
+    if (host === mobileSidebarContext.value) mobileDrawerOpen.value = false
+    sidebarSearchQuery.value = ''
+    sidebarSearchOpen.value = true
+    await nextTick()
+    floatingSearchInput.value?.focus()
+    return
+  }
   sidebarSearchOpen.value = true
   document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_EVENT, { detail: { section: activeNav.value.key } }))
   await nextTick()
@@ -110,6 +124,14 @@ async function openSidebarSearch(host: HTMLElement | null) {
 }
 
 async function closeSidebarSearch(host: HTMLElement | null, clear = false, restoreFocus = false) {
+  if (searchUsesFloatingDialog.value) {
+    if (clear) {
+      sidebarSearchQuery.value = ''
+      document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_INPUT_EVENT, { detail: { section: activeNav.value.key, value: '' } }))
+    }
+    sidebarSearchOpen.value = false
+    return
+  }
   const input = host?.querySelector<HTMLInputElement>('input[type="search"]')
   if (clear && input?.value) {
     input.value = ''
@@ -124,6 +146,12 @@ async function closeSidebarSearch(host: HTMLElement | null, clear = false, resto
   }
 }
 
+function updateFloatingSearch(event: Event) {
+  const value = (event.target as HTMLInputElement).value
+  sidebarSearchQuery.value = value
+  document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_INPUT_EVENT, { detail: { section: activeNav.value.key, value } }))
+}
+
 function handleSidebarInput(event: Event) {
   const input = event.target as HTMLInputElement
   if (input.matches('input[type="search"]') && !input.value) void closeSidebarSearch(input.closest('.sidebar-context'))
@@ -139,6 +167,7 @@ function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
 watch(() => route.fullPath, () => { mobileDrawerOpen.value = false })
 watch(() => activeNav.value.key, () => {
   sidebarSearchOpen.value = false
+  sidebarSearchQuery.value = ''
   profileMenuOpen.value = false
 })
 
@@ -226,7 +255,7 @@ onBeforeUnmount(() => {
       <section
         ref="desktopSidebarContext"
         class="sidebar-context"
-        :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
+        :class="{ 'sidebar-context--searching': sidebarSearchOpen && !searchUsesFloatingDialog }"
         @keydown.esc.capture="closeSidebarSearch(desktopSidebarContext, true, true)"
         @input.capture="handleSidebarInput"
         @focusout.capture="handleSidebarFocusout"
@@ -330,7 +359,7 @@ onBeforeUnmount(() => {
       <section
         ref="mobileSidebarContext"
         class="sidebar-context mobile-drawer__context"
-        :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
+        :class="{ 'sidebar-context--searching': sidebarSearchOpen && !searchUsesFloatingDialog }"
         @keydown.esc.capture="closeSidebarSearch(mobileSidebarContext, true, true)"
         @input.capture="handleSidebarInput"
         @focusout.capture="handleSidebarFocusout"
@@ -379,6 +408,26 @@ onBeforeUnmount(() => {
       </div>
     </aside>
 
+    <Teleport to="body">
+      <div v-if="sidebarSearchOpen && searchUsesFloatingDialog" class="sidebar-search-modal-backdrop" @click.self="closeSidebarSearch(null, true)">
+        <section class="sidebar-search-modal" role="dialog" aria-modal="true" :aria-label="floatingSearchLabel">
+          <label>
+            <AppIcon name="search" :size="18" />
+            <input
+              ref="floatingSearchInput"
+              :value="sidebarSearchQuery"
+              type="search"
+              :placeholder="floatingSearchLabel"
+              :aria-label="floatingSearchLabel"
+              @input="updateFloatingSearch"
+              @keydown.esc.prevent="closeSidebarSearch(null, true)"
+            />
+          </label>
+          <button type="button" aria-label="关闭搜索" title="关闭搜索" @click="closeSidebarSearch(null, true)"><AppIcon name="close" :size="17" /></button>
+        </section>
+      </div>
+    </Teleport>
+
     <main class="workspace-main" :inert="mobileDrawerOpen">
       <slot />
     </main>
@@ -484,6 +533,9 @@ onBeforeUnmount(() => {
   background: var(--surface);
 }
 
+.sidebar-search-modal-backdrop { position: fixed; z-index: 70; inset: 0; display: grid; place-items: start center; padding: min(18vh, 150px) 20px 20px; background: color-mix(in srgb, var(--text-primary) 20%, transparent); backdrop-filter: blur(3px); }
+.sidebar-search-modal { display: flex; width: min(100%, 580px); align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: 0 20px 60px color-mix(in srgb, var(--text-primary) 22%, transparent); }.sidebar-search-modal label { display: flex; min-width: 0; flex: 1; align-items: center; gap: 9px; min-height: 37px; padding: 0 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); background: var(--surface-soft); }.sidebar-search-modal label:focus-within { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.sidebar-search-modal input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: 13px var(--font-ui); }.sidebar-search-modal button { display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 0; border-radius: 9px; background: transparent; color: var(--text-muted); cursor: pointer; }.sidebar-search-modal button:hover { background: var(--surface-hover); color: var(--text-primary); }
+
 .sidebar-primary-action { margin: 3px 10px; }
 .sidebar-primary-action :deep(button) {
   display: flex;
@@ -582,6 +634,7 @@ onBeforeUnmount(() => {
   .workspace-main { grid-row: 2; }
   .drawer-scrim { display: block; position: fixed; z-index: 50; inset: 0; padding: 0; border: 0; background: var(--scrim); backdrop-filter: blur(2px); }
   .mobile-drawer { --sidebar-search-top: calc(max(9px, env(safe-area-inset-top)) + 49px); display: flex; position: fixed; z-index: 51; inset: 0 auto 0 0; width: min(304px, 88vw); min-height: 0; flex-direction: column; overflow: hidden; padding: max(9px, env(safe-area-inset-top)) 0 max(9px, env(safe-area-inset-bottom)); background: var(--surface); box-shadow: var(--shadow-float); transform: translateX(-102%); transition: transform 190ms var(--ease-out); }
+  .sidebar-search-modal-backdrop { place-items: end center; padding: 0; }.sidebar-search-modal { width: 100%; padding: 12px max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); border-radius: 18px 18px 0 0; }.sidebar-search-modal label { min-height: 42px; }
   .mobile-drawer--open { transform: translateX(0); }
   .mobile-drawer__header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; padding: 0 14px 5px 12px; }
   .mobile-drawer__context { margin-top: 2px; }

+ 1 - 0
src/client/views/ChatView.vue

@@ -376,6 +376,7 @@ watch(() => chat.activeSessionId, async id => {
         :active-id="chat.activeSessionId"
         :loading="chat.isLoading"
         :search="search"
+        external-search
         single-line
         :has-more="chat.hasMoreSessions"
         :loading-more="chat.isLoadingMoreSessions"

+ 1 - 0
src/client/views/GroupsView.vue

@@ -223,6 +223,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :active-id="groups.selectedRoomId"
         :loading="groups.isLoading"
         :search="search"
+        external-search
         search-placeholder="搜索群聊"
         empty-title="还没有群聊"
         empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"

+ 19 - 11
tests/e2e/workspace.spec.ts

@@ -103,6 +103,19 @@ test('uses the active session or group title in the browser title', async ({ pag
   await expect(page).toHaveTitle('设计与工程协作 · 夭夭')
 })
 
+test('uses a floating search dialog for group rooms', async ({ page }) => {
+  await page.getByRole('button', { name: '群聊' }).click()
+  await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
+
+  await page.locator('.desktop-sidebar').getByRole('button', { name: '搜索', exact: true }).click()
+  const searchDialog = page.getByRole('dialog', { name: '搜索群聊' })
+  await expect(searchDialog).toBeVisible()
+  await searchDialog.getByPlaceholder('搜索群聊').fill('设计')
+  await expect(page.getByRole('option', { name: /设计与工程协作/ })).toBeVisible()
+  await searchDialog.getByPlaceholder('搜索群聊').press('Escape')
+  await expect(searchDialog).toBeHidden()
+})
+
 test('edits every protocol v3 Agent setting with one inspector close control', async ({ page }) => {
   await page.getByRole('button', { name: '群聊' }).click()
   await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
@@ -693,21 +706,16 @@ test('uses the unified Grok-style sidebar, search trigger, and persistent collap
   await expect(sidebar.getByText('历史记录', { exact: true })).toBeVisible()
 
   const searchTrigger = page.getByRole('button', { name: '搜索', exact: true })
-  const triggerBox = await searchTrigger.boundingBox()
   await searchTrigger.click()
-  const search = page.getByPlaceholder('搜索会话').filter({ visible: true })
+  const searchDialog = page.getByRole('dialog', { name: '搜索会话' })
+  await expect(searchDialog).toBeVisible()
+  const search = searchDialog.getByPlaceholder('搜索会话')
   await expect(search).toBeVisible()
-  await page.waitForTimeout(150)
-  const inputBox = await search.evaluate(element => {
-    const rect = element.closest('label')?.getBoundingClientRect()
-    return rect ? { y: rect.y, height: rect.height } : null
-  })
-  expect(inputBox?.y).toBe(triggerBox?.y)
-  expect(inputBox?.height).toBe(triggerBox?.height)
+  await expect(sidebar.locator('.sidebar-search')).toHaveCount(0)
   await search.fill('验收')
   await expect(page.getByRole('option', { name: /夭夭 Web 验收会话/ })).toBeVisible()
   await search.press('Escape')
-  await expect(search).toBeHidden()
+  await expect(searchDialog).toBeHidden()
   await expect(searchTrigger).toBeVisible()
   await expect(searchTrigger).toHaveAttribute('aria-expanded', 'false')
 
@@ -723,7 +731,7 @@ test('uses the unified Grok-style sidebar, search trigger, and persistent collap
   await expect(sidebar).toHaveCSS('width', '68px')
   await page.getByRole('button', { name: '搜索', exact: true }).click()
   await expect(sidebar).toHaveCSS('width', '264px')
-  await expect(page.getByPlaceholder('搜索会话').filter({ visible: true })).toBeVisible()
+  await expect(page.getByRole('dialog', { name: '搜索会话' })).toBeVisible()
 })
 
 test('uses the mobile composer and keeps the closed drawer inert', async ({ page }) => {