Compare commits

...

2 Commits

Author SHA1 Message Date
chiguyong cc6634b2ab feat(ui): private board restrictions + scheme B assistant/user bubbles
Test / backend-test (pull_request) Has been cancelled Details
Test / frontend-unit (pull_request) Has been cancelled Details
Test / api-e2e (pull_request) Has been cancelled Details
Test / frontend-e2e (pull_request) Has been cancelled Details
U1: ChatInput @board button blocks existing-conversation board creation
    with modal — enforces "one board per conversation" constraint.
U2: BoardBannerCard simplified to plain title + round meta
    (no icons/bars/progress/expert chips).
U3: MessageShell assistant bubble (方案B neutral grayscale) with
    F4-A card-type exclusion + G1 empty-bubble hide.
U4: UserBubble dark text bubble for plain text
    (command card/file keep light bg).

Code review fixes (ce-code-review step 5):
- P1: UserBubble focus-visible --accent-primary → --color-primary
  (dark mode visibility fix).
- P2: CARD_BEARING_TYPES adds 'error' (ErrorCard double-bubble regression).
- P2: Remove dead expertColor prop (scheme B leftover).
- P0/P1: Extract bubbleUtils.ts pure functions + add 42 tests
  covering G1/F4-A/U4/U2 key decisions.

Tests: 180/181 pass (1 pre-existing tauri-auth failure unrelated).
Typecheck: clean.
2026-07-03 01:47:37 +08:00
chiguyong 981a794a54 docs(plan): private-board restrictions + scheme B bubbles plan ready
Plan document finalized after 4 rounds of ce-doc-review:
- F4-A exclusion list extended from 5 to 9 card-bearing types
- Verified root class names for all 9 card components
- Corrected chrome description (2 full chrome + 7 partial chrome)
- Added U1 modal focus restoration note (WAI-ARIA)
- Documented R4-DA1/R4-A3/R4-A4 as Open Questions for implementation
2026-07-03 01:14:37 +08:00
13 changed files with 501 additions and 141 deletions

View File

@ -8,7 +8,7 @@ status: ready
## Summary
收尾私董会(@board模块的 UI 细节与单会话状态约束,四处协同改动:(1) 在 `ChatInput.vue` 的"私董会"按钮 click 处拦截"当前会话已存在进行中的私董会"场景,弹 a-modal 提示并提供快捷新建会话按钮;(2) 将 `BoardBannerCard.vue` 从带边框/紫条/进度条/专家 chip 的重样式简化为单行标题+副标题;(3) 给 `MessageShell.vue` 中所有 `role === 'assistant'` 的消息内容添加方案 B 风格的浅灰圆角矩形气泡F1-A 独立 token / F4-A 排除 conclusion / D4-方案1 `:empty` 隐藏);(4) 将 `UserBubble.vue` 普通文本消息改为 demo 中的深色右对齐气泡(`--color-primary` + `--text-inverse`@board/@team 命令卡片保持现有浅色背景。改动范围仅限前端 Vue 组件与少量 store/type/token 文件,不动后端、不动方案 B 调色板、不动 StickyModeHeader 顶部条。
收尾私董会(@board模块的 UI 细节与单会话状态约束,四处协同改动:(1) 在 `ChatInput.vue` 的"私董会"按钮 click 处拦截"当前会话已存在进行中的私董会"场景,弹 a-modal 提示并提供快捷新建会话按钮;(2) 将 `BoardBannerCard.vue` 从带边框/紫条/进度条/专家 chip 的重样式简化为单行标题+副标题;(3) 给 `MessageShell.vue` 中所有 `role === 'assistant'` 的消息内容添加方案 B 风格的浅灰圆角矩形气泡F1-A 独立 token / F4-A 排除所有 card-bearing 类型 / D4-方案1 `:empty` 隐藏);(4) 将 `UserBubble.vue` 普通文本消息改为 demo 中的深色右对齐气泡(`--color-primary` + `--text-inverse`@board/@team 命令卡片保持现有浅色背景。改动范围仅限前端 Vue 组件与少量 store/type/token 文件,不动后端、不动方案 B 调色板、不动 StickyModeHeader 顶部条。
## Problem Frame
@ -38,9 +38,9 @@ status: ready
- R9`MessageShell.vue` 的 `.message-shell__content``background: var(--bg-message-bubble)` + `border-radius: var(--radius-md)` + `padding: var(--space-3) var(--space-4)` + `border: 1px solid var(--border-color)` + `color: var(--text-primary)`,仅 `role === 'assistant'` 时生效。**F1-A 决策**:引入独立 token `--bg-message-bubble`light `#ffffff` / dark `#1f1f1f`),与 `--bg-secondary`inline code/table 背景)解耦,避免气泡背景与代码/表格背景视觉冲突
- R10不使用 `!important`,不覆盖代码块/表格/路由 tag 样式
- R11不影响 `BoardRoundCard``AssistantText` 渲染
- R12私董会专家发言、普通 chat、@team 阶段、Debate 等都通过 `MessageShell + AssistantText` 统一获得气泡
- R12私董会专家发言、普通 chat 通过 `MessageShell + AssistantText` 统一获得气泡;@team 阶段使用 `TeamPlanCard`、Debate 使用 `DebateArgumentCard`/`DebateSummaryCard`/`DebateBannerCard` 等自带 card chrome 的组件(通过 `MessageShell` 渲染但不走 `AssistantText`),由 R14 排除气泡
- R13气泡宽度继承现有 `width: 100%; max-width: 100%`,不强制固定最大宽度
- R14**F4-A 决策**:气泡选择器排除 `board_conclusion` 类型——`BoardConclusionCard` 自带 card chromebackground/border/border-radius/shadow保留其自身样式不再被气泡包裹。实现方式`MessageShell.vue` 通过 `messageType` prop 或 `:not(:has(.board-conclusion-card))` 选择器排除
- R14**F4-A 决策Round 4 扩展)**:气泡选择器排除所有自带 card chrome 的 assistant 消息类型(共 9 种):`board_conclusion``BoardConclusionCard` → `.board-conclusion-card`full chromebg + border + radius + shadow、`team_plan``TeamPlanCard` → `.team-plan-card`full chrome、`debate_banner``DebateBannerCard` → `.debate-banner`partial chromebg + left-border + radius、`debate_argument``DebateArgumentCard` → `.debate-argument`partial、`debate_summary``DebateSummaryCard` → `.debate-summary`partial、`debate_resolved``DebateConclusionCard` → `.debate-conclusion`partial + 4 变体 bg、`collaboration_graph``CollaborationGraphCard` → `.collab-graph`partial、`review_result``ReviewResultCard` → `.review-card`partial + 3 变体 bg、`risk_flagged``RiskFlagCard` → `.risk-card`partial。**chrome 区分Round 4 修正)**:仅 `BoardConclusionCard` + `TeamPlanCard` 有完整 chromebg + border + radius + shadow其余 7 种只有 bg + left-border + radius无 shadow、无 full border。**已验证根 class 名Round 4 R4-F1**:注意 7 种 partial chrome 卡片 root class 名与组件名不匹配,不带 `-card` 后缀(`.debate-banner` / `.debate-argument` / `.debate-summary` / `.debate-conclusion` / `.collab-graph` / `.review-card` / `.risk-card`)。实现方式:在 `MessageShell.vue` 通过 `messageType` prop 或 `:has()` 选择器排除(具体机制依 G3 决策)。**第三种架构替代方案Round 4 R4-A3**:在 `MessageRenderSpec` 接口(`useMessageRenderer.ts:44-48`)新增 `bubble: boolean` 字段,在 renderer 层集中决策是否包裹气泡,避免 messageType prop 污染或 `:has()` 选择器脆弱性——见 Open Questions Round 4。**未来防护Round 4 R4-A4**:新增 card-bearing 类型时需手动加入 F4-A 排除列表,或采用上述 `bubble` 字段方案由 renderer 集中管理——见 Open Questions Round 4
- R15**D4-方案1 决策**:空 slot 内容pre-stream thinking / tool-call-only时气泡不渲染背景——通过 `:empty` 选择器隐藏 `background / border / padding`,仅显示 thinking dots。有内容流入后自动恢复气泡样式
**UserBubble 普通文本深色气泡R16-R19**
@ -55,10 +55,10 @@ status: ready
- **私董会状态判定取 `discussing | concluding`**`BoardState.status` 类型为 `"discussing" | "concluding" | "completed" | "dissolved"`chatStream.ts:65 确认)。`completed | dissolved` 的旧私董会不阻塞新私董会发起,符合 origin R3 要求。
- **"新建会话"按钮流程:`createConversation()` → `selectConversation(newId, true)`** — `createConversation()` 是同步函数chatStore.ts:333 确认),自动设置 `currentConversationId` 但不加载服务端会话;后续 `selectConversation(newId, true)` 强制 reload 确保状态干净。不自动打开 `BoardMeetingModal`,由用户在新会话中再次点击"私董会"按钮继续(见 origin R2
- **BoardBannerCard 简化为单行标题+副标题,不删除组件本身** — 保留 `BoardBannerCard.vue` 文件,仅重构 template + style。`useMessageRenderer.ts` 的 `board_started` 渲染路径不变origin R8保持 streaming 期间与 reload 后视觉一致。
- **气泡样式挂在 `MessageShell.vue` 的 `.message-shell__content` 上,不挂在 `AssistantText.vue`**`MessageShell` 是所有 assistant 消息的统一外壳,挂在这里可一次性覆盖 board_speech / board_summary / 普通聊天 / @team / Debate 等场景origin R12且不影响 `UserBubble.vue` 的 user 消息样式。**board_conclusion 例外**F4-A`BoardConclusionCard` 自带 card chrome,不被气泡包裹。
- **气泡样式挂在 `MessageShell.vue` 的 `.message-shell__content` 上,不挂在 `AssistantText.vue`**`MessageShell` 是所有 assistant 消息的统一外壳,挂在这里可一次性覆盖 board_speech / board_summary / 普通聊天 / @team / Debate 等场景origin R12且不影响 `UserBubble.vue` 的 user 消息样式。**card-bearing 类型例外F4-A Round 4 扩展,共 9 种)**`board_conclusion`/`team_plan`/`debate_banner`/`debate_argument`/`debate_summary`/`debate_resolved`/`collaboration_graph`/`review_result`/`risk_flagged` 渲染的 card 组件自带 card chrome详见 R14,不被气泡包裹。
- **气泡使用 CSS 变量,禁止硬编码** — 新增 `--bg-message-bubble: #ffffff` (light) / `#1f1f1f` (dark) 到 `tokens.css`,与 `--bg-secondary`inline code/table 背景 `#fbfbfa`解耦F1-A 决策。dark mode 自动切换。禁止硬编码 `#f3f4f6` / `#fbfbfa` / `#ffffff` 等值project rules 硬约束)。
- **`role === 'assistant'` 时启用气泡,`role === 'user'` 不启用** — 通过 `.message-shell--assistant .message-shell__content` 选择器限定,避免污染 user 消息的 `UserBubble` 样式。
- **F4-A 排除 conclusion 类型** — `board_conclusion` 消息渲染的 `BoardConclusionCard` 自带 `background: var(--bg-primary)` + `border` + `border-radius: var(--radius-card)` + `box-shadow`,气泡选择器需排除该类型,避免"白卡片嵌套在白气泡里"的视觉冲突。实现方式:在 `MessageShell.vue` 增加 `messageType` prop`:not(:has(.board-conclusion-card))` 选择器conclusion 类型不加气泡样式
- **F4-A 排除所有 card-bearing 类型Round 4 扩展,共 9 种)** — 排除 9 种 card-bearing assistant 消息类型:`board_conclusion`/`team_plan`/`debate_banner`/`debate_argument`/`debate_summary`/`debate_resolved`/`collaboration_graph`/`review_result`/`risk_flagged`。**chrome 区分Round 4 修正)**:仅 `BoardConclusionCard``.board-conclusion-card`+ `TeamPlanCard``.team-plan-card`)有完整 chromebg + border + radius + shadow其余 7 种(`DebateBannerCard`→`.debate-banner`、`DebateArgumentCard`→`.debate-argument`、`DebateSummaryCard`→`.debate-summary`、`DebateConclusionCard`→`.debate-conclusion`、`CollaborationGraphCard`→`.collab-graph`、`ReviewResultCard`→`.review-card`、`RiskFlagCard`→`.risk-card`)只有 bg + left-border + radius无 shadow、无 full border。气泡选择器需排除这些类型避免"card 嵌套在气泡里"的视觉冲突。实现方式:在 `MessageShell.vue` 增加 `messageType` prop`:has()` 选择器card-bearing 类型不加气泡样式。具体机制依 G3 决策。**第三种替代方案**:在 `MessageRenderSpec` 新增 `bubble: boolean` 字段由 renderer 集中决策(见 Open Questions Round 4 R4-A3。**未来防护**:新增 card 类型需手动加入排除列表或采用 `bubble` 字段方案(见 Open Questions Round 4 R4-A4
- **D4-方案1 `:empty` 隐藏空气泡** — 空 slot 内容pre-stream thinking / tool-call-only时通过 `:empty` 选择器隐藏 `background / border / padding`,仅显示 thinking dots 动画。有内容流入后自动恢复气泡样式,无需 JS 判断。
- **U4 仅普通文本消息深色气泡,命令卡片保持浅色**`UserBubble.vue` 同时渲染三种内容(普通文本 / @board|@team 命令卡片 / 文件附件命令卡片含结构化信息header + experts list深色背景会降低可读性。通过 `isPlainText` computed + `.user-bubble--text` modifier 精确限定深色样式仅作用于普通文本,命令卡片和文件附件继承 `.user-bubble` 默认 `--bg-tertiary` 浅色背景。
- **U4 用 `--color-primary` + `--text-inverse` 自动适配 dark mode**`--color-primary` 在 light mode 为 `#1a1a1a`dark mode 为 `#fbfbfa`(浅);`--text-inverse` 反之。user 气泡在 dark mode 下自动反转为浅色背景 + 深色文字,无需额外 dark mode 覆盖。
@ -89,6 +89,7 @@ status: ready
- `await chatStore.selectConversation(chatStore.currentConversationId!, true)`
- 不设置 `showBoardModal.value = true`(让用户在新会话中再次点击)
- "私董会"按钮 `@click` 改为 `handleBoardClick`
- **焦点恢复Round 4 R4-D3**a-modal 关闭时(无论"我知道了"还是"新建会话")需将焦点返回到触发按钮("私董会"按钮),符合 WAI-ARIA modal 对话模式。Ant Design Vue 的 `<a-modal>` 通过 `@cancel` / `@ok` 事件触发关闭,关闭后焦点默认落在 modal 本身;需在 `handleCreateNewConversationForBoard` 和"我知道了"按钮 click 后显式 `nextTick(() => boardButtonRef.value?.focus())`(或使用 autofocus 属性)。新建会话跳转后焦点自然落于新会话输入框,无需额外处理
**Patterns to follow:**
- `ChatInput.vue:63-71``showTeamModal` 模式v-model:open + ref + handleXxxSubmit
@ -164,7 +165,7 @@ status: ready
### U3. MessageShell 浅灰气泡方案B
**Goal:** 在 `MessageShell.vue``.message-shell__content` 上增加方案 B 风格的浅灰圆角矩形气泡样式,仅 `role === 'assistant'` 时生效,使用 CSS 变量绑定dark mode 自动切换。**F1-A 决策**:气泡用独立 token `--bg-message-bubble`(与 inline code/table 的 `--bg-secondary` 解耦);**F4-A 决策**:排除 `board_conclusion` 类型**D4-方案1 决策**:空内容通过 `:empty` 隐藏气泡。
**Goal:** 在 `MessageShell.vue``.message-shell__content` 上增加方案 B 风格的浅灰圆角矩形气泡样式,仅 `role === 'assistant'` 时生效,使用 CSS 变量绑定dark mode 自动切换。**F1-A 决策**:气泡用独立 token `--bg-message-bubble`(与 inline code/table 的 `--bg-secondary` 解耦);**F4-A 决策Round 4 扩展,共 9 种)**:排除所有 card-bearing assistant 类型(`board_conclusion` / `team_plan` / `debate_banner` / `debate_argument` / `debate_summary` / `debate_resolved` / `collaboration_graph` / `review_result` / `risk_flagged`),这些类型由各自 card 组件自带 card chrome2 种 full chrome + 7 种 partial chrome详见 R14**D4-方案1 决策**:空内容通过 `:empty` 隐藏气泡。
**Requirements:** R9, R10, R11, R12, R13, R14, R15
@ -186,7 +187,11 @@ status: ready
padding: var(--space-3) var(--space-4);
color: var(--text-primary);
}
/* F4-A: board_conclusion 类型不加气泡,保留 BoardConclusionCard 自身 card chrome */
/* F4-A: 所有 card-bearing assistant 类型不加气泡(共 9 种Round 4 扩展)。
已验证根 class 名:`.board-conclusion-card` / `.team-plan-card`full chrome含 shadow /
`.debate-banner` / `.debate-argument` / `.debate-summary` / `.debate-conclusion` / `.collab-graph` / `.review-card` / `.risk-card`
7 种 partial chrome仅 bg + left-border + radius无 shadow 无 full border注意 root class 名与组件名不匹配,不带 -card 后缀)。
实现期依 G3 决策扩展选择器或 messageType prop 排除列表 */
.message-shell--assistant.message-shell--conclusion .message-shell__content,
.message-shell--assistant:has(.board-conclusion-card) .message-shell__content {
background: transparent;
@ -220,7 +225,7 @@ status: ready
- 气泡内代码块slot 内含 `<pre><code>` → 代码块背景 `--code-bg` 不被气泡背景覆盖(视觉可区分)
- 气泡宽度:`.message-shell__content` 计算样式 `width: 100%``max-width: 100%`(跟随消息列宽)
- Dark mode切换到 dark mode → `.message-shell__content` 计算样式 `background-color: rgb(31, 31, 31)``--bg-message-bubble: #1f1f1f`
- **F4-A 排除 conclusion**:渲染 `<MessageShell role="assistant" messageType="board_conclusion">` + `BoardConclusionCard` slot → `.message-shell__content` 计算样式 `background-color: transparent``border: none`(不加气泡,`BoardConclusionCard` 自身 card chrome 保留
- **F4-A 排除 card-bearing 类型Round 4 扩展,共 9 种)**分别渲染 `<MessageShell role="assistant" messageType="board_conclusion|team_plan|debate_banner|debate_argument|debate_summary|debate_resolved|collaboration_graph|review_result|risk_flagged">` + 对应 card slot`BoardConclusionCard` / `TeamPlanCard` / `DebateBannerCard` / `DebateArgumentCard` / `DebateSummaryCard` / `DebateConclusionCard` / `CollaborationGraphCard` / `ReviewResultCard` / `RiskFlagCard`)→ 每种类型 `.message-shell__content` 计算样式 `background-color: transparent``border: none`(不加气泡,card 自身 chrome 保留2 种 full chrome + 7 种 partial chrome 详见 R14
- **D4-方案1 空内容隐藏**:渲染 `<MessageShell role="assistant">` + 空 slot → `.message-shell__content` 计算样式 `background-color: transparent``border: none``padding: 0``:empty` 选择器生效)
- **D4-方案1 有内容恢复**:渲染 `<MessageShell role="assistant">` + 非空 slot → `:empty` 不匹配,气泡样式正常应用
- 渲染契约:样式**不**使用 `!important`
@ -230,6 +235,7 @@ status: ready
- 启动 Tauri 客户端,发起普通 chatassistant 消息应有浅灰(实际为 `#ffffff` 纯白,与 `#fbfbfa` 的 inline code 区分)圆角矩形气泡
- 发起私董会专家发言board_speech应有同款气泡
- 发起私董会结论消息board_conclusion应保留 `BoardConclusionCard` 自身 card 样式,**不**被气泡包裹
- 同理验证 `team_plan` / `debate_banner` / `debate_argument` / `debate_summary` / `debate_resolved` / `collaboration_graph` / `review_result` / `risk_flagged` 消息保留各自 card chrome`TeamPlanCard` / `DebateBannerCard` / `DebateArgumentCard` / `DebateSummaryCard` / `DebateConclusionCard` / `CollaborationGraphCard` / `ReviewResultCard` / `RiskFlagCard`**不**被气泡包裹F4-A Round 4 扩展,共 9 种)
- 切换 dark mode气泡背景应自动变为深色`#1f1f1f`
- user 消息(右侧)应显示为 demo 中的深色气泡(`--color-primary` 背景 + `--text-inverse` 白字),@board/@team 命令卡片保持现有浅色背景(见 U4
- assistant 消息在 thinking 阶段(空内容)不应显示空气泡矩形,仅显示三点动画
@ -330,7 +336,7 @@ status: ready
- **F1feasibilityP1— 已决策 F1-A**U3 气泡背景与 inline code/table 背景冲突。**决策**:引入独立 token `--bg-message-bubble`light `#ffffff` / dark `#1f1f1f`)区分气泡背景与 inline code/table 背景(保持 `--bg-secondary: #fbfbfa`)。已固化到 R9、U3 Approach、Key Technical Decisions。**⚠️ Round 2 G5 挑战**`#ffffff` 与 `#fbfbfa` 色差 <1% 低于 JND `#ffffff` `--bg-primary` 相同导致气泡仅靠边框可见——见下方 G5
- **F3feasibilityP2— 已决策 F3-A**U2 删除 `BoardBannerCard``experts` prop 后 `useMessageRenderer.ts:145` 的 fallthrough 风险。**决策**:在 U2 Files 列表中加 `useMessageRenderer.ts`,删除 `board_banner` case 中 `experts` prop 传参。已固化到 U2 Files、U2 Approach、U2 Test scenarios。
- **F4feasibilityP2— 已决策 F4-A**U3 气泡包裹 `BoardConclusionCard` 的嵌套冲突。**决策**:气泡选择器排除 `board_conclusion` 类型,`BoardConclusionCard` 保留自身 card chrome 不被气泡包裹。已固化到 R14、U3 Approach、U3 Test scenarios、Key Technical Decisions。**⚠️ Round 2 G2/G3 挑战**`:has()` 事实错误 + `messageType` prop 选项需 ChatMessage.vue不在 Files——见下方 G2/G3。
- **F4feasibilityP2— 已决策 F4-A**U3 气泡包裹 `BoardConclusionCard` 的嵌套冲突。**决策**:气泡选择器排除 `board_conclusion` 类型,`BoardConclusionCard` 保留自身 card chrome 不被气泡包裹。已固化到 R14、U3 Approach、U3 Test scenarios、Key Technical Decisions。**⚠️ Round 2 G2/G3 挑战**`:has()` 事实错误 + `messageType` prop 选项需 ChatMessage.vue不在 Files——见下方 G2/G3。**⚠️ Round 3 扩展**F4-A 排除列表从 1 种扩展到 5 种(新增 `team_plan`/`debate_banner`/`debate_argument`/`debate_summary`)。**⚠️ Round 4 扩展R4-A1**:进一步扩展到 9 种(新增 `debate_resolved`/`collaboration_graph`/`review_result`/`risk_flagged`),并修正 chrome 描述2 种 full chrome + 7 种 partial chrome。详见 R14 与 Open Questions Round 4。
- **C2coherenceP2— 已应用 safe_auto**R7 表述已修订为"删除 `.board-banner-card` 的重样式background/border/border-radius/box-shadow`__bar / __chip` 等重样式类,保留 `.board-banner-card` 容器(仅 margin/padding+ `__title / __meta` 最小样式"。
- **D4design-lensP2— 已决策 D4-方案1**U3 空内容气泡表现。**决策**:通过 `:empty` 选择器隐藏 `background / border / padding`,仅显示 thinking dots。已固化到 R15、U3 Approach、U3 Test scenarios、Key Technical Decisions。**⚠️ Round 2 G1 推翻**`:empty` 永不匹配AssistantText 总渲染根 div——见下方 G1实现期必须替换方案。
@ -372,9 +378,35 @@ status: ready
- **D4-R2design-lensP2conf 75— U2 简化 banner 缺乏视觉分隔**:删除所有 chrome 后,两行纯文本 bannerfont-md semibold + font-xs tertiary可能融入消息流失去"section divider"作用。BankOutlined 图标删除后无视觉锚点。**待选方案**(a) 保留 2px 左边框 `--accent-board`(匹配私董会身份,克制);(b) 加小色点前缀;(c) 纯排版足够("私董会 —" 文本前缀即锚点)。
#### Round 3 复审新增2026-07-03
**P1 manualorigin scope 边界):**
- **R3-A1scope-guardianP1conf 100— U4 UserBubble 深色气泡超出 origin 范围**origin `docs/brainstorms/2026-07-02-private-board-restrictions-and-scheme-b-bubbles-requirements.md` 的 Key Decisions 明确声明"role === 'user' 的用户消息气泡保留现有 UserBubble.vue 的右对齐独立样式,不加 AssistantText 风格的浅灰块"。但本 plan U4 改动 `UserBubble.vue`,将普通文本消息改为 `--color-primary` 深色背景 + `--text-inverse` 白字的深色气泡R16-R19属于 origin 之外的 scope creep。**待选方案**(a) Drop U4 及 R16-R19保持 origin 不变U3 仍完成 assistant 气泡统一);(b) 保留 U4 但正式 amend origin requirements doc补充"用户普通文本消息改为深色右对齐气泡"决策。建议 (b),因为 U4 视觉冲击显著、demo 已确认方向,但需走 origin amend 流程避免 plan/origin 漂移。
**P2 gated_auto事实修正**
- **R3-F2feasibilityP2conf 50— selectConversation `force=true` 对 local 会话不生效**plan U1 Approach 中关于"切换会话时通过 `selectConversation(force=true)` 强制重新加载消息"的描述有事实偏差。`chatStore.ts:233` 的守卫为 `!conv?.is_local && (force || ...)`,对 `is_local=true` 的会话(含新建会话)会跳过 reload 调用——状态干净实际由 `createConversation` 保证(新建即清空 boardState/selectedExpert 等)。**建议 fix**:在 U1 Approach / Key Technical Decisions 条目中明确归因——"切换会话时 boardState 清空由 `createConversation` 在新建会话时执行;对非 local 会话调用 `selectConversation``force=true` 仍生效"。无需修改代码,仅修正 plan 文档归因。
#### Round 4 复审新增2026-07-03
**已应用auto-resolve 决策汇总):**
- **R4-C1coherenceP1conf 100safe_auto applied** — Lines 58 & 389 "board_conclusion 例外" 已更新为 "card-bearing 类型例外F4-A Round 4 扩展)"
- **R4-F1feasibilityP2conf 75gated_auto applied** — 已验证 9 种 card root class 名并添加到 R14、Key Technical Decisions F4-A、U3 CSS 注释(注意 7 种 partial chrome 卡片 root class 名不带 `-card` 后缀)
- **R4-A1adversarialP1conf 100auto-resolve applied** — F4-A 排除列表从 5 种扩展到 9 种(新增 `debate_resolved` / `collaboration_graph` / `review_result` / `risk_flagged`)。已更新 R14、Key Technical Decisions F4-A、U3 Goal、U3 CSS 注释、U3 Test scenarios、U3 Verification
- **R4-D1design-lensP2conf 100auto-resolve applied** — F4-A card chrome claim 已修正:仅 `BoardConclusionCard` + `TeamPlanCard` 有完整 chromebg + border + radius + shadow其余 7 种只有 partial chromebg + left-border + radius无 shadow 无 full border。已更新 R14、Key Technical Decisions F4-A、U3 CSS 注释
- **R4-D3design-lensP2conf 75auto-resolve applied** — U1 Approach 新增焦点恢复说明a-modal 关闭后焦点返回"私董会"按钮WAI-ARIA modal 对话模式)
**P2 manual待后续处理**
- **R4-DA1design-lens + adversarial cross-persona promoteP2conf 100— 7 种 partial chrome 卡片硬编码浅色dark mode 下与 F4-A 排除气泡后视觉冲突加剧**`DebateBannerCard``#f9f0ff`)、`DebateArgumentCard``#fafafa`)、`DebateSummaryCard``#fff7e6`)、`DebateConclusionCard`4 变体 `#fff7e6`/`#f6ffed`/`#fffbe6`/`#f5f5f5`/`#fff2f0`)、`CollaborationGraphCard``#f0f8ff`)、`ReviewResultCard`3 变体 `#fafafa`/`#f6ffed`/`#fffbe6`/`#fff2f0`)、`RiskFlagCard``#fff7e6`均使用硬编码浅色背景。U3 气泡用 `--bg-message-bubble` token 自动适配 dark mode但这些 card 被 F4-A 排除后dark mode 下仍显示硬编码浅色背景,对比度刺眼。**这是 pre-existing 问题,超出 U3 scope**U3 仅负责气泡,不负责修复 card 颜色)。**建议**:新建独立 unit如 U5: Card Dark Mode Adaptation将这 7 种 card 的硬编码颜色迁移到 CSS 变量,与 U3 并行但不阻塞。或暂记为 follow-up tech debt。
- **R4-A3adversarialP2conf 75— F4-A 遗漏第三种架构替代方案**:当前 G3 决策在 `messageType` prop 与 `:has()` 选择器间二选一。存在第三种方案:在 `MessageRenderSpec` 接口(`useMessageRenderer.ts:44-48`)新增 `bubble: boolean` 字段,在 renderer 层(`ChatMessage.vue`集中决策是否包裹气泡card-bearing 类型在 spec 定义时设 `bubble: false`。**优势**:新增 card 类型时无需改 `MessageShell.vue` 排除列表,避免 R4-A4 的未来防护问题spec 集中管理,单一数据源。**劣势**:需修改 `MessageRenderSpec` 接口和所有 spec 定义点12+ 类型),改动面比 prop/selector 方案大。**建议**:实现期若 G3 二选一难以决断,可考虑此方案作为 tie-breaker否则记为 future refactor 候选。
- **R4-A4adversarialP2conf 75— F4-A 排除列表对未来新增 card 类型无防护**:当前 F4-A 排除列表硬编码 9 种类型,新增 card-bearing 类型时需手动加入 `MessageShell.vue` 排除列表prop 或 selector 方案),否则新 card 会被气泡错误包裹。**与 R4-A3 关联**:采用 `MessageRenderSpec.bubble` 字段方案可从根本上解决renderer 集中决策),但需更大重构。**建议**:若采用 G3 prop/selector 方案,需在 `useMessageRenderer.ts` 新增 card 类型时强制走 code review checklist 验证 F4-A 排除列表是否同步更新;或采用 R4-A3 的 `bubble` 字段方案一劳永逸。
## System-Wide Impact
- **前端用户**:所有 Tauri 客户端和 Web GUI 用户将看到 (a) 私董会按钮在已有私董会的会话中弹提示而非 modal(b) 私董会开始消息显示为简洁两行文本;(c) 所有 assistant 消息有浅灰圆角气泡(`--bg-message-bubble: #ffffff`board_conclusion 例外(保留自身 card(d) thinking 阶段不显示空气泡。视觉变化明显但符合方案 B 整体取向。
- **前端用户**:所有 Tauri 客户端和 Web GUI 用户将看到 (a) 私董会按钮在已有私董会的会话中弹提示而非 modal(b) 私董会开始消息显示为简洁两行文本;(c) 所有 assistant 消息有浅灰圆角气泡(`--bg-message-bubble: #ffffff`card-bearing 类型例外(保留自身 card(d) thinking 阶段不显示空气泡。视觉变化明显但符合方案 B 整体取向。
- **后端/运维**:无影响(不动后端、不动部署、不动配置)。
- **其他团队**:无影响(仅前端 Vue 组件)。

View File

@ -70,9 +70,10 @@
专家团
</a-button>
<a-button
ref="boardButtonRef"
size="small"
:disabled="disabled"
@click="showBoardModal = true"
@click="handleBoardClick"
class="chat-input__action-btn chat-input__action-btn--board"
>
<template #icon><TeamOutlined /></template>
@ -125,6 +126,16 @@
<span class="chat-input__hint">Enter 发送Shift + Enter 换行</span>
</div>
</div>
<a-modal
v-model:open="showBoardBlockModal"
title="当前会话已存在私董会"
ok-text="新建会话"
cancel-text="我知道了"
@ok="handleCreateNewConversationForBoard"
@cancel="handleBoardBlockCancel"
>
<p>请新建会话来创建新的私董会</p>
</a-modal>
<BoardMeetingModal
v-model:open="showBoardModal"
@submit="handleBoardSubmit"
@ -137,8 +148,8 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, type Component } from 'vue'
import { Input as AInput, Button as AButton, Select as ASelect } from 'ant-design-vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick, type Component } from 'vue'
import { Input as AInput, Button as AButton, Select as ASelect, Modal as AModal } from 'ant-design-vue'
import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined, PoweroffOutlined, CommentOutlined } from '@ant-design/icons-vue'
import ContextPill from './ContextPill.vue'
import MentionDropdown from './MentionDropdown.vue'
@ -198,6 +209,8 @@ const availableModels = ref<ModelInfo[]>([])
const modelsLoading = ref(false)
const showBoardModal = ref(false)
const showTeamModal = ref(false)
const showBoardBlockModal = ref(false)
const boardButtonRef = ref<InstanceType<typeof AButton> | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const fileUploading = ref(false)
const isDragOver = ref(false)
@ -329,6 +342,32 @@ function handleStop(): void {
emit('stop')
}
function handleBoardClick(): void {
// U1: ""
const status = chatStore.boardState?.status
if (status === 'discussing' || status === 'concluding') {
showBoardBlockModal.value = true
return
}
showBoardModal.value = true
}
async function handleCreateNewConversationForBoard(): Promise<void> {
showBoardBlockModal.value = false
chatStore.createConversation()
await chatStore.selectConversation(chatStore.currentConversationId!, true)
// BoardMeetingModal""
}
function handleBoardBlockCancel(): void {
// R4-D3: a-modal WAI-ARIA modal
showBoardBlockModal.value = false
nextTick(() => {
const el = boardButtonRef.value?.$el as HTMLElement | undefined
el?.focus()
})
}
function handleBoardSubmit(command: string): void {
// The BoardMeetingModal constructs an @board:expert1,expert2 topic command.
// Send it through the normal chat pipeline the backend intercepts @board prefix.

View File

@ -6,8 +6,9 @@
:avatar="spec.shell.avatar"
:color="spec.shell.color"
:expert-name="message.expert_name"
:expert-color="message.expert_color"
:streaming="message.status === 'streaming'"
:message-type="spec.type"
:is-empty="isBubbleEmpty"
>
<component
:is="spec.component"
@ -21,6 +22,7 @@
import MessageShell from './messages/MessageShell.vue'
import { computed } from 'vue'
import { useMessageRenderer } from './helpers/useMessageRenderer'
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
import { useChatStore } from '@/stores/chatStore'
import type { IChatMessage } from '@/api/types'
@ -41,6 +43,12 @@ const componentListeners = computed(() =>
spec.value.type === 'error' ? { retry: handleRetry } : {}
)
/**
* U3 G1: 检测消息内容是否为空 (pre-stream / tool-call-only)
* 逻辑提取到 bubbleUtils.isAssistantBubbleEmpty 以支持单元测试
*/
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
function handleRetry(): void {
if (chatStore.isLoading) return
chatStore.resendLastUserMessage()

View File

@ -0,0 +1,57 @@
import type { IChatMessage } from '@/api/types'
/**
* F4-A (Round 4 ): card-bearing assistant
* card chrome (ErrorCard full chrome + partial chrome)
* : debate_started DebateBannerCard (plan debate_banner)
* ce-code-review P2: 加入 'error' ErrorCard.vue full chrome
* assistant ErrorCard chrome
*
* (F4-A ) leaky abstraction
* (ce-simplify-code P3) MessageShell Set
*/
const CARD_BEARING_TYPES = new Set<string>([
'board_conclusion',
'team_plan',
'debate_started',
'debate_argument',
'debate_summary',
'debate_resolved',
'collaboration_graph',
'review_result',
'risk_flagged',
'error',
])
export function isCardBearingType(type?: string): boolean {
return type ? CARD_BEARING_TYPES.has(type) : false
}
/**
* U3 G1: 检测 assistant (pre-stream / tool-call-only)
* AssistantText div:empty JS
* isEmpty = content + thinking + tool_calls
*
* (G1 P0 )
*/
export function isAssistantBubbleEmpty(message: IChatMessage): boolean {
if (message.role !== 'assistant') return false
return (
!message.content &&
!message.thinking &&
(!message.tool_calls || message.tool_calls.length === 0)
)
}
/**
* U4: 判定用户消息是否为普通文本 ( @board/@team )
* command card / file attachment
*
* (U4 )
*/
const FILE_MARKDOWN_RE = /^\[\]\s*\[(.+?)\]\((.+?)\)$/s
const COMMAND_RE = /^@(board|team)(?::([^\s]+))?\s+([\s\S]+)$/
export function isPlainUserText(content: string): boolean {
return !FILE_MARKDOWN_RE.test(content) && !COMMAND_RE.test(content)
}

View File

@ -135,14 +135,12 @@ export function useMessageRenderer(message: IChatMessage) {
case 'board_banner': {
const data = message.board_started
const experts = data?.experts ?? []
return {
type,
shell: { name: '私董会', meta: time },
component: BoardBannerCard,
props: {
topic: data?.topic || message.content || '未命名主题',
experts,
maxRounds: data?.max_rounds ?? 5,
currentRound: message.board_round ?? 1,
},

View File

@ -1,138 +1,37 @@
<template>
<div class="board-banner-card">
<div class="board-banner-card__bar" />
<div class="board-banner-card__body">
<div class="board-banner-card__title">
<BankOutlined class="board-banner-card__icon" />
<span>私董会 {{ topic }}</span>
</div>
<div class="board-banner-card__experts">
<span
v-for="(expert, idx) in experts"
:key="`${expert.name}-${idx}`"
class="board-banner-card__chip"
:class="{ 'board-banner-card__chip--moderator': expert.is_moderator }"
>
<span class="board-banner-card__chip-avatar">{{ expert.avatar }}</span>
<span>{{ expert.name }}</span>
</span>
</div>
<div class="board-banner-card__meta">
<span>轮次 {{ currentRound }} / {{ maxRounds }} </span>
<div class="board-banner-card__progress">
<div
class="board-banner-card__progress-fill"
:style="{ width: progressPercent + '%' }"
/>
</div>
</div>
</div>
<div class="board-banner-card__title">私董会 {{ topic }}</div>
<div class="board-banner-card__meta">轮次 {{ currentRound }} / {{ maxRounds }} </div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BankOutlined } from '@ant-design/icons-vue'
import type { IBoardExpert } from '@/api/types'
interface Props {
topic: string
experts: IBoardExpert[]
maxRounds: number
currentRound?: number
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
currentRound: 1,
})
const progressPercent = computed(() => {
if (props.maxRounds <= 0) return 0
return Math.min((props.currentRound / props.maxRounds) * 100, 100)
})
</script>
<style scoped>
.board-banner-card {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.board-banner-card__bar {
height: 4px;
background: var(--accent-board);
}
.board-banner-card__body {
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-3);
padding: var(--space-2) 0;
}
.board-banner-card__title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.board-banner-card__icon {
font-size: var(--font-lg);
}
.board-banner-card__experts {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.board-banner-card__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--accent-board-soft);
color: var(--accent-board);
border-radius: var(--radius-full);
font-size: var(--font-xs);
}
.board-banner-card__chip--moderator {
background: var(--accent-board);
color: var(--text-inverse);
}
.board-banner-card__chip-avatar {
font-size: 12px;
margin-bottom: var(--space-1);
}
.board-banner-card__meta {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.board-banner-card__progress {
flex: 1;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.board-banner-card__progress-fill {
height: 100%;
background: var(--accent-board);
border-radius: 2px;
transition: width 0.3s ease;
}
</style>

View File

@ -1,5 +1,11 @@
<template>
<div class="message-shell" :class="[`message-shell--${role}`]">
<div
class="message-shell"
:class="[
`message-shell--${role}`,
{ 'message-shell--card': isCardBearing, 'message-shell--empty': isEmpty },
]"
>
<div class="message-shell__avatar">
<slot name="avatar">
<div
@ -42,9 +48,11 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Avatar as AAvatar } from 'ant-design-vue'
import { RobotOutlined, UserOutlined } from '@ant-design/icons-vue'
import type { Component } from 'vue'
import { isCardBearingType } from '../helpers/bubbleUtils'
interface Props {
role: 'user' | 'assistant'
@ -52,23 +60,28 @@ interface Props {
meta?: string
avatar?: string | Component
color?: string
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为彩色 badge 替代普通 name 文本 */
/** U4 R10: 专家身份 badge 名称 — 存在时渲染为粗体文本替代普通 name */
expertName?: string
/** U4 R10: 专家身份 badge 颜色 */
expertColor?: string
/** U4: 流式进行中 — 显示省略号指示器 */
streaming?: boolean
/** U3 F4-A: 消息类型 — card-bearing 类型不加气泡 (由 card 自带 chrome) */
messageType?: string
/** U3 G1: 消息内容为空 (pre-stream / tool-call-only) — 隐藏空气泡矩形 */
isEmpty?: boolean
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
name: undefined,
meta: undefined,
avatar: undefined,
color: undefined,
expertName: undefined,
expertColor: undefined,
streaming: false,
messageType: undefined,
isEmpty: false,
})
const isCardBearing = computed(() => isCardBearingType(props.messageType))
</script>
<style scoped>
@ -186,4 +199,33 @@ withDefaults(defineProps<Props>(), {
.message-shell--user .message-shell__content {
align-items: flex-end;
}
/* U3: assistant (B) role=assistant user
F1-A: 用独立 token --bg-message-bubble ( inline code/table --bg-secondary 解耦)
不使用 !important AssistantText 内部 pre/hljs/code/table 自然继承 */
.message-shell--assistant .message-shell__content {
background: var(--bg-message-bubble);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
color: var(--text-primary);
}
/* F4-A: card-bearing assistant ( 9 Round 4 )
这些类型由各自 card 组件自带 chrome (BoardConclusionCard/TeamPlanCard full chrome
其余 7 partial chrome)避免气泡嵌套冲突 */
.message-shell--assistant.message-shell--card .message-shell__content {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
}
/* G1: 空内容 (pre-stream / tool-call-only) 隐藏气泡矩形仅显示 thinking dots
替代 Round 1 D4-方案1 :empty (AssistantText 总渲染根 div 导致 :empty 永不匹配) */
.message-shell--assistant.message-shell--empty .message-shell__content {
background: transparent;
border: none;
padding: 0;
}
</style>

View File

@ -2,7 +2,10 @@
<div
ref="rootRef"
class="user-bubble"
:class="{ 'user-bubble--focusable': msgId }"
:class="{
'user-bubble--focusable': msgId,
'user-bubble--text': isPlainText,
}"
:tabindex="msgId ? 0 : undefined"
@mouseenter="onBubbleMouseEnter"
@mouseleave="onBubbleMouseLeave"
@ -157,6 +160,14 @@ const fileAttachment = computed(() => {
}
})
/**
* U4: 普通文本消息判定 file attachment 且非 @board/@team command card
* 仅普通文本应用深色气泡command card / file attachment 保持浅色 --bg-tertiary
* 核心正则逻辑提取到 bubbleUtils.isPlainUserText 以支持单元测试
* 此处保留对 fileAttachment/commandBubble computed 的引用以维持响应性
*/
const isPlainText = computed(() => !fileAttachment.value && !commandBubble.value)
// --- Action toolbar visibility ---
// Three independent reasons the toolbar can stay open; the toolbar shows
// while ANY is active. This avoids the classic hover-toolbar bug where
@ -301,10 +312,20 @@ onUnmounted(() => {
}
.user-bubble--focusable:focus-visible {
outline: 2px solid var(--accent-primary, #1a1a1a);
outline: 2px solid var(--color-primary, #1a1a1a);
outline-offset: 2px;
}
/* U4: demo (--color-primary + --text-inverse)
command card / file attachment 保持 .user-bubble 默认 --bg-tertiary 浅色背景
--color-primary / --text-inverse light/dark mode 下自动反转 */
.user-bubble--text {
background: var(--color-primary);
color: var(--text-inverse);
padding: var(--space-3) var(--space-4);
max-width: 70%;
}
.user-bubble__text {
white-space: pre-wrap;
}

View File

@ -8,7 +8,6 @@
<MessageShell role="assistant" name="私董会" meta="11:00">
<BoardBannerCard
topic="是否将订单服务拆分为独立微服务"
:experts="boardExperts"
:max-rounds="3"
:current-round="1"
/>
@ -41,13 +40,7 @@ import {
BoardRoundCard,
BoardConclusionCard,
} from '@/components/chat/messages'
import type { IChatMessage, IBoardConcludedData, IBoardExpert } from '@/api/types'
const boardExperts: IBoardExpert[] = [
{ name: '架构师老张', avatar: '张', color: 'var(--accent-board)', is_moderator: false, persona: '架构' },
{ name: '后端负责人小李', avatar: '李', color: 'var(--accent-board)', is_moderator: false, persona: '后端' },
{ name: '主持人', avatar: '主', color: 'var(--accent-board)', is_moderator: true, persona: '主持' },
]
import type { IChatMessage, IBoardConcludedData } from '@/api/types'
const speech1: IChatMessage = {
id: 's4-speech-1',

View File

@ -66,6 +66,8 @@
--bg-tertiary: #f7f7f5;
--bg-elevated: #ffffff;
--bg-code: #1e1e2e;
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
--bg-message-bubble: #ffffff;
/* ── Foreground / Text ── */
--text-primary: #1a1a1a;
@ -224,6 +226,8 @@
--bg-tertiary: #2a2a2a;
--bg-elevated: #252525;
--bg-code: #11111b;
/* 消息气泡背景,与 inline code/table 背景解耦 (F1-A) */
--bg-message-bubble: #1f1f1f;
/* ── Foreground / Text ── */
--text-primary: #fbfbfa;

View File

@ -0,0 +1,87 @@
/**
* Unit tests for BoardBannerCard (U2 simplified banner).
*
* Covers the P1 testing gap flagged by ce-code-review. Verifies:
* - Renders "私董会 — {topic}" title
* - Renders "轮次:第 N / M 轮" meta
* - currentRound defaults to 1 when not provided
* - Custom currentRound renders correctly
* - No icons, borders, purple bars, progress bars, or expert chips (U2 simplification)
*
* Mount strategy: native Vue createApp + h (no @vue/test-utils dependency),
* consistent with AssistantText.test.ts / ThinkingBlock.test.ts pattern.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, type App } from 'vue'
import BoardBannerCard from '@/components/chat/messages/BoardBannerCard.vue'
interface Mounted {
container: HTMLElement
root: HTMLElement
app: App
unmount: () => void
}
function mountBoardBannerCard(props: Record<string, unknown>): Mounted {
const container = document.createElement('div')
document.body.appendChild(container)
const app = createApp({
render: () => h(BoardBannerCard as never, props as never),
})
app.mount(container)
const root = container.querySelector('.board-banner-card') as HTMLElement
return { container, root, app, unmount: () => { app.unmount(); container.remove() } }
}
describe('BoardBannerCard — U2 simplified banner', () => {
let mounted: Mounted | null = null
afterEach(() => {
mounted?.unmount()
mounted = null
})
it('renders "私董会 — {topic}" as title', () => {
mounted = mountBoardBannerCard({ topic: '技术选型讨论', maxRounds: 5 })
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
expect(title).toBeTruthy()
expect(title.textContent).toBe('私董会 — 技术选型讨论')
})
it('renders "轮次:第 N / M 轮" as meta', () => {
mounted = mountBoardBannerCard({ topic: '架构评审', maxRounds: 3, currentRound: 2 })
const meta = mounted.container.querySelector('.board-banner-card__meta') as HTMLElement
expect(meta).toBeTruthy()
expect(meta.textContent).toBe('轮次:第 2 / 3 轮')
})
it('defaults currentRound to 1 when not provided', () => {
mounted = mountBoardBannerCard({ topic: '新产品方向', maxRounds: 5 })
const meta = mounted.container.querySelector('.board-banner-card__meta') as HTMLElement
expect(meta.textContent).toBe('轮次:第 1 / 5 轮')
})
it('renders only title and meta — no icons, bars, progress, or expert chips', () => {
mounted = mountBoardBannerCard({ topic: '精简测试', maxRounds: 4, currentRound: 2 })
// Only two child elements: __title and __meta
const children = mounted.root.children
expect(children.length).toBe(2)
expect(children[0].className).toBe('board-banner-card__title')
expect(children[1].className).toBe('board-banner-card__meta')
// No icon elements, progress bars, or expert chips
expect(mounted.root.querySelector('svg, .anticon, .board-banner-card__bar, .board-banner-card__progress, .board-banner-card__experts, .board-banner-card__chip')).toBeNull()
})
it('handles CJK topic correctly', () => {
mounted = mountBoardBannerCard({ topic: '人工智能伦理边界探讨', maxRounds: 6 })
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
expect(title.textContent).toBe('私董会 — 人工智能伦理边界探讨')
})
it('handles empty topic gracefully', () => {
mounted = mountBoardBannerCard({ topic: '', maxRounds: 5 })
const title = mounted.container.querySelector('.board-banner-card__title') as HTMLElement
expect(title.textContent).toBe('私董会 — ')
})
})

View File

@ -0,0 +1,180 @@
/**
* Unit tests for bubbleUtils pure functions extracted from MessageShell /
* ChatMessage / UserBubble to support testing of key decisions:
*
* - isCardBearingType (F4-A): card-bearing assistant types skip bubble chrome
* - isAssistantBubbleEmpty (G1): empty content detection replaces :empty selector
* - isPlainUserText (U4): plain text vs file-attachment / @board/@team command
*
* These cover the P0/P1 testing gaps flagged by ce-code-review: G1 is a P0
* decision (:empty never matches), F4-A is a P0 regression guard (double
* bubble), U4 is a P0 feature (dark bubble only for plain text).
*/
import { describe, expect, it } from 'vitest'
import {
isCardBearingType,
isAssistantBubbleEmpty,
isPlainUserText,
} from '@/components/chat/helpers/bubbleUtils'
import type { IChatMessage } from '@/api/types'
function makeMsg(overrides: Partial<IChatMessage> = {}): IChatMessage {
return {
id: 'm1',
role: 'assistant',
content: '',
timestamp: '2026-07-01T00:00:00Z',
...overrides,
}
}
// ── isCardBearingType (F4-A) ────────────────────────────────────────
describe('isCardBearingType — F4-A card-bearing exclusion', () => {
const cardBearingTypes = [
'board_conclusion',
'team_plan',
'debate_started',
'debate_argument',
'debate_summary',
'debate_resolved',
'collaboration_graph',
'review_result',
'risk_flagged',
'error',
]
it.each(cardBearingTypes)('returns true for card-bearing type "%s"', (type) => {
expect(isCardBearingType(type)).toBe(true)
})
it('returns false for plain assistant type', () => {
expect(isCardBearingType('assistant')).toBe(false)
})
it('returns false for user type', () => {
expect(isCardBearingType('user')).toBe(false)
})
it('returns false for milestone type (not card-bearing)', () => {
expect(isCardBearingType('milestone')).toBe(false)
})
it('returns false for board_banner / board_speech / board_summary (partial chrome, no own bubble chrome)', () => {
// These use BoardRoundCard which relies on the assistant bubble chrome,
// so they must NOT be excluded.
expect(isCardBearingType('board_banner')).toBe(false)
expect(isCardBearingType('board_speech')).toBe(false)
expect(isCardBearingType('board_summary')).toBe(false)
})
it('returns false for undefined type', () => {
expect(isCardBearingType(undefined)).toBe(false)
})
it('returns false for empty string', () => {
expect(isCardBearingType('')).toBe(false)
})
it('returns false for unknown type', () => {
expect(isCardBearingType('unknown_type')).toBe(false)
})
})
// ── isAssistantBubbleEmpty (G1) ─────────────────────────────────────
describe('isAssistantBubbleEmpty — G1 empty content detection', () => {
it('returns true for assistant with no content, no thinking, no tool_calls', () => {
const msg = makeMsg({ role: 'assistant', content: '' })
expect(isAssistantBubbleEmpty(msg)).toBe(true)
})
it('returns true for assistant with undefined content and thinking', () => {
const msg = makeMsg({ role: 'assistant', content: undefined, thinking: undefined })
expect(isAssistantBubbleEmpty(msg)).toBe(true)
})
it('returns true for assistant with empty tool_calls array', () => {
const msg = makeMsg({ role: 'assistant', content: '', tool_calls: [] })
expect(isAssistantBubbleEmpty(msg)).toBe(true)
})
it('returns false for assistant with content', () => {
const msg = makeMsg({ role: 'assistant', content: 'hello' })
expect(isAssistantBubbleEmpty(msg)).toBe(false)
})
it('returns false for assistant with thinking content', () => {
const msg = makeMsg({ role: 'assistant', content: '', thinking: '思考中' })
expect(isAssistantBubbleEmpty(msg)).toBe(false)
})
it('returns false for assistant with non-empty tool_calls', () => {
const msg = makeMsg({
role: 'assistant',
content: '',
tool_calls: [{ id: 't1', name: 'search', arguments: '{}' } as never],
})
expect(isAssistantBubbleEmpty(msg)).toBe(false)
})
it('returns false for user role even when content is empty', () => {
const msg = makeMsg({ role: 'user', content: '' })
expect(isAssistantBubbleEmpty(msg)).toBe(false)
})
it('returns false for user role with content', () => {
const msg = makeMsg({ role: 'user', content: 'hi' })
expect(isAssistantBubbleEmpty(msg)).toBe(false)
})
})
// ── isPlainUserText (U4) ────────────────────────────────────────────
describe('isPlainUserText — U4 plain text detection', () => {
it('returns true for plain text', () => {
expect(isPlainUserText('你好,帮我重构这个函数')).toBe(true)
})
it('returns true for empty string (no command, no file)', () => {
expect(isPlainUserText('')).toBe(true)
})
it('returns true for text with @ mention that is not board/team', () => {
expect(isPlainUserText('@user hello')).toBe(true)
})
it('returns false for @board command', () => {
expect(isPlainUserText('@board 讨论技术选型')).toBe(false)
})
it('returns false for @team command', () => {
expect(isPlainUserText('@team 完成需求分析')).toBe(false)
})
it('returns false for @board with expert list', () => {
expect(isPlainUserText('@board:expert1,expert2 讨论架构')).toBe(false)
})
it('returns false for @team with template', () => {
expect(isPlainUserText('@team:dev_team 实现登录功能')).toBe(false)
})
it('returns false for @board with rounds param', () => {
expect(isPlainUserText('@board rounds=3 讨论方案')).toBe(false)
})
it('returns false for file attachment markdown', () => {
expect(isPlainUserText('[文件] [report.pdf](https://example.com/report.pdf)')).toBe(false)
})
it('returns true for text containing @board in the middle (not a prefix)', () => {
// Only prefix @board/@team commands are recognized; mid-text @ is plain.
expect(isPlainUserText('请帮我 @board 这个问题')).toBe(true)
})
it('returns true for multiline plain text', () => {
expect(isPlainUserText('第一行\n第二行\n第三行')).toBe(true)
})
})

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-CHN0ThS0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DC_0XUg6.css">
<script type="module" crossorigin src="/assets/index-N9Dybwcy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BgFZbme0.css">
</head>
<body>
<div id="app"></div>