Parcourir la source

统一桌面和移动网页系统提示的极简样式

Samien il y a 1 mois
Parent
commit
3e0a07c

+ 37 - 0
design-qa.md

@@ -231,3 +231,40 @@ final result: passed
 - 未部署到服务器;未实际发送生产消息。iOS 原生端证据记录在移动仓库的 `design-qa.md`。
 
 final result: passed
+
+
+---
+
+# 2026-09-21:统一系统提示为居中灰色图标和标题
+
+## Findings
+
+- 无未解决的 P0/P1/P2 视觉问题。任务分派、任务结果、回复中断、响应超时、子任务完成、上下文压缩、后台任务共用同一提示组件。
+- 正文与系统事件分开;任务结果点击打开对应任务,其余提示点击查看完整详情。失败前已生成的正文与附件保留。
+- 普通消息的气泡遵循已有外观设置;设计图的无气泡样式不覆盖用户已有设置。当前确认范围为系统提示。
+
+## Source / evidence / normalization
+
+- 确认图:`docs/system-notices/approved-design.png`,1619 × 971像素。合并比较:`docs/system-notices/comparison.png`,上方为源图,下方为正式 Vue 组件的实际渲染。
+- 截图 `desktop.png`:1440 × 1024,CSS viewport 同尺寸、1x;`mobile.png`、`mobile-dark.png`、`ordinary.png`:390 × 844、1x。
+- 源图是桌面/手机双栏概念画板,按各自消息内容区域归一比较图标、字体、水平对齐和留白,不拉伸整张画板。iOS 原生抽屉交互另见移动仓库验收记录。
+- 同目录 `*-expanded.png` 保留展开详情状态。13px 标签在原尺寸移动截图可直接判断,不需要另做放大裁切;合并图用于全景对照。
+- 本地入口:`http://127.0.0.1:15301/tests/fixtures/system-notices/index.html`,`?ordinary` 为普通聊天,`?dark` 为深色。使用正式消息时间线与本地示例数据。
+
+## Fidelity / comparison history
+
+- 字体:复用产品系统字体,13px 常规字重、1.6 行高;图标16px,间隔6px,支持长标题换行。
+- 布局:相对整条消息时间线居中,无默认背景/边框/阴影,最小点击高度44px。细节内容仅在打开后显示,恢复左对齐。
+- 颜色:浅色标签 rgb(111,111,105),深色 rgb(160,160,154),沿用语义 token。任务结果不再单独使用蓝色卡片。
+- 图像:采用已有线性图标库,无装饰性位图、替代头像或重绘截图。
+- 文案:只显示图标与短标题,去掉默认副标题、状态说明和「打开任务」操作字样;完整内容与导航可访问性标签保留。
+- 首轮发现中断提示仍跟随气泡宽度偏左:移到时间线整行,最终截图及 `checks.json` 中全部提示的水平中心偏差为0px。
+
+## Verification
+
+- 完整 `npm run build` 与 Vue 类型检查通过;5 个相关测试文件共19项通过。
+- 浏览器4组 viewport/theme/surface 检查通过:键盘展开、错误详情、部分回复保留、任务链接、无横向溢出、无页面异常。
+- iOS 与 Android 同步使用灰色居中提示;iOS 按后续要求改为半屏/全屏抽屉,详情不撑开时间线。
+- 未部署到服务器或安装到真机。
+
+final result: passed

BIN
docs/system-notices/approved-design.png


+ 234 - 0
docs/system-notices/checks.json

@@ -0,0 +1,234 @@
+[
+  {
+    "name": "desktop",
+    "alignment": [
+      {
+        "title": "已分派子任务",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "任务已有新结果",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "响应超时",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "回复暂时中断",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "子任务已完成",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "上下文已压缩",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "后台子任务已完成",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      }
+    ],
+    "keyboardDisclosure": true,
+    "diagnostic": true,
+    "partialReply": true,
+    "errors": []
+  },
+  {
+    "name": "mobile",
+    "alignment": [
+      {
+        "title": "已分派子任务",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "任务已有新结果",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "响应超时",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "回复暂时中断",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "子任务已完成",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "上下文已压缩",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "后台子任务已完成",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      }
+    ],
+    "keyboardDisclosure": true,
+    "diagnostic": true,
+    "partialReply": true,
+    "errors": []
+  },
+  {
+    "name": "mobile-dark",
+    "alignment": [
+      {
+        "title": "已分派子任务",
+        "offset": 0,
+        "color": "rgb(160, 160, 154)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "任务已有新结果",
+        "offset": 0,
+        "color": "rgb(160, 160, 154)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "响应超时",
+        "offset": 0,
+        "color": "rgb(160, 160, 154)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "回复暂时中断",
+        "offset": 0,
+        "color": "rgb(160, 160, 154)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "子任务已完成",
+        "offset": 0,
+        "color": "rgb(160, 160, 154)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "上下文已压缩",
+        "offset": 0,
+        "color": "rgb(160, 160, 154)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "后台子任务已完成",
+        "offset": 0,
+        "color": "rgb(160, 160, 154)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      }
+    ],
+    "keyboardDisclosure": true,
+    "diagnostic": true,
+    "partialReply": true,
+    "errors": []
+  },
+  {
+    "name": "ordinary",
+    "alignment": [
+      {
+        "title": "已分派子任务",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "任务已有新结果",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "响应超时",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "回复暂时中断",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "子任务已完成",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "上下文已压缩",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      },
+      {
+        "title": "后台子任务已完成",
+        "offset": 0,
+        "color": "rgb(111, 111, 105)",
+        "background": "rgba(0, 0, 0, 0)",
+        "height": 44
+      }
+    ],
+    "keyboardDisclosure": true,
+    "diagnostic": true,
+    "partialReply": true,
+    "errors": []
+  }
+]

BIN
docs/system-notices/comparison.png


BIN
docs/system-notices/desktop-expanded.png


BIN
docs/system-notices/desktop.png


BIN
docs/system-notices/mobile-dark-expanded.png


BIN
docs/system-notices/mobile-dark.png


BIN
docs/system-notices/mobile-expanded.png


BIN
docs/system-notices/mobile.png


BIN
docs/system-notices/ordinary-expanded.png


BIN
docs/system-notices/ordinary.png


+ 6 - 27
src/client/components/messages/MessageFailureNotice.vue

@@ -1,39 +1,18 @@
 <script setup lang="ts">
-import AppIcon from '@/components/common/AppIcon.vue'
+import SystemMessageNotice from './SystemMessageNotice.vue'
 import type { MessageFailure } from '@/utils/messageFailure'
 defineProps<{ failure: MessageFailure }>()
 </script>
 
 <template>
-  <div class="failure-notice">
-    <details v-if="failure.detail">
-      <summary :aria-label="`${failure.title},详情`">
-        <AppIcon name="info" :size="16" aria-hidden="true" />
-        <span class="failure-notice__label" role="status">{{ failure.title }}</span>
-        <span class="failure-notice__action"><span class="failure-notice__closed">详情</span><span class="failure-notice__open">收起</span><AppIcon name="chevron-down" :size="13" aria-hidden="true" /></span>
-      </summary>
-      <div class="failure-notice__details">
-        <p>{{ failure.hint }}</p>
-        <pre>{{ failure.detail }}</pre>
-      </div>
-    </details>
-    <div v-else class="failure-notice__summary" role="status"><AppIcon name="info" :size="16" aria-hidden="true" /><span>{{ failure.title }} · {{ failure.hint }}</span></div>
-  </div>
+  <SystemMessageNotice class="failure-notice" :title="failure.title" icon="info">
+    <p>{{ failure.hint }}</p>
+    <pre v-if="failure.detail">{{ failure.detail }}</pre>
+  </SystemMessageNotice>
 </template>
 
 <style scoped>
-.failure-notice { width: min(100%, 460px); margin-top: 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); color: var(--text-secondary); text-align: left; font-size: 13px; line-height: 1.5; }
-summary, .failure-notice__summary { display: flex; min-height: 44px; align-items: center; gap: 8px; padding: 8px 12px; list-style: none; box-sizing: border-box; }
-summary { cursor: pointer; border-radius: 11px; }
-summary::-webkit-details-marker { display: none; }
-summary:hover, summary:active { background: var(--surface-hover); }
-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
-.failure-notice__label { flex: 1; min-width: 0; font-weight: 500; }
-.failure-notice__action { display: flex; align-items: center; gap: 5px; margin-left: auto; white-space: nowrap; font-size: 12px; }
-.failure-notice__open, details[open] .failure-notice__closed { display: none; }
-details[open] .failure-notice__open { display: inline; }
-details[open] .failure-notice__action :deep(svg) { transform: rotate(180deg); }
-.failure-notice__details { padding: 0 12px 12px; }
+.failure-notice { margin-top: 6px; }
 p { margin: 0 0 8px; }
 pre { max-height: 240px; overflow: auto; margin: 0; padding-top: 10px; border-top: 1px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--font-code); }
 </style>

+ 30 - 0
src/client/components/messages/MessageSystemEvent.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { UiMessage } from './types'
+import { systemMessageNotice } from '@/utils/systemMessageNotice'
+import MarkdownContent from './MarkdownContent.vue'
+import SystemMessageNotice from './SystemMessageNotice.vue'
+
+const props = defineProps<{ message: UiMessage }>()
+const notice = computed(() => systemMessageNotice(props.message))
+const metadata = computed(() => {
+  const data = props.message.timelineMetadata
+  if (!data) return ''
+  if (props.message.timelineKind === 'delegation-complete') {
+    const completed = Number(data.completed_count ?? 0)
+    const failed = Number(data.failed_count ?? 0)
+    const seconds = Number(data.duration_seconds ?? 0)
+    const duration = seconds > 0 ? ` · 耗时 ${Math.round(seconds)} 秒` : ''
+    return `${Number(data.task_count) || completed + failed || 1} 个子任务 · ${completed} 已完成 · ${failed} 失败${duration}`
+  }
+  if (props.message.timelineKind === 'background-process') return [data.exit_code != null ? `退出码:${data.exit_code}` : '', data.signal ? `信号:${data.signal}` : ''].filter(Boolean).join(' · ')
+  return ''
+})
+</script>
+
+<template>
+  <SystemMessageNotice v-if="notice" :title="notice.title" :icon="notice.icon">
+    <p v-if="metadata">{{ metadata }}</p>
+    <MarkdownContent v-if="message.content" process-content :content="message.content" />
+  </SystemMessageNotice>
+</template>

+ 15 - 60
src/client/components/messages/MessageTimeline.vue

@@ -6,6 +6,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
 import { messageFailure } from '@/utils/messageFailure'
+import TaskResultNotice from '@/components/workspace/TaskResultNotice.vue'
+import MessageSystemEvent from '@/components/messages/MessageSystemEvent.vue'
+import { systemMessageNotice } from '@/utils/systemMessageNotice'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import TypingIndicator from '@/components/common/TypingIndicator.vue'
@@ -107,34 +110,6 @@ function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'load
   return 'idle'
 }
 
-function delegationSummary(metadata?: Record<string, unknown>): string {
-  const total = Number(metadata?.task_count ?? 0)
-  const completed = Number(metadata?.completed_count ?? 0)
-  const failed = Number(metadata?.failed_count ?? 0)
-  const seconds = Number(metadata?.duration_seconds ?? 0)
-  const duration = seconds > 0 ? ` · ${seconds < 60 ? `${Math.round(seconds)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`}` : ''
-  return `${total || completed + failed || 1} 个子任务 · ${completed} 已完成${failed ? ` · ${failed} 失败` : ''}${duration}`
-}
-
-function systemSummary(content: string): string {
-  const model = content.match(/(?:changed to|切换为)\s*([^\s,。\]]+?)(?=\s+via\b|\s*$|\])/i)?.[1]
-  if (model) return `模型已切换:${model}`
-  return '系统信息'
-}
-
-function backgroundProcessSummary(metadata?: Record<string, unknown>): string {
-  const exitCode = Number(metadata?.exit_code)
-  const signal = typeof metadata?.signal === 'string' ? metadata.signal : ''
-  if (exitCode === 0) return '后台子任务已完成'
-  if (signal) return `后台子任务已终止 · ${signal}`
-  return Number.isFinite(exitCode) ? `后台子任务失败 · 退出码 ${exitCode}` : '后台子任务已结束'
-}
-
-function isCompactionEvent(message: UiMessage): boolean {
-  return message.timelineMetadata?.eventKind === 'compaction'
-    || message.content.trim().toLocaleLowerCase().startsWith('[context compaction')
-}
-
 function onScroll() {
   const el = scroller.value
   if (!el) return
@@ -265,7 +240,9 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           class="message"
           :class="[`message--${message.role}`, {
             'message--failed': message.status === 'failed',
-            'message--failure-notice': messageFailure(message)?.replacesContent,
+            'message--failure-notice': messageFailure(message)?.replacesContent && !message.attachments?.length,
+            'message--has-failure': !!messageFailure(message),
+            'message--notice': !!systemMessageNotice(message),
             'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
             'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
             'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
@@ -274,30 +251,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           <AgentAvatar v-if="message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar" :name="message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')" :avatar="message.profile ? agentAvatars[message.profile] || '' : ''" :size="27" :state="avatarState(message)" />
           <div class="message__body">
             <MessageFailureNotice v-if="messageFailure(message)?.replacesContent && !message.attachments?.length" :failure="messageFailure(message)!" />
-            <template v-else-if="message.timelineKind === 'delegation-complete'">
-              <details class="delegation-event">
-                <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
-            <template v-else-if="message.timelineKind === 'background-process'">
-              <details class="system-event background-process-event">
-                <summary><AppIcon name="tools" :size="13" /><span>{{ backgroundProcessSummary(message.timelineMetadata) }}</span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
-            <template v-else-if="message.timelineKind === 'system' && isCompactionEvent(message)">
-              <details class="delegation-event compaction-event">
-                <summary><AppIcon name="archive" :size="14" /><span><strong>上下文已压缩</strong><small>压缩摘要已归档 · 点击查看</small></span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
-            <template v-else-if="message.timelineKind === 'system'">
-              <details class="system-event">
-                <summary><AppIcon name="settings" :size="13" /><span>{{ systemSummary(message.content) }}</span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
+            <TaskResultNotice v-else-if="message.role === 'system' && message.taskReference" :content="message.content" :task-reference="message.taskReference" />
+            <MessageSystemEvent v-else-if="systemMessageNotice(message)" :message="message" />
             <template v-else>
             <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
               <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
@@ -358,7 +313,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
             </div>
 
-            <MessageFailureNotice v-if="messageFailure(message)" :failure="messageFailure(message)!" />
 
             <div v-if="hasConversationActions(message)" class="message__actions">
               <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
@@ -377,6 +331,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
             </div>
             </template>
           </div>
+          <MessageFailureNotice v-if="messageFailure(message) && (!messageFailure(message)?.replacesContent || !!message.attachments?.length)" :failure="messageFailure(message)!" />
         </article>
         </template>
         </template>
@@ -405,6 +360,12 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 </template>
 
 <style scoped>
+.message--has-failure { flex-wrap: wrap; }
+.message--has-failure > .failure-notice { flex: 0 0 100%; }
+.message.message--notice, .message.message--failure-notice { justify-content: center; }
+.message--notice .message__avatar, .message--failure-notice .message__avatar { display: none; }
+.message.message--notice .message__body, .message.message--failure-notice .message__body { width: 100%; max-width: 100%; margin: 0; padding: 0; border: 0; background: transparent; }
+
 .timeline-frame { position: relative; display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
 .timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
 .timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }
@@ -430,9 +391,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
-.message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }
-.delegation-event { min-width: 230px; max-width: 460px; text-align: left; }.delegation-event summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }.delegation-event summary::-webkit-details-marker { display: none; }.delegation-event summary > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }.delegation-event strong { color: var(--text-secondary); font-size: 12px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 10px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
-.system-event { max-width: 460px; text-align: left; }.system-event summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; color: var(--text-muted); font-size: 11px; }.system-event summary::-webkit-details-marker { display: none; }.system-event :deep(.markdown) { max-height: 220px; margin-top: 8px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
 .message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
 .message--assistant-internal { margin-bottom: 6px; }.message--assistant-internal .message__reasoning { margin-bottom: 4px; padding-bottom: 4px; }.message--assistant-internal .message__tools { margin-top: 4px; }
 .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
@@ -453,9 +411,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
 @keyframes shimmer { to { background-position: -200% 0; } }
 
-.message--failure-notice { justify-content: flex-start; }
-.message--failure-notice .message__avatar { display: none; }
-.message--failure-notice .message__body { width: min(460px, 100%); max-width: 100%; padding: 0; border: 0; background: transparent; text-align: left; }
 
 @media (max-width: 768px) {
   .timeline-header { min-height: 54px; padding: 8px 12px; }.timeline-state { font-size: 0; }.timeline-state .status-dot { width: 7px; height: 7px; }

+ 35 - 0
src/client/components/messages/SystemMessageNotice.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+import type { SystemNoticeIcon } from '@/utils/systemMessageNotice'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+defineProps<{ title: string; icon?: SystemNoticeIcon; href?: string; accessibleLabel?: string }>()
+</script>
+
+<template>
+  <div class="system-message-notice">
+    <a v-if="href" class="system-message-notice__label" :href="href" :aria-label="accessibleLabel || title">
+      <AppIcon :name="icon || 'info'" :size="16" aria-hidden="true" /><span>{{ title }}</span>
+    </a>
+    <details v-else-if="$slots.default">
+      <summary class="system-message-notice__label" :aria-label="accessibleLabel || `${title},查看详情`">
+        <AppIcon :name="icon || 'info'" :size="16" aria-hidden="true" /><span role="status">{{ title }}</span>
+      </summary>
+      <div class="system-message-notice__details"><slot /></div>
+    </details>
+    <div v-else class="system-message-notice__label" role="status">
+      <AppIcon :name="icon || 'info'" :size="16" aria-hidden="true" /><span>{{ title }}</span>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.system-message-notice { width: 100%; min-width: 0; color: var(--text-muted); font-size: 13px; line-height: 1.6; text-align: center; }
+.system-message-notice__label { display: flex; width: fit-content; max-width: 100%; min-height: 44px; align-items: center; justify-content: center; gap: 6px; box-sizing: border-box; margin: 0 auto; padding: 8px 4px; border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; font-weight: 400; text-decoration: none; list-style: none; overflow-wrap: anywhere; }
+.system-message-notice__label :deep(svg) { flex-shrink: 0; }
+summary, a { cursor: pointer; }
+summary::-webkit-details-marker { display: none; }
+summary:hover, a:hover { color: var(--text-secondary); }
+summary:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+.system-message-notice__details { width: min(640px, 100%); max-height: 360px; box-sizing: border-box; overflow: auto; margin: 4px auto 12px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 8px; color: var(--text-secondary); background: var(--surface); text-align: left; overflow-wrap: anywhere; }
+.system-message-notice__details :deep(.markdown) { font-size: 13px; text-align: left; }
+</style>

+ 6 - 11
src/client/components/messages/TurnTrace.vue

@@ -3,6 +3,7 @@ import { computed } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import MarkdownContent from './MarkdownContent.vue'
 import ToolTrace from './ToolTrace.vue'
+import SystemMessageNotice from './SystemMessageNotice.vue'
 import type { TurnTraceGroup } from '@/utils/turnTrace'
 
 const props = defineProps<{ group: TurnTraceGroup; streamIntervalMs?: number }>()
@@ -16,14 +17,8 @@ const statusLabel = computed(() => props.group.status === 'running' ? '进行中
 </script>
 
 <template>
-  <details class="turn-trace" :class="`turn-trace--${group.status}`">
-    <summary>
-      <AppIcon name="brain" :size="13" />
-      <strong>思考与工具</strong>
-      <small>{{ summary }}</small>
-      <em v-if="statusLabel">{{ statusLabel }}</em>
-      <span class="turn-trace__caret">›</span>
-    </summary>
+  <SystemMessageNotice class="turn-trace" icon="brain" :title="group.status === 'running' ? '正在思考与使用工具' : group.status === 'error' ? '思考与工具出现错误' : '思考与工具'">
+    <p class="turn-trace__summary">{{ summary }}<template v-if="statusLabel"> · {{ statusLabel }}</template></p>
     <div class="turn-trace__content">
       <template v-for="entry in group.entries" :key="entry.id">
         <section v-if="entry.type === 'reasoning'" class="turn-trace__item turn-trace__reasoning">
@@ -33,12 +28,12 @@ const statusLabel = computed(() => props.group.status === 'running' ? '进行中
         <ToolTrace v-else class="turn-trace__item turn-trace__tool" :tool="entry.tool" expanded />
       </template>
     </div>
-  </details>
+  </SystemMessageNotice>
 </template>
 
 <style scoped>
-.turn-trace { width: min(680px, 100%); margin: 2px 0 9px; border-radius: 10px; color: var(--text-muted); transition: background-color 100ms ease; }.turn-trace[open] { background: color-mix(in srgb, var(--surface-soft) 45%, var(--surface)); }.turn-trace > summary { display: flex; width: fit-content; min-height: 28px; align-items: center; gap: 6px; padding: 2px 7px 2px 2px; border-radius: 7px; cursor: pointer; list-style: none; transition: background-color 100ms ease, color 100ms ease; }.turn-trace > summary::-webkit-details-marker { display: none; }.turn-trace > summary:hover, .turn-trace > summary:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-secondary); }.turn-trace > summary strong { color: var(--text-secondary); font-size: 10px; font-weight: 540; }.turn-trace > summary small { font-size: 9px; }.turn-trace > summary em { color: var(--warning); font-size: 8px; font-style: normal; }.turn-trace--error > summary em { color: var(--danger); }
-.turn-trace__caret { display: inline-block; width: 9px; font-size: 14px; line-height: 1; transition: transform 120ms ease; }.turn-trace[open] .turn-trace__caret { transform: rotate(90deg); }
+.turn-trace { margin: 2px 0 9px; }
+.turn-trace__summary { margin: 0 0 8px; color: var(--text-secondary); font-size: 12px; }
 .turn-trace__content { display: grid; max-height: min(420px, 52vh); margin: 1px 0 6px; padding: 2px 8px 4px 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
 .turn-trace__item { min-width: 0; }
 .turn-trace__reasoning { max-width: 620px; padding: 7px 0 5px; color: var(--text-secondary); }.turn-trace__reasoning header { display: flex; min-height: 22px; align-items: center; gap: 5px; margin-bottom: 6px; color: var(--text-muted); font-size: 9px; }.turn-trace__reasoning :deep(.markdown) { font-size: 11px; line-height: 1.58; }

+ 2 - 76
src/client/components/workspace/TaskResultNotice.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed } from 'vue'
-import AppIcon from '@/components/common/AppIcon.vue'
+import SystemMessageNotice from '@/components/messages/SystemMessageNotice.vue'
 
 const props = defineProps<{
   content: string
@@ -26,79 +26,5 @@ const href = computed(() => `/conversations/${encodeURIComponent(props.taskRefer
 </script>
 
 <template>
-  <section class="task-result-notice" :class="{ 'task-result-notice--complete': !waitingForReview }" aria-label="任务结果通知">
-    <span class="task-result-notice__icon"><AppIcon :name="waitingForReview ? 'file' : 'check'" :size="18" /></span>
-    <div class="task-result-notice__content">
-      <strong>{{ title }}</strong>
-      <span>{{ context }}</span>
-      <small>{{ supporting }}</small>
-    </div>
-    <a class="task-result-notice__action" :href="href">
-      <span>打开任务</span><AppIcon class="task-result-notice__chevron" name="chevron-left" :size="15" />
-    </a>
-  </section>
+  <SystemMessageNotice class="task-result-notice" :title="title" :icon="waitingForReview ? 'file' : 'check'" :href="href" :accessible-label="`${title},${context},${supporting},打开任务`" />
 </template>
-
-<style scoped>
-.task-result-notice {
-  display: grid;
-  width: min(640px, 100%);
-  min-width: 0;
-  align-items: center;
-  gap: 12px;
-  grid-template-columns: 40px minmax(0, 1fr) auto;
-  padding: 13px 14px;
-  border: 1px solid color-mix(in srgb, var(--workflow-accent) 24%, var(--line));
-  border-radius: 14px;
-  background: color-mix(in srgb, var(--workflow-accent) 4%, var(--surface-raised));
-  box-sizing: border-box;
-  text-align: left;
-}
-.task-result-notice--complete {
-  border-color: color-mix(in srgb, var(--success) 24%, var(--line));
-  background: color-mix(in srgb, var(--success) 5%, var(--surface-raised));
-}
-.task-result-notice__icon {
-  display: grid;
-  width: 38px;
-  height: 38px;
-  place-items: center;
-  border-radius: 11px;
-  background: var(--surface-raised);
-  color: var(--text-secondary);
-  box-shadow: inset 0 0 0 1px var(--line);
-}
-.task-result-notice--complete .task-result-notice__icon { color: var(--success); }
-.task-result-notice__content { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
-.task-result-notice__content strong { color: var(--text-primary); font-size: 14px; font-weight: 650; line-height: 1.35; }
-.task-result-notice__content span,
-.task-result-notice__content small { overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
-.task-result-notice__content span { font-size: 12px; line-height: 1.45; }
-.task-result-notice__content small { color: var(--text-muted); font-size: 11px; line-height: 1.4; }
-.task-result-notice__action {
-  display: inline-flex;
-  min-height: 44px;
-  align-items: center;
-  justify-content: center;
-  gap: 4px;
-  padding: 0 4px 0 12px;
-  border-radius: 10px;
-  color: var(--workflow-accent);
-  font-size: 13px;
-  font-weight: 620;
-  text-decoration: none;
-  white-space: nowrap;
-}
-.task-result-notice__action:hover { background: color-mix(in srgb, var(--workflow-accent) 8%, transparent); }
-.task-result-notice__action:focus-visible { outline: 2px solid var(--workflow-accent); outline-offset: 2px; }
-.task-result-notice__chevron { transform: rotate(180deg); }
-
-@media (max-width: 600px) {
-  .task-result-notice { gap: 10px; grid-template-columns: 36px minmax(0, 1fr); padding: 12px; }
-  .task-result-notice__icon { width: 36px; height: 36px; }
-  .task-result-notice__content strong { font-size: 14px; }
-  .task-result-notice__content span { font-size: 12px; }
-  .task-result-notice__content small { white-space: normal; }
-  .task-result-notice__action { grid-column: 2; width: max-content; min-height: 40px; margin: -2px 0 -6px -12px; padding-inline: 12px 6px; }
-}
-</style>

+ 13 - 61
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -6,6 +6,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
 import { messageFailure } from '@/utils/messageFailure'
+import MessageSystemEvent from '@/components/messages/MessageSystemEvent.vue'
+import { systemMessageNotice } from '@/utils/systemMessageNotice'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
@@ -177,34 +179,6 @@ function avatarState(message: UiMessage): 'idle' | 'working' | 'waiting' | 'load
   return 'idle'
 }
 
-function delegationSummary(metadata?: Record<string, unknown>): string {
-  const total = Number(metadata?.task_count ?? 0)
-  const completed = Number(metadata?.completed_count ?? 0)
-  const failed = Number(metadata?.failed_count ?? 0)
-  const seconds = Number(metadata?.duration_seconds ?? 0)
-  const duration = seconds > 0 ? ` · ${seconds < 60 ? `${Math.round(seconds)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`}` : ''
-  return `${total || completed + failed || 1} 个子任务 · ${completed} 已完成${failed ? ` · ${failed} 失败` : ''}${duration}`
-}
-
-function systemSummary(content: string): string {
-  const model = content.match(/(?:changed to|切换为)\s*([^\s,。\]]+?)(?=\s+via\b|\s*$|\])/i)?.[1]
-  if (model) return `模型已切换:${model}`
-  return '系统信息'
-}
-
-function backgroundProcessSummary(metadata?: Record<string, unknown>): string {
-  const exitCode = Number(metadata?.exit_code)
-  const signal = typeof metadata?.signal === 'string' ? metadata.signal : ''
-  if (exitCode === 0) return '后台子任务已完成'
-  if (signal) return `后台子任务已终止 · ${signal}`
-  return Number.isFinite(exitCode) ? `后台子任务失败 · 退出码 ${exitCode}` : '后台子任务已结束'
-}
-
-function isCompactionEvent(message: UiMessage): boolean {
-  return message.timelineMetadata?.eventKind === 'compaction'
-    || message.content.trim().toLocaleLowerCase().startsWith('[context compaction')
-}
-
 function onScroll() {
   const el = scroller.value
   if (!el) return
@@ -359,7 +333,9 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           class="message"
           :class="[`message--${message.role}`, {
             'message--failed': message.status === 'failed',
-            'message--failure-notice': messageFailure(message)?.replacesContent,
+            'message--failure-notice': messageFailure(message)?.replacesContent && !message.attachments?.length,
+            'message--has-failure': !!messageFailure(message),
+            'message--notice': !!systemMessageNotice(message),
             'message--communication': !!message.communication,
             'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
             'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
@@ -375,30 +351,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           <div class="message__body">
             <MessageFailureNotice v-if="messageFailure(message)?.replacesContent && !message.attachments?.length" :failure="messageFailure(message)!" />
             <TaskResultNotice v-else-if="message.role === 'system' && message.taskReference" :content="message.content" :task-reference="message.taskReference" />
-            <template v-else-if="message.timelineKind === 'delegation-complete'">
-              <details class="delegation-event">
-                <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
-            <template v-else-if="message.timelineKind === 'background-process'">
-              <details class="system-event background-process-event">
-                <summary><AppIcon name="tools" :size="13" /><span>{{ backgroundProcessSummary(message.timelineMetadata) }}</span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
-            <template v-else-if="message.timelineKind === 'system' && isCompactionEvent(message)">
-              <details class="delegation-event compaction-event">
-                <summary><AppIcon name="archive" :size="14" /><span><strong>上下文已压缩</strong><small>压缩摘要已归档 · 点击查看</small></span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
-            <template v-else-if="message.timelineKind === 'system'">
-              <details class="system-event">
-                <summary><AppIcon name="settings" :size="13" /><span>{{ systemSummary(message.content) }}</span></summary>
-                <MarkdownContent process-content v-if="message.content" :content="message.content" />
-              </details>
-            </template>
+            <MessageSystemEvent v-else-if="systemMessageNotice(message)" :message="message" />
             <template v-else>
             <div v-if="!message.communication && (message.role !== 'assistant' || showAssistantIdentity)" class="message__meta">
               <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
@@ -460,7 +413,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
             </div>
 
-            <MessageFailureNotice v-if="messageFailure(message)" :failure="messageFailure(message)!" />
 
             <div v-if="hasConversationActions(message)" class="message__actions">
               <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
@@ -479,6 +431,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
             </div>
             </template>
           </div>
+          <MessageFailureNotice v-if="messageFailure(message) && (!messageFailure(message)?.replacesContent || !!message.attachments?.length)" :failure="messageFailure(message)!" />
         </article>
         </template>
         </template>
@@ -509,6 +462,12 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 </template>
 
 <style scoped>
+.message--has-failure { flex-wrap: wrap; }
+.message--has-failure > .failure-notice { flex: 0 0 100%; }
+.message.message--notice, .message.message--failure-notice { justify-content: center; }
+.message--notice .message__avatar, .message--failure-notice .message__avatar { display: none; }
+.message.message--notice .message__body, .message.message--failure-notice .message__body { width: 100%; max-width: 100%; margin: 0; padding: 0; border: 0; background: transparent; }
+
 .message.message--communication { flex-direction: column; align-items: flex-start; gap: 12px; margin-top: 24px; }
 .communication-caption { display: flex; width: 100%; min-width: 0; justify-content: center; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12px; font-weight: 400; line-height: 16px; }
 .communication-caption > span:first-child { flex-shrink: 0; white-space: nowrap; }
@@ -539,10 +498,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
-.message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { width: min(640px, 100%); max-width: 92%; padding: 0; color: var(--text-muted); text-align: center; }
-.message--system .message__body:has(> :not(.task-result-notice)) { width: auto; max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; }
-.delegation-event { min-width: 230px; max-width: 460px; text-align: left; }.delegation-event summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }.delegation-event summary::-webkit-details-marker { display: none; }.delegation-event summary > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }.delegation-event strong { color: var(--text-secondary); font-size: 12px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 10px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
-.system-event { max-width: 460px; text-align: left; }.system-event summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; color: var(--text-muted); font-size: 11px; }.system-event summary::-webkit-details-marker { display: none; }.system-event :deep(.markdown) { max-height: 220px; margin-top: 8px; overflow: auto; color: var(--text-secondary); font-size: 11px; }
 .message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
 .message--assistant-internal { margin-bottom: 6px; }.message--assistant-internal .message__reasoning { margin-bottom: 4px; padding-bottom: 4px; }.message--assistant-internal .message__tools { margin-top: 4px; }
 .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
@@ -563,9 +518,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
 @keyframes shimmer { to { background-position: -200% 0; } }
 
-.message--failure-notice { justify-content: flex-start; }
-.message--failure-notice .message__avatar { display: none; }
-.message--failure-notice .message__body { width: min(460px, 100%); max-width: 100%; padding: 0; border: 0; background: transparent; text-align: left; }
 
 @media (max-width: 768px) {
   .timeline-header { min-height: 54px; padding: 8px 12px; }

+ 23 - 0
src/client/utils/systemMessageNotice.ts

@@ -0,0 +1,23 @@
+import type { UiMessage } from '@/components/messages/types'
+
+/** System events are navigation/disclosures, never part of an assistant's prose. */
+export type SystemNoticeIcon = 'info' | 'branch' | 'groups' | 'tools' | 'archive' | 'check' | 'settings' | 'file' | 'brain'
+
+export function systemMessageNotice(message: UiMessage): { title: string; icon: SystemNoticeIcon } | undefined {
+  if (message.communication || message.role === 'user') return
+  if (message.timelineKind === 'delegation-complete') return { title: '子任务已完成', icon: 'groups' }
+  if (message.timelineKind === 'background-process') {
+    const code = message.timelineMetadata?.exit_code
+    const signal = message.timelineMetadata?.signal
+    return { title: signal ? '后台子任务已终止' : code === undefined || code === null ? '后台子任务已结束' : Number(code) === 0 ? '后台子任务已完成' : '后台子任务失败', icon: 'tools' }
+  }
+  if (message.role !== 'system' && message.timelineKind !== 'system') return
+  const content = message.content.trim()
+  if (message.timelineMetadata?.eventKind === 'compaction' || /^\[context compaction/i.test(content)) return { title: '上下文已压缩', icon: 'archive' }
+  if (/^(?:由管理员分派的子任务|(?:已)?分派(?:了)?(?:终审|子)?任务)/.test(content)) return { title: '已分派子任务', icon: 'branch' }
+  if (/^执行失败[::]/.test(content)) return { title: /timeout|timed?\s*out|超时|no SSE events/i.test(content) ? '响应超时' : '回复暂时中断', icon: 'info' }
+  if (/^(?:回复重点|重点提示)[::]?/.test(content)) return { title: '回复重点', icon: 'info' }
+  if (/^(?:任务|子任务).*(?:已完成|完成通知)/.test(content.split('\n')[0] || '')) return { title: '任务已完成', icon: 'check' }
+  const model = content.match(/(?:changed to|切换为)\s*([^\s,。\]]+?)(?=\s+via\b|\s*$|\])/i)?.[1]
+  return { title: model ? '模型已切换' : '系统提示', icon: model ? 'settings' : 'info' }
+}

+ 1 - 1
tests/client/messageFailure.test.ts

@@ -36,7 +36,7 @@ describe('conversation failure presentation', () => {
   it('uses a native disclosure with the complete original diagnostic', () => {
     const wrapper = mount(MessageFailureNotice, { props: { failure: messageFailure(failed)! } })
     expect(wrapper.get('details').attributes('open')).toBeUndefined()
-    expect(wrapper.get('summary').text()).toContain('响应超时')
+    expect(wrapper.get('summary').text()).toBe('响应超时')
     expect(wrapper.get('summary').text()).not.toContain('SSE')
     expect(wrapper.get('pre').text()).toBe(error)
     expect(wrapper.get('[role="status"]').text()).toBe('响应超时')

+ 40 - 0
tests/client/systemMessageNotice.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { systemMessageNotice } from '@/utils/systemMessageNotice'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import type { UiMessage } from '@/components/messages/types'
+
+const assignment: UiMessage = { id: 'assigned', role: 'system', content: '由管理员分派的子任务:终审文章\n\n- 核验来源\n- 检查配图' }
+
+describe('system event disclosures', () => {
+  it('classifies role-only assignments without treating real replies as notices', () => {
+    expect(systemMessageNotice(assignment)).toEqual({ title: '已分派子任务', icon: 'branch' })
+    expect(systemMessageNotice({ ...assignment, role: 'assistant' })).toBeUndefined()
+    expect(systemMessageNotice({ ...assignment, role: 'user' })).toBeUndefined()
+    expect(systemMessageNotice({ ...assignment, communication: { direction: 'incoming', peerId: 'p', peerName: '竹儿', peerAvatar: '', peerKind: 'agent', content: assignment.content } })).toBeUndefined()
+  })
+
+  it('distinguishes background completion, interruption and missing exit status', () => {
+    const message: UiMessage = { id: 'process', role: 'system', content: 'process', timelineKind: 'background-process' }
+    expect(systemMessageNotice(message)?.title).toBe('后台子任务已结束')
+    expect(systemMessageNotice({ ...message, timelineMetadata: { exit_code: null } })?.title).toBe('后台子任务已结束')
+    expect(systemMessageNotice({ ...message, timelineMetadata: { exit_code: 0 } })?.title).toBe('后台子任务已完成')
+    expect(systemMessageNotice({ ...message, timelineMetadata: { exit_code: 1 } })?.title).toBe('后台子任务失败')
+    expect(systemMessageNotice({ ...message, timelineMetadata: { exit_code: 143, signal: 'SIGTERM' } })?.title).toBe('后台子任务已终止')
+  })
+
+  it.each([MessageTimeline, WorkspaceMessageTimeline])('keeps full requirements behind a short title in both timelines', async component => {
+    vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
+    Element.prototype.scrollTo = vi.fn()
+    const wrapper = mount(component, { props: { identity: 'test', messages: [assignment] }, global: { stubs: { AgentAvatar: true, MarkdownContent: { props: ['content'], template: '<p>{{ content }}</p>' } } } })
+    expect(wrapper.get('summary').text()).toBe('已分派子任务')
+    expect(wrapper.get('details').attributes('open')).toBeUndefined()
+    expect(wrapper.get('.system-message-notice__details').text()).toContain('核验来源')
+    expect(wrapper.find('.message__content').exists()).toBe(false)
+    await nextTick()
+    wrapper.unmount()
+    vi.unstubAllGlobals()
+  })
+})

+ 3 - 3
tests/client/taskResultNotice.test.ts

@@ -8,8 +8,8 @@ it('turns a delivered team result into a clear task entry point', () => {
     taskReference: { conversationId: 'team', taskId: 'task' },
   } })
 
-  expect(wrapper.get('strong').text()).toBe('任务已有新结果')
-  expect(wrapper.text()).toContain('公众号文案团队 · 完成 Jev 公众号文章')
-  expect(wrapper.text()).toContain('等待管理员复核后继续')
+  expect(wrapper.get('a').text()).toBe('任务已有新结果')
+  expect(wrapper.get('a').attributes('aria-label')).toContain('公众号文案团队 · 完成 Jev 公众号文章')
+  expect(wrapper.get('a').attributes('aria-label')).toContain('等待管理员复核后继续')
   expect(wrapper.get('a').attributes('href')).toBe('/conversations/team?taskId=task')
 })

+ 1 - 0
tests/fixtures/system-notices/index.html

@@ -0,0 +1 @@
+<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聊天错误提示预览</title></head><body><div id="app"></div><script type="module" src="./main.ts"></script></body></html>

+ 25 - 0
tests/fixtures/system-notices/main.ts

@@ -0,0 +1,25 @@
+import { createApp, defineComponent, h } from 'vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import type { UiMessage } from '@/components/messages/types'
+import '@/styles/tokens.css'
+import '@/styles/global.css'
+const query = new URLSearchParams(location.search)
+document.documentElement.classList.toggle('dark', query.has('dark'))
+const messages: UiMessage[] = [
+  { id: 'user', role: 'user', content: '检查文章和配图,整理成预览包。' },
+  { id: 'assigned', role: 'system', content: '由管理员分派的子任务:终审公众号预览包\n\n核验标题、事实来源和配图一致性。\n\n- 只核查内容\n- 提供可以执行的修改建议\n\n文件:`article.md`' },
+  { id: 'answer', role: 'assistant', content: '文案和配图已完成。\n\n预览包已整理好,等待终审。' },
+  { id: 'result', role: 'system', content: '「公众号文案团队」的任务「判断与执行拆分」已有执行结果,等待管理员复核。', taskReference: { conversationId: 'preview-team', taskId: 'preview-task' } },
+  { id: 'timeout', role: 'assistant', content: '执行失败:请求超时', error: 'Codex stream produced no SSE events for 120s', status: 'failed' },
+  { id: 'partial', role: 'assistant', content: '已完成第一项检查。', error: 'Connection closed while reading the response', status: 'failed' },
+  { id: 'complete', role: 'system', content: '子任务结果已完成。\n\n完整核验记录保留在这里。', timelineKind: 'delegation-complete', timelineMetadata: { task_count: 3, completed_count: 3 } },
+  { id: 'compaction', role: 'system', content: '[CONTEXT COMPACTION]\n\n已归档的摘要。', timelineKind: 'system' },
+  { id: 'background', role: 'system', content: '后台检查完成,退出码 0。', timelineKind: 'background-process', timelineMetadata: { exit_code: 0 } },
+]
+const simple = query.has('reference')
+createApp(defineComponent({ setup() {
+  return () => h('main', { style: 'height:100dvh;display:flex;flex-direction:column' }, [
+    h(query.has('ordinary') ? MessageTimeline : WorkspaceMessageTimeline, { identity: 'system-notice-preview', title: '公众号文案团队', showAssistantIdentity: false, messages: simple ? messages.slice(0, 4) : messages }),
+  ])
+} })).mount('#app')