Sfoglia il codice sorgente

修复发送和排队时的聊天布局跳动

Samien 1 mese fa
parent
commit
5ebefde5a7

+ 22 - 15
src/client/components/composer/ComposerShell.vue

@@ -503,13 +503,17 @@ defineExpose({
       <button type="button" aria-label="取消引用" @click="emit('clearReference')"><AppIcon name="close" :size="13" /></button>
     </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="机器人输入状态" aria-live="polite" aria-atomic="true">
-          <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
-          <strong>{{ activityText }}</strong>
-        </div>
-      </Transition>
+    <!-- Status changes share one row without resizing the transcript. -->
+    <div class="composer-activity-slot">
+      <slot name="activity">
+        <Transition name="composer-activity">
+          <div v-if="activityText" class="composer-activity" role="status" aria-label="机器人输入状态" aria-live="polite" aria-atomic="true">
+            <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
+            <strong>{{ activityText }}</strong>
+          </div>
+        </Transition>
+      </slot>
+      <span v-if="sending" class="composer-sending" role="status" aria-live="polite"><i />正在发送</span>
     </div>
 
     <slot name="before-input" />
@@ -633,12 +637,14 @@ defineExpose({
           <button v-else-if="mentionOptions.length" type="button" class="composer-tool composer-mention-hint" :disabled="disabled" aria-label="提及成员" @click="startMention"><At class="composer-action-icon" aria-hidden="true" /><span>提及成员</span></button>
         </div>
         <div class="composer-actions">
-          <button v-if="mode === 'chat' && streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
-            {{ queueMode ? '排队' : 'Steer' }}
-          </button>
-          <span v-if="sending" class="composer-sending" role="status" aria-live="polite"><i />正在发送</span>
-          <span v-if="canStop && !showStop" class="composer-stop-hitbox">
-            <button class="composer-stop" type="button" aria-label="停止生成" title="停止生成" @click="emit('stop')">
+          <!-- Keep action widths stable when queue/send/stop controls switch. -->
+          <span v-if="mode === 'chat'" class="composer-queue-slot">
+            <button v-if="streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
+              {{ queueMode ? '排队' : 'Steer' }}
+            </button>
+          </span>
+          <span v-if="stopWhileRunning || mode === 'chat'" class="composer-stop-hitbox">
+            <button v-if="canStop && !showStop" class="composer-stop" type="button" aria-label="停止生成" title="停止生成" @click="emit('stop')">
               <AppIcon name="stop" :size="15" />
             </button>
           </span>
@@ -686,7 +692,7 @@ defineExpose({
 
 <style scoped>
 .composer-area { position: relative; z-index: 12; flex: 0 0 auto; padding: 12px max(24px, calc((100% - 760px) / 2)) max(18px, env(safe-area-inset-bottom)); background: var(--conversation-canvas); }
-.composer-activity-slot { display: flex; width: 100%; max-width: 760px; min-height: 20px; margin: 0 auto 3px; align-items: center; }
+.composer-activity-slot { display: flex; width: 100%; max-width: 760px; height: 20px; margin: 0 auto 3px; align-items: center; gap: 8px; }
 .composer-activity { display: inline-flex; min-width: 0; align-items: center; gap: 7px; color: var(--text-muted); font-size: 10px; }
 .composer-activity strong { overflow: hidden; color: var(--text-secondary); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
 .composer-typing-dots { display: inline-flex; align-items: center; gap: 2px; }.composer-typing-dots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: composer-typing 1.05s ease-in-out infinite; }.composer-typing-dots i:nth-child(2) { animation-delay: 140ms; }.composer-typing-dots i:nth-child(3) { animation-delay: 280ms; }
@@ -755,6 +761,7 @@ defineExpose({
 .composer-setting-row:hover { background: var(--surface-hover); color: var(--text-primary); }
 .composer-setting-row > span { display: inline-flex; align-items: center; gap: 7px; }
 .queue-toggle { height: 26px; padding: 0 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }
+.composer-queue-slot { display: grid; width: 48px; height: 34px; place-items: center; }
 .queue-toggle:hover, .queue-toggle.active { background: var(--surface-hover); color: var(--text-primary); }
 .composer-send-hitbox { display: grid; place-items: center; width: 34px; height: 34px; }
 .composer-stop-hitbox { display: grid; place-items: center; width: 30px; height: 34px; }
@@ -764,7 +771,7 @@ defineExpose({
 .composer-send--stop { background: var(--accent); }
 .composer-stop { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; transition: color 150ms ease, border-color 150ms ease; }
 .composer-stop:hover { color: var(--danger); border-color: var(--danger); }
-.composer-sending { display: inline-flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.composer-sending i { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
+.composer-sending { display: inline-flex; flex-shrink: 0; margin-left: auto; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.composer-sending i { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
 @keyframes composer-pulse { 50% { opacity: .25; transform: scale(.7); } }
 .composer-attachments { display: flex; width: 100%; min-width: 0; flex-wrap: wrap; gap: 8px; }
 .composer-context + .composer-attachments { padding-top: 16px; }

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

@@ -1060,8 +1060,6 @@ onBeforeUnmount(() => {
           </div>
         </template>
       </MessageTimeline>
-      <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用机器人</p>
-      <p v-if="active?.collaborationWaiting" class="task-queue-status" role="status"><button @click="knowledgePanel?.open('collaboration', active.memberIds[0])">等待同伴回复 · 查看协作</button></p>
       <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
       <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
         v-model:delivery-mode="deliveryMode" :delivery-available="active.kind === 'group' && !activeTask?.goal"
@@ -1071,6 +1069,10 @@ onBeforeUnmount(() => {
         :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event">
+        <template #activity>
+          <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用机器人</p>
+          <p v-else-if="active.collaborationWaiting" class="task-queue-status" role="status"><button @click="knowledgePanel?.open('collaboration', active.memberIds[0])">等待同伴回复 · 查看协作</button></p>
+        </template>
         <template #before-input>
           <div v-if="knowledgeEnabled" class="knowledge-shortcuts composer-knowledge">
             <label v-if="active.kind === 'direct' && projects.some(p => p.memberIds.includes(active!.memberIds[0]!))">当前项目<select v-model="directProjectId"><option value="">不关联项目</option><option v-for="project in projects.filter(p => !p.archived && p.memberIds.includes(active!.memberIds[0]!))" :key="project.id" :value="project.id">{{ project.name }}</option></select></label>
@@ -1285,7 +1287,7 @@ onBeforeUnmount(() => {
   font-size: 13px;
 }
 .task-picker-shell{position:relative;display:inline-flex;align-items:center;max-width:210px;min-width:0}.task-picker-shell>.app-icon{position:absolute;right:11px;pointer-events:none;color:var(--text-muted)}.task-picker{width:100%;min-width:0;max-width:210px;min-height:38px;appearance:none;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-primary);padding:6px 34px 6px 14px;font:inherit;font-size:12px;font-weight:520;text-overflow:ellipsis;cursor:pointer;box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 5%,transparent);transition:border-color 140ms ease,background-color 140ms ease,box-shadow 140ms ease}.task-picker:hover{border-color:var(--line-strong);background:var(--surface-hover)}.task-picker:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-picker:disabled{cursor:wait;opacity:.55}
-.task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
+.task-queue-status{min-width:0;overflow:hidden;margin:0;font-size:12px;line-height:20px;color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap}.task-queue-status button{padding:0;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer}.task-queue-status button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 .group-options{margin:12px 0;border-top:1px solid var(--line)}.group-options summary{min-height:44px;align-content:center;cursor:pointer;font-size:13px;color:var(--text-secondary)}.group-options summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.group-help{font-size:13px;line-height:1.6;color:var(--text-secondary)}
 @media(max-width:720px){.task-picker-shell,.task-picker{max-width:118px}.task-picker{min-height:44px;padding-left:12px}}
 .team-management-permission > span {

+ 96 - 0
tests/e2e/workspace-chat.spec.ts

@@ -1,4 +1,5 @@
 import { test, expect, type Page } from '@playwright/test'
+import { writeFile } from 'node:fs/promises'
 async function login(page: Page) {
   await page.goto('/conversations')
   await page.getByRole('textbox', { name: '账号', exact: true }).fill('fixture')
@@ -23,6 +24,101 @@ async function createAgent(page: Page, name: string) {
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
 }
 
+for (const { surface, kind } of [
+  { surface: 'Web', kind: 'direct' }, { surface: 'desktop bridge', kind: 'direct' },
+  { surface: 'Web', kind: 'group' }, { surface: 'desktop bridge', kind: 'group' },
+] as const) {
+test(`sending and queued Bot replies keep the ${kind} chat frame stable through ${surface}`, async ({ page }, testInfo) => {
+  await page.setViewportSize({ width: surface === 'Web' ? 1280 : 1024, height: 800 })
+  if (surface === 'desktop bridge') await page.addInitScript(() => {
+    Object.assign(window, { yaoyaoDesktop: { deviceHost: async () => ({ deviceHost: 'local' }) } })
+  })
+  await login(page)
+  const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()
+  const headers = { 'X-CSRF-Token': capabilities.csrfToken, Origin: new URL(page.url()).origin }
+  const created = await page.request.post('/api/app/agents', { headers, data: { name: `发送布局验收 ${surface} ${kind}`, profile: 'default' } })
+  expect(created.ok(), await created.text()).toBe(true)
+  const agent = (await created.json()).agent
+  const conversations = (await (await page.request.get('/api/app/conversations')).json()).conversations
+  let conversation = conversations.find((c: any) => c.kind === 'direct' && c.memberIds[0] === agent.id)
+  if (kind === 'group') {
+    const peerResponse = await page.request.post('/api/app/agents', { headers, data: { name: `群聊布局成员 ${surface}`, profile: 'default' } })
+    expect(peerResponse.ok(), await peerResponse.text()).toBe(true)
+    const peer = (await peerResponse.json()).agent
+    const groupResponse = await page.request.post('/api/app/conversations', { headers, data: {
+      name: `发送布局群 ${surface}`, memberIds: [agent.id, peer.id], administratorId: agent.id, autoReplyIds: [agent.id],
+    } })
+    expect(groupResponse.ok(), await groupResponse.text()).toBe(true)
+    conversation = (await groupResponse.json()).conversation
+  }
+  await page.goto(`/conversations/${conversation.id}`)
+  const input = page.locator('.composer-textarea')
+  await input.fill('[hold-workspace] 先执行第一条消息')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  await expect(page.getByTestId('chat-run-thinking-dots')).toBeVisible()
+  await expect(page.locator('.message--assistant')).toBeVisible()
+  await expect(input).toHaveValue('')
+  await input.fill('排队执行第二条消息')
+  const messageIds = await page.locator('[data-message-id]').evaluateAll(rows => rows.map(row => row.getAttribute('data-message-id')))
+  await page.evaluate(() => {
+    const selectors = ['.timeline-header', '.timeline', '.composer-area', '.composer-shell', '.composer-textarea', '.composer-tools', '.composer-actions']
+    const nodes = selectors.map(selector => document.querySelector(selector)!)
+    const previousMessages = [...document.querySelectorAll('[data-message-id]')]
+    const samples: Array<{ connected: boolean; rects: number[][] }> = []
+    let frame: number
+    const record = () => {
+      samples.push({ connected: [...nodes, ...previousMessages].every(node => node.isConnected), rects: nodes.map(node => {
+        const box = node.getBoundingClientRect(); return [box.x, box.y, box.width, box.height]
+      }) })
+      frame = requestAnimationFrame(record)
+    }
+    record()
+    Object.assign(window, { stopSendLayoutRecording: () => { cancelAnimationFrame(frame); return { selectors, samples } } })
+  })
+  let release!: () => void
+  const gate = new Promise<void>(resolve => { release = resolve })
+  const pattern = `**/api/app/conversations/${conversation.id}/messages`
+  await page.route(pattern, async route => {
+    expect(route.request().postDataJSON().deviceHost).toBe(surface === 'Web' ? null : 'local')
+    await gate; await route.continue()
+  })
+  try {
+    await page.getByRole('button', { name: '发送消息', exact: true }).click()
+    await expect(page.locator('.composer-sending')).toBeVisible()
+    await page.evaluate(async () => { for (let count = 0; count < 3; count++) await new Promise(requestAnimationFrame) })
+    release()
+    await expect(page.getByText('正在等待可用机器人', { exact: true })).toBeVisible()
+    await expect(input).toHaveValue('')
+    await expect(page.locator('[data-message-id]')).toHaveCount(messageIds.length + 1)
+    await page.screenshot({ path: testInfo.outputPath('send-layout-queued.png') })
+    await page.request.post('http://127.0.0.1:19120/__release')
+    await expect(page.locator('.message--assistant')).toHaveCount(2)
+    await page.request.post('http://127.0.0.1:19120/__release')
+    await expect(page.getByTestId('chat-run-thinking-dots')).toHaveCount(0)
+    await input.fill('完成后再发送一条普通消息')
+    await page.getByRole('button', { name: '发送消息', exact: true }).click()
+    await expect(page.locator('.message--user')).toHaveCount(3)
+    await expect(page.locator('.message--assistant')).toHaveCount(3)
+    await page.request.post('http://127.0.0.1:19120/__release')
+    await expect(page.getByTestId('chat-run-thinking-dots')).toHaveCount(0)
+    await expect(input).toHaveValue('')
+  } finally {
+    release(); await page.unroute(pattern)
+  }
+  const recording = await page.evaluate(() => (window as unknown as { stopSendLayoutRecording(): { selectors: string[]; samples: { connected: boolean; rects: number[][] }[] } }).stopSendLayoutRecording())
+  const recordingPath = testInfo.outputPath('send-layout-frames.json')
+  await writeFile(recordingPath, JSON.stringify(recording, null, 2))
+  await testInfo.attach('send-layout-frames.json', { path: recordingPath, contentType: 'application/json' })
+  expect(recording.samples.every(sample => sample.connected)).toBe(true)
+  for (const [index, selector] of recording.selectors.entries()) {
+    for (let coordinate = 0; coordinate < 4; coordinate++) {
+      const values = recording.samples.map(sample => sample.rects[index]![coordinate]!)
+      expect(Math.max(...values) - Math.min(...values), `${selector} coordinate ${coordinate} shifted`).toBeLessThanOrEqual(1)
+    }
+  }
+})
+}
+
 test('UTF-8 attachments stay inside the composer and retain their names after sending and reload', async ({ page }, testInfo) => {
   await login(page)
   const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()