first commit
This commit is contained in:
103
src/features/loyaly-ai/components/MessageBubble.tsx
Normal file
103
src/features/loyaly-ai/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user