Sfoglia il codice sorgente

优化资源搜索分栏导航并收紧设置中心布局

LSamien 2 mesi fa
parent
commit
f5398ab9e6

File diff suppressed because it is too large
+ 65 - 23
src/client/components/app/FloatingResourceSearch.vue


+ 13 - 13
src/client/components/app/SettingsCenterDialog.vue

@@ -370,17 +370,17 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
 </template>
 
 <style scoped>
-.settings-center-layer { position: fixed; z-index: 300; inset: 0; display: grid; place-items: center; padding: 32px; background: color-mix(in srgb, #000 24%, transparent); backdrop-filter: blur(4px); }
-.settings-center { display: grid; width: min(1080px, calc(100vw - 64px)); height: min(780px, calc(100dvh - 64px)); overflow: hidden; grid-template-rows: 64px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 16px; outline: 0; background: var(--surface-raised); box-shadow: 0 20px 60px rgba(0,0,0,.18); color: var(--text-primary); }
-.settings-center__header { display: flex; align-items: center; justify-content: space-between; padding: 0 20px 0 24px; border-bottom: 1px solid var(--line); }
+.settings-center-layer { position: fixed; z-index: 300; inset: 0; display: grid; place-items: center; padding: 48px; background: color-mix(in srgb, #000 24%, transparent); backdrop-filter: blur(4px); }
+.settings-center { display: grid; width: min(920px, calc(100vw - 96px)); height: min(680px, calc(100dvh - 96px)); overflow: hidden; grid-template-rows: 56px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 16px; outline: 0; background: var(--surface-raised); box-shadow: 0 20px 60px rgba(0,0,0,.18); color: var(--text-primary); }
+.settings-center__header { display: flex; align-items: center; justify-content: space-between; padding: 0 16px 0 20px; border-bottom: 1px solid var(--line); }
 .settings-center__header h2 { margin: 0; font-size: 20px; letter-spacing: -.025em; }
 .settings-center__close,.mobile-back { display: grid; width: 44px; height: 44px; place-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; }
 .settings-center__close:hover,.mobile-back:hover { background: var(--surface-soft); }
 .settings-center__close:disabled,.mobile-back:disabled { cursor: not-allowed; opacity: .45; }
 .mobile-back { display: none; }
-.settings-center__body { display: grid; min-height: 0; grid-template-columns: 292px minmax(0, 1fr); }
-.settings-sidebar { position: relative; min-height: 0; overflow-y: auto; padding: 14px 24px 16px; border-right: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
-.settings-agent-selector { position: sticky; z-index: 3; top: -14px; margin: -14px 0 10px; padding: 14px 0 10px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
+.settings-center__body { display: grid; min-height: 0; grid-template-columns: 248px minmax(0, 1fr); }
+.settings-sidebar { position: relative; min-height: 0; overflow-y: auto; padding: 12px 18px 14px; border-right: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
+.settings-agent-selector { position: sticky; z-index: 3; top: -12px; margin: -12px 0 10px; padding: 12px 0 10px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
 .settings-agent-selector > button { display: grid; width: 100%; min-height: 52px; grid-template-columns: 46px minmax(0, 1fr) 20px; align-items: center; gap: 12px; padding: 3px 6px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
 .settings-agent-selector > button:hover { background: var(--surface-hover); }
 .settings-agent-selector span,.settings-agent-menu span { display: grid; min-width: 0; gap: 3px; }
@@ -401,12 +401,12 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
 .settings-sidebar nav button span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .settings-sidebar nav button em { padding: 2px 6px; border: 1px solid var(--line); border-radius: 999px; color: var(--text-muted); font: normal 10px var(--font-ui); }
 .settings-content { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); background: var(--surface-raised); }
-.settings-content--with-footer { grid-template-rows: auto minmax(0, 1fr) 76px; }
-.settings-content__header { padding: 26px 40px 16px; border-bottom: 1px solid var(--line); }
+.settings-content--with-footer { grid-template-rows: auto minmax(0, 1fr) 68px; }
+.settings-content__header { padding: 21px 30px 14px; border-bottom: 1px solid var(--line); }
 .settings-content__header h3 { margin: 0; font-size: 23px; letter-spacing: -.035em; }
 .settings-content__header p { margin: 8px 0 0; color: var(--text-secondary); font-size: 14px; }
-.settings-content__scroll { min-height: 0; overflow-y: auto; padding: 22px 40px 24px; overscroll-behavior: contain; }
-.settings-content__footer { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 0 24px; border-top: 1px solid var(--line); background: var(--surface-raised); }
+.settings-content__scroll { min-height: 0; overflow-y: auto; padding: 18px 30px 20px; overscroll-behavior: contain; }
+.settings-content__footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding: 0 20px; border-top: 1px solid var(--line); background: var(--surface-raised); }
 .settings-content__footer button { display: inline-flex; min-width: 104px; min-height: 40px; align-items: center; justify-content: center; padding: 0 16px; border-radius: 9px; cursor: pointer; font: 650 13px var(--font-ui); }
 .settings-footer__cancel { border: 1px solid var(--line); background: var(--surface-raised); color: var(--text-primary); }
 .settings-footer__save { border: 0; background: var(--accent); color: var(--text-on-solid); }
@@ -427,9 +427,9 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
 .settings-center-fade-enter-from,.settings-center-fade-leave-to { opacity: 0; }
 .settings-center-fade-enter-from .settings-center,.settings-center-fade-leave-to .settings-center { transform: translateY(8px) scale(.99); }
 @media (max-width: 1023px) {
-  .settings-center-layer { padding: 16px; }
-  .settings-center { width: calc(100vw - 32px); height: calc(100dvh - 32px); }
-  .settings-center__body { grid-template-columns: 260px minmax(0, 1fr); }
+  .settings-center-layer { padding: 24px; }
+  .settings-center { width: min(920px, calc(100vw - 48px)); height: min(680px, calc(100dvh - 48px)); }
+  .settings-center__body { grid-template-columns: 240px minmax(0, 1fr); }
   .settings-sidebar { padding-inline: 18px; }
   .settings-content__header { padding-inline: 30px; }
   .settings-content__scroll { padding-inline: 30px; }

+ 4 - 1
src/client/components/app/types.ts

@@ -1,4 +1,4 @@
-export type SidebarItem = {
+export type SidebarItemBase = {
   id: string
   title: string
   subtitle?: string
@@ -16,4 +16,7 @@ export type SidebarItem = {
   icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch' | 'topic' | 'chevron-left' | 'plus' | 'archive'
   avatar?: string
   avatarMembers?: Array<{ name: string; avatar?: string }>
+  emptyText?: string
 }
+
+export type SidebarItem = SidebarItemBase & { children?: SidebarItemBase[] }

+ 30 - 9
src/client/components/groups/TopicTeamPickerDialog.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onBeforeUnmount, onMounted } from 'vue'
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import type { GroupRoomSummary } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
@@ -11,12 +11,17 @@ const props = withDefaults(defineProps<{
 }>(), { currentRoomId: '' })
 
 const emit = defineEmits<{ close: []; select: [roomId: string] }>()
+const expandedRoomId = ref('')
 
 function close() { emit('close') }
+function toggleDetails(roomId: string) {
+  expandedRoomId.value = expandedRoomId.value === roomId ? '' : roomId
+}
 function onKeydown(event: KeyboardEvent) {
   if (props.open && event.key === 'Escape') close()
 }
 
+watch(() => props.open, open => { if (open) expandedRoomId.value = '' })
 onMounted(() => document.addEventListener('keydown', onKeydown))
 onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
 </script>
@@ -32,12 +37,25 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
           </header>
           <p class="topic-team-picker__hint">话题将创建在所选团队中。</p>
           <div class="topic-team-picker__list">
-            <button v-for="room in rooms" :key="room.id" type="button" :class="{ current: room.id === currentRoomId }" @click="emit('select', room.id)">
-              <TeamAvatar :name="room.name" :avatar="room.avatar || ''" :members="room.avatarMembers?.map(member => ({ name: member.displayName })) || []" :fallback-key="room.id" :size="34" />
-              <span><strong>{{ room.name }}</strong><small>{{ room.agentCount }} 个 Agent</small></span>
-              <em v-if="room.id === currentRoomId">当前</em>
-              <AppIcon class="topic-team-picker__next" name="chevron-left" :size="15" />
-            </button>
+            <article v-for="room in rooms" :key="room.id" :class="{ current: room.id === currentRoomId, expanded: expandedRoomId === room.id }">
+              <button class="topic-team-picker__choose" type="button" @click="emit('select', room.id)">
+                <TeamAvatar :name="room.name" :avatar="room.avatar || ''" :members="room.avatarMembers?.map(member => ({ name: member.displayName })) || []" :fallback-key="room.id" :size="34" />
+                <span class="topic-team-picker__copy"><strong>{{ room.name }}</strong><small>{{ room.agentCount }} 个 Agent</small></span>
+                <em v-if="room.id === currentRoomId">当前</em>
+                <AppIcon class="topic-team-picker__next" name="chevron-left" :size="15" />
+              </button>
+              <button class="topic-team-picker__details-trigger" type="button" :aria-expanded="expandedRoomId === room.id" :aria-label="`${expandedRoomId === room.id ? '收起' : '查看'}${room.name}详情`" @click="toggleDetails(room.id)">
+                详情<AppIcon name="chevron-down" :size="13" />
+              </button>
+              <section v-if="expandedRoomId === room.id" class="topic-team-picker__details" :aria-label="`${room.name}团队详情`">
+                <p><strong>团队说明</strong><span>{{ room.instructions || '未填写团队说明' }}</span></p>
+                <dl>
+                  <div><dt>成员</dt><dd>{{ room.avatarMembers?.map(member => member.displayName).join('、') || `${room.agentCount} 个 Agent` }}</dd></div>
+                  <div><dt>协作方式</dt><dd>{{ room.orchestrationMode === 'host' ? '管理员协调' : '自由讨论' }}</dd></div>
+                  <div><dt>最多回复</dt><dd>{{ room.maxReplyRounds }} 轮</dd></div>
+                </dl>
+              </section>
+            </article>
           </div>
           <p v-if="!rooms.length" class="topic-team-picker__empty">当前没有可用团队,请先新建团队。</p>
         </section>
@@ -53,8 +71,11 @@ header { display: flex; align-items: flex-start; justify-content: space-between;
 .icon-button { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; padding: 0; border: 0; border-radius: 9px; background: transparent; color: var(--text-muted); cursor: pointer; }.icon-button:hover, .icon-button:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }
 .topic-team-picker__hint { margin: 0 7px 10px; color: var(--text-muted); font-size: 11px; }
 .topic-team-picker__list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
-.topic-team-picker__list > button { display: flex; width: 100%; min-height: 52px; align-items: center; gap: 10px; padding: 7px 9px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.topic-team-picker__list > button:hover, .topic-team-picker__list > button:focus-visible { outline: 0; background: var(--surface-soft); }.topic-team-picker__list > button.current { background: color-mix(in srgb, var(--accent) 7%, transparent); }
-.topic-team-picker__list > button > span { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.topic-team-picker__list strong, .topic-team-picker__list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.topic-team-picker__list strong { font-size: 14px; font-weight: 620; }.topic-team-picker__list small { color: var(--text-muted); font-size: 10px; font-weight: 400; }.topic-team-picker__list em { flex: 0 0 auto; padding: 2px 6px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-size: 9px; font-style: normal; font-weight: 620; }.topic-team-picker__list :deep(.app-icon) { flex: 0 0 auto; color: var(--text-muted); }
+.topic-team-picker__list article { display: grid; position: relative; grid-template-columns: minmax(0, 1fr) auto; border-radius: 10px; }.topic-team-picker__list article:hover, .topic-team-picker__list article:focus-within { background: var(--surface-soft); }.topic-team-picker__list article.current { background: color-mix(in srgb, var(--accent) 7%, transparent); }.topic-team-picker__list article.expanded { margin-bottom: 4px; background: var(--surface-soft); }
+.topic-team-picker__choose { display: flex; min-width: 0; min-height: 52px; align-items: center; gap: 10px; padding: 7px 4px 7px 9px; border: 0; border-radius: 10px 0 0 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.topic-team-picker__choose:focus-visible, .topic-team-picker__details-trigger:focus-visible { outline: 0; box-shadow: inset 0 0 0 1px var(--line-strong); }
+.topic-team-picker__copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.topic-team-picker__copy strong, .topic-team-picker__copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.topic-team-picker__copy strong { font-size: 14px; font-weight: 620; }.topic-team-picker__copy small { color: var(--text-muted); font-size: 10px; font-weight: 400; }.topic-team-picker__choose em { flex: 0 0 auto; padding: 2px 6px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-size: 9px; font-style: normal; font-weight: 620; }.topic-team-picker__list :deep(.app-icon) { flex: 0 0 auto; color: var(--text-muted); }
+.topic-team-picker__details-trigger { display: flex; min-width: 54px; align-items: center; justify-content: center; gap: 3px; padding: 0 7px; border: 0; border-radius: 0 10px 10px 0; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }.topic-team-picker__details-trigger :deep(.app-icon) { transition: transform 120ms ease; }.topic-team-picker__details-trigger[aria-expanded="true"] :deep(.app-icon) { transform: rotate(180deg); }
+.topic-team-picker__details { grid-column: 1 / -1; margin: 0 9px 9px; padding: 10px; border-top: 1px solid var(--line); color: var(--text-secondary); }.topic-team-picker__details p { display: grid; gap: 4px; margin: 0 0 9px; }.topic-team-picker__details p strong { font-size: 10px; }.topic-team-picker__details p span { color: var(--text-muted); font-size: 10px; line-height: 1.5; }.topic-team-picker__details dl { display: grid; gap: 6px; margin: 0; }.topic-team-picker__details dl div { display: grid; grid-template-columns: 62px minmax(0, 1fr); gap: 8px; font-size: 10px; }.topic-team-picker__details dt { color: var(--text-muted); }.topic-team-picker__details dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
 .topic-team-picker__next { transform: rotate(180deg); }
 .topic-team-picker__empty { margin: 0; padding: 34px 12px; color: var(--text-muted); font-size: 12px; text-align: center; }
 .topic-team-picker-fade-enter-active, .topic-team-picker-fade-leave-active { transition: opacity 130ms ease; }.topic-team-picker-fade-enter-active .topic-team-picker, .topic-team-picker-fade-leave-active .topic-team-picker { transition: transform 160ms var(--ease-out); }.topic-team-picker-fade-enter-from, .topic-team-picker-fade-leave-to { opacity: 0; }.topic-team-picker-fade-enter-from .topic-team-picker, .topic-team-picker-fade-leave-to .topic-team-picker { transform: translateY(7px) scale(.99); }

+ 105 - 16
src/client/views/GroupsView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import type { GroupAgent, GroupRoomSummary, GroupTopicSummary, ModelOption } from '@shared/types'
@@ -22,7 +22,7 @@ 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 type { SidebarItem } from '@/components/app/types'
+import type { SidebarItem, SidebarItemBase } from '@/components/app/types'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
@@ -57,8 +57,11 @@ const roomActionMenu = ref<{ roomId: string; topicId?: string; x: number; y: num
 const topicActionRenaming = ref(false)
 const topicActionRenameValue = ref('')
 const archivedOverlayOpen = ref(false)
-const archivedRoomList = ref<GroupRoomSummary[]>([])
-const archivedTopicList = ref<GroupTopicSummary[]>([])
+const archivedRoomList = shallowRef<GroupRoomSummary[]>([])
+const archivedTopicList = shallowRef<GroupTopicSummary[]>([])
+const archivedTopicCatalog = shallowRef<GroupTopicSummary[]>([])
+const archivedTopicRoomId = ref('')
+const archivedSearchLoading = ref(false)
 const pushProtocolSupported = ref(false)
 const pushConfigured = ref(false)
 const subscribedPushRooms = ref<Set<string>>(new Set())
@@ -74,7 +77,7 @@ function handleModelCatalogChanged(event: Event) {
 }
 
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
-const roomSidebarItems = computed(() => activeRooms.value.map(room => {
+const roomSidebarItems = computed<SidebarItemBase[]>(() => activeRooms.value.map(room => {
   const currentMembers = room.id === groups.selectedRoomId && displayAgents.value.length
     ? displayAgents.value.slice(0, 4).map(agent => ({
       profile: agent.profile,
@@ -100,8 +103,11 @@ const allTopics = computed(() => {
 const sidebarSubtitle = computed(() => groups.availability === 'available'
   ? `${allTopics.value.length} 个话题`
   : `9119 团队 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL}`)
+const SEARCH_ALL_ID = 'search:all'
 const SEARCH_ARCHIVED_ID = 'search:archived'
 const SEARCH_ROOM_PREFIX = 'room:'
+const SEARCH_ARCHIVED_ROOM_PREFIX = 'archived-room:'
+const SEARCH_ARCHIVED_TOPIC_PREFIX = 'archived-topic:'
 function topicSidebarItemId(roomId: string, topicId: string): string { return `topic:${roomId}:${topicId}` }
 function topicFromSidebarItemId(id: string): { roomId: string; topicId: string } | undefined {
   const match = /^topic:([^:]+):([^:]+)$/.exec(id)
@@ -148,10 +154,67 @@ const sidebarItems = computed<SidebarItem[]>(() => {
   }
   return items
 })
+function archivedRoomSidebarItems(): SidebarItemBase[] {
+  return archivedRoomList.value.map(room => ({
+  id: room.id,
+  title: room.name || '未命名团队',
+  subtitle: room.lastMessage?.content || `${room.agentCount} 个 Agent`,
+  meta: sidebarDate(room.updatedAt),
+  avatar: room.avatar || '',
+  avatarMembers: (room.avatarMembers || []).map(member => ({ name: member.displayName || member.profile })),
+  }))
+}
+const archivedRoomById = computed(() => new Map(archivedRoomList.value.map(room => [room.id, room])))
+const archivedTopicRoomName = computed(() => activeRoomById.value.get(archivedTopicRoomId.value)?.name
+  || archivedRoomById.value.get(archivedTopicRoomId.value)?.name
+  || '所选团队')
+function archivedTopicSidebarItem(topic: GroupTopicSummary): SidebarItem {
+  const room = activeRoomById.value.get(topic.roomId) || archivedRoomById.value.get(topic.roomId)
+  const roomItem = roomSidebarItems.value.find(item => item.id === topic.roomId)
+    || archivedRoomSidebarItems().find(item => item.id === topic.roomId)
+  return {
+    id: `${SEARCH_ARCHIVED_TOPIC_PREFIX}${topic.roomId}:${topic.id}`,
+    title: topic.title,
+    subtitle: `${room?.name || '未知团队'}:${topic.preview || '暂无消息'}`,
+    meta: sidebarDate(topic.updatedAt),
+    section: '话题',
+    avatar: roomItem?.avatar ?? '',
+    avatarMembers: roomItem?.avatarMembers,
+    showMore: false,
+  }
+}
 const groupSearchItems = computed<SidebarItem[]>(() => [
-  ...roomSidebarItems.value.map(item => ({ ...item, id: `${SEARCH_ROOM_PREFIX}${item.id}`, section: '团队', showMore: false })),
-  { id: SEARCH_ARCHIVED_ID, title: '已归档内容', subtitle: '查看已归档团队和话题', section: '已归档', icon: 'archive' as const, showMore: false },
-  ...allTopics.value.map(topic => topicSidebarItem(topic, '话题')),
+  {
+    id: SEARCH_ALL_ID,
+    title: '全部',
+    subtitle: `${allTopics.value.length} 个话题`,
+    section: '团队',
+    icon: 'groups',
+    showMore: false,
+    emptyText: '暂无话题',
+    children: allTopics.value.map(topic => topicSidebarItem(topic, '话题')),
+  },
+  ...roomSidebarItems.value.map(item => ({
+    ...item,
+    id: `${SEARCH_ROOM_PREFIX}${item.id}`,
+    section: '团队',
+    showMore: false,
+    emptyText: '该团队暂无话题',
+    children: allTopics.value.filter(topic => topic.roomId === item.id).map(topic => topicSidebarItem(topic, '话题')),
+  })),
+  {
+    id: SEARCH_ARCHIVED_ID,
+    title: '已归档内容',
+    subtitle: archivedSearchLoading.value ? '正在加载归档内容…' : `${archivedRoomList.value.length} 个团队 · ${archivedTopicCatalog.value.length} 个话题`,
+    section: '归档',
+    icon: 'archive' as const,
+    showMore: false,
+    emptyText: '暂无归档团队或话题',
+    children: [
+      ...archivedRoomSidebarItems().map(item => ({ ...item, id: `${SEARCH_ARCHIVED_ROOM_PREFIX}${item.id}`, section: '团队', showMore: false })),
+      ...archivedTopicCatalog.value.map(archivedTopicSidebarItem),
+    ],
+  },
 ])
 const localAgentIdentities = computed(() => new Map(auth.profiles.map(profile => [profile.name, {
   name: profile.agentName || profile.displayName || profile.name,
@@ -317,6 +380,15 @@ async function selectGroupSearchItem(id: string) {
     await openArchivedOverlay()
     return
   }
+  if (id.startsWith(SEARCH_ARCHIVED_ROOM_PREFIX)) {
+    await openArchivedOverlay(id.slice(SEARCH_ARCHIVED_ROOM_PREFIX.length))
+    return
+  }
+  if (id.startsWith(SEARCH_ARCHIVED_TOPIC_PREFIX)) {
+    const match = /^archived-topic:([^:]+):/.exec(id)
+    await openArchivedOverlay(match?.[1])
+    return
+  }
   if (id.startsWith(SEARCH_ROOM_PREFIX)) {
     await selectRoom(id.slice(SEARCH_ROOM_PREFIX.length))
     return
@@ -562,17 +634,32 @@ async function archiveRoom() {
   await router.replace(groupRoute())
 }
 
-async function openArchivedOverlay() {
+async function loadArchivedSearchCatalog() {
+  if (archivedSearchLoading.value) return
+  archivedSearchLoading.value = true
+  try {
+    const archivedRooms = await groups.archivedRooms()
+    archivedRoomList.value = archivedRooms
+    const roomIds = [...new Set([...activeRooms.value.map(room => room.id), ...archivedRooms.map(room => room.id)])]
+    const topics = await Promise.all(roomIds.map(roomId => groups.archivedTopics(roomId).catch(() => [])))
+    archivedTopicCatalog.value = topics.flat().sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
+  } finally {
+    archivedSearchLoading.value = false
+  }
+}
+
+async function openArchivedOverlay(roomId = groups.selectedRoomId) {
   archivedOverlayOpen.value = true
-  archivedRoomList.value = await groups.archivedRooms()
-  archivedTopicList.value = groups.selectedRoomId
-    ? await groups.archivedTopics(groups.selectedRoomId)
+  await loadArchivedSearchCatalog()
+  archivedTopicRoomId.value = roomId || ''
+  archivedTopicList.value = roomId
+    ? await groups.archivedTopics(roomId)
     : []
 }
 
 async function restoreArchivedRoom(roomId: string) {
   await groups.restoreRoom(roomId)
-  archivedRoomList.value = await groups.archivedRooms()
+  await loadArchivedSearchCatalog()
 }
 
 async function archiveTopicFromAction() {
@@ -607,10 +694,11 @@ async function archiveRoomFromAction() {
 }
 
 async function restoreArchivedTopic(topicId: string) {
-  const roomId = groups.selectedRoomId
+  const roomId = archivedTopicRoomId.value
   if (!roomId) return
   await groups.restoreTopic(roomId, topicId)
   archivedTopicList.value = await groups.archivedTopics(roomId)
+  await loadArchivedSearchCatalog()
 }
 
 function openLocalFile({ name, url }: { name: string; url: string }) {
@@ -658,6 +746,7 @@ onMounted(async () => {
     if (groups.topicProtocol) {
       await Promise.all(activeRooms.value.map(room => groups.loadRoomTopics(room.id).catch(() => undefined)))
     }
+    void loadArchivedSearchCatalog()
     const requested = typeof route.params.roomId === 'string' ? route.params.roomId : ''
     const requestedTopic = typeof route.params.topicId === 'string' ? route.params.topicId : undefined
     if (requested) {
@@ -812,7 +901,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
     </template>
   </WorkspaceView>
 
-  <FloatingResourceSearch section="groups" label="搜索话题、团队或归档" :items="groupSearchItems" @select="selectGroupSearchItem" />
+  <FloatingResourceSearch section="groups" label="搜索话题、团队或归档" :items="groupSearchItems" split @open="loadArchivedSearchCatalog" @select="selectGroupSearchItem" />
   <TopicTeamPickerDialog :open="topicTeamPickerOpen" :rooms="activeRooms" :current-room-id="groups.selectedRoomId" @close="topicTeamPickerOpen = false" @select="chooseTopicTeam" />
   <CreateGroupDialog :open="createOpen" :profiles="profiles" :avatar-enabled="groups.roomAvatarProtocol" :host-enabled="groups.hostProtocol" :host-flow-enabled="groups.hostFlowProtocol" :room-instructions-enabled="groups.roomInstructionsProtocol" :error="createError" :busy="groups.isLoading || creatingRoom" @close="createOpen = false" @create="createRoom" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
@@ -846,7 +935,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         <section class="archived-overlay" role="dialog" aria-modal="true" aria-label="已归档团队和话题">
           <header><div><small>团队归档</small><strong>已归档内容</strong></div><button type="button" aria-label="关闭" @click="archivedOverlayOpen = false"><AppIcon name="close" :size="16" /></button></header>
           <section class="archived-section"><h3>团队</h3><p v-if="!archivedRoomList.length">没有已归档团队</p><article v-for="archived in archivedRoomList" :key="archived.id"><span>{{ archived.name }}</span><button type="button" @click="restoreArchivedRoom(archived.id)">恢复</button></article></section>
-          <section class="archived-section"><h3>当前团队的话题</h3><p v-if="!groups.selectedRoom">请先打开一个团队查看其已归档话题</p><p v-else-if="!archivedTopicList.length">没有已归档话题</p><article v-for="topic in archivedTopicList" :key="topic.id"><span>{{ topic.title }}</span><button type="button" @click="restoreArchivedTopic(topic.id)">恢复</button></article></section>
+          <section class="archived-section"><h3>{{ archivedTopicRoomName }}的话题</h3><p v-if="!archivedTopicRoomId">请先选择一个团队查看其已归档话题</p><p v-else-if="!archivedTopicList.length">没有已归档话题</p><article v-for="topic in archivedTopicList" :key="topic.id"><span>{{ topic.title }}</span><button type="button" @click="restoreArchivedTopic(topic.id)">恢复</button></article></section>
         </section>
       </div>
     </Transition>

+ 41 - 9
tests/client/floatingResourceSearch.test.ts

@@ -5,15 +5,28 @@ import FloatingResourceSearch from '../../src/client/components/app/FloatingReso
 afterEach(() => { document.body.innerHTML = '' })
 
 describe('FloatingResourceSearch', () => {
-  it('groups team, archived, and topic results and emits the selected entry', async () => {
+  it('keeps team and archived filters on the left while updating right-side results', async () => {
     const wrapper = mount(FloatingResourceSearch, {
       props: {
         section: 'groups',
         label: '搜索话题、团队或归档',
+        split: true,
         items: [
-          { id: 'room:r1', title: '测试团队', subtitle: '2 个 Agent', section: '团队', avatar: '' },
-          { id: 'search:archived', title: '已归档内容', subtitle: '查看已归档团队和话题', section: '已归档', icon: 'archive' },
-          { id: 'topic:r1:t1', title: '发布检查', subtitle: '测试团队:准备上线', section: '话题', avatar: '', topic: true },
+          {
+            id: 'search:all', title: '全部', subtitle: '1 个话题', section: '团队', icon: 'groups',
+            children: [{ id: 'topic:r1:t1', title: '发布检查', subtitle: '测试团队:准备上线', section: '话题', avatar: '' }],
+          },
+          {
+            id: 'room:r1', title: '测试团队', subtitle: '2 个 Agent', section: '团队', avatar: '',
+            children: [{ id: 'topic:r1:t1', title: '团队内话题', subtitle: '测试团队:准备上线', section: '话题', avatar: '' }],
+          },
+          {
+            id: 'search:archived', title: '已归档内容', subtitle: '1 个团队 · 1 个话题', section: '归档', icon: 'archive',
+            children: [
+              { id: 'archived-room:r2', title: '旧团队', subtitle: '2 个 Agent', section: '团队', avatar: '' },
+              { id: 'archived-topic:r2:t2', title: '旧话题', subtitle: '旧团队:历史消息', section: '话题', avatar: '' },
+            ],
+          },
         ],
       },
       attachTo: document.body,
@@ -23,17 +36,36 @@ describe('FloatingResourceSearch', () => {
     await wrapper.vm.$nextTick()
 
     const dialog = document.querySelector('[role="dialog"]')
+    expect(dialog?.textContent).toContain('全部')
     expect(dialog?.textContent).toContain('团队')
-    expect(dialog?.textContent).toContain('已归档')
+    expect(dialog?.textContent).toContain('归档')
     expect(dialog?.textContent).toContain('话题')
-    expect(dialog?.querySelectorAll('.floating-resource-search__section')).toHaveLength(3)
-    expect(dialog?.querySelectorAll('.team-avatar')).toHaveLength(2)
+    expect(dialog?.querySelectorAll('.floating-resource-search__navigation-heading')).toHaveLength(2)
+    expect(dialog?.querySelector('.floating-resource-search__context')?.textContent).toContain('全部')
+    expect(dialog?.textContent).toContain('发布检查')
+    expect(wrapper.emitted('open')).toHaveLength(1)
 
-    const archived = [...document.querySelectorAll<HTMLButtonElement>('[role="option"]')]
+    const team = [...document.querySelectorAll<HTMLButtonElement>('.floating-resource-search__navigation button')]
+      .find(button => button.textContent?.trim() === '测试团队')
+    team?.click()
+    await wrapper.vm.$nextTick()
+    expect(document.body.textContent).toContain('团队内话题')
+    expect(document.querySelector('[aria-label="返回搜索结果"]')).toBeNull()
+    expect(wrapper.emitted('select')).toBeUndefined()
+
+    const archived = [...document.querySelectorAll<HTMLButtonElement>('.floating-resource-search__navigation button')]
       .find(button => button.textContent?.includes('已归档内容'))
     archived?.click()
     await wrapper.vm.$nextTick()
-    expect(wrapper.emitted('select')).toEqual([['search:archived']])
+    expect(document.body.textContent).toContain('旧团队')
+    expect(document.body.textContent).toContain('旧话题')
+    expect(wrapper.emitted('select')).toBeUndefined()
+
+    const archivedTopic = [...document.querySelectorAll<HTMLButtonElement>('[role="option"]')]
+      .find(button => button.textContent?.includes('旧话题'))
+    archivedTopic?.click()
+    await wrapper.vm.$nextTick()
+    expect(wrapper.emitted('select')).toEqual([['archived-topic:r2:t2']])
     wrapper.unmount()
   })
 })

+ 26 - 2
tests/client/topicTeamPickerDialog.test.ts

@@ -4,7 +4,8 @@ import TopicTeamPickerDialog from '../../src/client/components/groups/TopicTeamP
 
 const rooms = [{
   id: 'room-a', name: '产品团队', cwd: '', createdAt: 1, updatedAt: 2, archived: false,
-  agentCount: 3, unreadCount: 0, maxReplyRounds: 3,
+  agentCount: 3, unreadCount: 0, maxReplyRounds: 3, instructions: '负责产品设计与评审', orchestrationMode: 'host' as const,
+  avatarMembers: [{ profile: 'product', nodeId: 'local', displayName: '产品负责人' }],
 }, {
   id: 'room-b', name: '运维团队', cwd: '', createdAt: 1, updatedAt: 2, archived: false,
   agentCount: 2, unreadCount: 0, maxReplyRounds: 3,
@@ -24,13 +25,36 @@ describe('TopicTeamPickerDialog', () => {
     expect(document.body.textContent).toContain('运维团队')
     expect(document.body.textContent).toContain('当前')
 
-    const buttons = [...document.querySelectorAll<HTMLButtonElement>('.topic-team-picker__list > button')]
+    const avatars = [...document.querySelectorAll<HTMLElement>('.topic-team-picker__choose .team-avatar')]
+    expect(avatars[0]?.style.width).toBe('34px')
+    expect(avatars[0]?.style.height).toBe('34px')
+
+    const buttons = [...document.querySelectorAll<HTMLButtonElement>('.topic-team-picker__choose')]
     buttons[1]?.click()
     await wrapper.vm.$nextTick()
     expect(wrapper.emitted('select')).toEqual([['room-b']])
     wrapper.unmount()
   })
 
+  it('shows team details without selecting or creating a topic', async () => {
+    const wrapper = mount(TopicTeamPickerDialog, {
+      props: { open: true, rooms, currentRoomId: 'room-a' },
+      attachTo: document.body,
+    })
+
+    const details = document.querySelector<HTMLButtonElement>('[aria-label="查看产品团队详情"]')
+    details?.click()
+    await wrapper.vm.$nextTick()
+
+    const panel = document.querySelector('[aria-label="产品团队团队详情"]')
+    expect(panel?.textContent).toContain('负责产品设计与评审')
+    expect(panel?.textContent).toContain('产品负责人')
+    expect(panel?.textContent).toContain('管理员协调')
+    expect(panel?.textContent).toContain('3 轮')
+    expect(wrapper.emitted('select')).toBeUndefined()
+    wrapper.unmount()
+  })
+
   it('closes on Escape without selecting a team', async () => {
     const wrapper = mount(TopicTeamPickerDialog, { props: { open: true, rooms }, attachTo: document.body })
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))

Some files were not shown because too many files changed in this diff