104 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|