Просмотр исходного кода

WIP: 在浮动搜索中展示会话和群聊结果

samien 2 месяцев назад
Родитель
Сommit
212a2c581c

Разница между файлами не показана из-за своего большого размера
+ 75 - 0
src/client/components/app/FloatingResourceSearch.vue


+ 0 - 8
src/client/components/app/ResourceSidebar.vue

@@ -5,7 +5,6 @@ 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[]
@@ -78,11 +77,6 @@ function closeSearch(clear = false) {
 
 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) {
@@ -97,12 +91,10 @@ 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 })

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

@@ -15,7 +15,6 @@ 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
@@ -52,10 +51,8 @@ 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' },
@@ -79,9 +76,6 @@ 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) {
@@ -109,14 +103,6 @@ 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()
@@ -124,14 +110,6 @@ 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 = ''
@@ -146,12 +124,6 @@ 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'))
@@ -167,7 +139,6 @@ function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
 watch(() => route.fullPath, () => { mobileDrawerOpen.value = false })
 watch(() => activeNav.value.key, () => {
   sidebarSearchOpen.value = false
-  sidebarSearchQuery.value = ''
   profileMenuOpen.value = false
 })
 
@@ -255,7 +226,7 @@ onBeforeUnmount(() => {
       <section
         ref="desktopSidebarContext"
         class="sidebar-context"
-        :class="{ 'sidebar-context--searching': sidebarSearchOpen && !searchUsesFloatingDialog }"
+        :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
         @keydown.esc.capture="closeSidebarSearch(desktopSidebarContext, true, true)"
         @input.capture="handleSidebarInput"
         @focusout.capture="handleSidebarFocusout"
@@ -359,7 +330,7 @@ onBeforeUnmount(() => {
       <section
         ref="mobileSidebarContext"
         class="sidebar-context mobile-drawer__context"
-        :class="{ 'sidebar-context--searching': sidebarSearchOpen && !searchUsesFloatingDialog }"
+        :class="{ 'sidebar-context--searching': sidebarSearchOpen }"
         @keydown.esc.capture="closeSidebarSearch(mobileSidebarContext, true, true)"
         @input.capture="handleSidebarInput"
         @focusout.capture="handleSidebarFocusout"
@@ -408,26 +379,6 @@ 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>
@@ -533,8 +484,6 @@ 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) {
@@ -634,7 +583,6 @@ 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; }

+ 3 - 5
src/client/views/ChatView.vue

@@ -17,6 +17,7 @@ import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import SessionOutline from '@/components/messages/SessionOutline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
+import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
 import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/workspace/pendingComposer'
@@ -29,7 +30,6 @@ const auth = useAuthStore()
 const chat = useChatStore()
 const route = useRoute()
 const router = useRouter()
-const search = ref('')
 const quoted = ref<UiMessage | null>(null)
 const preview = ref<UiLibraryItem | null>(null)
 const filePreview = ref<UiLibraryItem | null>(null)
@@ -49,8 +49,7 @@ const headerSessionMenuButton = ref<HTMLButtonElement | null>(null)
 const restoringRouteProfile = ref('')
 
 const sessions = computed(() => [...chat.sessions]
-  .filter(session => !['cron', 'ios_group'].includes(session.source))
-  .filter(session => !search.value.trim() || `${session.title} ${session.preview || ''}`.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())))
+  .filter(session => !['cron', 'ios_group'].includes(session.source)))
 const agentNames = computed(() => new Map(auth.profiles.map(profile => [
   profile.name,
   profile.agentName || profile.displayName || profile.name,
@@ -375,7 +374,6 @@ watch(() => chat.activeSessionId, async id => {
         :items="sidebarItems"
         :active-id="chat.activeSessionId"
         :loading="chat.isLoading"
-        :search="search"
         external-search
         single-line
         :has-more="chat.hasMoreSessions"
@@ -383,7 +381,6 @@ watch(() => chat.activeSessionId, async id => {
         search-placeholder="搜索会话"
         empty-title="还没有会话"
         empty-description="新建会话后,从输入框开始聊天。"
-        @search="search = $event"
         @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
         @select="chooseSession"
         @more="openSessionActions"
@@ -462,6 +459,7 @@ watch(() => chat.activeSessionId, async id => {
     </template>
   </WorkspaceView>
 
+  <FloatingResourceSearch section="chat" label="搜索会话" :items="sidebarItems" @select="chooseSession" />
   <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="addPreviewToComposer" @source="filePreview = null" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" @add="addMediaToComposer" />
 

+ 5 - 8
src/client/views/GroupsView.vue

@@ -18,6 +18,7 @@ import { mediaItemsFromMessages, mediaUrlIdentity, previewItemFromUrl } from '@/
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
+import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
@@ -30,7 +31,6 @@ const auth = useAuthStore()
 const groups = useGroupsStore()
 const route = useRoute()
 const router = useRouter()
-const search = ref('')
 const createOpen = ref(false)
 const managerOpen = ref(false)
 const showThinking = ref(true)
@@ -44,10 +44,8 @@ const modelOptionsError = ref<Record<string, string>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
 const agentUpdateError = ref<Record<string, string>>({})
 
-const filteredRooms = computed(() => groups.rooms
-  .filter(room => !room.archived)
-  .filter(room => !search.value.trim() || `${room.name} ${room.lastMessage?.content || ''}`.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())))
-const sidebarItems = computed(() => filteredRooms.value.map(roomSidebarItem))
+const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
+const sidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
 const messages = computed(() => groups.messages.map(groupMessageToUi))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
@@ -210,7 +208,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
 </script>
 
 <template>
-  <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${filteredRooms.length} 个活跃房间` : `9119 群聊 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL}`" :inspector-open="managerOpen && !!room" inspector-close-label="关闭群聊管理" @close-inspector="managerOpen = false">
+  <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${activeRooms.length} 个活跃房间` : `9119 群聊 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL}`" :inspector-open="managerOpen && !!room" inspector-close-label="关闭群聊管理" @close-inspector="managerOpen = false">
     <template #sidebar-action>
       <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" title="新建群聊" aria-label="新建群聊" @click="createOpen = true">
         <YaoYaoSidebarIcon name="add" />
@@ -222,12 +220,10 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :items="sidebarItems"
         :active-id="groups.selectedRoomId"
         :loading="groups.isLoading"
-        :search="search"
         external-search
         search-placeholder="搜索群聊"
         empty-title="还没有群聊"
         empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"
-        @search="search = $event"
         @select="selectRoom"
         @more="id => { selectRoom(id); managerOpen = true }"
       />
@@ -308,6 +304,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
     </template>
   </WorkspaceView>
 
+  <FloatingResourceSearch section="groups" label="搜索群聊" :items="sidebarItems" @select="selectRoom" />
   <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />

+ 9 - 4
tests/e2e/workspace.spec.ts

@@ -111,8 +111,9 @@ test('uses a floating search dialog for group rooms', async ({ page }) => {
   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')
+  const roomResult = searchDialog.getByRole('option', { name: /设计与工程协作/ })
+  await expect(roomResult).toBeVisible()
+  await roomResult.click()
   await expect(searchDialog).toBeHidden()
 })
 
@@ -712,9 +713,13 @@ test('uses the unified Grok-style sidebar, search trigger, and persistent collap
   const search = searchDialog.getByPlaceholder('搜索会话')
   await expect(search).toBeVisible()
   await expect(sidebar.locator('.sidebar-search')).toHaveCount(0)
+  const originalSessionCount = await sidebar.getByRole('option').count()
   await search.fill('验收')
-  await expect(page.getByRole('option', { name: /夭夭 Web 验收会话/ })).toBeVisible()
-  await search.press('Escape')
+  const sessionResult = searchDialog.getByRole('option', { name: /夭夭 Web 验收会话/ })
+  await expect(sessionResult).toBeVisible()
+  await expect(sidebar.getByRole('option')).toHaveCount(originalSessionCount)
+  await sessionResult.click()
+  await expect(page).toHaveURL(/\/chat\/session-demo\?profile=yaoyao/)
   await expect(searchDialog).toBeHidden()
   await expect(searchTrigger).toBeVisible()
   await expect(searchTrigger).toHaveAttribute('aria-expanded', 'false')

Некоторые файлы не были показаны из-за большого количества измененных файлов