feat(ui): private board restrictions + scheme B assistant/user bubbles #19
|
|
@ -0,0 +1,243 @@
|
|||
---
|
||||
module: frontend/chat
|
||||
date: 2026-07-03
|
||||
problem_type: design_pattern
|
||||
component: frontend_stimulus
|
||||
severity: medium
|
||||
applies_when:
|
||||
- "Implementing message bubble styling for Vue chat components"
|
||||
- "Deciding between CSS :empty selector and JS computed for empty-content detection"
|
||||
- "Excluding card-bearing message types from bubble chrome"
|
||||
- "Making Vue computed properties unit-testable without @vue/test-utils"
|
||||
tags:
|
||||
- vue
|
||||
- css
|
||||
- message-bubble
|
||||
- testing
|
||||
- computed-properties
|
||||
- design-pattern
|
||||
related_components:
|
||||
- assistant
|
||||
- testing_framework
|
||||
---
|
||||
|
||||
# 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:
|
||||
|
||||
1. **Empty assistant bubbles**: Pre-stream and tool-call-only messages render
|
||||
an empty bubble rectangle — visually broken.
|
||||
2. **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.
|
||||
3. **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.
|
||||
|
||||
```vue
|
||||
<!-- 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:
|
||||
|
||||
```ts
|
||||
// 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).
|
||||
|
||||
```ts
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- MessageShell.vue -->
|
||||
const isCardBearing = computed(() => isCardBearingType(props.messageType))
|
||||
```
|
||||
|
||||
```css
|
||||
/* 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:
|
||||
|
||||
```ts
|
||||
// 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:
|
||||
|
||||
```ts
|
||||
// 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
|
||||
|
||||
- **`:empty` failure 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 `error` type was added during code review
|
||||
(P2 finding) after spotting ErrorCard's full chrome.
|
||||
- **Test coverage of decisions**: G1 (`:empty` replacement) 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 to `bubbleUtils.ts` added 36 tests covering all
|
||||
edge cases.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- **Any Vue component where `:empty` seems 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 `messageType` prop
|
||||
+ Set lookup. Avoid `:has()` — it couples styling to DOM structure.
|
||||
- **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)
|
||||
|
||||
```vue
|
||||
<!-- 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)
|
||||
|
||||
```vue
|
||||
<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
|
||||
|
||||
```ts
|
||||
// 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)
|
||||
|
||||
```ts
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
```
|
||||
Loading…
Reference in New Issue