feat(ui): task-chat de-emphasized self-talk — bubble-less muted narration with CSS typewriter entry

Mid-run agent messages are tagged interstitial by the transcript adapter and
render bubble-less/avatar-less in muted text; the run's posted reply keeps its
bubble. Settled history keeps the self-talk interleaved at its streamed
timestamp (deduped against posted comment bodies). New --motion-typewriter-*
tokens drive a CSS-only steps() clip reveal per streamed block, zeroed under
prefers-reduced-motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:44 -07:00
1 parent 76770ace54
commit 2714832c3d
7 files changed
+149 -10

No files matched your search

+56 -10
View File
@@ -49,7 +49,8 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
* ONE expandable parent row — its status line (whimsy gerund or in-flight
* tool state + elapsed + tokens) is the turn's single visible line, with the
* chronological tool/thinking activity nested behind an expand (PAP-354).
* Mid-run agent text stays outside as bubbles. When the run terminates the
* Mid-run agent text stays outside as muted, bubble-less interstitial
* narration (PAP-355) — live and settled alike. When the run terminates the
* same turn id flips settled, so the header morphs in place into the one-line
* "✓ Worked · …" summary (expand state preserved; runs already terminal at
* mount collapse instantly). Settled turns interleave after the run's last
@@ -239,9 +240,20 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const items = useMemo<TaskChatItem[]>(() => {
// Settled turns for every terminal run whose transcript we have. The
// transcript's assistant text is excluded — it already landed as the run's
// comment bubble; the turn holds the activity (thinking/tools/diffs).
// transcript's FINAL reply is excluded — it already landed as the run's
// comment bubble — but interstitial self-talk stays in the thread, muted
// (PAP-355): it interleaves into the backbone at its streamed timestamp so
// live and history agree, and it never nests inside the folded turn.
const normalizeBody = (s: string) => s.replace(/\s+/g, " ").trim();
const commentBodiesByRun = new Map<string, Set<string>>();
for (const comment of comments) {
if (comment.deletedAt || !comment.runId) continue;
const set = commentBodiesByRun.get(comment.runId) ?? new Set<string>();
set.add(normalizeBody(comment.body));
commentBodiesByRun.set(comment.runId, set);
}
const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = [];
const interstitialEntries: { ms: number; order: number; id: string; item: TaskChatItem }[] = [];
for (const source of runs) {
if (!isTerminalRunStatus(source.status)) continue;
if (liveRun && source.id === liveRun.id) continue;
@@ -254,11 +266,39 @@ export function TaskChatThread(props: TaskChatThreadProps) {
Number.isFinite(started) && Number.isFinite(finished)
? Math.max(0, finished - started)
: undefined;
const children = transcriptToTaskChatItems(entries, {
const parsed = transcriptToTaskChatItems(entries, {
runId: source.id,
agentName: meta?.agentName,
running: false,
}).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message");
});
const messages = parsed.filter((it) => it.kind === "message");
// The reply that landed as a posted comment must not render twice: drop
// messages matching a run comment's body, and — since the harness may
// post the reply lightly transformed — a succeeded run's trailing
// message when the run has comments at all.
const bodies = commentBodiesByRun.get(source.id);
let selfTalk = bodies
? messages.filter((m) => !bodies.has(normalizeBody(m.text)))
: messages;
if (
bodies &&
source.status === "succeeded" &&
selfTalk.length > 0 &&
selfTalk[selfTalk.length - 1] === messages[messages.length - 1]
) {
selfTalk = selfTalk.slice(0, -1);
}
for (const m of selfTalk) {
interstitialEntries.push({
ms: m.atMs ?? (Number.isFinite(started) ? started : 0),
order: 1,
id: m.id,
item: m,
});
}
const children = parsed.filter(
(it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message",
);
if (children.length === 0) continue;
settledTurns.push({
turn: {
@@ -290,8 +330,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
}
const merged = interstitialEntries.length
? [...orderedEntries, ...interstitialEntries].sort(
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
)
: orderedEntries;
const out: TaskChatItem[] = [];
for (const entry of orderedEntries) {
for (const entry of merged) {
out.push(entry.item);
const following = turnsByAnchor.get(entry.id);
if (following) out.push(...following);
@@ -307,9 +353,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
});
// Parent-row model (PAP-354): the run's status line IS the live turn —
// one expandable row owning the activity. Only tool/thinking/usage rows
// nest inside it; mid-run agent text stays in the thread as bubbles
// above, so the parent row sits last — the slot its settled summary
// takes over in place.
// nest inside it; mid-run agent text stays in the thread above as muted
// interstitial narration (PAP-355), so the parent row sits last — the
// slot its settled summary takes over in place.
for (const it of parsed) {
if (it.kind === "message") out.push(it);
}
@@ -337,7 +383,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
});
}
return out;
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]);
}, [comments, orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
@@ -33,6 +33,26 @@ function initialsForName(name: string) {
* chip); system notices are centered and recede.
*/
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
if (item.interstitial) {
// Mid-run self-talk: no bubble, no avatar header — muted narration that
// stays in the thread but never competes with the final reply. While
// streaming, block-level chunks type themselves in (tc-typewriter-stream);
// history renders instantly.
return (
<div
className={cn(
"w-full text-sm text-muted-foreground",
item.streaming && "tc-typewriter-stream",
)}
data-testid="task-chat-interstitial"
>
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
);
}
if (item.author === "system") {
return (
<div className="tc-enter-bubble flex justify-center py-1">
@@ -29,6 +29,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-ease-out", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-in", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
{ name: "--motion-typewriter-steps", group: "Easing", kind: "easing" },
// Duration primitives
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
@@ -52,6 +53,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
{ name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-typewriter-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
@@ -76,6 +76,18 @@ export interface TaskChatMessageItem {
modeLabel?: string;
/** Assigned agent icon name (AgentIconName) for the avatar header. */
agentIcon?: string | null;
/**
* Agent self-talk streamed inside a run turn (interstitial narration between
* tool calls). Renders bubble-less and muted so it never competes with the
* run's final reply — which arrives as a posted comment and is never
* interstitial. Set for live and settled transcripts alike.
*/
interstitial?: boolean;
/**
* Epoch ms of the message's first streamed chunk — the sort key used to
* interleave settled-transcript self-talk into the comment backbone.
*/
atMs?: number;
}
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
@@ -237,3 +237,44 @@ describe("isNestableLiveChild", () => {
}
});
});
describe("interstitial self-talk classification (PAP-355)", () => {
const stream: TranscriptEntry[] = [
{ kind: "assistant", ts: TS, text: "Let me check the adapter." } as TranscriptEntry,
toolCall("Read", { file_path: "a.ts" }),
{ kind: "assistant", ts: "2026-07-31T12:00:05.000Z", text: "Found it — fixing now." } as TranscriptEntry,
];
it("tags agent messages streamed inside a live run turn as interstitial", () => {
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: true }).filter(
(it) => it.kind === "message",
);
expect(messages).toHaveLength(2);
for (const m of messages) {
if (m.kind !== "message") continue;
expect(m.interstitial).toBe(true);
expect(m.streaming).toBe(true);
}
});
it("tags settled-history messages interstitial too, so live and history agree", () => {
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter(
(it) => it.kind === "message",
);
expect(messages).toHaveLength(2);
for (const m of messages) {
if (m.kind !== "message") continue;
expect(m.interstitial).toBe(true);
expect(m.streaming).toBe(false);
}
});
it("stamps atMs from the first streamed chunk for history interleaving", () => {
const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter(
(it) => it.kind === "message",
);
if (messages[0].kind !== "message" || messages[1].kind !== "message") return;
expect(messages[0].atMs).toBe(Date.parse(TS));
expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z"));
});
});
@@ -188,6 +188,7 @@ export function transcriptToTaskChatItems(
const it = items[messageIndex];
if (it.kind === "message") it.text += entry.text;
} else {
const atMs = Date.parse(entry.ts);
items.push({
id: `${runId}:msg:${i}`,
kind: "message",
@@ -195,6 +196,10 @@ export function transcriptToTaskChatItems(
authorName: agentName,
text: entry.text,
streaming: running,
// Everything the agent says inside a run turn is self-talk until it
// lands as the posted comment — live and history tag it alike.
interstitial: true,
atMs: Number.isFinite(atMs) ? atMs : undefined,
});
messageIndex = items.length - 1;
thinkingIndex = -1;
+13
View File
@@ -236,6 +236,8 @@
--motion-count-tween: var(--motion-duration-slow);
--motion-streaming-cursor-blink: 1.1s;
--motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */
--motion-typewriter-reveal: 450ms; /* interstitial self-talk chunk typing itself in */
--motion-typewriter-steps: steps(24, end); /* discrete reveal — the "typed" feel */
--motion-scroll-pill-enter: 200ms; /* scroll-to-latest pill entry */
--motion-scroll-pill-exit: 400ms; /* scroll-to-latest pill exit */
--motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1);
@@ -535,6 +537,7 @@
--motion-plan-entry-stagger: 0ms;
--motion-streaming-cursor-blink: 0ms;
--motion-turn-fold: 0ms;
--motion-typewriter-reveal: 0ms;
--motion-scroll-pill-enter: 0ms;
--motion-scroll-pill-exit: 0ms;
--motion-pane-glide: 0ms;
@@ -570,6 +573,10 @@
0%, 45% { opacity: 1; }
55%, 100% { opacity: 0; }
}
@keyframes tc-typewriter {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
.tc-enter-bubble { animation: tc-fade-rise var(--motion-bubble-enter) var(--motion-ease-out-expo) both; }
.tc-enter-marker { animation: tc-fade-in var(--motion-marker-enter) var(--motion-ease-out) both; }
@@ -580,6 +587,11 @@
.tc-approval { animation: tc-approval-pulse var(--motion-approval-pulse) var(--motion-ease-standard) infinite; }
.tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; }
.tc-enter-plan-entry { animation: tc-fade-rise var(--motion-plan-check) var(--motion-ease-out-expo) both; }
/* Interstitial self-talk: each block-level chunk (a new streamed paragraph /
list / code block) wipes in left-to-right in discrete steps as it arrives —
CSS-only typewriter, no per-char timers. Text appended to an existing block
lands instantly; history omits the class entirely. */
.tc-typewriter-stream .paperclip-markdown > * { animation: tc-typewriter var(--motion-typewriter-reveal) var(--motion-typewriter-steps) both; }
/* Scroll-to-latest pill (stock shadcn MessageScrollerButton motion, tokenized).
The pill is centered with translateX(-50%), so the keyframes carry the X
@@ -620,6 +632,7 @@
.tc-scroll-pill-in,
.tc-scroll-pill-out,
.tc-enter-plan-entry { animation: none; }
.tc-typewriter-stream .paperclip-markdown > * { animation: none; }
.tc-turn-fold,
.tc-pane-glide { transition: none; }
/* The pill keyframes carry the X-centering; with animation:none restore it. */