console: the WhatsApp entry says which voice is a machine

"Priya Raghavan and Engage" reads as two colleagues. Every other entry in the
trail already answers this — the agent's disc carries a sparkle, its name
carries an AI tag, a person's disc carries their initials — and the exchange
entry was the one place printing bare names, on the screen whose whole claim is
that the work was done autonomously.

So the participants are chips rather than a sentence, and so is the label on
every message, in the preview and in the thread: Priya Raghavan wears her
initials, Engage wears the sparkle and the AI tag, and ops replying by hand
wears their initials too — a person answering in the thread must never be
dressed as the machine. The agent chip opens its card, as it does everywhere
else in the trail; the label inside the preview card stays a span, because the
card is already a button.

The disc's tint and the bubble's tint are the same token, so a chip dropped
into a bubble lost its disc; it is white inside one. `.bub span` narrowed to
direct children other than the attribution row, so the chip's own spans are
not restyled and CallTranscript's plain labels still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-10 13:26:04 +05:30
parent 708cf4bc40
commit da8ce9147f
5 changed files with 92 additions and 23 deletions

View File

@ -8,6 +8,11 @@
font-size: 11px; font-weight: 700; letter-spacing: .02em; user-select: none; font-size: 11px; font-weight: 700; letter-spacing: .02em; user-select: none;
background: var(--zk-tint-blue); color: var(--zk-blue); background: var(--zk-tint-blue); color: var(--zk-blue);
} }
/* xs is the label on a chat bubble, where the disc sits beside 12.5px type. */
.who--xs { gap: 5px; }
.who--xs .who__disc { width: 16px; height: 16px; font-size: 8.5px; }
.who--xs .who__name { font-size: var(--fs-3xs); }
.who--xs .who__tag { font-size: 10px; padding: 0 4px; }
.who--md .who__disc { width: 36px; height: 36px; font-size: 13px; } .who--md .who__disc { width: 36px; height: 36px; font-size: 13px; }
.who--lg .who__disc { width: 40px; height: 40px; font-size: 14px; } .who--lg .who__disc { width: 40px; height: 40px; font-size: 14px; }

View File

@ -34,13 +34,30 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.bub span { /* Direct children only, and not the attribution row: the chip in there brings
its own spans, and CallTranscript still labels its turns with a plain one. */
.bub > span:not(.bub__by) {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
opacity: 0.65; opacity: 0.65;
} }
/* Who said it, and whether they are a person. */
.bub__by {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.bub__by time {
font-size: var(--fs-2xs);
color: var(--zk-grey);
}
/* The disc's tint and the bubble's tint are the same token, so a chip dropped
into a bubble loses its disc entirely. White puts it back. */
.bub .who__disc { background: #fff; }
.bub--them { .bub--them {
align-self: flex-start; align-self: flex-start;
background: var(--zk-line-soft); background: var(--zk-line-soft);

View File

@ -1,5 +1,6 @@
import { Fragment, useEffect, useRef } from 'react' import { Fragment, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import AgentChip from './AgentChip.jsx'
import { buildThread, listOf } from '../api/thread.js' import { buildThread, listOf } from '../api/thread.js'
import './Conversation.css' import './Conversation.css'
@ -137,12 +138,17 @@ export default function Conversation({ rows, name, anchor, onClose }) {
{ep.turns.map((t) => ( {ep.turns.map((t) => (
<div key={t.key} className={'bub bub--' + t.side}> <div key={t.key} className={'bub bub--' + t.side}>
<p>{t.text}</p> <p>{t.text}</p>
<span> <span className="bub__by">
{/* Named on every message, both directions. The previous {/* Named AND marked, both directions. It stamped only the
version stamped only the time, so a thread read back time, so a thread read back months later could not say
months later could not say which of our replies a which of our replies a person wrote and which Engage
person wrote and which Engage did. */} did and a bare "Engage" reads as a colleague. The
{t.side === 'them' ? (name || 'the customer') : t.by} · {at(t.at)} sparkle disc and the AI tag are how the rest of this
console says "machine"; a person keeps their initials. */}
{t.side === 'them'
? <AgentChip name={name || 'the customer'} size="xs" />
: <AgentChip agentKey={t.agentKey} name={t.by} size="xs" />}
<time dateTime={t.at}>{at(t.at)}</time>
</span> </span>
</div> </div>
))} ))}

View File

@ -97,19 +97,25 @@
.tl__wa:hover { border-color: var(--zk-blue-light); } .tl__wa:hover { border-color: var(--zk-blue-light); }
.tl__wa:hover .tl__wafoot { text-decoration: underline; } .tl__wa:hover .tl__wafoot { text-decoration: underline; }
/* The participants, as chips. A person's disc carries their initials, an
agent's carries the sparkle and an AI tag which is the whole point of the
line: one of these is not a colleague. */
.tl__wavoices { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 1px; }
.tl__wavoices .who--btn { margin-left: -2px; }
.tl__wapre { display: flex; flex-direction: column; gap: 6px; } .tl__wapre { display: flex; flex-direction: column; gap: 6px; }
.tl__wamsg { .tl__wamsg {
max-width: 88%; padding: 7px 11px 6px; max-width: 88%; padding: 7px 11px 6px;
border-radius: 12px; background: var(--zk-line-soft); border-radius: 12px; background: var(--zk-line-soft);
} }
.tl__wamsg em { .tl__wamsg-who { display: block; margin-bottom: 3px; }
display: block; font-style: normal; margin-bottom: 2px; /* Same reason as in the dialog: the disc's tint and the bubble's are one
font-size: var(--fs-3xs); font-weight: 600; color: var(--zk-grey); token, so the disc vanishes into it without this. */
} .tl__wamsg .who__disc { background: #fff; }
/* Two lines of each message, no more. A preview that grows with whatever /* Two lines of each message, no more. A preview that grows with whatever
somebody typed has stopped being a preview. */ somebody typed has stopped being a preview. */
.tl__wamsg > span { .tl__wamsg-txt {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden; overflow: hidden;
font-size: 14.5px; line-height: 1.45; color: var(--zk-ink); font-size: 14.5px; line-height: 1.45; color: var(--zk-ink);
@ -120,7 +126,6 @@
.tl__wamsg--us { .tl__wamsg--us {
align-self: flex-end; background: var(--zk-tint-blue); border-bottom-right-radius: 4px; align-self: flex-end; background: var(--zk-tint-blue); border-bottom-right-radius: 4px;
} }
.tl__wamsg--us em { color: var(--zk-blue); }
.tl__wafoot { .tl__wafoot {
display: flex; align-items: center; gap: 4px; margin-top: 9px; display: flex; align-items: center; gap: 4px; margin-top: 9px;

View File

@ -184,15 +184,41 @@ function WaGlyph() {
) )
} }
/** Who spoke in an exchange: "Priya and Engage". Named rather than counted /**
* "2 participants" is a fact nobody needed. */ * Who spoke in an exchange as CHIPS, not as a sentence.
*
* "Priya Raghavan and Engage" reads as two colleagues. On a screen whose whole
* claim is that the work was done autonomously, the one fact every line has to
* state is which of the two is a machine, and a bare name states the opposite.
*
* Every other entry in the trail already says it the same way the agent's
* disc carries a sparkle, its name carries an AI tag, a person's disc carries
* their initials so this is the existing convention reaching somewhere it was
* missing rather than a new one.
*
* The customer comes first: they are the reason the exchange exists.
*/
function voicesOf(ep, customerName) { function voicesOf(ep, customerName) {
const who = [] const who = []
if (ep.turns.some((t) => t.side === 'them')) who.push(customerName || 'the customer') if (ep.turns.some((t) => t.side === 'them')) {
for (const t of ep.turns) { who.push({ key: 'them', agentKey: null, name: customerName || 'the customer' })
if (t.side === 'us' && !who.includes(t.by)) who.push(t.by)
} }
return listOf(who, 3) for (const t of ep.turns) {
if (t.side !== 'us') continue
// Keyed on the agent, or on the name for a person answering by hand: ops
// replying in the thread is a person and must never wear the sparkle.
const key = t.agentKey || `person:${t.by}`
if (!who.some((w) => w.key === key)) who.push({ key, agentKey: t.agentKey, name: t.by })
}
return who
}
/** The label on a preview bubble: the same chip, small, and never a button
* the card it sits in is already one, and a button inside a button is not. */
function TurnWho({ turn, customerName }) {
return turn.side === 'them'
? <AgentChip name={customerName || 'the customer'} size="xs" />
: <AgentChip agentKey={turn.agentKey} name={turn.by} size="xs" />
} }
/** /**
@ -466,8 +492,16 @@ export default function Timeline({ rows, customerName, onOpenAgent, onOpenChat,
</span> </span>
<span className="tl__when">{spanAt(it.ep.from, it.ep.to)}</span> <span className="tl__when">{spanAt(it.ep.from, it.ep.to)}</span>
</div> </div>
<div className="tl__by"> <div className="tl__wavoices">
<span className="tl__byname">{voicesOf(it.ep, customerName)}</span> {voicesOf(it.ep, customerName).map((w) => (
<AgentChip
key={w.key}
agentKey={w.agentKey}
name={w.name}
size="sm"
onOpen={w.agentKey ? onOpenAgent : undefined}
/>
))}
</div> </div>
{it.ep.after.length ? ( {it.ep.after.length ? (
@ -488,8 +522,10 @@ export default function Timeline({ rows, customerName, onOpenAgent, onOpenChat,
<span className="tl__wapre"> <span className="tl__wapre">
{it.ep.preview.map((t) => ( {it.ep.preview.map((t) => (
<span key={t.key} className={'tl__wamsg tl__wamsg--' + t.side}> <span key={t.key} className={'tl__wamsg tl__wamsg--' + t.side}>
<em>{t.side === 'them' ? (customerName || 'the customer') : t.by}</em> <span className="tl__wamsg-who">
<span>{t.text}</span> <TurnWho turn={t} customerName={customerName} />
</span>
<span className="tl__wamsg-txt">{t.text}</span>
</span> </span>
))} ))}
</span> </span>