diff --git a/src/components/AgentChip.css b/src/components/AgentChip.css
index 08373cc..cccbdda 100644
--- a/src/components/AgentChip.css
+++ b/src/components/AgentChip.css
@@ -8,6 +8,11 @@
font-size: 11px; font-weight: 700; letter-spacing: .02em; user-select: none;
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--lg .who__disc { width: 40px; height: 40px; font-size: 14px; }
diff --git a/src/components/Conversation.css b/src/components/Conversation.css
index 1c757cc..407e8d6 100644
--- a/src/components/Conversation.css
+++ b/src/components/Conversation.css
@@ -34,13 +34,30 @@
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;
margin-top: 4px;
font-size: var(--fs-2xs);
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 {
align-self: flex-start;
background: var(--zk-line-soft);
diff --git a/src/components/Conversation.jsx b/src/components/Conversation.jsx
index 7f771c7..eef1564 100644
--- a/src/components/Conversation.jsx
+++ b/src/components/Conversation.jsx
@@ -1,5 +1,6 @@
import { Fragment, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
+import AgentChip from './AgentChip.jsx'
import { buildThread, listOf } from '../api/thread.js'
import './Conversation.css'
@@ -137,12 +138,17 @@ export default function Conversation({ rows, name, anchor, onClose }) {
{ep.turns.map((t) => (
{t.text}
-
- {/* Named on every message, both directions. The previous
- version stamped only the time, so a thread read back
- months later could not say which of our replies a
- person wrote and which Engage did. */}
- {t.side === 'them' ? (name || 'the customer') : t.by} · {at(t.at)}
+
+ {/* Named AND marked, both directions. It stamped only the
+ time, so a thread read back months later could not say
+ which of our replies a person wrote and which Engage
+ did — and a bare "Engage" reads as a colleague. The
+ sparkle disc and the AI tag are how the rest of this
+ console says "machine"; a person keeps their initials. */}
+ {t.side === 'them'
+ ?
+ : }
+
))}
diff --git a/src/components/Timeline.css b/src/components/Timeline.css
index 8d687da..2035fb2 100644
--- a/src/components/Timeline.css
+++ b/src/components/Timeline.css
@@ -97,19 +97,25 @@
.tl__wa:hover { border-color: var(--zk-blue-light); }
.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__wamsg {
max-width: 88%; padding: 7px 11px 6px;
border-radius: 12px; background: var(--zk-line-soft);
}
-.tl__wamsg em {
- display: block; font-style: normal; margin-bottom: 2px;
- font-size: var(--fs-3xs); font-weight: 600; color: var(--zk-grey);
-}
+.tl__wamsg-who { display: block; margin-bottom: 3px; }
+/* Same reason as in the dialog: the disc's tint and the bubble's are one
+ 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
somebody typed has stopped being a preview. */
-.tl__wamsg > span {
+.tl__wamsg-txt {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden;
font-size: 14.5px; line-height: 1.45; color: var(--zk-ink);
@@ -120,7 +126,6 @@
.tl__wamsg--us {
align-self: flex-end; background: var(--zk-tint-blue); border-bottom-right-radius: 4px;
}
-.tl__wamsg--us em { color: var(--zk-blue); }
.tl__wafoot {
display: flex; align-items: center; gap: 4px; margin-top: 9px;
diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx
index e55dadd..6066d01 100644
--- a/src/components/Timeline.jsx
+++ b/src/components/Timeline.jsx
@@ -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) {
const who = []
- if (ep.turns.some((t) => t.side === 'them')) who.push(customerName || 'the customer')
- for (const t of ep.turns) {
- if (t.side === 'us' && !who.includes(t.by)) who.push(t.by)
+ if (ep.turns.some((t) => t.side === 'them')) {
+ who.push({ key: 'them', agentKey: null, name: customerName || 'the customer' })
}
- 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'
+ ?
+ :
}
/**
@@ -466,8 +492,16 @@ export default function Timeline({ rows, customerName, onOpenAgent, onOpenChat,
{spanAt(it.ep.from, it.ep.to)}
-