19.1 ChatMessage
The atom of the conversation surface. One bubble — agent, user, or system. The from prop drives alignment, bubble fill, and the position of avatars and reactions; the optional status, confidence, timestamp, and actions slots layer on top without changing the basic shape.
ChatMessage
.chat-msgFive variants side by side — agent with name and timestamp, user with timestamp, agent streaming, system pill, and a tool-call agent message with no meta and a trace-button in the actions slot. The same primitive shape carries the whole transcript.
<!-- One row per variant. .is-from-* drives alignment + bubble fill; -->
<!-- the inner shape (avatar, stack, meta, bubble) stays the same. -->
<div class="chat-msg is-from-agent">
<div class="chat-msg-avatar" aria-hidden="true">CW</div>
<div class="chat-msg-stack">
<div class="chat-msg-meta">
<span class="chat-msg-name">Charlie’s Wines agent</span>
<span class="chat-msg-time">14:02</span>
</div>
<div class="chat-msg-bubble-wrap">
<div class="chat-msg-bubble">
<div class="chat-msg-body">Hey Jay…</div>
</div>
</div>
</div>
</div>
<!-- Streaming adds .is-status-streaming and a <span.chat-msg-caret> -->
<div class="chat-msg is-from-agent is-status-streaming">…
<div class="chat-msg-bubble">
<div class="chat-msg-body">Looking that up for you</div>
<span class="chat-msg-caret" aria-hidden="true"></span>
</div>
</div>
<!-- System pill — centred note, role="status". -->
<div class="chat-msg is-from-system" role="status">
<div class="chat-msg-system-line">
Block transition · Hook → Align
<span class="chat-msg-system-time">14:03</span>
</div>
</div>
.chat-msg {
display: grid;
grid-template-columns: 32px 1fr;
gap: var(--s-3);
margin: 0;
padding: 0 var(--s-4);
}
.chat-msg.is-compact {
grid-template-columns: 24px 1fr;
gap: var(--s-2);
}
.chat-msg + .chat-msg { margin-top: var(--s-4); }
.chat-msg.is-compact + .chat-msg.is-compact { margin-top: var(--s-2); }
.chat-msg.is-from-user {
grid-template-columns: 1fr 32px;
}
.chat-msg.is-from-user.is-compact {
grid-template-columns: 1fr 24px;
}
.chat-msg.is-from-user .chat-msg-avatar { grid-column: 2; }
.chat-msg.is-from-user .chat-msg-stack { grid-column: 1; align-items: flex-end; }
.chat-msg-avatar {
width: 32px;
height: 32px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg-warm);
color: var(--fg);
font: 600 12px/1 var(--f-mono);
flex-shrink: 0;
overflow: hidden;
}
.chat-msg.is-compact .chat-msg-avatar {
width: 24px;
height: 24px;
font-size: 10.5px;
}
.chat-msg.is-from-agent .chat-msg-avatar {
background: color-mix(in oklab, var(--accent) 18%, var(--bg-warm));
color: var(--accent);
}
.chat-msg.is-from-user .chat-msg-avatar {
background: var(--ink);
color: var(--paper);
}
.chat-msg-stack {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.chat-msg-meta {
display: inline-flex;
align-items: baseline;
gap: 6px;
color: var(--fg-soft);
font: 400 11.5px/1 var(--f-body);
}
.chat-msg.is-from-user .chat-msg-meta { flex-direction: row-reverse; }
.chat-msg-name {
font: 500 12.5px/1 var(--f-body);
color: var(--fg);
}
.chat-msg-time {
font: 400 11px/1 var(--f-mono);
color: var(--fg-faint);
font-variant-numeric: tabular-nums;
}
.chat-msg-confidence {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--fg-faint);
display: inline-block;
}
.chat-msg-confidence.is-confidence-high {
background: var(--success-text, #1A6A3F);
}
.chat-msg-confidence.is-confidence-medium {
background: var(--warning, #F9AD03);
}
.chat-msg-confidence.is-confidence-low {
background: var(--error-text, #8B2417);
}
.chat-msg-bubble-wrap {
position: relative;
display: inline-flex;
max-width: min(560px, 80%);
}
.chat-msg.is-compact .chat-msg-bubble-wrap {
max-width: min(480px, 86%);
}
.chat-msg.is-from-user .chat-msg-bubble-wrap { align-self: flex-end; }
/* …additional rules trimmed for brevity — see _shared.css */
import { ChatMessage, MessageTraceButton } from '@magicblocksai/ui';
// agent — with name + timestamp
<ChatMessage from="agent" name="Charlie’s Wines agent" avatar="CW" timestamp="14:02">
Hey Jay — looking for the secret deals?
</ChatMessage>
// user — timestamp only, no name
<ChatMessage from="user" avatar="JS" timestamp="14:02">
Yes please.
</ChatMessage>
// agent — streaming. The blinking caret is rendered automatically.
<ChatMessage from="agent" avatar="CW" status="streaming">
Looking that up for you
</ChatMessage>
// system — centred pill, no avatar, role="status"
<ChatMessage from="system" timestamp="14:03">
Block transition · Hook → Align
</ChatMessage>
// agent — with a per-message trace button in the actions slot
<ChatMessage
from="agent"
avatar="SA"
actions={<MessageTraceButton noteworthy count={3} onClick={openTrace} />}
>
Pulled three matching offers from the regional catalogue.
</ChatMessage>
19.2 ChatTranscript
The conversation viewer shell — sticky header, scrollable message area, pinned footer. Wraps a stack of <ChatMessage> children. New messages auto-scroll the view to the bottom only when the operator is already there; if they’ve scrolled up to read earlier turns, new arrivals don’t yank the view.
ChatTranscript
.chat-transcriptA six-message exchange between an operator-side customer-support agent and a customer asking for a password reset. Header carries the agent name and a live badge; the message stack groups agent + user turns; the footer slot holds the composer.
<!-- The transcript is three slots: header, scroll, footer. -->
<!-- The scroll area carries role="log" + aria-live="polite" so -->
<!-- screen-reader users hear newly-arrived messages. -->
<div class="chat-transcript">
<div class="chat-transcript-header">
<strong>Spark CRM · support</strong>
<span class="badge tone-success">Live</span>
</div>
<div class="chat-transcript-scroll" role="log" aria-live="polite" aria-relevant="additions">
<div class="chat-transcript-list">
<div class="chat-msg is-from-agent">…</div>
<div class="chat-msg is-from-user">…</div>
<!-- …four more messages… -->
</div>
</div>
<div class="chat-transcript-footer">
<form class="chat-composer">…</form>
</div>
</div>
.chat-transcript {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100%;
min-height: 320px;
background: var(--bg-paper);
border: 1px solid var(--hair);
border-radius: var(--r-md);
overflow: hidden;
}
.chat-transcript.is-compact { font-size: 12.5px; }
.chat-transcript-header {
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--hair-soft);
background: var(--bg-paper);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
}
.chat-transcript-scroll {
overflow-y: auto;
overflow-x: hidden;
padding: var(--s-4) 0;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
.chat-transcript-scroll { scroll-behavior: auto; }
}
.chat-transcript-list {
display: flex;
flex-direction: column;
gap: 0;
}
.chat-transcript-footer {
border-top: 1px solid var(--hair-soft);
padding: var(--s-3) var(--s-4);
background: var(--bg-paper);
}
.chat-transcript-skeleton {
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: 0 var(--s-4);
}
.chat-transcript-skeleton-row {
height: 36px;
border-radius: 14px;
background: color-mix(in oklab, var(--bg-warm) 50%, var(--bg-paper));
max-width: 60%;
animation: chat-transcript-skel-pulse 1.5s ease-in-out infinite;
}
.chat-transcript-skeleton-row.is-user {
align-self: flex-end;
max-width: 50%;
}
.chat-transcript-skeleton-row.is-short { max-width: 40%; }
@media (prefers-reduced-motion: reduce) {
.chat-transcript-skeleton-row { animation: none; opacity: 0.7; }
}
.chat-transcript-empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--s-6) var(--s-4);
color: var(--fg-soft);
font: 400 13px/1.5 var(--f-body);
text-align: center;
}
.live-chat-tester .chat-transcript {
height: 100%;
min-height: 360px;
}
import {
ChatTranscript,
ChatMessage,
ChatComposer,
Badge,
} from '@magicblocksai/ui';
<ChatTranscript
header={
<>
<strong>Spark CRM · support</strong>
<Badge tone="success">Live</Badge>
</>
}
footer={<ChatComposer placeholder="Reply to Hoang…" onSubmit={send} />}
>
<ChatMessage from="agent" name="Support agent" avatar="SP" timestamp="09:14">
Hi Hoang — what can I help with this morning?
</ChatMessage>
<ChatMessage from="user" avatar="HT" timestamp="09:14">
Hi — my password reset email never arrived. I tried twice last night.
</ChatMessage>
<ChatMessage from="agent" name="Support agent" avatar="SP" timestamp="09:15">
Let me check the delivery log. One moment.
</ChatMessage>
<ChatMessage from="system" timestamp="09:15">
Tool call · mailgun.search
</ChatMessage>
<ChatMessage from="agent" name="Support agent" avatar="SP" timestamp="09:16">
Found it — both attempts hit a Gmail spam filter at your domain.
I've resent through our backup route. It should arrive in under a minute.
</ChatMessage>
<ChatMessage from="user" avatar="HT" timestamp="09:17">
Got it. Thank you!
</ChatMessage>
</ChatTranscript>
19.3 ChatComposer
An autogrowing textarea + Send button, designed to live inside <ChatTranscript footer={…}> but works standalone. Submits on Enter; Shift+Enter inserts a newline. The composer does not clear itself — the consumer owns the value and decides when to clear (typically after the send resolves).
ChatComposer
.chat-composerThree states side by side: default (empty, Send disabled), with attachment chips visible in the attachments slot, and the sending busy state (textarea disabled, Send button shows a spinner).
<!-- Default — Send disabled until the textarea is non-empty. -->
<form class="chat-composer">
<div class="chat-composer-row">
<textarea class="chat-composer-input" rows="1" placeholder="Type a message…" aria-label="Message"></textarea>
<button type="submit" class="chat-composer-send" disabled aria-label="Send">
<span class="chat-composer-send-label">Send</span>
</button>
</div>
</form>
<!-- With attachments — chips in the left-side slot. -->
<form class="chat-composer">
<div class="chat-composer-row">
<div class="chat-composer-attachments">
<span class="chat-attach-chip">policy.pdf</span>
<span class="chat-attach-chip">screenshot.png</span>
</div>
<textarea class="chat-composer-input" rows="1" placeholder="Add a note…" aria-label="Message"></textarea>
<button type="submit" class="chat-composer-send" disabled aria-label="Send">…</button>
</div>
</form>
<!-- Sending — textarea disabled, Send shows spinner. -->
<form class="chat-composer is-sending">
<div class="chat-composer-row">
<textarea class="chat-composer-input" rows="1" disabled>Pulling that record now…</textarea>
<button type="submit" class="chat-composer-send" disabled aria-label="Send">
<span class="chat-composer-spinner" aria-hidden="true"></span>
</button>
</div>
</form>
.chat-composer { display: flex; flex-direction: column; gap: 4px; }
.chat-composer.is-disabled { opacity: 0.55; pointer-events: none; }
.chat-composer-row {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 6px 6px 6px 10px;
border: 1px solid var(--hair);
border-radius: var(--r-md);
background: var(--bg-paper);
transition: border-color var(--dur-2) var(--ease),
box-shadow var(--dur-2) var(--ease);
}
.chat-composer-row:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 25%, transparent);
}
.chat-composer-attachments,
.chat-composer-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.chat-composer-input {
flex: 1;
appearance: none;
background: transparent;
border: 0;
outline: none;
resize: none;
padding: 6px 0;
min-height: 24px;
font: 400 14px/1.4 var(--f-body);
color: var(--fg);
overflow-y: hidden;
}
.chat-composer-input::placeholder { color: var(--fg-faint); }
.chat-composer-send {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: var(--r-sm);
background: var(--accent);
color: var(--paper);
font: 500 13px/1 var(--f-body);
cursor: pointer;
transition: filter var(--dur-2) var(--ease);
}
.chat-composer-send:hover:not(:disabled) { filter: brightness(0.95); }
.chat-composer-send:disabled {
background: var(--bg-warm);
color: var(--fg-faint);
cursor: not-allowed;
}
.chat-composer-send-label { line-height: 1; }
.chat-composer-spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid currentColor;
border-right-color: transparent;
animation: chat-composer-spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.chat-composer-spinner { animation: none; opacity: 0.5; }
}
.chat-composer-helper {
font: 400 11.5px/1.4 var(--f-body);
color: var(--fg-faint);
padding: 0 var(--s-2);
}
import { ChatComposer, IconButton } from '@magicblocksai/ui';
// default — empty, Send disabled until input is non-empty
<ChatComposer placeholder="Type a message…" onSubmit={send} />
// with attachment chips — pass anything into the `attachments` slot
<ChatComposer
placeholder="Add a note for these files…"
attachments={
<>
<span className="chat-attach-chip">policy.pdf</span>
<span className="chat-attach-chip">screenshot.png</span>
</>
}
onSubmit={send}
/>
// sending — textarea disabled, Send shows spinner
<ChatComposer
value="Pulling that record now…"
sending
disabled
onSubmit={send}
/>
19.4 MessageTraceButton
The per-message robot-head icon. Consumers drop it into a <ChatMessage actions={…}> slot to open the trace timeline for that specific turn. The optional dot makes noteworthy messages announce themselves — a block transition, an action firing, a guardrail hitting. Operators learn to hunt for the dot first.
MessageTraceButton
.message-trace-buttonThe button on its own, plus an inline-context view showing where it lives in practice — on the top-right of an agent message, accompanied by the dot indicator for a turn that fired an action.
<!-- Stand-alone. Add .is-noteworthy to render the indicator dot. -->
<!-- Optional .message-trace-button-count renders a number badge. -->
<button type="button" class="message-trace-button is-size-sm is-noteworthy"
aria-label="View trace" title="View trace">
<!-- inline 14×14 robot-head SVG -->
<span class="message-trace-button-count" aria-label="3 events">3</span>
<span class="message-trace-button-dot" aria-hidden="true"></span>
</button>
<!-- Drop it into a ChatMessage actions slot for inline context. -->
<div class="chat-msg is-from-agent">
<!-- …avatar + stack… -->
<div class="chat-msg-bubble-wrap">
<div class="chat-msg-bubble"><!-- message body --></div>
<div class="chat-msg-actions" role="group" aria-label="Message actions">
<button class="message-trace-button is-size-sm is-noteworthy">…</button>
</div>
</div>
</div>
.message-trace-button {
appearance: none;
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
background: transparent;
border: 0;
border-radius: var(--r-xs);
cursor: pointer;
color: var(--fg-soft);
transition: color var(--dur-2) var(--ease),
background var(--dur-2) var(--ease);
}
.message-trace-button:hover {
color: var(--fg);
background: var(--bg-warm);
}
.message-trace-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.message-trace-button.is-size-xs { padding: 2px 4px; }
.message-trace-button.is-size-md {
padding: 6px 10px;
font: 500 12.5px/1 var(--f-body);
}
.message-trace-button-label {
font: 500 12px/1 var(--f-body);
line-height: 1.1;
}
.message-trace-button-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
padding: 0 5px;
height: 16px;
border-radius: 999px;
background: var(--bg-warm);
color: var(--fg);
font: 600 10.5px/1 var(--f-mono);
font-variant-numeric: tabular-nums;
}
.message-trace-button.is-noteworthy { color: var(--accent); }
.message-trace-button-dot {
position: absolute;
top: 3px;
right: 3px;
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--accent);
border: 1.5px solid var(--bg-paper);
}
import { ChatMessage, MessageTraceButton } from '@magicblocksai/ui';
// Stand-alone — three events fired, dot indicator on.
<MessageTraceButton noteworthy count={3} tooltipLabel="View trace" onClick={openTrace} />
// In context — the canonical use, dropped into ChatMessage actions slot.
<ChatMessage
from="agent"
name="Sales Agent"
avatar="SA"
timestamp="14:05"
actions={
<MessageTraceButton
noteworthy
count={3}
tooltipLabel="3 events fired — view trace"
onClick={() => openTraceFor('msg_42')}
/>
}
>
Sure — we have a 15% off code for new customers: WELCOME15. Want me to text it?
</ChatMessage>
19.5 SummaryBanner
The collapsible AI-generated TL;DR card — the kit’s standard “here’s a summary of this conversation” banner that sits above a transcript. Defaults to expanded; the chevron in the header toggles. Pair with <TraceTimeline> below to give operators a one-glance read-out before they dive into the per-turn detail.
SummaryBanner
.summary-bannerThree side-by-side variants — the default neutral treatment, the accented pink-rail treatment used on AI-content surfaces, and the static hideToggle variant that always shows the body. Each carries a short headline and a one-line body.
<!-- Default — chevron toggles open/closed. .is-open keeps body visible. -->
<section class="summary-banner is-open" aria-labelledby="sb-1">
<header class="summary-banner-head">
<span class="summary-banner-icon" aria-hidden="true"><!-- sparkle SVG --></span>
<div class="summary-banner-title-block">
<span id="sb-1" class="summary-banner-title">Conversation summary</span>
<span class="summary-banner-caption">Updated 3 minutes ago.</span>
</div>
<button type="button" class="summary-banner-toggle" aria-expanded="true"
aria-controls="sb-1-body" aria-label="Collapse summary">
<span class="summary-banner-chevron" aria-hidden="true"></span>
</button>
</header>
<div id="sb-1-body" class="summary-banner-body">
<p>Hoang reported a missing password-reset email…</p>
</div>
</section>
<!-- Accent — pink left rail, the AI-content treatment. -->
<section class="summary-banner is-accent is-open">…</section>
<!-- Static — no chevron; body always visible. -->
<section class="summary-banner is-open is-static">…</section>
.summary-banner {
display: flex;
flex-direction: column;
background: var(--bg-paper);
border: 1px solid var(--hair);
border-radius: var(--r-md);
overflow: hidden;
position: relative;
}
.summary-banner.is-accent {
border-left: 3px solid var(--accent);
}
.summary-banner-head {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
}
.summary-banner-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--r-xs);
background: color-mix(in oklab, var(--accent) 12%, var(--bg-paper));
color: var(--accent);
}
.summary-banner-title-block {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.summary-banner-title {
font: 500 13.5px/1.3 var(--f-body);
color: var(--fg);
}
.summary-banner-caption {
font: 400 12px/1.4 var(--f-body);
color: var(--fg-soft);
}
.summary-banner-meta {
display: inline-flex;
align-items: center;
gap: var(--s-2);
}
.summary-banner-toggle {
appearance: none;
background: transparent;
border: 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-soft);
cursor: pointer;
border-radius: var(--r-xs);
}
.summary-banner-toggle:hover {
color: var(--fg);
background: var(--bg-warm);
}
.summary-banner-chevron {
width: 10px;
height: 10px;
position: relative;
transition: transform var(--dur-2) var(--ease);
}
.summary-banner-chevron::before {
content: "";
position: absolute;
inset: 0;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: translate(-1px, -1px) rotate(45deg);
}
.summary-banner.is-open .summary-banner-chevron {
transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
.summary-banner-chevron { transition: none; }
}
.summary-banner-body {
padding: 0 var(--s-4) var(--s-4);
border-top: 1px solid var(--hair-soft);
padding-top: var(--s-3);
font: 400 13.5px/1.55 var(--f-body);
color: var(--fg);
}
.summary-banner.is-static .summary-banner-body { border-top: 0; padding-top: 0; }
.summary-banner-body > *:first-child { margin-top: 0; }
.summary-banner-body > *:last-child { margin-bottom: 0; }
.summary-banner-body p { margin: 0 0 8px; }
/* …additional rules trimmed for brevity — see _shared.css */
import { SummaryBanner } from '@magicblocksai/ui';
// default — neutral treatment
<SummaryBanner
title="Conversation summary"
caption="Updated 3 minutes ago."
>
<p>Hoang reported a missing password-reset email. The agent ran a Mailgun
search, found both attempts in a Gmail spam route, and resent via the
backup channel.</p>
</SummaryBanner>
// accent — pink left rail, AI-content surfaces
<SummaryBanner accent title="Today's agent changes" caption="Three edits since yesterday.">
<p>Switched the Hook persona to "Friendly mortgage rep". Added two key
facts to Qualify. Removed the Embed action.</p>
</SummaryBanner>
// static — no chevron, body always visible
<SummaryBanner hideToggle title="Pending review" caption="Awaiting approval from the duty manager.">
<p>Two flagged turns from the overnight queue need a human eye before
the agent ships its weekly digest.</p>
</SummaryBanner>
19.6 LiveChatTester
The composed “try my agent” shell. Wraps <ChatTranscript> + <ChatComposer> with a sticky agent-identity header and an optional reset affordance. Used for sandboxes, channel previews, and any place an operator wants to chat with their own agent without leaving the builder.
LiveChatTester
.live-chat-testerThe full tester. Sticky agent header carries an avatar, the agent name, a version pill, a status dot, and a restart affordance; the transcript renders a four-message canned exchange; the footer composer is empty and idle.
<!-- LiveChatTester wraps a chat-transcript with a custom header -->
<!-- that carries the agent identity card. The composer is the -->
<!-- standard chat-composer pinned to the transcript footer. -->
<div class="live-chat-tester">
<div class="chat-transcript">
<div class="chat-transcript-header">
<header class="live-chat-tester-head">
<div class="live-chat-tester-avatar" aria-hidden="true">CW</div>
<div class="live-chat-tester-title-block">
<div class="live-chat-tester-name-row">
<span class="live-chat-tester-name">Charlie’s Wines agent</span>
<span class="live-chat-tester-version">
<span class="badge tone-warning">draft v15</span>
</span>
</div>
<div class="live-chat-tester-status-row">
<span class="live-chat-tester-status-dot is-online" aria-hidden="true"></span>
<span class="live-chat-tester-status-label">Online</span>
<span class="live-chat-tester-sep" aria-hidden="true">·</span>
<span class="live-chat-tester-caption">Powered by gpt-4.1</span>
</div>
</div>
<button type="button" class="live-chat-tester-reset" aria-label="Restart">
<!-- inline 14×14 restart icon SVG -->
<span>Restart</span>
</button>
</header>
</div>
<div class="chat-transcript-scroll" role="log" aria-live="polite" aria-relevant="additions">
<div class="chat-transcript-list">
<!-- …four chat-msg rows… -->
</div>
</div>
<div class="chat-transcript-footer">
<form class="chat-composer">…</form>
</div>
</div>
</div>
.live-chat-tester {
display: flex;
flex-direction: column;
height: 100%;
min-height: 360px;
}
.live-chat-tester.is-disabled { opacity: 0.55; pointer-events: none; }
.live-chat-tester .chat-transcript {
height: 100%;
min-height: 360px;
}
.live-chat-tester-head {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: var(--s-3);
width: 100%;
}
.live-chat-tester-avatar {
width: 36px;
height: 36px;
border-radius: 999px;
background: color-mix(in oklab, var(--accent) 18%, var(--bg-warm));
color: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
font: 600 13px/1 var(--f-mono);
flex-shrink: 0;
}
.live-chat-tester-title-block {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.live-chat-tester-name-row {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.live-chat-tester-name {
font: 600 14px/1.3 var(--f-display);
color: var(--fg);
}
.live-chat-tester-version {
font: 500 11px/1 var(--f-mono);
padding: 2px 6px;
border-radius: 999px;
background: var(--bg-warm);
color: var(--fg-soft);
}
.live-chat-tester-status-row {
display: inline-flex;
align-items: center;
gap: 6px;
font: 400 12px/1.3 var(--f-body);
color: var(--fg-soft);
}
.live-chat-tester-status-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--fg-faint);
}
.live-chat-tester-status-dot.is-online {
background: #1A8754;
box-shadow: 0 0 0 2px color-mix(in oklab, #1A8754 24%, transparent);
}
.live-chat-tester-status-dot.is-offline { background: var(--fg-faint); }
.live-chat-tester-status-dot.is-thinking {
background: #F9AD03;
animation: live-chat-status-blink 1.4s ease-in-out infinite;
}
.live-chat-tester-status-dot.is-error {
background: #C0392B;
}
@media (prefers-reduced-motion: reduce) {
.live-chat-tester-status-dot.is-thinking { animation: none; }
}
.live-chat-tester-status-label { font-weight: 500; color: var(--fg); }
.live-chat-tester-sep { opacity: 0.5; }
.live-chat-tester-caption { color: var(--fg-soft); }
.live-chat-tester-actions { display: inline-flex; align-items: center; gap: 4px; }
/* …additional rules trimmed for brevity — see _shared.css */
import { useState } from 'react';
import { LiveChatTester, ChatMessage, Badge } from '@magicblocksai/ui';
function Example() {
const [input, setInput] = useState('');
return (
<LiveChatTester
agentName="Charlie’s Wines agent"
agentAvatar="CW"
agentVersion={<Badge tone="warning">draft v15</Badge>}
status="online"
caption="Powered by gpt-4.1"
composerValue={input}
onComposerValueChange={setInput}
onSubmit={(text) => sendToAgent(text)}
onReset={() => resetTranscript()}
>
<ChatMessage from="agent" avatar="CW">Hi! Try asking me anything about your agent.</ChatMessage>
<ChatMessage from="user" avatar="JS">What's on the secret-deals page right now?</ChatMessage>
<ChatMessage from="agent" avatar="CW">
Three offers — a 2021 Aglianico at 22% off, a 2020 Riesling at 18%,
and a magnum bundle at 15%. Want the SMS code?
</ChatMessage>
<ChatMessage from="user" avatar="JS">Yes — text me the Aglianico one.</ChatMessage>
</LiveChatTester>
);
}
19.7 Sessions list
The conversation hub — every session the agents have handled, in one searchable, sortable, filterable table. Saved views (Latest · With goal · Negative) live at the top of the table; the row chips encode status, sentiment, channel, and goal in one glance. Click any row to open the session detail (composition 19.8).
All conversations — filter, sort, scan
.cv-list · .cv-tableTable-first composition. Top bar carries the title + sub-line + search + Filter + Sort + Export. Saved-view tabs (Latest / With goal / Negative / Flagged) below; sync indicator on the right. Each row composes a contact cell (avatar + name + sentiment dot + masked id), a status chip, a channel chip with icon, a one-line preview, message + event counts, goal pill, and a timestamp. Clickable rows show a hover state; the third row demos the selected state.
All conversations 724
Your conversation hub — monitor how users interact with your AI across every session, channel, and outcome.
| Contact | Status | Channel | Last message | Goal | Msgs | Started | ||
|---|---|---|---|---|---|---|---|---|
|
NM
Nectar Mint
s_AUacy1ZX…Uvca
|
Live | Web Chat | That's a great question, Hạ Vi! While I don't have specific details about support availability… | Meeting booked | 12 | 2m ago | ||
|
BC
Bessie Cooper
s_PpKmt8…dQwf
|
Active | Hello! I'm here to help. To better assist you, please provide me with the following information… | Qualified | 8 | 3d ago | |||
|
AF
Albert Flores
s_Lk89tR…eYzW
|
Inactive | Web Chat | Hello! I'm here to help. To better assist you, please provide me with the following information… | No goal | 3 | 10m ago | ||
|
RR
Ronald Richards
s_Vk7Mq3…Bfrx
|
Flagged | SMS | I asked for a refund and you keep transferring me. Get me a real person… | No goal | 14 | 3d ago | ||
|
LA
Leslie Alexander
s_Mn4Pq8…Cdef
|
Closed | Booked. Looking forward to the demo on Tuesday at 2pm. Thanks for the help! | Demo booked | 17 | 5h ago | |||
|
JW
Jenny Wilson
s_Qw8Rt2…Ghij
|
Active | Looking at your pricing page—the Scale tier looks closest. Can you confirm what the AI message cap is? | Pricing intent | 6 | 35m ago | |||
|
JJ
Jacob Jones
s_Yu7Wq1…Klmn
|
Closed | Web Chat | Got the discount code, thanks. Will probably order in the next day or two. | Quote sent | 9 | 1d ago |
<div class="cv-list">
<header class="cv-list-head">
<div class="cv-list-head-title">
<h2>All conversations <span class="cv-count">724</span></h2>
<p class="cv-sub">Your conversation hub…</p>
</div>
<div class="cv-list-head-actions">
<span class="cv-search"><input type="search" placeholder="Search…" /></span>
<button class="cv-pill">Filter</button>
<button class="cv-pill">Sort: Latest</button>
<button class="cv-pill">Export</button>
</div>
</header>
<div class="cv-list-views">
<div class="cv-list-views-tabs">
<button class="cv-list-views-tab is-active">Latest <span>724</span></button>
<button class="cv-list-views-tab">With goal <span>186</span></button>
<button class="cv-list-views-tab">Negative <span>23</span></button>
…
</div>
<div class="cv-list-sync">Synced 12m ago · Refresh</div>
</div>
<div class="tbl-wrap">
<table class="tbl">
<thead>
<tr>
<th class="tbl-check"><input type="checkbox" /></th>
<th>Contact</th><th>Status</th><th>Channel</th>
<th>Last message</th><th>Goal</th>
<th class="tbl-right">Msgs</th>
<th class="tbl-sort is-active">Started</th>
<th class="tbl-right"></th>
</tr>
</thead>
<tbody>
<tr class="is-selected">
<td class="tbl-check"><input type="checkbox" checked /></td>
<td>
<div class="tbl-person">
<span class="av">NM</span>
<div>
<div class="tbl-name">Nectar Mint <span class="cv-sentiment-dot is-positive"></span></div>
<div class="tbl-sub">s_AUacy…Uvca</div>
</div>
</div>
</td>
<td><span class="badge cv-chip-live"><span class="dot dot-green"></span> Live</span></td>
<td><span class="cv-channel">Web Chat</span></td>
<td><span class="cv-preview">That's a great question…</span></td>
<td><span class="badge"><span class="dot dot-green"></span> Meeting booked</span></td>
<td class="tbl-right tbl-num">12</td>
<td><span class="mono tbl-mono">2m ago</span></td>
<td class="tbl-right"><button class="tbl-icon">⋯</button></td>
</tr>
…
</tbody>
</table>
</div>
<footer class="cv-list-foot">…pagination…</footer>
</div>
/* Sentiment dot — the genuinely-new per-row signal. Four tones map
to the platform's sentiment classifier output. */
.cv-sentiment-dot.is-positive { background: #0F8062; }
.cv-sentiment-dot.is-neutral { background: var(--warm-7); }
.cv-sentiment-dot.is-negative { background: #C13449; }
.cv-sentiment-dot.is-mixed {
background: linear-gradient(90deg, #0F8062 50%, #C13449 50%);
}
/* Status chips compose the kit's `.badge` + `.dot` from chapter 7.4.
Wrap a Live badge in `.cv-chip-live` to add the pulse on its dot. */
.cv-chip-live .dot-green {
animation: cv-pulse 2.4s var(--ease) infinite;
}
@keyframes cv-pulse { 0%, 60%, 100% { opacity: 1; } 70% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) {
.cv-chip-live .dot-green { animation: none; }
}
/* Saved-view tab row composes the kit's `.tabs` underline-bar from
chapter 6.3 — no chapter-private styling for the tabs themselves. */
/* PROVISIONAL — pending kit additions flagged in gap list.
Pending kit exports:
SessionsTable — table primitive with chips + sentiment
SessionRow — single row
SearchInput — pill search field
SortMenu — sort selector
Existing: FilterChipGroup, SavedViewsRail, SyncStatus, FilterPopover. */
export function SessionsListPage({ sessions }) {
const [view, setView] = useState("latest");
const [query, setQuery] = useState("");
return (
<PageShell title="All conversations" count={sessions.length}>
<Toolbar>
<SearchInput value={query} onChange={setQuery} />
<FilterPopover />
<SortMenu defaultValue="started_desc" />
</Toolbar>
<SavedViewsRail value={view} onValueChange={setView}
views={["latest","with_goal","negative","flagged"]}
/>
<SessionsTable
sessions={visible(sessions, view, query)}
onRowClick={s => navigate(`/sessions/${s.id}`)}
/>
</PageShell>
);
}
19.8 Session detail
Open a row from 19.7 and the table collapses to a compact left rail; the conversation takes the main pane. Each agent message carries a small “trace” button that expands the reasoning inline beneath it — knowledge retrieved, facts captured, goals triggered, tool calls, errors, guardrail and security events — colour-coded by kind. A “Show all traces” toggle at the top opens every trace at once for power review. No secondary right rail; the testing chat bar lives outside.
Conversation — Nectar Mint · meeting booking
.cv-detail · .cv-traceTwo-pane layout. Left rail (320px) shows the compact session list with the selected conversation highlighted; the rail keeps search + saved-view tabs so the operator can switch sessions without leaving the page. Main pane shows the agent profile + agent version + actions in the head, then the conversation thread with inline-expandable traces below the agent turns that did interesting work. Click Show all traces to open them all at once.
Nectar Mint Live
Web Chat · Started Friday 2:18pm · Test error Agent · v15-
step 1CaptureFacts captured01:57:38NameHạ ViEmailthuht+251125002@magicblocks.ai
-
step 2JourneyBlock transition+18msFromHookToQualifyCauseName + email captured
While I don't have specific details about support availability in Ho Chi Minh City, I can definitely help you with general information about MagicBlocks.
What kind of support are you looking for?
-
step 1Knowledge2 sources retrieved from RAG01:57:38
“For customers in the APAC region, including Vietnam, our local support team operates 9am–6pm Singapore Time (GMT+8). Outside these hours, follow the global escalation path documented in…”
“Ho Chi Minh City (Saigon) is the largest city in Vietnam. Region: APAC. Default time zone: Asia/Ho_Chi_Minh (GMT+7). Business hours: 8am–5pm local.”
Match“Ho Chi Minh City” → APAC region -
step 2CaptureFacts captured+18msLocationHo Chi Minh City
-
step 3GoalGoal triggered+34msGoalBook a meetingTimeApr 18, 9:00 AM
-
step 4ToolTool call · Slack+142msActionChecked staging agentResult200 OK · 142ms
-
step 5FormForm completed+86msFormMeeting bookingSubmittedApr 18, 8:58 AM
-
step 6FormForm submitted to Calendar+44msStatusConfirmed
-
step 7ModelModel error Retried on Haiku 4.5 fallback…+430msCodeRATE_LIMIT on follow-up call
-
step 8FormForm post error Suggested fix: add email address+72msReasonMissing required field
-
step 9SecurityJailbreak attempt intercepted Refused safely · conversation continued+12msDetectionPrompt injection signature
-
step 10MetaMessage ID+8ms
m_AUacy1ZXTWTzUuVca_Tm7SiC1Uiem2v
<div class="cv-detail">
<aside class="cv-rail">
<div class="cv-rail-head">
<button class="cv-rail-back">‹ All conversations</button>
<input type="search" placeholder="Find a session…" />
<div class="cv-rail-tabs">
<button class="is-active">Latest</button>
<button>With goal</button>
<button>Negative</button>
</div>
</div>
<div class="cv-rail-list">
<div class="cv-rail-row is-active">…Nectar Mint…</div>
…other compact session rows…
</div>
</aside>
<main class="cv-pane">
<header class="cv-pane-head">
<h3>Nectar Mint <span class="badge cv-chip-live"><span class="dot dot-green"></span> Live</span></h3>
<span class="cv-pane-head-sub">Web Chat · Started Friday 2:18pm · v15</span>
</header>
<div class="cv-thread">
<div class="cv-thread-controls">
3 of 4 agent turns produced reasoning events.
<button data-toggle-all-traces>Show all traces</button>
</div>
<!-- The trace toggle is the shipped .message-trace-button (chapter
19.4): data-trace-toggle + aria-controls drive the inline
.trace-timeline (chapter 20.2/20.3) disclosure below — one
authoritative trace primitive, no bespoke pill. (Per the 19.4
canonical pattern you can instead place the button in an agent
message's actions slot; here it heads the inline trace.) -->
<div class="cv-trace" id="cv-trace-3" data-open="true">
<button class="message-trace-button is-size-sm is-noteworthy"
data-trace-toggle aria-controls="cv-trace-3" aria-expanded="true">
…robot-head SVG…
<span class="message-trace-button-count">10</span>
<span class="message-trace-button-dot"></span>
</button>
<div class="trace-timeline">
<ol class="trace-timeline-list" role="list">
<li class="trace-timeline-item">
<div class="trace-event is-tone-success">
…leading icon · eyebrow · title · time · bullet items…
</div>
</li>
…step 2 (success) Facts captured…
…step 3 (success) Goal triggered…
<li class="trace-timeline-item is-section-break">
<div class="trace-event is-tone-warn">…Tool call · Slack…</div>
</li>
…more warn / error / security / info events…
</ol>
</div>
</div>
</div>
</main>
</div>
/* The ONLY chapter-private CSS the trace needs — the inline-anchoring
wrapper that hangs the timeline beneath an agent message. The
colour-coded step styling itself lives in chapter 20 (Explainability)
and is already shipped + production-styled. */
.cv-trace {
margin-left: 48px;
border-left: 2px solid var(--accent);
padding-left: var(--s-4);
display: none;
}
.cv-trace[data-open="true"] { display: flex; flex-direction: column; }
/* "Show all traces" master toggle opens every trace at once for
power review. The thread carries .is-all-traces-open. */
.cv-thread.is-all-traces-open .cv-trace { display: flex; }
.cv-thread.is-all-traces-open .message-trace-button {
background: var(--accent); color: var(--paper); border-color: var(--accent);
}
// Per-message trace toggle + "Show all traces" master toggle.
// Idempotent and defensive — both work if the section is present.
document.addEventListener('DOMContentLoaded', () => {
// Per-message trace
document.querySelectorAll('[data-trace-toggle]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('aria-controls');
const trace = id && document.getElementById(id);
if (!trace) return;
const open = trace.getAttribute('data-open') === 'true';
trace.setAttribute('data-open', open ? 'false' : 'true');
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
});
});
// Master toggle — opens / closes every trace at once
document.querySelectorAll('[data-toggle-all-traces]').forEach((btn) => {
btn.addEventListener('click', () => {
const thread = btn.closest('.cv-thread');
if (!thread) return;
const on = thread.classList.toggle('is-all-traces-open');
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.textContent = on ? 'Hide all traces' : 'Show all traces';
// sync per-message toggles
thread.querySelectorAll('[data-trace-toggle]').forEach((t) =>
t.setAttribute('aria-expanded', on ? 'true' : 'false'));
thread.querySelectorAll('.cv-trace').forEach((t) =>
t.setAttribute('data-open', on ? 'true' : 'false'));
});
});
});
import {
ChatTranscript,
ChatMessage,
TraceTimeline,
TraceItemCard,
MessageTraceButton,
} from "@magicblocksai/ui";
/* Session detail composition. Page chrome (rail + pane + transcript)
is consumer-owned; the kit ships the trace-rendering primitives:
- <TraceTimeline> handles full timelines (steps + items)
- <TraceItemCard> is also exported standalone for citation cards,
retrieval-debug surfaces, or any "label · value · meta ·
expandable details" shape outside of a timeline. */
export function SessionDetailPage({ session, sessions, onSelect }) {
return (
<div className="cv-screen-frame">
<SessionsRail
sessions={sessions}
activeId={session.id}
onSelect={onSelect}
/>
<main>
<ChatTranscript>
{session.messages.map(m => (
<ChatMessage key={m.id} {...m}
actions={m.trace && <MessageTraceButton for={m.trace} />}
/>
))}
</ChatTranscript>
{/* Below the message: expandable RAG passages from the trace. */}
<div className="rag-passages">
{session.ragHits.map(hit => (
<TraceItemCard
key={hit.id}
tone="success"
label={hit.source}
value={hit.heading}
meta={`${hit.similarity.toFixed(2)} sim`}
defaultOpen={hit.id === session.ragHits[0].id}
details={<p>{hit.passage}</p>}
/>
))}
</div>
</main>
</div>
);
}
19.9 Live takeover console
When a conversation needs a person, the AI pauses and an operator replies as the brand on the contact's own channel — guardrails off. Handing back chooses where the AI resumes.
TakeoverConsole
.takeover-consoleTwo control states — AI driving (a Take-over CTA) and human in control (guardrails-off composer + a JourneyBlockPicker hand-back). Composes ChatTranscript, ChatComposer, JourneyBlockPicker and KeyFactGrid; the operator-context sidebar carries the contact, key facts so far, a “Sage suggests” draft, and the journey position. The operator's own replies use the accent .is-from-operator “human” role.
<div class="takeover-console" data-state="human_in_control">
<div class="tc-main">
<div class="tc-controlbar">
<span class="tc-state"><span class="tc-state-dot"></span> You're in control · AI paused</span>
<span class="tc-channel"><span class="channel-chip" data-channel="sms">SMS</span> <span class="tc-channel-id">+1 (415) 555-0132</span></span>
<div class="tc-controlbar-action">
<button class="jbp-trigger">Hand back <span class="jbp-caret"></span></button>
</div>
</div>
<div class="tc-transcript">
<div class="chat-transcript">
<!-- agent · system "took over · AI paused" · operator bubble (.is-from-operator + .chat-msg-human) -->
…message stack…
<div class="chat-transcript-footer">
<form class="chat-composer">
<textarea class="chat-composer-input" placeholder="Reply as Acme…"></textarea>
<button class="chat-composer-send">Send as human</button>
<div class="chat-composer-helper">
<span class="tc-guard"><span class="tc-guard-dot"></span> Guardrails off · sending as a human</span>
</div>
</form>
</div>
</div>
</div>
</div>
<aside class="tc-aside">
<div class="tc-sage">…Sage suggests + Insert…</div>
<div class="tc-facts">…<div class="key-fact-grid" data-layout="list">…</div></div>
<div class="tc-journey">…Journey position…</div>
</aside>
</div>
import {
TakeoverConsole,
JourneyBlockPicker,
ChatTranscript,
ChatMessage,
} from "@magicblocksai/ui";
/* SessionPage mode="live" renders this console under the shared hero.
The AI pauses on takeover; the operator replies as the brand on the
contact's own channel with guardrails OFF. Hand back picks the
Journey Block the AI resumes from. */
export function LiveTakeover({ session, onHandBack }) {
const [draft, setDraft] = useState("");
return (
<TakeoverConsole
defaultState="ai_driving" /* → human_in_control on "Take over" */
channel={session.channel}
channelLabel={session.channelLabel} /* "+1 (415) 555-0132" */
brandName="Acme"
composerValue={draft}
onComposerChange={setDraft}
onSend={(text) => { sendAsHuman(session.id, text); setDraft(""); }}
transcript={
<ChatTranscript>
{session.messages.map((m) => (
<ChatMessage key={m.id} from={m.from} name={m.name}>{m.body}</ChatMessage>
))}
{/* the operator's own replies use the accent "human" role: */}
<ChatMessage from="operator" name="You">…your reply…</ChatMessage>
</ChatTranscript>
}
handBackOptions={[
{ id: "resume", kind: "resume", label: "Resume where the AI paused", detail: "Recommended" },
{ id: "meeting", kind: "block", label: "Book a meeting" },
{ id: "end", kind: "end", label: "End — no further automation" },
]}
onHandBack={onHandBack}
keyFacts={session.keyFacts}
sage={{ text: session.sageDraft, onInsert: () => setDraft(session.sageDraft) }}
journeyPosition="Post-demo nurture · step 2"
/>
);
}
/* The hand-back menu is also exported standalone: */
<JourneyBlockPicker
options={handBackOptions}
onSelect={onHandBack}
/>