Jelajahi Sumber

将群聊交付目标入口收进加号菜单

Samien 1 bulan lalu
induk
melakukan
f523319061

+ 40 - 0
design-qa.md

@@ -131,3 +131,43 @@ final result: passed
 - Android Compose 编译、模型/网络测试和 Debug APK 构建通过;新增请求测试确认自动允许仅提交审批 ID 和 answer。Android 尚未做设备截图,手机端尚未安装新版。
 
 final result: passed
+
+## 2026-09-21:群聊交付目标入口收进加号菜单
+
+### Findings
+
+- 无未解决的 P0/P1/P2 设计差异。入口默认隐藏在「+」中,启用后显示「交付目标 ×」,退出不清空草稿。
+- 预期差异:保留现有输入框高度、字体栈和思考按钮样式;示意图中的黑色发送按钮在空草稿时沿用产品的灰色禁用状态。只调整入口位置和启用标签,不重做整个输入器。
+
+### Source visual truth / implementation evidence
+
+- 源图:`/Users/samien/.codex/generated_images/01a0bf4c-963e-7eb0-a445-1ec9413c1ed0/exec-9ae39651-447d-412c-a319-91a5e892efc9.png`,1536 × 1024,桌面与 iOS 双栏概念画板。
+- 本地验证:`http://127.0.0.1:5173/test-results/delivery-mode/`,直接渲染正式 `ComposerShell`;夹具不请求生产接口。
+- 全景:`/Users/samien/git/yaoyao/test-results/delivery-mode/desktop-menu.png`、`desktop-active.png`,1200 × 900,CSS viewport 同尺寸、1x。
+- 聚焦证据:同目录 `desktop-menu-focus.png`(776 × 258,原图 x=212/y=634)、`desktop-active-focus.png`(776 × 136,原图 x=212/y=756)。只裁剪原始截图,未重绘 UI。
+- 响应式:同目录 `web-narrow-menu.png`(375 × 812)、`web-320-dark.png`(320 × 740),1x。
+- 将源图与两个聚焦截图放在同一个比较输入中复核。源图不是完整应用页面;按输入器内容区比较层级、顺序、间距和文案,不将整张双栏画板拉伸成网页,也不把密度差异当作缺陷。
+
+### Required fidelity surfaces
+
+- 字体:沿用 Inter / SF Pro / PingFang SC 字体栈;菜单 14px、说明 12px、标签 13px。四字标签与单行说明清晰,无截断;已有思考控件字号不变。
+- 布局:左下「+」锚定上方菜单;附件、提及、分隔线、交付目标顺序一致。开启标签紧随加号,后接思考及提及。菜单宽 280px,视口约束避免越界;桌面标签 36px,窄屏 44px。
+- 颜色:白色菜单、细边框、浮层阴影、浅蓝标签延续源图。新增 `--delivery-accent`:浅色 #245fc5、深色 #78a9ff,12% 底色;计算的文字对比度约 5.02:1 / 6.02:1。
+- 图标/图像:复用现有 AppIcon 和 Tabler 的纸夹、加号、@、Target、关闭图标;标准图标的平台笔画差异可接受,无新增位图、模糊缩放或装饰图形。
+- 文案:菜单为「添加附件」「提及成员」「交付目标」「让团队持续完成一个结果」;启用后占位「描述希望团队交付的结果…」。关闭按钮有完整无障碍标签。
+
+### Comparison history
+
+1. [P2] 初始强调色在浅蓝底上对比约 4.40:1。新增更深的浅色目标专用 token,复核最终 active 截图,现为约 5.02:1;深色维持浅蓝前景。
+2. [P2] 极窄屏工具栏重复保留提及入口会挤压空间。窄屏隐藏重复提及按钮,保留加号菜单中的同一功能。最终 320px 深色截图无裁切:加号 x=27..71,标签 x=74..195,思考 x=198..228,发送 x=259..293。
+
+### Interaction / implementation checklist
+
+- [x] 菜单开启目标、关闭标签、草稿保持、附件入口与提及成员。
+- [x] Escape、方向键、Home/End、Tab、外部点击与焦点恢复。
+- [x] 禁用目标时保留原因;单聊与已有目标不显示创建入口。
+- [x] 真实会话视图集成测试验证 goal/chat 发送模式,不改变后端交付逻辑。
+- [x] 19 项相关测试通过;类型检查及完整 `npm run build` 通过;浏览器控制台无 error/warn。
+- 未部署到服务器;未实际发送生产消息。iOS 原生端证据记录在移动仓库的 `design-qa.md`。
+
+final result: passed

+ 113 - 3
src/client/components/composer/ComposerShell.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import { At, Target } from '@vicons/tabler'
 import { createId } from '@/utils/id'
 import type { ComposerAttachment, ComposerOption, ComposerReference, ComposerSubmit } from './types'
 
@@ -28,6 +29,10 @@ const props = withDefaults(defineProps<{
   slashCommands?: ComposerOption[]
   attachmentsEnabled?: boolean
   activityText?: string
+  deliveryAvailable?: boolean
+  deliveryMode?: boolean
+  deliveryDisabled?: boolean
+  deliveryDisabledReason?: string
 }>(), {
   mode: 'chat',
   draftKey: '',
@@ -52,6 +57,10 @@ const props = withDefaults(defineProps<{
   slashCommands: () => [],
   attachmentsEnabled: true,
   activityText: '',
+  deliveryAvailable: false,
+  deliveryMode: false,
+  deliveryDisabled: false,
+  deliveryDisabledReason: '',
 })
 
 const emit = defineEmits<{
@@ -65,6 +74,7 @@ const emit = defineEmits<{
   toolTraceToggle: []
   clearReference: []
   error: [message: string]
+  'update:deliveryMode': [enabled: boolean]
 }>()
 
 const MAX_FILE_SIZE = 25 * 1024 * 1024
@@ -83,6 +93,10 @@ const menuQuery = ref('')
 const chosenMentionIds = ref<string[]>([])
 const reasoningOpen = ref(false)
 const settingsOpen = ref(false)
+const actionsOpen = ref(false)
+const actionsAnchor = ref<HTMLElement | null>(null)
+const actionsTrigger = ref<HTMLButtonElement | null>(null)
+const hasGroupActions = computed(() => props.mode === 'group' && (props.mentionOptions.length > 0 || props.deliveryAvailable))
 const localError = ref('')
 let errorTimer: number | undefined
 
@@ -282,6 +296,61 @@ function updateMenu() {
   menuKind.value = null
 }
 
+function openActions() {
+  actionsOpen.value = !actionsOpen.value
+  reasoningOpen.value = false
+  settingsOpen.value = false
+  menuKind.value = null
+  if (actionsOpen.value) nextTick(() => actionsAnchor.value?.querySelector<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')?.focus())
+}
+
+function closeActions(restoreFocus = false) {
+  actionsOpen.value = false
+  if (restoreFocus) actionsTrigger.value?.focus()
+}
+
+function actionsKeydown(event: KeyboardEvent) {
+  if (event.key === 'Escape') {
+    event.preventDefault(); event.stopPropagation(); closeActions(true)
+  } else if (event.key === 'Tab') {
+    closeActions(true)
+  } else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
+    event.preventDefault()
+    const items = Array.from(actionsAnchor.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [])
+    const index = items.indexOf(document.activeElement as HTMLButtonElement)
+    const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length
+    items[next]?.focus()
+  }
+}
+
+function chooseAttachment() {
+  closeActions(true)
+  fileInput.value?.click()
+}
+
+function startMention() {
+  closeActions()
+  const el = textarea.value
+  if (!el) return
+  const cursor = el.selectionStart
+  const before = text.value.slice(0, cursor)
+  const insertion = `${before && !/\s$/.test(before) ? ' ' : ''}@`
+  text.value = before + insertion + text.value.slice(el.selectionEnd)
+  nextTick(() => {
+    el.focus()
+    el.setSelectionRange(cursor + insertion.length, cursor + insertion.length)
+    updateMenu()
+    autoSize()
+  })
+}
+
+function changeDeliveryMode(enabled: boolean) {
+  if (props.disabled || props.deliveryDisabled || props.sending) return
+  emit('update:deliveryMode', enabled)
+  closeActions()
+  nextTick(() => textarea.value?.focus())
+}
+
 function selectOption(option: ComposerOption) {
   if (option.disabled) return
   const el = textarea.value
@@ -386,6 +455,7 @@ function selectReasoningByIndex(event: Event) {
 
 function onDocumentPointer(event: PointerEvent) {
   const target = event.target as HTMLElement
+  if (!actionsAnchor.value?.contains(target)) closeActions()
   if (!target.closest('.composer-shell')) {
     menuKind.value = null
     reasoningOpen.value = false
@@ -398,7 +468,9 @@ watch(() => props.draftKey, () => {
   restoreDraft()
   reasoningOpen.value = false
   settingsOpen.value = false
+  actionsOpen.value = false
 })
+watch(() => props.disabled, value => { if (value) closeActions() })
 
 onMounted(() => {
   restoreDraft()
@@ -472,7 +544,7 @@ defineExpose({
         :style="manualHeight !== null ? { height: `${manualHeight}px` } : undefined"
         rows="1"
         :disabled="disabled"
-        :placeholder="placeholder"
+        :placeholder="deliveryAvailable && deliveryMode ? '描述希望团队交付的结果…' : placeholder"
         @input="updateMenu"
         @click="updateMenu"
         @keydown="handleKeydown"
@@ -483,9 +555,29 @@ defineExpose({
 
       <div class="composer-toolbar">
         <div class="composer-tools">
-          <button class="composer-tool composer-tool--icon" type="button" :disabled="disabled || !attachmentsEnabled" :title="attachmentsEnabled ? '添加附件' : '当前上游不支持附件'" aria-label="添加附件" @click="fileInput?.click()">
+          <div v-if="hasGroupActions" ref="actionsAnchor" class="composer-popover-anchor composer-actions-anchor" @keydown="actionsKeydown">
+            <button ref="actionsTrigger" class="composer-tool composer-add" type="button" :disabled="disabled" aria-label="添加" aria-haspopup="menu" :aria-expanded="actionsOpen" @click="openActions" @keydown.down.prevent="!actionsOpen && openActions()">
+              <AppIcon name="plus" :size="20" />
+            </button>
+            <Transition name="composer-menu">
+              <div v-if="actionsOpen" class="composer-popover composer-add-menu" role="menu" aria-label="添加到消息">
+                <button type="button" role="menuitem" :disabled="!attachmentsEnabled" @click="chooseAttachment"><AppIcon name="paperclip" :size="20" /><span>添加附件</span></button>
+                <button v-if="mentionOptions.length" type="button" role="menuitem" @click="startMention"><At class="composer-action-icon" aria-hidden="true" /><span>提及成员</span></button>
+                <template v-if="deliveryAvailable">
+                  <div class="composer-add-menu__divider" role="separator" />
+                  <button type="button" role="menuitem" :disabled="deliveryDisabled || sending" @click="changeDeliveryMode(!deliveryMode)">
+                    <Target class="composer-action-icon" aria-hidden="true" /><span><strong>交付目标</strong><small>{{ deliveryDisabledReason || (deliveryMode ? '已开启;点击返回普通聊天' : '让团队持续完成一个结果') }}</small></span><AppIcon v-if="deliveryMode" name="check" :size="16" />
+                  </button>
+                </template>
+              </div>
+            </Transition>
+          </div>
+          <button v-else class="composer-tool composer-tool--icon" type="button" :disabled="disabled || !attachmentsEnabled" :title="attachmentsEnabled ? '添加附件' : '当前上游不支持附件'" aria-label="添加附件" @click="fileInput?.click()">
             <AppIcon name="paperclip" :size="16" />
           </button>
+          <button v-if="deliveryAvailable && deliveryMode" class="composer-delivery-chip" type="button" :disabled="disabled || deliveryDisabled || sending" aria-label="关闭交付目标,返回普通聊天" title="关闭交付目标,返回普通聊天" @click="changeDeliveryMode(false)">
+            <Target class="composer-action-icon" aria-hidden="true" /><span>交付目标</span><AppIcon name="close" :size="15" />
+          </button>
           <div v-if="mode === 'chat'" class="composer-popover-anchor">
             <button class="composer-tool" type="button" :class="{ active: reasoningOpen }" :disabled="disabled || !reasoningOptions.length" :title="`推理强度:${reasoningLabel}`" :aria-label="`推理强度:${reasoningLabel}`" :aria-expanded="reasoningOpen" @click="reasoningOpen = !reasoningOpen; settingsOpen = false">
               <AppIcon name="brain" :size="16" /><span>{{ reasoningLabel }}</span><AppIcon name="chevron-down" :size="11" />
@@ -533,7 +625,7 @@ defineExpose({
           >
             <AppIcon name="bolt" :size="15" />
           </button>
-          <span v-else-if="mentionOptions.length" class="composer-mention-hint"><b>@</b><span>提及成员</span></span>
+          <button v-else-if="mentionOptions.length" type="button" class="composer-tool composer-mention-hint" :disabled="disabled" aria-label="提及成员" @click="startMention"><At class="composer-action-icon" aria-hidden="true" /><span>提及成员</span></button>
         </div>
         <div class="composer-actions">
           <button v-if="mode === 'chat' && streaming && canSubmit" class="queue-toggle" :class="{ active: queueMode }" type="button" :title="queueMode ? '消息将排队发送' : '消息将 Steer 当前会话'" @click="emit('queueToggle')">
@@ -619,6 +711,20 @@ defineExpose({
 .composer-tools { flex: 1; }
 .composer-actions { flex: 0 0 auto; gap: 7px; }
 .composer-popover-anchor { position: relative; display: flex; flex: 0 0 auto; }
+.composer-action-icon { display: block; width: 20px; height: 20px; flex: 0 0 auto; }
+.composer-tool.composer-add { width: 36px; height: 36px; padding: 0; background: var(--surface-soft); color: var(--text-primary); }
+.composer-popover.composer-add-menu { width: min(280px, calc(100vw - 64px)); padding: 8px; cursor: default; }
+.composer-add-menu button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 44px; padding: 9px 12px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
+.composer-add-menu button:hover:not(:disabled) { background: var(--surface-hover); }
+.composer-add-menu button:disabled { opacity: .5; cursor: not-allowed; }
+.composer-add-menu button > span { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 3px; }
+.composer-add-menu strong { font-weight: 500; }
+.composer-add-menu small { color: var(--text-secondary); font-size: 12px; line-height: 1.4; }
+.composer-add-menu__divider { height: 1px; margin: 6px 12px; background: var(--line); }
+.composer-delivery-chip { --goal-color: var(--delivery-accent); display: inline-flex; flex: 0 0 auto; align-items: center; gap: 7px; min-height: 36px; padding: 0 11px; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--goal-color) 12%, transparent); color: var(--goal-color); font: inherit; font-size: 13px; white-space: nowrap; cursor: pointer; }
+.composer-delivery-chip:hover:not(:disabled) { background: color-mix(in srgb, var(--goal-color) 18%, transparent); }
+.composer-delivery-chip:disabled { opacity: .5; cursor: not-allowed; }
+.composer-add:focus-visible, .composer-add-menu button:focus-visible, .composer-delivery-chip:focus-visible, .composer-mention-hint:focus-visible { outline: 2px solid var(--workflow-accent); outline-offset: 2px; }
 .composer-tool { display: inline-flex; min-width: 28px; height: 28px; max-width: 175px; align-items: center; justify-content: center; gap: 4px; padding: 0 6px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; white-space: nowrap; }
 .composer-tool:hover, .composer-tool.active { background: var(--surface-hover); color: var(--text-primary); }
 .composer-tool:disabled { cursor: not-allowed; opacity: .36; }
@@ -687,6 +793,10 @@ defineExpose({
   .composer-tool span, .composer-tool > :deep(svg:last-child), .composer-mention-hint span { display: none; }
   .composer-tool { width: 30px; min-width: 30px; padding: 0; }
   .composer-tools { gap: 3px; }
+  .composer-mention-hint { display: none; }
+  .composer-tool.composer-add { width: 44px; height: 44px; }
+  .composer-add > :deep(svg:last-child) { display: block; }
+  .composer-delivery-chip { min-height: 44px; padding-inline: 10px; }
   .composer-toolbar { align-items: flex-end; gap: 7px; }
   .composer-send-hitbox { width: 44px; height: 44px; margin: -5px; }
   .composer-send { width: 34px; min-width: 34px; height: 34px; }

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

@@ -23,6 +23,7 @@
   --accent: #20201e;
   --accent-rgb: 32, 32, 30;
   --workflow-accent: #2868d7;
+  --delivery-accent: #245fc5;
   --workflow-on-accent: #ffffff;
   --danger: #b33d33;
   --success: #377a54;
@@ -64,6 +65,7 @@
   --accent: #efefec;
   --accent-rgb: 239, 239, 236;
   --workflow-accent: #78a9ff;
+  --delivery-accent: #78a9ff;
   --workflow-on-accent: #101b2f;
   --danger: #ed786f;
   --success: #77b88e;

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

@@ -1014,6 +1014,9 @@ onBeforeUnmount(() => {
       <p v-if="active?.collaborationWaiting" class="task-queue-status" role="status"><button @click="knowledgePanel?.open('collaboration', active.memberIds[0])">等待同伴回复 · 查看协作</button></p>
       <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
       <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
+        v-model:delivery-mode="deliveryMode" :delivery-available="active.kind === 'group' && !activeTask?.goal"
+        :delivery-disabled="!canRequestGoal || busy || loading || !!activeTask?.activeRunId"
+        :delivery-disabled-reason="!canRequestGoal ? '负责人需要是本机上未归档的 Bot' : ''"
         :disabled="active.archived || loading || active.id !== selected" :sending="busy" stop-while-running :streaming="!!active.activeRunId"
         :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
@@ -1025,10 +1028,6 @@ onBeforeUnmount(() => {
             <label v-if="deliveryMode && active.collaborationMode === 'discussion'">交付负责人<select v-model="coordinatorId"><option value="">{{ coordinators[0]?.name ?? '请先授权负责人' }}</option><option v-for="agent in coordinators" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select></label>
           </div>
           <TaskPlan :key="activeTask?.id" :goal="activeTask?.goal" :assignments="assignments" :agents="agents" :save-criteria="saveGoalCriteria" @stop="stopTask" @resume="resumeTask" />
-          <div v-if="active.kind === 'group' && !activeTask?.goal" class="delivery-mode">
-            <button type="button" :aria-pressed="deliveryMode" :disabled="!canRequestGoal || busy || loading || !!activeTask?.activeRunId" aria-describedby="delivery-mode-help" @click="deliveryMode = !deliveryMode">交付目标</button>
-            <small id="delivery-mode-help">{{ !canRequestGoal ? '负责人需要是本机上未归档的 Bot' : deliveryMode ? '发送后由负责人推进交付;验收要求可随时查看和调整' : '默认直接聊天;需要持续完成具体交付时开启' }}</small>
-          </div>
         </template>
       </ComposerShell>
     </section>
@@ -1238,7 +1237,6 @@ onBeforeUnmount(() => {
 .task-picker-shell{position:relative;display:inline-flex;align-items:center;max-width:210px;min-width:0}.task-picker-shell>.app-icon{position:absolute;right:11px;pointer-events:none;color:var(--text-muted)}.task-picker{width:100%;min-width:0;max-width:210px;min-height:38px;appearance:none;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-primary);padding:6px 34px 6px 14px;font:inherit;font-size:12px;font-weight:520;text-overflow:ellipsis;cursor:pointer;box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 5%,transparent);transition:border-color 140ms ease,background-color 140ms ease,box-shadow 140ms ease}.task-picker:hover{border-color:var(--line-strong);background:var(--surface-hover)}.task-picker:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-picker:disabled{cursor:wait;opacity:.55}
 .task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
 .group-options{margin:12px 0;border-top:1px solid var(--line)}.group-options summary{min-height:44px;align-content:center;cursor:pointer;font-size:13px;color:var(--text-secondary)}.group-options summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.group-help{font-size:13px;line-height:1.6;color:var(--text-secondary)}
-.delivery-mode{display:flex;align-items:center;gap:10px;margin:0 auto 8px;max-width:760px;width:100%;font-size:13px}.delivery-mode button{flex-shrink:0;min-height:44px;padding:7px 12px;border:1px solid var(--line);border-radius:999px;background:var(--surface);color:var(--text-secondary);font:inherit;cursor:pointer}.delivery-mode button:hover:not(:disabled){background:var(--surface-hover)}.delivery-mode button[aria-pressed=true]{border-color:var(--accent);color:var(--text-primary);background:var(--surface-hover)}.delivery-mode button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.delivery-mode button:disabled{opacity:.55;cursor:default}.delivery-mode small{font-size:12px;color:var(--text-secondary);line-height:1.5}
 @media(max-width:720px){.task-picker-shell,.task-picker{max-width:118px}.task-picker{min-height:44px;padding-left:12px}}
 .team-management-permission > span {
   display: flex;

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

@@ -14,6 +14,80 @@ beforeEach(() => {
 afterEach(() => vi.unstubAllGlobals())
 
 describe('composer availability', () => {
+  it('opens delivery mode from the plus menu and closes the chip without losing the draft', async () => {
+    const wrapper = mount(ComposerShell, { props: { mode: 'group', deliveryAvailable: true } })
+    const input = wrapper.get('textarea')
+    await input.setValue('保留这份交付草稿')
+    expect(wrapper.find('.composer-delivery-chip').exists()).toBe(false)
+    expect(wrapper.find('[role="menu"]').exists()).toBe(false)
+    await wrapper.get('[aria-label="添加"]').trigger('click')
+    const goal = wrapper.findAll('[role="menuitem"]').find(item => item.text().includes('交付目标'))!
+    await goal.trigger('click')
+    expect(wrapper.emitted('update:deliveryMode')?.[0]).toEqual([true])
+    expect(wrapper.find('[role="menu"]').exists()).toBe(false)
+    await wrapper.setProps({ deliveryMode: true })
+    expect(input.attributes('placeholder')).toContain('描述希望团队交付的结果')
+    expect(wrapper.get('.composer-delivery-chip').text()).toBe('交付目标')
+    await wrapper.get('.composer-delivery-chip').trigger('click')
+    expect(wrapper.emitted('update:deliveryMode')?.[1]).toEqual([false])
+    await wrapper.setProps({ deliveryMode: false })
+    expect(wrapper.find('.composer-delivery-chip').exists()).toBe(false)
+    expect(input.element.value).toBe('保留这份交付草稿')
+    await wrapper.get('[aria-label="发送消息"]').trigger('click')
+    expect(wrapper.emitted('send')?.[0]).toEqual([{ text: '保留这份交付草稿', files: [], mentionIds: [] }])
+    wrapper.unmount()
+  })
+
+  it('keeps unavailable goal actions disabled with an explanation', async () => {
+    const wrapper = mount(ComposerShell, { props: { mode: 'group', deliveryAvailable: true, deliveryDisabled: true, deliveryDisabledReason: '负责人不可用' } })
+    await wrapper.get('[aria-label="添加"]').trigger('click')
+    const goal = wrapper.findAll('[role="menuitem"]').find(item => item.text().includes('交付目标'))!
+    expect(goal.text()).toContain('负责人不可用')
+    expect(goal.attributes('disabled')).toBeDefined()
+    await goal.trigger('click')
+    expect(wrapper.emitted('update:deliveryMode')).toBeUndefined()
+    wrapper.unmount()
+  })
+
+  it('opens mentions from the plus menu and inserts the selected member', async () => {
+    const wrapper = mount(ComposerShell, { props: { mode: 'group', mentionOptions: [{ id: 'bot', label: '小夭' }] } })
+    await wrapper.get('[aria-label="添加"]').trigger('click')
+    await wrapper.findAll('[role="menuitem"]').find(item => item.text() === '提及成员')!.trigger('click')
+    expect(wrapper.get('textarea').element.value).toBe('@')
+    await wrapper.get('[role="option"]').trigger('mousedown')
+    expect(wrapper.get('textarea').element.value).toBe('@小夭 ')
+    expect(wrapper.find('[role="menu"]').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
+  it('supports menu keyboard navigation, Escape focus return, and outside dismissal', async () => {
+    const wrapper = mount(ComposerShell, { attachTo: document.body, props: { mode: 'group', deliveryAvailable: true } })
+    const trigger = wrapper.get('[aria-label="添加"]')
+    await trigger.trigger('click')
+    const items = wrapper.findAll('[role="menuitem"]')
+    expect(document.activeElement).toBe(items[0]!.element)
+    await items[0]!.trigger('keydown', { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(items[1]!.element)
+    await items[1]!.trigger('keydown', { key: 'Escape' })
+    expect(wrapper.find('[role="menu"]').exists()).toBe(false)
+    expect(document.activeElement).toBe(trigger.element)
+    await trigger.trigger('click')
+    document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
+    await wrapper.vm.$nextTick()
+    expect(wrapper.find('[role="menu"]').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
+  it('retains attachments and hides goal controls in ordinary chat', async () => {
+    const wrapper = mount(ComposerShell, { props: { mode: 'chat' } })
+    const pick = vi.spyOn(wrapper.get<HTMLInputElement>('input[type="file"]').element, 'click')
+    await wrapper.get('[aria-label="添加附件"]').trigger('click')
+    expect(pick).toHaveBeenCalledOnce()
+    expect(wrapper.find('[aria-label="添加"]').exists()).toBe(false)
+    expect(wrapper.find('.composer-delivery-chip').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
   it('keeps the group-chat send action available while an Agent is running', () => {
     const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true } })
     expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(true)

+ 52 - 0
tests/client/conversationDeliveryMode.test.ts

@@ -0,0 +1,52 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { shallowRef } from 'vue'
+import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
+import ConversationsView from '@/views/ConversationsView.vue'
+import ComposerShell from '@/components/composer/ComposerShell.vue'
+import { apiRequest } from '@/api/client'
+import type { WorkspaceConversation } from '@shared/workspace'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn(), setApiCsrfToken: vi.fn(), ApiError: class extends Error {} }))
+vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
+vi.mock('@/stores/theme', () => ({ useThemeStore: () => ({}) }))
+
+let wrapper: VueWrapper | undefined
+afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.mocked(apiRequest).mockReset() })
+
+it.each([true, false])('sends the selected delivery mode through the real composer (enabled=%s)', async enabled => {
+  const group: WorkspaceConversation = { id: 'group', kind: 'group', name: '开发团队', avatar: '', memberIds: ['bot'], instructions: '', administratorId: 'bot', mode: 'host', autoReplyIds: [], maxReplyRounds: 3, archived: false, pinned: false, readSeq: 0, lastSeq: 0, preview: '', createdAt: 1, updatedAt: 1 }
+  const agents = [{ id: 'bot', name: '开发者', avatar: '', instructions: '', nodeId: 'local', profile: 'default', archived: false }]
+  vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {}, removeItem: () => {} })
+  vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
+  vi.mocked(apiRequest).mockImplementation(async (path, options) => {
+    if (path === '/api/app/capabilities') return { features: [], csrfToken: 'fixture' } as never
+    if (path === '/api/app/workspace/snapshot') return { agents, conversations: [group], cursor: 1, details: [] } as never
+    if (path === '/api/app/agents') return { agents } as never
+    if (path === '/api/app/agents/sources') return { sources: [] } as never
+    if (path === '/api/app/conversations') return { conversations: [group], cursor: 1 } as never
+    if (path.startsWith('/api/app/events')) return { events: [], cursor: 1 } as never
+    if (path.split('?')[0] === '/api/app/conversations/group') return { conversation: group, messages: [], runs: [], run: null, interactions: [], tasks: [] } as never
+    if (path.endsWith('/messages') && options?.method === 'POST') throw new Error('fixture: keep submitted draft')
+    return {} as never
+  })
+  const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
+  await router.push('/conversations/group'); await router.isReady()
+  wrapper = mount(ConversationsView, { shallow: true, attachTo: document.body, global: {
+    plugins: [router], provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) },
+    stubs: { WorkspaceShell: { template: '<div><slot /></div>' }, ComposerShell: false, Teleport: true },
+  } })
+  await flushPromises()
+  const composer = wrapper.getComponent(ComposerShell)
+  expect(composer.props('deliveryAvailable')).toBe(true)
+  expect(wrapper.find('.delivery-mode').exists()).toBe(false)
+  await composer.get('[aria-label="添加"]').trigger('click')
+  await composer.findAll('[role="menuitem"]').find(item => item.text().includes('交付目标'))!.trigger('click')
+  expect(composer.props('deliveryMode')).toBe(true)
+  await composer.get('textarea').setValue('完成交付报告')
+  if (!enabled) await composer.get('.composer-delivery-chip').trigger('click')
+  await composer.get('[aria-label="发送消息"]').trigger('click')
+  await flushPromises()
+  expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/group/messages', expect.objectContaining({ method: 'POST', body: expect.objectContaining({ mode: enabled ? 'goal' : 'chat', content: '完成交付报告' }) }))
+  expect(composer.get('textarea').element.value).toBe('完成交付报告')
+})