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

Preserve team avatar identity across sidebar navigation

LSamien 2 месяцев назад
Родитель
Сommit
44952d6dfe

+ 1 - 1
src/client/components/app/FloatingResourceSearch.vue

@@ -109,7 +109,7 @@ onBeforeUnmount(() => document.removeEventListener(SIDEBAR_SEARCH_EVENT, show))
                 <div v-if="row.showSection" class="floating-resource-search__section">{{ row.item.section }}</div>
                 <button type="button" role="option" :aria-haspopup="row.hasChildren ? 'listbox' : undefined" @click="choose(row.item)">
                   <span v-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" class="floating-resource-search__icon">
-                    <TeamAvatar :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.id" :size="30" />
+                    <TeamAvatar :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.avatarFallbackKey || row.item.id" :size="30" />
                   </span>
                   <span v-else-if="row.item.icon" class="floating-resource-search__icon"><AppIcon :name="row.item.icon" :size="16" /></span>
                   <span class="floating-resource-search__copy"><strong>{{ row.item.title }}</strong><small v-if="row.item.subtitle">{{ row.item.subtitle }}</small></span>

+ 1 - 1
src/client/components/app/ResourceSidebar.vue

@@ -149,7 +149,7 @@ defineExpose({ focusSearch })
               <AppIcon name="chevron-down" :size="13" />
             </button>
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
-              <TeamAvatar v-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.id" :size="23" />
+              <TeamAvatar v-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.avatarFallbackKey || row.item.id" :size="23" />
               <AppIcon v-else-if="row.item.icon" :name="row.item.icon" :size="row.item.topic ? 14 : 15" />
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />

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

@@ -15,6 +15,7 @@ export type SidebarItemBase = {
   status?: 'online' | 'working' | 'offline'
   icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch' | 'topic' | 'chevron-left' | 'plus' | 'archive'
   avatar?: string
+  avatarFallbackKey?: string
   avatarMembers?: Array<{ name: string; avatar?: string }>
   emptyText?: string
 }

+ 1 - 0
src/client/components/workspace/viewModels.ts

@@ -217,6 +217,7 @@ export function roomSidebarItem(
     section: historySection(room.updatedAt),
     unread: room.unreadCount,
     avatar: room.avatar || '',
+    avatarFallbackKey: room.id,
     avatarMembers: (room.avatarMembers || []).map(member => ({
       name: member.displayName || member.profile,
       avatar: member.nodeId === 'local' ? avatars[member.profile] || avatarsByName[member.displayName] : undefined,

+ 2 - 0
src/client/stores/groups.ts

@@ -403,7 +403,9 @@ export const useGroupsStore = defineStore('groups', () => {
     const previousRoomId = selectedRoomId.value
     const previousTopicId = selectedTopicId.value
     const sameRoom = selectedRoomId.value === roomId
+    // Keep the public selection pair coherent while the destination snapshot is loading.
     selectedRoomId.value = roomId
+    selectedTopicId.value = sameRoom ? previousTopicId : requestedTopicId
     const rollback = () => {
       if (expectedSelection !== selectionGeneration) return
       selectedRoomId.value = previousRoomId

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

@@ -130,6 +130,7 @@ function topicSidebarItem(topic: GroupTopicSummary, section?: string): SidebarIt
     section,
     topic: true,
     avatar: roomItem?.avatar ?? '',
+    avatarFallbackKey: topic.roomId,
     avatarMembers: roomItem?.avatarMembers,
     active: topic.roomId === groups.selectedRoomId && topic.id === groups.selectedTopicId,
   }
@@ -148,6 +149,7 @@ const sidebarItems = computed<SidebarItem[]>(() => {
       section: '最近话题',
       topic: true,
       avatar: roomItem?.avatar ?? '',
+      avatarFallbackKey: groups.selectedRoomId,
       avatarMembers: roomItem?.avatarMembers,
       active: true,
     })
@@ -161,6 +163,7 @@ function archivedRoomSidebarItems(): SidebarItemBase[] {
   subtitle: room.lastMessage?.content || `${room.agentCount} 个 Agent`,
   meta: sidebarDate(room.updatedAt),
   avatar: room.avatar || '',
+  avatarFallbackKey: room.id,
   avatarMembers: (room.avatarMembers || []).map(member => ({ name: member.displayName || member.profile })),
   }))
 }
@@ -179,6 +182,7 @@ function archivedTopicSidebarItem(topic: GroupTopicSummary): SidebarItem {
     meta: sidebarDate(topic.updatedAt),
     section: '话题',
     avatar: roomItem?.avatar ?? '',
+    avatarFallbackKey: topic.roomId,
     avatarMembers: roomItem?.avatarMembers,
     showMore: false,
   }

+ 1 - 0
tests/client/groupHost.test.ts

@@ -98,6 +98,7 @@ describe('group host controls', () => {
       ],
     } satisfies GroupRoomSummary
     const item = roomSidebarItem(room, { default: 'data:image/png;base64,LOCAL==' }, { 丫头: 'data:image/png;base64,LOCAL==' })
+    expect(item.avatarFallbackKey).toBe(room.id)
     expect(item.avatarMembers).toEqual([
       { name: '丫头', avatar: 'data:image/png;base64,LOCAL==' },
       { name: '远端丫头', avatar: undefined },

+ 97 - 0
tests/client/groupsSelection.test.ts

@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import type { GroupRoomDetail, GroupRoomSummary, GroupTopicSummary } from '@shared/types'
+
+const api = vi.hoisted(() => ({
+  getGroupCapabilities: vi.fn(),
+  getGroupMessages: vi.fn(),
+  getGroupRoom: vi.fn(),
+  getGroupRooms: vi.fn(),
+  getGroupTopics: vi.fn(),
+  getPinnedGroupTopics: vi.fn(),
+}))
+
+vi.mock('@/api/groups', async importOriginal => ({
+  ...await importOriginal<typeof import('@/api/groups')>(),
+  ...api,
+}))
+
+vi.mock('@/api/realtime', async importOriginal => ({
+  ...await importOriginal<typeof import('@/api/realtime')>(),
+  GroupEventSocket: class {
+    async connect(): Promise<void> {}
+    close(): void {}
+  },
+}))
+
+vi.mock('@/stores/auth', () => ({
+  useAuthStore: () => ({
+    user: { id: 'groups-selection-test', role: 'user' },
+    activeProfile: { name: 'default' },
+    status: 'authenticated',
+    isAuthenticated: true,
+  }),
+}))
+
+import { useGroupsStore } from '@/stores/groups'
+
+const room = (id: string, name: string): GroupRoomSummary => ({
+  id, name, cwd: '', avatar: '', createdAt: 1, updatedAt: 1, archived: false,
+  agentCount: 0, lastMessage: null, unreadCount: 0, maxReplyRounds: 3,
+})
+
+const detail = (summary: GroupRoomSummary): GroupRoomDetail => ({
+  ...summary, agents: [], runs: [], pendingInteractions: [], latestCursor: 0,
+})
+
+const topic = (id: string, roomId: string): GroupTopicSummary => ({
+  id, roomId, title: id, preview: '', messageCount: 0, createdAt: 1, updatedAt: 1,
+})
+
+describe('groups selection state', () => {
+  beforeEach(() => {
+    setActivePinia(createPinia())
+    vi.clearAllMocks()
+  })
+
+  it('publishes the target topic with its room instead of retaining the previous room topic', async () => {
+    const roomA = room('room-a', '团队 A')
+    const roomB = room('room-b', '团队 B')
+    const topicA = topic('topic-a', roomA.id)
+    const topicB = topic('topic-b', roomB.id)
+    let resolveRoomB!: (value: GroupRoomDetail) => void
+    const roomBRequest = new Promise<GroupRoomDetail>(resolve => { resolveRoomB = resolve })
+
+    api.getGroupCapabilities.mockResolvedValue({
+      protocolVersion: 12,
+      journalEpoch: '11111111-1111-4111-8111-111111111111',
+      latestCursor: 0,
+      limits: {},
+      eventTypes: [],
+      features: [],
+    })
+    api.getGroupRooms.mockResolvedValue({ items: [roomA, roomB], nextCursor: null })
+    api.getPinnedGroupTopics.mockResolvedValue({ items: [] })
+    api.getGroupRoom.mockImplementation((roomId: string) => roomId === roomA.id ? Promise.resolve(detail(roomA)) : roomBRequest)
+    api.getGroupTopics.mockImplementation((roomId: string) => Promise.resolve({ items: roomId === roomA.id ? [topicA] : [topicB], nextCursor: null }))
+    api.getGroupMessages.mockResolvedValue({ items: [] })
+
+    const groups = useGroupsStore()
+    await groups.start()
+    expect(groups.selectedRoomId).toBe(roomA.id)
+    expect(groups.selectedTopicId).toBe(topicA.id)
+
+    await groups.loadRoomTopics(roomB.id)
+    const selection = groups.selectRoom(roomB.id, topicB.id)
+
+    expect(groups.selectedRoomId).toBe(roomB.id)
+    expect(groups.selectedTopicId).toBe(topicB.id)
+    expect(groups.selectedTopic?.id).toBe(topicB.id)
+
+    resolveRoomB(detail(roomB))
+    await selection
+    expect(groups.selectedRoomId).toBe(roomB.id)
+    expect(groups.selectedTopicId).toBe(topicB.id)
+    groups.stop()
+  })
+})

+ 20 - 0
tests/client/resourceSidebar.test.ts

@@ -0,0 +1,20 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
+
+describe('ResourceSidebar', () => {
+  it('keeps fallback avatars stable across topics from the same team', () => {
+    const wrapper = mount(ResourceSidebar, {
+      props: {
+        items: [
+          { id: 'topic:room-1:topic-1', title: '话题一', topic: true, avatar: '', avatarFallbackKey: 'room-1' },
+          { id: 'topic:room-1:topic-2', title: '话题二', topic: true, avatar: '', avatarFallbackKey: 'room-1' },
+        ],
+      },
+    })
+
+    const sources = wrapper.findAll<HTMLImageElement>('.team-avatar__image').map(image => image.attributes('src'))
+    expect(sources).toHaveLength(2)
+    expect(new Set(sources).size).toBe(1)
+  })
+})

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

@@ -153,6 +153,8 @@ test('shows the team animal avatar in the list and five choices during creation'
   await expect(roomAvatar).toBeVisible()
   await expect(roomAvatar.locator('.team-avatar__image')).toBeVisible()
   await expect(roomAvatar).toHaveCSS('border-radius', '50%')
+  const releaseTopicAvatar = page.locator('[data-sidebar-id="topic:22222222-2222-4222-8222-222222222222:88888888-8888-4888-8888-888888888888"] .team-avatar__image').first()
+  await expect(releaseTopicAvatar).toHaveAttribute('src', await roomAvatar.locator('.team-avatar__image').getAttribute('src') || '')
 
   await page.getByRole('button', { name: '新建团队' }).click()
   const dialog = page.getByRole('dialog', { name: '新建团队' })