Files
loyaly_cutomerweb/src/features/loyaly-ai/components/MessageBubble.tsx
2026-09-25 16:31:10 +05:30

104 lines
4.5 KiB
TypeScript

'use client';
import {
ChatMessage as ChatMessageRow,
ChatMessageBubble,
} from '@astryxdesign/core/Chat';
import {VStack} from '@astryxdesign/core/Layout';
import {MessageContent} from './MessageContent';
import {TypingIndicator} from './TypingIndicator';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
import type {ChatMessage} from '@/features/loyaly-ai/types/chat';
/**
* One turn.
*
* User turns are a filled bubble on the right; assistant turns are `ghost` —
* transparent, full-width, no container. That asymmetry is the single biggest
* reason ChatGPT and Claude read as conversation rather than as messaging: a
* long analytical answer inside a chat bubble reads as a quote, while the same
* text set flush against the panel reads as prose written for you.
*
* It also solves a practical problem. Assistant answers contain tables and
* code blocks, and a bubble with a max-width would force both to scroll
* horizontally inside a 380px panel.
*
* Timestamps are deliberately absent. They are metadata about a chat log, and
* this is a working session — ChatGPT, Claude and Gemini all omit them for the
* same reason.
*/
export function MessageBubble({message}: {message: ChatMessage}) {
const isAssistant = message.role === 'assistant';
// Nothing has arrived yet: the dots stand in for the answer, and are
// replaced by it rather than joined to it.
const isThinking = isAssistant && message.isStreaming && message.parts.length === 0;
return (
<ChatMessageRow
sender={message.role}
density="compact"
avatar={
isAssistant ? (
<div className="w-6 h-6 rounded-full flex items-center justify-center bg-amber-500/10 border border-amber-500/25 shrink-0 mt-0.5 shadow-xs overflow-hidden">
<BrandMark size={16} />
</div>
) : undefined
}
>
<ChatMessageBubble
variant={isAssistant ? 'ghost' : 'filled'}
/**
* The assistant bubble must have a DEFINITE width. This is the fix for
* report titles rendering one character per line.
*
* ChatMessage lays its row out with `display:flex; align-items:
* flex-start`, so the bubble is a flex item on the main axis and sizes
* shrink-to-fit — its width is asked of its contents. ResponseRenderer
* then sets `container-type: inline-size` to drive the report's
* container queries, and inline-axis size containment means the report
* contributes ZERO to that intrinsic measurement. The bubble asked how
* wide its content wanted to be, containment answered "nothing", and
* the bubble collapsed to its min-content width.
*
* Astryx's own `word-break: break-word` on the bubble is what made the
* collapse land on a single GLYPH rather than a single word: that
* value is defined as `word-break: normal` plus `overflow-wrap:
* anywhere`, and `anywhere` is the one wrapping mode that lets a break
* opportunity count against min-content size. Min-content therefore
* became the widest character — "S / t / o / r / e".
*
* Stating the width breaks the cycle: the bubble no longer measures
* its contents, so containment has nothing to poison.
*
* `max-w-full` is deliberate too. `styles.content` applies
* `max-width: max(80%, 280px)` to EVERY bubble including `ghost`,
* so an assistant report in the 440px rail was being capped around
* 302px — despite this component's contract that ghost turns are
* full-width. Tailwind's utilities layer is declared after
* `astryx-base` in globals.css, so these win over StyleX's
* `:not(#\#)` specificity hack by cascade layer rather than by
* out-specifying it.
*/
className={isAssistant ? 'w-full max-w-full min-w-0' : undefined}
>
{isThinking ? (
<TypingIndicator />
) : (
<VStack gap={3}>
{message.parts.map((part, index) => (
<MessageContent
// Index is a stable key here: parts only ever grow at the end,
// and the streaming text part is replaced in place rather than
// reordered — see applyStreamedPart.
key={`${part.type}-${index}`}
part={part}
isStreaming={message.isStreaming}
/>
))}
</VStack>
)}
</ChatMessageBubble>
</ChatMessageRow>
);
}