mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
1 parent
76770ace54
commit
2714832c3d
7 files changed
+149
-10
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user