فهرست منبع

修复聊天设置入口并同步机器人跨群聊忙碌状态

Samien 1 ماه پیش
والد
کامیت
5035bdf4ba

+ 6 - 5
src/client/components/workspace/ConversationList.vue

@@ -1,15 +1,16 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
-import { workspaceConversationItem } from './viewModels'
+import { workspaceAgentActivity, workspaceConversationItem } from './viewModels'
 import type { WorkspaceAgent, WorkspaceConversation } from '@shared/workspace'
 const props = withDefaults(defineProps<{ conversations: WorkspaceConversation[]; agents?: WorkspaceAgent[]; selected?: string }>(), { agents: () => [] })
-const emit = defineEmits<{ select: [id: string]; pin: [id: string]; archive: [id: string]; delete: [id: string] }>()
+const emit = defineEmits<{ select: [id: string]; settings: [id: string]; pin: [id: string]; archive: [id: string]; delete: [id: string] }>()
 const menuId = ref('')
 const menuPosition = ref({ left: '8px', top: '8px' })
 const menuConversation = computed(() => props.conversations.find(c => c.id === menuId.value))
+const agentActivity = computed(() => workspaceAgentActivity(props.conversations))
 const rows = computed(() => props.conversations.filter(c => !c.archived).map(c => ({
-  ...workspaceConversationItem(c, props.agents),
+  ...workspaceConversationItem(c, props.agents, agentActivity.value),
   section: '',
 })))
 function openMenu(id: string, event: MouseEvent) {
@@ -22,7 +23,7 @@ function action(kind: 'pin' | 'archive' | 'delete') {
   else emit('delete', menuId.value)
   menuId.value = ''
 }
-function viewConversation(){emit('select',menuId.value);menuId.value=''}
+function openSettings(){const id=menuId.value;menuId.value='';emit('settings',id)}
 </script>
 <template>
   <div class="conversation-list">
@@ -32,7 +33,7 @@ function viewConversation(){emit('select',menuId.value);menuId.value=''}
     <Teleport to="body">
       <div v-if="menuConversation" class="conversation-menu-dismiss" @pointerdown.self="menuId = ''" @keydown.esc="menuId = ''">
         <section class="conversation-actions" :style="menuPosition" role="menu" aria-label="聊天操作">
-          <button role="menuitem" @click="viewConversation">{{ menuConversation.kind === 'direct' ? '查看机器人' : '查看群聊' }}</button>
+          <button role="menuitem" @click="openSettings">{{ menuConversation.kind === 'direct' ? '机器人设置' : '群聊设置' }}</button>
           <button role="menuitem" @click="action('pin')">{{ menuConversation.pinned ? '取消置顶' : '置顶聊天' }}</button>
           <button role="menuitem" @click="action('archive')">{{ menuConversation.archived ? '恢复聊天' : '归档聊天' }}</button>
           <button class="danger" role="menuitem" @click="action('delete')">删除聊天</button>

+ 21 - 3
src/client/components/workspace/viewModels.ts

@@ -425,14 +425,32 @@ export function workspaceAvatarState(conversation: import('@shared/workspace').W
   return conversation.activeRunStatus === 'waiting' || conversation.activeRunStatus === 'uncertain' ? 'waiting' : 'working'
 }
 
-export function workspaceConversationItem(c: import('@shared/workspace').WorkspaceConversation, agents: import('@shared/workspace').WorkspaceAgent[]): SidebarItem {
+type WorkspaceAgentActivity = 'working' | 'waiting' | 'loading'
+
+/** A Bot can be busy in several chats; one finished run must not hide another. */
+export function workspaceAgentActivity(conversations: import('@shared/workspace').WorkspaceConversation[]): Map<string, WorkspaceAgentActivity> {
+  const activity = new Map<string, WorkspaceAgentActivity>()
+  const priority = { working: 3, waiting: 2, loading: 1 }
+  for (const conversation of conversations) {
+    for (const id of conversation.memberIds) {
+      const state = workspaceAvatarState(conversation, id)
+      if (state !== 'working' && state !== 'waiting' && state !== 'loading') continue
+      const previous = activity.get(id)
+      if (!previous || priority[state] > priority[previous]) activity.set(id, state)
+    }
+  }
+  return activity
+}
+
+export function workspaceConversationItem(c: import('@shared/workspace').WorkspaceConversation, agents: import('@shared/workspace').WorkspaceAgent[], agentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>): SidebarItem {
+  const activity = c.kind === 'direct' ? agentActivity?.get(c.memberIds[0] || '') : undefined
   return {
     id: c.id, title: c.name, subtitle: c.preview || '开始聊天', pinned: c.pinned,
     section: c.pinned ? '已置顶' : '聊天', avatar: c.kind === 'group' ? '' : c.avatar,
     avatarMembers: c.kind === 'group' ? workspaceAvatarMembers(c.memberIds, agents, c) : [],
     meta: formatConversationTime(c.lastMessageAt ?? c.createdAt),
     avatarKind: c.kind === 'direct' ? 'agent' : 'team',
-    avatarState: workspaceAvatarState(c, c.memberIds[0] || ''), avatarActivityKey: c.lastSeq,
-    unread: c.unreadCount ?? Math.max(0, c.lastSeq - c.readSeq), status: c.activeRunId ? 'working' : undefined,
+    avatarState: activity ?? workspaceAvatarState(c, c.memberIds[0] || ''), avatarActivityKey: c.lastSeq,
+    unread: c.unreadCount ?? Math.max(0, c.lastSeq - c.readSeq), status: activity || c.activeRunId ? 'working' : undefined,
   }
 }

+ 31 - 22
src/client/views/ConversationsView.vue

@@ -31,7 +31,7 @@ import TeamPresetPicker from '@/components/workspace/TeamPresetPicker.vue'
 import { TEAM_PRESETS, type TeamPreset } from '@/components/groups/teamPresets'
 import type { WorkspaceMemberRole } from '@shared/workspace'
 import AppIcon from '@/components/common/AppIcon.vue'
-import { workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem, workspaceMessagesToUi } from '@/components/workspace/viewModels'
+import { workspaceAgentActivity, workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem, workspaceMessagesToUi } from '@/components/workspace/viewModels'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
 import { apiRequest, ApiError } from '@/api/client'
@@ -74,8 +74,9 @@ const text = ref(''),
 const identityBusy = ref(false),
   identityError = ref(''),
   identityResetVersion = ref(0)
+const agentActivity = computed(() => workspaceAgentActivity(conversations.value))
 const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived => {
-  const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value))
+  const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value, agentActivity.value))
   return { id: archived ? 'archived' : 'unarchived', title: archived ? '已归档' : '未归档', children: items, emptyText: archived ? '没有已归档聊天' : '没有未归档聊天' }
 }))
 const archivedIds = computed(() => new Set(conversations.value.filter(c => c.archived).map(c => c.id)))
@@ -129,9 +130,11 @@ async function sendFromComposer(payload: ComposerSubmit) {
 }
 const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
   editingId = ref(''),
+  editingConversationId = ref(''),
   scroller = ref<HTMLElement>(),
   fileInput = ref<HTMLInputElement>(),
   dialogElement = ref<HTMLDialogElement>()
+const editingConversation = computed(() => conversations.value.find(c => c.id === editingConversationId.value) ?? (active.value?.id === editingConversationId.value ? active.value : undefined))
 const remotePickerOpen=ref(false)
 const isRemoteAgent=computed(()=>dialog.value==='editAgent' && !!agents.value.find(a=>a.id===editingId.value)?.remoteAgentId)
 async function remoteAdded(agent:Agent){
@@ -391,10 +394,15 @@ async function select(id: string) {
   pendingRequestId = undefined
   await router.push(`/conversations/${id}`)
 }
-async function openDialog(kind: NonNullable<typeof dialog.value>) {
+function openConversationSettings(id: string) {
+  const conversation = conversations.value.find(c => c.id === id)
+  if (conversation) void openDialog(conversation.kind === 'direct' ? 'editAgent' : 'editGroup', conversation)
+}
+async function openDialog(kind: NonNullable<typeof dialog.value>, conversation = active.value) {
   error.value = ''
   dialog.value = kind
   editingId.value = ''
+  editingConversationId.value = kind === 'editAgent' || kind === 'editGroup' ? conversation?.id ?? '' : ''
   selectedPresetId.value = 'custom'
   Object.assign(form, {
     name: '',
@@ -417,8 +425,8 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
       form.source = s.sources[0] ? JSON.stringify([s.sources[0].nodeId, s.sources[0].profile]) : ''
     }
     if (kind === 'editAgent') {
-      const a = members.value[0]
-      if (!a) return
+      const a = agents.value.find(a => a.id === conversation?.memberIds[0])
+      if (!a) throw new Error('机器人不存在,请刷新后重试')
       editingId.value = a.id
       Object.assign(form, a)
       form.source=JSON.stringify([a.nodeId,a.profile])
@@ -427,12 +435,12 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
         if(remote)Object.assign(form,remote)
       }
     }
-    if (kind === 'editGroup' && active.value) {
-      editingId.value = active.value.id
-      Object.assign(form, active.value, {
-        memberIds: [...active.value.memberIds],
-        autoReplyIds: [...active.value.autoReplyIds],
-        memberRoles: Object.fromEntries(Object.entries(active.value.memberRoles ?? {}).map(([id, role]) => [id, { ...role }])),
+    if (kind === 'editGroup' && conversation) {
+      editingId.value = conversation.id
+      Object.assign(form, conversation, {
+        memberIds: [...conversation.memberIds],
+        autoReplyIds: [...conversation.autoReplyIds],
+        memberRoles: Object.fromEntries(Object.entries(conversation.memberRoles ?? {}).map(([id, role]) => [id, { ...role }])),
       })
     }
     await nextTick()
@@ -445,6 +453,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
 function closeDialog() {
   dialogElement.value?.close()
   dialog.value = null
+  editingConversationId.value = ''
 }
 watch(
   () => form.memberIds.slice(),
@@ -611,9 +620,9 @@ async function changeLifecycle(operation: WorkspaceLifecycleAction, id: string,
   }
 }
 async function stopMember(id: string) {
-  if (!active.value) return
+  if (!editingConversation.value) return
   try {
-    await apiRequest(`/api/app/conversations/${active.value.id}/agents/${id}/stop`, { method: 'POST', body: {} })
+    await apiRequest(`/api/app/conversations/${editingConversation.value.id}/agents/${id}/stop`, { method: 'POST', body: {} })
     await load(); await refresh()
   } catch (cause) { error.value = cause instanceof Error ? cause.message : '停止失败' }
 }
@@ -714,10 +723,10 @@ onBeforeUnmount(() => {
     @create-remote-agent="remotePickerOpen = true"
   >
     <template #sidebar
-      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
     /></template>
     <template #mobile-sidebar
-      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
     /></template>
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
@@ -778,11 +787,11 @@ onBeforeUnmount(() => {
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
-    <Teleport to="body"><dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
+    <Teleport to="body"><dialog v-if="dialog" ref="dialogElement" class="editor" aria-labelledby="conversation-editor-title" @cancel.prevent="closeDialog">
       <form @submit.prevent="save">
         <header>
-          <h2>
-            {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : '编辑资料' }}
+          <h2 id="conversation-editor-title">
+            {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : dialog === 'editAgent' ? '机器人设置' : '群聊设置' }}
           </h2>
           <button type="button" aria-label="关闭" @click="closeDialog">×</button>
         </header>
@@ -792,7 +801,7 @@ onBeforeUnmount(() => {
         <p v-if="isRemoteAgent">引用的机器人配置由远端管理,请在远端修改名称、头像和角色规则。</p>
         <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
         <div v-else-if="!isAgentDialog" class="team-avatar-settings">
-          <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? active : undefined)" :size="64" />
+          <TeamAvatar :name="form.name" :members="workspaceAvatarMembers(form.memberIds, agents, dialog === 'editGroup' ? editingConversation : undefined)" :size="64" />
           <small>群聊头像由成员头像自动组合,随成员头像更新。</small>
         </div>
         <label
@@ -842,20 +851,20 @@ onBeforeUnmount(() => {
           <legend>选择成员</legend>
           <label
             v-for="a in agents.filter((a) =>
-              !a.temporaryGoalId && (!a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id))),
+              !a.temporaryGoalId && (!a.archived || (dialog === 'editGroup' && editingConversation?.memberIds.includes(a.id))),
             )"
             :key="a.id"
             ><input
               v-model="form.memberIds"
               type="checkbox"
               :value="a.id"
-              :disabled="busy || (dialog === 'editGroup' && (a.id === active?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
+              :disabled="busy || (dialog === 'editGroup' && (a.id === editingConversation?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
             />{{ a.name }}</label
           ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived && !a.temporaryGoalId).length < 2"
             >至少需要两个机器人才能创建群聊。</small
           >
           <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
-          <button v-for="a in agents.filter(a => active?.activeAgentStates?.[a.id])" :key="`stop:${a.id}`" type="button" @click="stopMember(a.id)">停止 {{ a.name }}</button>
+          <button v-for="a in agents.filter(a => editingConversation?.activeAgentStates?.[a.id])" :key="`stop:${a.id}`" type="button" @click="stopMember(a.id)">停止 {{ a.name }}</button>
         </fieldset>
         <fieldset v-if="dialog === 'editGroup' && Object.keys(form.memberRoles).length">
           <legend>角色分工</legend>

+ 57 - 0
tests/client/conversationSettings.test.ts

@@ -0,0 +1,57 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
+import { shallowRef } from 'vue'
+import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
+import ConversationsView from '@/views/ConversationsView.vue'
+import { apiRequest } from '@/api/client'
+import type { WorkspaceConversation } from '@shared/workspace'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn(), ApiError: class extends Error {} }))
+vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
+vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
+
+const originalShowModal = HTMLDialogElement.prototype.showModal
+const originalClose = HTMLDialogElement.prototype.close
+let wrapper: VueWrapper | undefined
+afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks(); vi.mocked(apiRequest).mockReset(); HTMLDialogElement.prototype.showModal = originalShowModal; HTMLDialogElement.prototype.close = originalClose })
+
+it.each(['direct', 'group'] as const)('opens and saves the menu target %s while another chat is selected', async kind => {
+  const base: WorkspaceConversation = { id: 'current', kind: 'direct', name: '当前聊天', avatar: '', memberIds: ['current-bot'], instructions: '', administratorId: 'current-bot', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
+  const target: WorkspaceConversation = { ...base, id: 'target', kind, name: '目标聊天', memberIds: ['target-bot', ...(kind === 'group' ? ['current-bot'] : [])], administratorId: 'target-bot', instructions: '目标规则', activeAgentStates: kind === 'group' ? { 'target-bot': 'running' } : {} }
+  const conversations = [base, target]
+  const agents = ['current-bot', 'target-bot'].map(id => ({ id, name: id === 'target-bot' ? '目标机器人' : '当前机器人', avatar: '', instructions: `${id}规则`, nodeId: 'local', profile: 'default', archived: false }))
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path === '/api/app/agents') return { agents } as never
+    if (path === '/api/app/agents/sources') return { sources: [{ nodeId: 'local', profile: 'default', name: '基础机器人' }] } as never
+    if (path === '/api/app/conversations') return { conversations, cursor: 1 } as never
+    if (path.startsWith('/api/app/events')) return { events: [], cursor: 1 } as never
+    const conversation = conversations.find(c => path === `/api/app/conversations/${c.id}`)
+    if (conversation) return { conversation, messages: [], runs: [], run: null, interactions: [], tasks: [] } as never
+    return {} as never
+  })
+  HTMLDialogElement.prototype.showModal = function () { this.open = true }
+  HTMLDialogElement.prototype.close = function () { this.open = false }
+  const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
+  await router.push('/conversations/current'); await router.isReady()
+  wrapper = shallowMount(ConversationsView, { attachTo: document.body, global: {
+    provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) }, plugins: [router],
+    stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, Teleport: false },
+  } })
+  await flushPromises()
+  wrapper.findComponent({ name: 'ConversationList' }).vm.$emit('settings', 'target')
+  await flushPromises()
+  const dialog = document.querySelector<HTMLDialogElement>('dialog.editor')!
+  expect(dialog.open).toBe(true)
+  expect(dialog.querySelector('h2')?.textContent?.trim()).toBe(kind === 'direct' ? '机器人设置' : '群聊设置')
+  expect(dialog.querySelector<HTMLInputElement>('input[maxlength="100"]')?.value).toBe(kind === 'direct' ? '目标机器人' : '目标聊天')
+  expect(router.currentRoute.value.path).toBe('/conversations/current')
+  if (kind === 'group') {
+    expect(dialog.querySelector<HTMLInputElement>('input[value="target-bot"]')?.disabled).toBe(true)
+    const stop = [...dialog.querySelectorAll('button')].find(button => button.textContent === '停止 目标机器人')!
+    stop.click(); await flushPromises()
+    expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/target/agents/target-bot/stop', expect.objectContaining({ method: 'POST' }))
+  }
+  dialog.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+  await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith(kind === 'direct' ? '/api/app/agents/target-bot' : '/api/app/conversations/target', expect.objectContaining({ method: 'PATCH', body: expect.objectContaining({ name: kind === 'direct' ? '目标机器人' : '目标聊天' }) }))
+})

+ 25 - 4
tests/client/workspacePresentation.test.ts

@@ -50,9 +50,9 @@ it('composes real member avatars and refreshes them without changing group membe
 })
 
 it.each([
-  ['direct', '查看机器人'],
-  ['group', '查看群聊'],
-] as const)('opens a %s conversation from its actions menu with the expected label', async (kind, label) => {
+  ['direct', '机器人设置'],
+  ['group', '群聊设置'],
+] as const)('opens settings for a %s conversation from its actions menu', async (kind, label) => {
   const conversation: WorkspaceConversation = {
     id: kind,
     kind,
@@ -79,7 +79,8 @@ it.each([
   const view = [...menu.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === label)!
   view.click()
   await nextTick()
-  expect(wrapper.emitted('select')).toEqual([[conversation.id]])
+  expect(wrapper.emitted('settings')).toEqual([[conversation.id]])
+  expect(wrapper.emitted('select')).toBeUndefined()
   expect(document.body.querySelector('[aria-label="聊天操作"]')).toBeNull()
   wrapper.unmount()
 })
@@ -94,3 +95,23 @@ it('keeps inline thinking in the process trace and its media out of the message
   expect(messages[0].content).toBe('[报告](/tmp/report.pdf)')
   expect(messages[0].reasoning).toBe('![过程图](/tmp/process.png)')
 })
+
+
+it('keeps a Bot busy across group replies until its final active conversation finishes', async () => {
+  const base: WorkspaceConversation = { id: 'direct', kind: 'direct', name: 'Bot', avatar: '', memberIds: ['a'], instructions: '', administratorId: 'a', mode: 'host', autoReplyIds: [], maxReplyRounds: 1, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
+  const group: WorkspaceConversation = { ...base, id: 'group', kind: 'group', memberIds: ['a', 'b'], activeRunId: 'g-run', activeAgentStates: { a: 'running', b: 'waiting' } }
+  const other: WorkspaceConversation = { ...group, id: 'other', activeRunId: 'other-run', activeAgentStates: { a: 'waiting' } }
+  const wrapper = mount(ConversationList, { props: { conversations: [base, group, other, { ...base, id: 'unrelated', memberIds: ['unrelated'] }] } })
+  const bot = () => wrapper.get('[data-sidebar-id="direct"]')
+  expect(bot().find('.agent-avatar--working').exists()).toBe(true)
+  expect(bot().find('.presence--working').exists()).toBe(true)
+  expect(wrapper.get('[data-sidebar-id="unrelated"]').find('.agent-avatar--working').exists()).toBe(false)
+  await wrapper.setProps({ conversations: [base, { ...group, activeRunId: undefined, activeAgentStates: {}, avatarSignals: { a: { id: 'done', state: 'success', at: Date.now() } } }, other] })
+  expect(bot().find('.agent-avatar--waiting').exists()).toBe(true)
+  await wrapper.setProps({ conversations: [base, { ...other, activeAgentStates: { a: 'queued' } }] })
+  expect(bot().find('.agent-avatar--loading').exists()).toBe(true)
+  await wrapper.setProps({ conversations: [base] })
+  expect(bot().find('.agent-avatar--idle').exists()).toBe(true)
+  expect(bot().find('.presence--working').exists()).toBe(false)
+  wrapper.unmount()
+})