소스 검색

重设计任务进度与提问交互

Samien 1 개월 전
부모
커밋
008c763

+ 45 - 0
design-qa-task-interactions.md

@@ -0,0 +1,45 @@
+# 交互组件设计 QA
+
+## 对照基准
+
+- 视觉真源:`/Users/samien/.codex/generated_images/01a0bea3-706a-7812-b693-85595b23cbe3/exec-d9fe933f-bbe4-4105-9457-1eebb7c4c03d.png`
+- 真源像素:1536 × 1024;画面同时给出桌面端与手机端目标状态。
+- 实现地址:`http://127.0.0.1:15301/conversations/11111111-1111-4111-8111-111111111111?taskId=22222222-2222-4222-8222-222222222222`
+- 实现截图:Codex 内置浏览器 tab 4 的本轮桌面与手机截图(浏览器后端未提供可持久化的本地截图路径)。
+- 检查视口:桌面 1280 × 720;手机 CSS 视口 390 × 844。
+- 像素密度:浏览器默认 DPR;比较以 CSS 像素布局、层级、颜色、文字密度和交互状态为准。
+
+## 设计意图
+
+- 任务结果通知从居中的系统文字改为可点击的短行动卡,保留任务上下文、复核状态和唯一主行动。
+- 交付进展改为分段进度、四阶段状态和可折叠详情,首屏只保留决策所需信息。
+- 询问问题在桌面端使用紧凑选择卡,在手机端使用底部操作面板;预设答案、自由输入与提交反馈保持一致。
+- 采用克制的蓝色工作流强调色;常规文本和容器继续沿用现有中性色设计系统。
+
+## 对照记录
+
+### 第 1 轮
+
+- P1:桌面端提问卡过高,滚动到底部时标题区域被顶部裁切。
+- P2:进度与选择状态仍使用全局黑色强调色,与视觉真源的蓝色行动层级不一致。
+- P2:390px 手机视口中第 4 个进度步骤被横向裁切。
+
+修正:将自由输入和提交按钮在桌面端合并为一行;新增语义化工作流强调色;手机步骤宽度收紧为 78px,并保留超出 4 项时的横向滚动能力。
+
+### 第 2 轮
+
+- 桌面 1280 × 720:提问标题、两个选项、自由输入与提交按钮在同一可视区完整呈现。
+- 手机 390 × 844:底部操作面板、52px 选项触控区、自由输入与全宽提交按钮完整呈现。
+- 手机进度卡:4 个阶段标记均在首屏卡片内可见,长标题按现有密度截断。
+- 选择状态:原生单选值、蓝色边框、勾选图标和提交按钮同步更新。
+- 任务通知:语义结构、任务链接与复核辅助文字均由可访问性树验证。
+
+## 最终检查
+
+- 布局:通过;桌面与手机断点无横向溢出。
+- 层级与间距:通过;行动卡、进度卡和问答卡均与现有聊天宽度对齐。
+- 颜色与状态:通过;完成、进行中、待复核、禁用和选中状态可区分。
+- 字体与可读性:通过;沿用现有字体与字号体系,长任务名和结果文本有受控换行或截断。
+- 交互:通过;原生 radio、textarea、按钮与 details 均可键盘聚焦,主要触控目标不小于 44px。
+- 基础状态:通过;未选择时提交按钮禁用,选择后启用;详情折叠态和结果态已验证。该组组件没有独立的加载或空数据界面。
+- 结果:通过,无未解决的 P0/P1/P2 视觉问题。

+ 112 - 18
src/client/components/messages/InteractionCard.vue

@@ -1,39 +1,133 @@
 <script setup lang="ts">
-import { ref } from 'vue'
+import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import type { UiInteraction } from './types'
 
-defineProps<{ interaction: UiInteraction; busy?: boolean }>()
+const props = defineProps<{ interaction: UiInteraction; busy?: boolean }>()
 const emit = defineEmits<{ approve: [approved: boolean]; clarify: [text: string] }>()
-const answer = ref('')
+const selectedOption = ref('')
+const customAnswer = ref('')
+const answer = computed(() => customAnswer.value.trim() || selectedOption.value)
+
+function selectOption(option: string): void {
+  selectedOption.value = option
+  customAnswer.value = ''
+}
+
+function submit(): void {
+  if (!answer.value || props.busy) return
+  emit('clarify', answer.value)
+}
+
+watch(() => props.interaction.id, () => {
+  selectedOption.value = ''
+  customAnswer.value = ''
+})
 </script>
 
 <template>
-  <section class="interaction-card" aria-live="polite">
+  <section class="interaction-card" :class="`interaction-card--${interaction.kind}`" aria-live="polite" :aria-labelledby="`interaction-title-${interaction.id}`">
+    <span class="interaction-card__handle" aria-hidden="true" />
     <div class="interaction-card__heading">
-      <span><AppIcon :name="interaction.kind === 'approval' ? 'check' : 'chat'" :size="16" /></span>
-      <div><small>{{ interaction.kind === 'approval' ? '需要审批' : '需要补充信息' }}</small><strong>{{ interaction.title || interaction.prompt }}</strong></div>
+      <span class="interaction-card__icon"><AppIcon :name="interaction.kind === 'approval' ? 'check' : 'chat'" :size="18" /></span>
+      <div>
+        <small>{{ interaction.kind === 'approval' ? '需要审批' : '需要你的回答' }}</small>
+        <strong :id="`interaction-title-${interaction.id}`">{{ interaction.title || interaction.prompt }}</strong>
+      </div>
     </div>
-    <p v-if="interaction.title">{{ interaction.prompt }}</p>
-    <details v-if="interaction.detail"><summary>查看详情</summary><pre>{{ interaction.detail }}</pre></details>
+    <p v-if="interaction.title" class="interaction-card__prompt">{{ interaction.prompt }}</p>
+    <details v-if="interaction.detail" class="interaction-card__details"><summary>查看详情<AppIcon name="chevron-down" :size="13" /></summary><pre>{{ interaction.detail }}</pre></details>
     <div v-if="interaction.kind === 'approval'" class="interaction-card__actions">
       <button class="quiet-button" type="button" :disabled="busy" @click="emit('approve', false)">拒绝</button>
       <button class="solid-button" type="button" :disabled="busy" @click="emit('approve', true)">允许</button>
     </div>
-    <form v-else class="interaction-card__answer" @submit.prevent="answer.trim() && emit('clarify', answer.trim())">
-      <div v-if="interaction.options?.length" class="interaction-card__options">
-        <button v-for="option in interaction.options" :key="option" type="button" @click="answer = option">{{ option }}</button>
+    <form v-else class="interaction-card__answer" @submit.prevent="submit">
+      <fieldset v-if="interaction.options?.length" class="interaction-card__options">
+        <legend>请选择一个回答</legend>
+        <label v-for="option in interaction.options" :key="option" :class="{ selected: selectedOption === option }">
+          <input v-model="selectedOption" type="radio" :value="option" :disabled="busy" @change="selectOption(option)" />
+          <span>{{ option }}</span>
+          <AppIcon v-if="selectedOption === option" name="check" :size="15" />
+        </label>
+      </fieldset>
+      <div class="interaction-card__custom">
+        <label :for="`interaction-answer-${interaction.id}`">其他回答</label>
+        <div class="interaction-card__custom-row">
+          <textarea :id="`interaction-answer-${interaction.id}`" v-model="customAnswer" rows="2" :disabled="busy" placeholder="补充你的想法(可选)" @input="selectedOption = ''" />
+          <button class="solid-button" type="submit" :disabled="busy || !answer">
+            {{ busy ? '提交中…' : '提交回答' }}<AppIcon v-if="!busy" name="arrow-up" :size="14" />
+          </button>
+        </div>
       </div>
-      <textarea v-model="answer" rows="2" placeholder="输入回复" />
-      <button class="solid-button" type="submit" :disabled="busy || !answer.trim()">提交</button>
+      <small class="interaction-card__hint">选择预设答案,或输入你自己的回答</small>
     </form>
   </section>
 </template>
 
 <style scoped>
-.interaction-card { width: min(560px, 100%); margin: 16px auto 6px; padding: 14px; border: 1px solid var(--line-strong); border-radius: 13px; background: var(--surface-raised); box-shadow: 0 12px 32px rgba(0,0,0,.06); }
-.interaction-card__heading { display: flex; align-items: flex-start; gap: 10px; }.interaction-card__heading > span { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); }.interaction-card__heading div { display: flex; flex: 1; flex-direction: column; }.interaction-card__heading small { color: var(--warning); font-size: 9px; font-weight: 650; letter-spacing: .07em; }.interaction-card__heading strong { margin-top: 2px; font-size: 12px; line-height: 1.45; }
-p { margin: 10px 0 0 40px; color: var(--text-secondary); font-size: 11px; line-height: 1.6; } details { margin: 9px 0 0 40px; color: var(--text-muted); font-size: 10px; } summary { cursor: pointer; } pre { max-height: 180px; overflow: auto; font: 9px/1.5 var(--font-code); white-space: pre-wrap; }
-.interaction-card__actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 13px; }.interaction-card__answer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 12px; }.interaction-card__answer textarea { width: 100%; resize: vertical; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 12px; }.interaction-card__answer textarea:focus { border-color: var(--line-strong); }
-.interaction-card__options { display: flex; width: 100%; flex-wrap: wrap; gap: 5px; }.interaction-card__options button { padding: 5px 8px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 10px; }.interaction-card__options button:hover { background: var(--surface-hover); color: var(--text-primary); }
+.interaction-card {
+  position: relative;
+  width: min(640px, 100%);
+  margin: 20px auto 4px;
+  padding: 16px;
+  border: 1px solid var(--line);
+  border-radius: 16px;
+  background: var(--surface-raised);
+  box-shadow: 0 10px 34px color-mix(in srgb, var(--text-primary) 8%, transparent);
+  box-sizing: border-box;
+}
+.interaction-card__handle { display: none; }
+.interaction-card__heading { display: flex; align-items: flex-start; gap: 12px; }
+.interaction-card__icon { display: grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border-radius: 11px; background: color-mix(in srgb, var(--workflow-accent) 10%, var(--surface-soft)); color: var(--workflow-accent); }
+.interaction-card__heading > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
+.interaction-card__heading small { color: var(--text-muted); font-size: 10px; font-weight: 650; letter-spacing: .06em; }
+.interaction-card__heading strong { color: var(--text-primary); font-size: 15px; font-weight: 650; line-height: 1.5; }
+.interaction-card__prompt { margin: 10px 0 0 50px; color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
+.interaction-card__details { margin: 10px 0 0 50px; color: var(--text-muted); font-size: 11px; }
+.interaction-card__details summary { display: inline-flex; min-height: 32px; align-items: center; gap: 5px; cursor: pointer; list-style: none; }
+.interaction-card__details summary::-webkit-details-marker { display: none; }
+.interaction-card__details[open] summary .app-icon { transform: rotate(180deg); }
+.interaction-card__details pre { max-height: 180px; overflow: auto; font: 10px/1.55 var(--font-code); white-space: pre-wrap; }
+.interaction-card__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
+.interaction-card button { min-height: 44px; padding: 9px 15px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--text-primary); cursor: pointer; font: 13px var(--font-ui); }
+.interaction-card button:hover:not(:disabled) { background: var(--surface-hover); }
+.interaction-card button:disabled { cursor: default; opacity: .5; }
+.interaction-card .solid-button { display: inline-flex; min-width: 112px; align-items: center; justify-content: center; gap: 7px; border-color: var(--workflow-accent); background: var(--workflow-accent); color: var(--workflow-on-accent); font-weight: 620; }
+.interaction-card .solid-button:hover:not(:disabled) { background: color-mix(in srgb, var(--workflow-accent) 88%, var(--text-primary)); }
+.interaction-card__answer { display: grid; gap: 10px; margin-top: 13px; }
+.interaction-card__options { display: grid; min-width: 0; gap: 9px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; padding: 0; border: 0; }
+.interaction-card__options legend { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
+.interaction-card__options label { display: grid; min-width: 0; min-height: 54px; align-items: center; gap: 9px; grid-template-columns: 18px minmax(0, 1fr) 16px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); box-sizing: border-box; color: var(--text-secondary); cursor: pointer; transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease; }
+.interaction-card__options label:hover { border-color: var(--line-strong); background: var(--surface-hover); }
+.interaction-card__options label.selected { border-color: var(--workflow-accent); background: color-mix(in srgb, var(--workflow-accent) 6%, var(--surface)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-accent) 10%, transparent); color: var(--text-primary); }
+.interaction-card__options input { width: 17px; height: 17px; margin: 0; accent-color: var(--workflow-accent); cursor: pointer; }
+.interaction-card__options span { min-width: 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 560; line-height: 1.45; }
+.interaction-card__options .app-icon { color: var(--workflow-accent); }
+.interaction-card__custom { display: grid; gap: 6px; }
+.interaction-card__custom > label { color: var(--text-secondary); font-size: 11px; font-weight: 560; }
+.interaction-card__custom-row { display: flex; align-items: stretch; gap: 8px; }
+.interaction-card__custom textarea { width: 100%; min-height: 58px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 11px; outline: 0; background: var(--surface-soft); box-sizing: border-box; color: var(--text-primary); font: 13px/1.55 var(--font-ui); resize: vertical; }
+.interaction-card__custom textarea::placeholder { color: var(--input-placeholder-color); }
+.interaction-card__custom textarea:focus { border-color: var(--workflow-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-accent) 14%, transparent); }
+.interaction-card__hint { margin-top: -2px; color: var(--text-muted); font-size: 10px; line-height: 1.4; }
+.interaction-card :is(button,input,textarea,summary):focus-visible { outline: 2px solid var(--workflow-accent); outline-offset: 2px; }
+
+@media (max-width: 768px) {
+  .interaction-card { position: sticky; z-index: 5; bottom: 8px; width: 100%; margin: 22px auto 0; padding: 24px 14px max(16px, env(safe-area-inset-bottom)); border-radius: 22px 22px 15px 15px; box-shadow: 0 -10px 34px color-mix(in srgb, var(--text-primary) 14%, transparent); }
+  .interaction-card__handle { display: block; position: absolute; top: 8px; left: 50%; width: 38px; height: 4px; border-radius: 999px; background: var(--line-strong); transform: translateX(-50%); }
+  .interaction-card__heading { gap: 10px; }
+  .interaction-card__icon { width: 36px; height: 36px; flex-basis: 36px; }
+  .interaction-card__heading strong { font-size: 15px; }
+  .interaction-card__prompt,.interaction-card__details { margin-left: 46px; }
+  .interaction-card__answer { gap: 11px; margin-top: 14px; }
+  .interaction-card__options { grid-template-columns: 1fr; }
+  .interaction-card__options label { min-height: 52px; padding-block: 10px; }
+  .interaction-card__custom-row { align-items: stretch; flex-direction: column; gap: 10px; }
+  .interaction-card__custom textarea { min-height: 58px; }
+  .interaction-card__hint { display: none; }
+  .interaction-card .solid-button { width: 100%; min-height: 50px; }
+  .interaction-card__actions > button { min-height: 48px; flex: 1; }
+}
+
+@media (prefers-reduced-motion: reduce) { .interaction-card__options label { transition: none; } }
 </style>

+ 3 - 2
src/client/components/messages/MessageTimeline.vue

@@ -240,7 +240,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         <p>聊点什么</p>
       </div>
       <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
-      <div v-else class="message-stack">
+      <div v-else class="message-stack" :class="{ 'message-stack--interaction': interaction }">
         <div v-if="forkSourceTitle" class="fork-divider" role="separator">
           <span><AppIcon name="branch" :size="13" />FORK 来源 <strong>{{ forkSourceTitle }}</strong></span>
         </div>
@@ -404,6 +404,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .timeline-state { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 9px; }
 .timeline { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; background: var(--conversation-canvas); }
 .message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }
+.message-stack--interaction { padding-bottom: 10px; }
 .timeline-frame--transparent .message-stack { padding-top: 58px; }
 .load-older { display: block; margin: 0 auto 18px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }.load-older:hover { background: var(--surface-soft); color: var(--text-secondary); }
 .message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
@@ -446,7 +447,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 @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; }
   .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }
-  .message-stack { padding: 18px 14px 30px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }
+  .message-stack { padding: 18px 14px 30px; }.message-stack--interaction { padding-bottom: 8px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }
   .message__actions { opacity: .65; }.message__meta time { display: none; }
 }
 </style>

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

@@ -22,6 +22,7 @@ export type UiLocalFileLink = {
 
 export type UiMessage = {
   communication?: import('@shared/workspace').WorkspaceCommunication
+  taskReference?: { conversationId: string; taskId: string }
   id: string
   role: 'user' | 'assistant' | 'system' | 'tool'
   author?: string

+ 108 - 16
src/client/components/workspace/TaskPlan.vue

@@ -2,6 +2,7 @@
 import { computed, nextTick, ref } from 'vue'
 import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
 import type { WorkspaceAgent } from '@shared/workspace'
+import AppIcon from '@/components/common/AppIcon.vue'
 const props = defineProps<{
   goal?: AgentGoal
   assignments: AgentAssignment[]
@@ -11,6 +12,7 @@ const props = defineProps<{
 defineEmits<{ stop: []; resume: [] }>()
 const labels: Record<string, string> = { pending: '待执行', running: '进行中', review: '待复核', complete: '已完成', failed: '执行失败', blocked: '遇到阻碍', cancelling: '正在停止', cancelled: '已停止', waiting: '需要补充信息' }
 const completed = computed(() => props.assignments.filter(a => a.status === 'complete').length)
+const progress = computed(() => props.assignments.length ? Math.round(completed.value / props.assignments.length * 100) : props.goal?.status === 'complete' ? 100 : 0)
 const editable = computed(() => !!props.saveCriteria && !!props.goal && ['running', 'review', 'waiting'].includes(props.goal.status))
 const editing = ref(false), saving = ref(false), draft = ref(''), error = ref('')
 const editor = ref<HTMLTextAreaElement>(), editButton = ref<HTMLButtonElement>()
@@ -47,16 +49,35 @@ async function save() {
 </script>
 <template>
   <section v-if="goal || assignments.length" class="task-plan" aria-label="目标进度">
-    <header>
-      <strong>交付进展</strong>
-      <span v-if="goal">{{ labels[goal.status] }}</span>
-      <small v-if="assignments.length">{{ completed }}/{{ assignments.length }} 项完成</small>
-      <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
-      <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
+    <header class="task-plan__header">
+      <span class="task-plan__icon"><AppIcon name="files" :size="17" /></span>
+      <div class="task-plan__heading">
+        <strong>交付进展</strong>
+        <span v-if="goal" class="task-plan__status"><i />{{ labels[goal.status] }}</span>
+      </div>
+      <strong v-if="assignments.length" class="task-plan__count">{{ completed }}/{{ assignments.length }} <small>项完成</small></strong>
+      <div class="task-plan__actions">
+        <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
+        <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
+      </div>
     </header>
-    <p v-if="goal?.result" class="result">{{ goal.result }}</p>
+
+    <div v-if="assignments.length" class="task-plan__progress" role="progressbar" aria-label="交付完成进度" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progress">
+      <span v-for="item in assignments" :key="item.id" :class="{ complete: item.status === 'complete', active: ['running','review'].includes(item.status), failed: ['failed','blocked'].includes(item.status) }" />
+    </div>
+    <div v-if="assignments.length" class="task-plan__steps" aria-label="任务步骤">
+      <div v-for="(item, index) in assignments" :key="item.id" :class="{ complete: item.status === 'complete', active: ['running','review'].includes(item.status), failed: ['failed','blocked'].includes(item.status) }">
+        <span><AppIcon v-if="item.status === 'complete'" name="check" :size="11" /><template v-else>{{ index + 1 }}</template></span>
+        <strong>{{ item.title }}</strong>
+      </div>
+    </div>
+
+    <div v-if="goal?.result" class="task-plan__result">
+      <small>最新结果</small>
+      <p class="result">{{ goal.result }}</p>
+    </div>
     <details>
-      <summary>任务与验收详情</summary>
+      <summary><AppIcon name="file" :size="15" /><span>任务与验收详情</span><AppIcon class="task-plan__chevron" name="chevron-down" :size="14" /></summary>
       <div class="plan-details">
         <p v-if="goal" class="objective">{{ goal.objective }}</p>
         <ol v-if="assignments.length">
@@ -85,12 +106,83 @@ async function save() {
   </section>
 </template>
 <style scoped>
-.task-plan{width:100%;max-width:760px;margin:0 auto 8px;border:1px solid var(--line);border-radius:12px;padding:0 14px;font-size:13px;box-sizing:border-box}
-header{display:flex;align-items:center;gap:10px;min-height:44px;flex-wrap:wrap}header>span{color:var(--text-secondary)}header>small{margin-right:auto}header>button{margin-left:auto}
-summary{min-height:44px;align-content:center;cursor:pointer;color:var(--text-secondary)}small{font-size:12px;color:var(--text-secondary)}
-button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}button:hover:not(:disabled){background:var(--surface-hover)}button:disabled{opacity:.55;cursor:default}
-.plan-details{max-height:32vh;overflow:auto;padding-bottom:12px}.result{max-height:12vh;overflow:auto;margin:0;padding:8px 0}.objective{color:var(--text-secondary)}
-ol,.criteria{padding-left:18px;margin-top:0}li{padding:7px 0}li div{display:flex;gap:12px;justify-content:space-between}li div span{white-space:nowrap;color:var(--text-secondary)}p{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.criteria span{font-size:12px;color:var(--text-secondary)}
-label{display:grid;gap:8px}textarea{width:100%;box-sizing:border-box;resize:vertical;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;line-height:1.5}.criteria-actions{display:flex;gap:8px;margin-top:10px}.criteria-error{color:var(--danger)}
-summary:focus-visible,button:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.task-plan {
+  width: 100%;
+  max-width: 760px;
+  margin: 0 auto 10px;
+  overflow: hidden;
+  border: 1px solid var(--line);
+  border-radius: 15px;
+  background: var(--surface-raised);
+  box-shadow: 0 6px 22px color-mix(in srgb, var(--text-primary) 5%, transparent);
+  box-sizing: border-box;
+  font-size: 13px;
+}
+.task-plan__header { display: flex; min-height: 58px; align-items: center; gap: 11px; padding: 8px 14px; }
+.task-plan__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 10px; background: var(--surface-soft); color: var(--text-secondary); }
+.task-plan__heading { display: flex; min-width: 0; align-items: center; gap: 8px; }
+.task-plan__heading > strong { color: var(--text-primary); font-size: 14px; font-weight: 650; white-space: nowrap; }
+.task-plan__status { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
+.task-plan__status i { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 12%, transparent); }
+.task-plan__count { margin-left: auto; color: var(--text-primary); font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.task-plan__count small { margin-left: 2px; color: var(--text-muted); font-size: 11px; font-weight: 450; }
+.task-plan__actions { display: flex; gap: 6px; }
+.task-plan__actions:empty { display: none; }
+.task-plan__actions button { min-height: 40px; padding-inline: 10px; border-color: transparent; color: var(--text-secondary); }
+.task-plan__progress { display: grid; gap: 5px; grid-template-columns: repeat(auto-fit, minmax(28px, 1fr)); padding: 0 14px; }
+.task-plan__progress span { height: 3px; border-radius: 999px; background: var(--line-strong); }
+.task-plan__progress span.complete { background: var(--workflow-accent); }
+.task-plan__progress span.active { background: color-mix(in srgb, var(--workflow-accent) 52%, var(--line)); }
+.task-plan__progress span.failed { background: var(--danger); }
+.task-plan__steps { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); padding: 10px 14px 13px; }
+.task-plan__steps > div { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--text-muted); }
+.task-plan__steps > div > span { display: grid; width: 20px; height: 20px; flex: 0 0 20px; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; background: var(--surface); color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
+.task-plan__steps > div.complete > span { border-color: var(--workflow-accent); background: var(--workflow-accent); color: var(--workflow-on-accent); }
+.task-plan__steps > div.active > span { border-color: var(--workflow-accent); color: var(--workflow-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-accent) 14%, transparent); }
+.task-plan__steps > div.failed > span { border-color: var(--danger); color: var(--danger); }
+.task-plan__steps strong { min-width: 0; overflow: hidden; font-size: 11px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
+.task-plan__steps .complete strong,
+.task-plan__steps .active strong { color: var(--text-secondary); }
+.task-plan__result { margin: 0 14px 12px; padding: 11px 12px; border-radius: 10px; background: var(--surface-soft); }
+.task-plan__result > small { display: block; margin-bottom: 3px; color: var(--text-muted); font-size: 10px; font-weight: 620; }
+.result { max-height: 12vh; margin: 0; overflow: auto; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
+details { border-top: 1px solid var(--line); }
+summary { display: flex; min-height: 46px; align-items: center; gap: 8px; padding: 0 14px; color: var(--text-secondary); cursor: pointer; list-style: none; }
+summary::-webkit-details-marker { display: none; }
+summary > span { flex: 1; font-size: 12px; font-weight: 520; }
+.task-plan__chevron { transition: transform 160ms ease; }
+details[open] .task-plan__chevron { transform: rotate(180deg); }
+.plan-details { max-height: 34vh; overflow: auto; padding: 2px 14px 14px; }
+.objective { color: var(--text-secondary); }
+ol,.criteria { padding-left: 20px; margin-top: 0; }
+li { padding: 7px 0; }
+li div { display: flex; gap: 12px; justify-content: space-between; }
+li div span { color: var(--text-secondary); white-space: nowrap; }
+p { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
+.criteria span { color: var(--text-secondary); font-size: 12px; }
+button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-primary); cursor: pointer; font: inherit; }
+button:hover:not(:disabled) { background: var(--surface-hover); }
+button:disabled { cursor: default; opacity: .55; }
+label { display: grid; gap: 8px; }
+textarea { width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface); box-sizing: border-box; color: var(--text-primary); font: inherit; line-height: 1.5; resize: vertical; }
+textarea:focus { border-color: var(--workflow-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-accent) 14%, transparent); }
+.criteria-actions { display: flex; gap: 8px; margin-top: 10px; }
+.criteria-error { color: var(--danger); }
+summary:focus-visible,button:focus-visible { outline: 2px solid var(--workflow-accent); outline-offset: 2px; }
+
+@media (max-width: 600px) {
+  .task-plan { border-radius: 14px; }
+  .task-plan__header { min-height: 56px; padding-inline: 12px; }
+  .task-plan__icon { display: none; }
+  .task-plan__heading { align-items: flex-start; flex-direction: column; gap: 1px; }
+  .task-plan__actions button { min-width: 44px; padding-inline: 8px; font-size: 12px; }
+  .task-plan__progress { padding-inline: 12px; }
+  .task-plan__steps { grid-auto-columns: minmax(78px, 1fr); grid-auto-flow: column; grid-template-columns: none; gap: 8px; padding-inline: 12px; overflow-x: auto; scrollbar-width: none; }
+  .task-plan__steps::-webkit-scrollbar { display: none; }
+  .task-plan__result { margin-inline: 12px; }
+  summary { padding-inline: 12px; }
+  .plan-details { padding-inline: 12px; }
+}
+
+@media (prefers-reduced-motion: reduce) { .task-plan__chevron { transition: none; } }
 </style>

+ 104 - 0
src/client/components/workspace/TaskResultNotice.vue

@@ -0,0 +1,104 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+const props = defineProps<{
+  content: string
+  taskReference: { conversationId: string; taskId: string }
+}>()
+
+const lines = computed(() => props.content
+  .split(/\r?\n/)
+  .map(line => line.trim())
+  .filter(line => line && !/^\[打开任务\]\(/.test(line)))
+
+const waitingForReview = computed(() => props.content.includes('等待管理员复核'))
+const title = computed(() => waitingForReview.value ? '任务已有新结果' : props.content.includes('已完成') ? '任务已完成' : '任务状态已更新')
+const context = computed(() => {
+  const match = lines.value[0]?.match(/^「([^」]+)」的任务「([^」]+)」/)
+  return match ? `${match[1]} · ${match[2]}` : lines.value[0] || '团队交付任务'
+})
+const supporting = computed(() => {
+  if (waitingForReview.value) return '等待管理员复核后继续'
+  return lines.value.slice(1).join(' ') || '打开任务查看完整交付与验收状态'
+})
+const href = computed(() => `/conversations/${encodeURIComponent(props.taskReference.conversationId)}?taskId=${encodeURIComponent(props.taskReference.taskId)}`)
+</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>
+</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>

+ 9 - 4
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -6,6 +6,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
 import TypingIndicator from '@/components/common/TypingIndicator.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
 import WorkspaceApprovalCard from './WorkspaceApprovalCard.vue'
+import TaskResultNotice from './TaskResultNotice.vue'
 import MarkdownContent from '../messages/MarkdownContent.vue'
 import ToolTrace from '../messages/ToolTrace.vue'
 import TurnTrace from '../messages/TurnTrace.vue'
@@ -319,7 +320,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         <p>聊点什么</p>
       </div>
       <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
-      <div v-else class="message-stack">
+      <div v-else class="message-stack" :class="{ 'message-stack--interaction': interaction }">
         <div v-if="forkSourceTitle" class="fork-divider" role="separator">
           <span><AppIcon name="branch" :size="13" />FORK 来源 <strong>{{ forkSourceTitle }}</strong></span>
         </div>
@@ -351,7 +352,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
             <span class="communication-caption__name">{{ message.communication.peerName }}</span>
           </div>
           <div class="message__body">
-            <template v-if="message.timelineKind === 'delegation-complete'">
+            <TaskResultNotice v-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" />
@@ -499,6 +501,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .timeline-state { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 9px; }
 .timeline { min-height: 0; flex: 1; overflow-y: auto; overflow-anchor: none; overscroll-behavior: contain; background: var(--conversation-canvas); }
 .message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }
+.message-stack--interaction { padding-bottom: 10px; }
 .timeline-frame--transparent .message-stack { padding-top: 58px; }
 .load-older { display: block; margin: 0 auto 18px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }.load-older:hover { background: var(--surface-soft); color: var(--text-secondary); }
 .message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
@@ -515,7 +518,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .dark .message--user .message__body { background: #2d323a; }
 .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; }
+.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%; }
@@ -541,7 +545,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 @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; }
   .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }
-  .message-stack { padding: 18px 14px 30px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }
+  .message-stack { padding: 18px 14px 30px; }.message-stack--interaction { padding-bottom: 8px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }
+  .message--system .message__body { max-width: 100%; }
   .message__actions { opacity: .65; }.message__meta time { display: none; }
 }
 </style>

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

@@ -385,6 +385,7 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
   return messages.filter(message => message.visible !== false).map(message => ({
     id: message.id, role: message.role, author: message.agentName,
     communication: message.communication,
+    taskReference: message.taskReference,
     profile: message.agentId, createdAt: message.createdAt,
     content: (message.communication?.content ?? (message.role === 'assistant' ? visibleMessageText(message.content) : message.content)).replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
       const file = message.attachments.find(file => file.sourcePath === path)

+ 4 - 0
src/client/styles/tokens.css

@@ -22,6 +22,8 @@
   --line-strong: rgba(25, 25, 22, .19);
   --accent: #20201e;
   --accent-rgb: 32, 32, 30;
+  --workflow-accent: #2868d7;
+  --workflow-on-accent: #ffffff;
   --danger: #b33d33;
   --success: #377a54;
   --warning: #9b6b1f;
@@ -61,6 +63,8 @@
   --line-strong: rgba(255, 255, 250, .17);
   --accent: #efefec;
   --accent-rgb: 239, 239, 236;
+  --workflow-accent: #78a9ff;
+  --workflow-on-accent: #101b2f;
   --danger: #ed786f;
   --success: #77b88e;
   --warning: #d9ab60;

+ 30 - 0
tests/client/interactionCard.test.ts

@@ -0,0 +1,30 @@
+import { expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import InteractionCard from '@/components/messages/InteractionCard.vue'
+
+it('submits a selected clarification option and exposes the selected state', async () => {
+  const wrapper = mount(InteractionCard, { props: { interaction: {
+    id: 'clarify-1', kind: 'clarification', prompt: '先创建草稿,还是继续调整?', options: ['先创建草稿', '继续调整'],
+  } } })
+
+  const inputs = wrapper.findAll('input[type="radio"]')
+  await inputs[0]!.setValue(true)
+  expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true)
+  expect(wrapper.findAll('.interaction-card__options label')[0]!.classes()).toContain('selected')
+  await wrapper.get('form').trigger('submit')
+  expect(wrapper.emitted('clarify')).toEqual([['先创建草稿']])
+})
+
+it('prefers a custom clarification answer and resets the draft for the next question', async () => {
+  const wrapper = mount(InteractionCard, { props: { interaction: {
+    id: 'clarify-1', kind: 'clarification', prompt: '需要哪种格式?', options: ['文档'],
+  } } })
+
+  await wrapper.get('textarea').setValue('请同时提供 PDF')
+  await wrapper.get('form').trigger('submit')
+  expect(wrapper.emitted('clarify')).toEqual([['请同时提供 PDF']])
+
+  await wrapper.setProps({ interaction: { id: 'clarify-2', kind: 'clarification', prompt: '是否继续?' } })
+  expect(wrapper.get('textarea').element.value).toBe('')
+  expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
+})

+ 5 - 2
tests/client/taskPlan.test.ts

@@ -1,14 +1,17 @@
 import { expect, it, vi } from 'vitest'
 import { flushPromises, mount } from '@vue/test-utils'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
-import type { AgentGoal } from '@shared/agentTasks'
+import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
 
 const goal: AgentGoal = { id:'goal',conversationId:'group',coordinatorId:'lead',authorityRevision:1,objective:'交付报告',acceptanceCriteria:['提供来源'],acceptanceRevision:1,status:'running',origin:{conversationId:'group',runId:'run',agentId:'lead'},artifactIds:[],revision:1,automaticWakes:0,createdAt:1,updatedAt:1 }
+const assignment = (id: string, status: AgentAssignment['status']): AgentAssignment => ({ id,goalId:'goal',conversationId:'group',agentId:'writer',title:`步骤 ${id}`,brief:'完成步骤',acceptanceCriteria:[],dependsOn:[],status,attempt:1,artifactIds:[],createdAt:1,updatedAt:1 })
 
 it('keeps progress and results visible while task details start collapsed', () => {
-  const wrapper = mount(TaskPlan,{props:{goal:{...goal,result:'初稿已交付'},assignments:[],agents:[]}})
+  const wrapper = mount(TaskPlan,{props:{goal:{...goal,result:'初稿已交付'},assignments:[assignment('一','complete'),assignment('二','running')],agents:[]}})
   expect(wrapper.get('details').attributes('open')).toBeUndefined()
   expect(wrapper.get('header').text()).toContain('进行中')
+  expect(wrapper.get('[role=progressbar]').attributes('aria-valuenow')).toBe('50')
+  expect(wrapper.get('.task-plan__count').text()).toContain('1/2')
   expect(wrapper.get('.result').text()).toBe('初稿已交付')
   wrapper.unmount()
 })

+ 15 - 0
tests/client/taskResultNotice.test.ts

@@ -0,0 +1,15 @@
+import { expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TaskResultNotice from '@/components/workspace/TaskResultNotice.vue'
+
+it('turns a delivered team result into a clear task entry point', () => {
+  const wrapper = mount(TaskResultNotice, { props: {
+    content: '「公众号文案团队」的任务「完成 Jev 公众号文章」已有执行结果,等待管理员复核。\n[打开任务](/conversations/team?taskId=task)',
+    taskReference: { conversationId: 'team', taskId: 'task' },
+  } })
+
+  expect(wrapper.get('strong').text()).toBe('任务已有新结果')
+  expect(wrapper.text()).toContain('公众号文案团队 · 完成 Jev 公众号文章')
+  expect(wrapper.text()).toContain('等待管理员复核后继续')
+  expect(wrapper.get('a').attributes('href')).toBe('/conversations/team?taskId=task')
+})

+ 5 - 0
tests/client/workspacePresentation.test.ts

@@ -34,6 +34,11 @@ describe('workspace chat uses the established presentation', () => {
     expect(message?.content).toBe('当前为 v2')
     expect(message?.role).toBe('system')
   })
+  it('preserves task references for the task-result notification', () => {
+    const taskReference = { conversationId: 'team', taskId: 'task' }
+    const [message] = workspaceMessagesToUi([{ id: 'result', conversationId: 'origin', seq: 1, role: 'system', taskReference, content: '任务已有结果', reasoning: '', tools: [], attachments: [], status: 'complete', createdAt: 1 }])
+    expect(message?.taskReference).toEqual(taskReference)
+  })
 })
 
 import { mount } from '@vue/test-utils'