8.1 KiB
| module | date | problem_type | component | severity | applies_when | tags | related_components | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| frontend/chat | 2026-07-03 | design_pattern | frontend_stimulus | medium |
|
|
|
Message Bubble Empty-Content Detection & Card-Type Exclusion Pattern
Context
When implementing Scheme B (neutral grayscale) assistant message bubbles in the chat UI, three interacting concerns surfaced that the naive CSS-only approach could not solve:
- Empty assistant bubbles: Pre-stream and tool-call-only messages render an empty bubble rectangle — visually broken.
- Card-bearing double chrome: Assistant messages rendered as dedicated cards (ErrorCard, BoardConclusionCard, etc.) already carry their own background/border/padding. Wrapping them in an assistant bubble produces double chrome.
- Testability: Project convention uses pure-TS vitest tests
(
tests/unit/**/*.test.ts) without@vue/test-utils. Computed properties embedded in Vue SFCs are not directly unit-testable.
Guidance
1. Never use :empty CSS selector for Vue component emptiness
Vue components always render a root element (even <template><div></div></template>),
so :empty never matches. Use a JS computed property that checks the
underlying data fields instead.
<!-- MessageShell.vue -->
<template>
<div :class="[..., { 'message-shell--empty': isEmpty }]">
<slot />
</div>
</template>
<script setup lang="ts">
interface Props {
isEmpty?: boolean // computed by parent, passed as prop
}
</script>
<style scoped>
.message-shell--assistant.message-shell--empty .message-shell__content {
background: transparent;
border: none;
padding: 0;
}
</style>
Parent computes emptiness from message data:
// ChatMessage.vue
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
2. Exclude card-bearing types via messageType prop, not :has() selector
Maintain a Set<string> of card-bearing message types and check membership via
a computed. Pass messageType as a prop rather than using :has() CSS selector
(better browser support, testable, no CSS selector complexity).
// bubbleUtils.ts (pure function — testable)
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
}
<!-- MessageShell.vue -->
const isCardBearing = computed(() => isCardBearingType(props.messageType))
/* F4-A: card-bearing types skip bubble chrome */
.message-shell--assistant.message-shell--card .message-shell__content {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
}
Important: error type must be in the exclusion set — ErrorCard.vue ships
full chrome (background + border + border-radius + padding). Without exclusion,
the assistant bubble wraps ErrorCard producing double-background regression.
3. Extract computed logic to pure functions for testability
When project convention avoids @vue/test-utils, extract computed logic to
pure functions in a helpers/ module. The Vue computed becomes a thin wrapper
preserving reactivity:
// bubbleUtils.ts
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)
)
}
// ChatMessage.vue — thin reactive wrapper
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
This pattern lets you test the logic with plain vitest:
// bubbleUtils.test.ts
it('returns true for assistant with no content, no thinking, no tool_calls', () => {
const msg = makeMsg({ role: 'assistant', content: '' })
expect(isAssistantBubbleEmpty(msg)).toBe(true)
})
Why This Matters
:emptyfailure mode is silent: CSS doesn't error, the selector just never matches. Empty bubbles render with full chrome — visually broken but not detectable without manual inspection. A P0 review finding.- Double-chrome regression: ErrorCard wrapping was missed in initial F4-A
planning (only 9 types listed). The
errortype was added during code review (P2 finding) after spotting ErrorCard's full chrome. - Test coverage of decisions: G1 (
:emptyreplacement) and F4-A (card exclusion) are P0 decisions in the plan. Without extracted pure functions, these decisions have no unit test coverage — only visual verification. Extracting tobubbleUtils.tsadded 36 tests covering all edge cases.
When to Apply
- Any Vue component where
:emptyseems tempting: Don't use it. Vue components render root elements. Use JS computed. - Card-vs-bubble styling decisions: When a component renders different
"chrome" levels (full card vs. bubble vs. plain), use a
messageTypeprop- Set lookup. Avoid
:has()— it couples styling to DOM structure.
- Set lookup. Avoid
- Computed properties with non-trivial logic: If the logic is a P0/P1 decision (G1, F4-A) or has edge cases (undefined fields, empty arrays), extract to a pure function. The Vue computed should be a one-line wrapper.
- Projects without
@vue/test-utils: Pure-function extraction is the primary path to computed-property test coverage.
Examples
Before (broken — :empty never matches)
<!-- AssistantText always renders a root div, so :empty never matches -->
<style scoped>
.message-shell--assistant .message-shell__content:empty {
display: none;
}
</style>
After (working — JS computed via pure function)
<template>
<MessageShell :is-empty="isBubbleEmpty" :message-type="spec.type">
<AssistantText :content="message.content" />
</MessageShell>
</template>
<script setup lang="ts">
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
</script>
Card-bearing exclusion — complete type list
// All 10 types that ship their own chrome and must skip the bubble:
const CARD_BEARING_TYPES = new Set<string>([
'board_conclusion', // BoardConclusionCard — full chrome
'team_plan', // TeamPlanCard — full chrome
'debate_started', // DebateBannerCard — full chrome (plan typo'd as debate_banner)
'debate_argument', // DebateArgumentCard — partial chrome
'debate_summary', // DebateSummaryCard — partial chrome
'debate_resolved', // DebateResolvedCard — partial chrome
'collaboration_graph', // CollaborationGraph — partial chrome
'review_result', // ReviewResultCard — partial chrome
'risk_flagged', // RiskFlaggedCard — partial chrome
'error', // ErrorCard — full chrome (added in code review P2)
])
Test file structure (pure-TS, no @vue/test-utils)
// tests/unit/helpers/bubbleUtils.test.ts
import { describe, expect, it } from 'vitest'
import { isCardBearingType, isAssistantBubbleEmpty } from '@/components/chat/helpers/bubbleUtils'
import type { IChatMessage } from '@/api/types'
function makeMsg(overrides: Partial<IChatMessage> = {}): IChatMessage {
return { id: 'm1', role: 'assistant', content: '', timestamp: '...', ...overrides }
}
describe('isCardBearingType', () => {
it.each(['board_conclusion', 'error', 'team_plan'])('returns true for %s', (type) => {
expect(isCardBearingType(type)).toBe(true)
})
it('returns false for undefined', () => {
expect(isCardBearingType(undefined)).toBe(false)
})
})