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

WIP: 收紧群聊输入状态语义

[gstack-context]
Decisions: queued 不显示正在输入;断线时清空;状态槽紧贴 composer 且位于引用和附件之后
Remaining: 最终完整测试、重启 8800、真实浏览器验收
Tried: queued-running-idle 与引用顺序回归通过
Skill: /investigate
[/gstack-context]
samien 2 месяцев назад
Родитель
Сommit
774627ec17

+ 9 - 9
src/client/components/composer/ComposerShell.vue

@@ -394,15 +394,6 @@ defineExpose({
 
 
 <template>
 <template>
   <div class="composer-area">
   <div class="composer-area">
-    <div v-if="mode === 'group'" class="composer-activity-slot">
-      <Transition name="composer-activity">
-        <div v-if="activityText" class="composer-activity" role="status" aria-label="Agent 输入状态" aria-live="polite" aria-atomic="true">
-          <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
-          <strong>{{ activityText }}</strong>
-        </div>
-      </Transition>
-    </div>
-
     <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
     <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
       <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
       <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
         <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
         <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
@@ -418,6 +409,15 @@ defineExpose({
       <button type="button" aria-label="取消引用" @click="emit('clearReference')"><AppIcon name="close" :size="13" /></button>
       <button type="button" aria-label="取消引用" @click="emit('clearReference')"><AppIcon name="close" :size="13" /></button>
     </div>
     </div>
 
 
+    <div v-if="mode === 'group'" class="composer-activity-slot">
+      <Transition name="composer-activity">
+        <div v-if="activityText" class="composer-activity" role="status" aria-label="Agent 输入状态" aria-live="polite" aria-atomic="true">
+          <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
+          <strong>{{ activityText }}</strong>
+        </div>
+      </Transition>
+    </div>
+
     <div
     <div
       class="composer-shell"
       class="composer-shell"
       :class="{ 'composer-shell--drag': dragOver, 'composer-shell--disabled': disabled }"
       :class="{ 'composer-shell--drag': dragOver, 'composer-shell--disabled': disabled }"

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

@@ -66,9 +66,10 @@ const mentionOptions = computed<ComposerOption[]>(() => [
   ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
   ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
 ])
 ])
 const typingAgentNames = computed(() => [...new Set(groups.agents
 const typingAgentNames = computed(() => [...new Set(groups.agents
-  .filter(agent => ['queued', 'running'].includes(agent.status))
+  .filter(agent => agent.status === 'running')
   .map(agent => agent.displayName || agent.profile))])
   .map(agent => agent.displayName || agent.profile))])
 const typingActivity = computed(() => {
 const typingActivity = computed(() => {
+  if (!connected.value) return ''
   const names = typingAgentNames.value
   const names = typingAgentNames.value
   if (!names.length) return ''
   if (!names.length) return ''
   if (names.length <= 3) return `${names.join('、')}正在输入…`
   if (names.length <= 3) return `${names.join('、')}正在输入…`

+ 16 - 0
tests/client/composer.test.ts

@@ -25,4 +25,20 @@ describe('composer availability', () => {
     fast.trigger('click')
     fast.trigger('click')
     expect(wrapper.emitted('fastModeToggle')?.[0]).toEqual([false])
     expect(wrapper.emitted('fastModeToggle')?.[0]).toEqual([false])
   })
   })
+
+  it('keeps named group activity directly above the input after a reference', () => {
+    const wrapper = mount(ComposerShell, {
+      props: {
+        mode: 'group',
+        activityText: '夭夭正在输入…',
+        reference: { id: 'message-1', author: '夭夭', content: '上一条消息' },
+      },
+    })
+    const reference = wrapper.get('.composer-reference')
+    const activity = wrapper.get('.composer-activity-slot')
+    const shell = wrapper.get('.composer-shell')
+    expect(reference.element.nextElementSibling).toBe(activity.element)
+    expect(activity.element.nextElementSibling).toBe(shell.element)
+    expect(activity.get('[role="status"]').text()).toBe('夭夭正在输入…')
+  })
 })
 })

+ 3 - 1
tests/e2e/workspace.spec.ts

@@ -149,12 +149,14 @@ test('pins the named Agent typing status above the group composer', async ({ pag
   await page.getByRole('button', { name: '发送消息' }).click()
   await page.getByRole('button', { name: '发送消息' }).click()
 
 
   const typing = composer.getByRole('status', { name: 'Agent 输入状态' })
   const typing = composer.getByRole('status', { name: 'Agent 输入状态' })
+  await page.waitForTimeout(250)
+  await expect(typing).toHaveCount(0)
   await expect(typing).toHaveText('夭夭正在输入…')
   await expect(typing).toHaveText('夭夭正在输入…')
   await expect(page.locator('.message-stack .thinking-indicator')).toHaveCount(0)
   await expect(page.locator('.message-stack .thinking-indicator')).toHaveCount(0)
   const typingBox = await typing.boundingBox()
   const typingBox = await typing.boundingBox()
   const shellBox = await composer.locator('.composer-shell').boundingBox()
   const shellBox = await composer.locator('.composer-shell').boundingBox()
   expect(typingBox?.y).toBeLessThan(shellBox?.y ?? 0)
   expect(typingBox?.y).toBeLessThan(shellBox?.y ?? 0)
-  await expect(typing).toHaveCount(0, { timeout: 3_000 })
+  await expect(typing).toHaveCount(0, { timeout: 4_000 })
 })
 })
 
 
 test('previews an octet-stream Markdown file from the file library', async ({ page }) => {
 test('previews an octet-stream Markdown file from the file library', async ({ page }) => {

+ 3 - 2
tests/fixtures/fake-hermes.mjs

@@ -135,8 +135,9 @@ const server = createServer(async (request, response) => {
     const input = await body(request)
     const input = await body(request)
     const sent = { ...groupMessage, seq: 3, id: input.clientMessageId, clientMessageId: input.clientMessageId, content: input.content, createdAt: now(), updatedAt: now() }
     const sent = { ...groupMessage, seq: 3, id: input.clientMessageId, clientMessageId: input.clientMessageId, content: input.content, createdAt: now(), updatedAt: now() }
     json(response, 200, { message: sent, runs: [{ id: '66666666-6666-4666-8666-666666666666', roomId, agentId, status: 'queued' }] })
     json(response, 200, { message: sent, runs: [{ id: '66666666-6666-4666-8666-666666666666', roomId, agentId, status: 'queued' }] })
-    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'running', runId: '66666666-6666-4666-8666-666666666666' }), 80)
-    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'idle', runId: null }), 1500)
+    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'queued', runId: '66666666-6666-4666-8666-666666666666' }), 40)
+    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'running', runId: '66666666-6666-4666-8666-666666666666' }), 650)
+    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'idle', runId: null }), 1800)
     return
     return
   }
   }
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: groupMessages })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: groupMessages })