mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat(ui): self-talk as a live element of the parent row — streaming text takes the line with 1lh line-scroll, finished self-talk nests like tool rows, settled turns follow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1daeef878b
commit
7eadf663ad
12 files changed
+466
-97
No files matched your search
@@ -7,12 +7,13 @@ import {
|
||||
deriveRunStatusLabel,
|
||||
isNestableLiveChild,
|
||||
isTerminalRunStatus,
|
||||
normalizeCommentBody,
|
||||
settledRunChildren,
|
||||
transcriptToTaskChatItems,
|
||||
} from "@/components/task-chat/transcript-adapter";
|
||||
import type {
|
||||
TaskChatInteractionItem,
|
||||
TaskChatItem,
|
||||
TaskChatTurnChildItem,
|
||||
TaskChatTurnItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
|
||||
@@ -49,8 +50,10 @@ 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 muted, bubble-less interstitial
|
||||
* narration (PAP-355) — live and settled alike. When the run terminates the
|
||||
* Mid-run agent text follows the same grammar (PAP-356): while streaming it
|
||||
* takes over the parent row's line (typewriter + line-scroll), and once
|
||||
* finished it nests inside the turn as a muted expandable row — live and
|
||||
* settled alike (accepted §5.4). 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
|
||||
@@ -241,19 +244,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const items = useMemo<TaskChatItem[]>(() => {
|
||||
// Settled turns for every terminal run whose transcript we have. The
|
||||
// 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();
|
||||
// comment bubble — and interstitial self-talk nests INSIDE the folded turn
|
||||
// alongside the tool rows (PAP-356, accepted §5.4), in transcript order,
|
||||
// instead of interleaving in the thread.
|
||||
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));
|
||||
set.add(normalizeCommentBody(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;
|
||||
@@ -271,34 +272,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
agentName: meta?.agentName,
|
||||
running: false,
|
||||
});
|
||||
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",
|
||||
);
|
||||
const children = settledRunChildren(parsed, {
|
||||
commentBodies: commentBodiesByRun.get(source.id),
|
||||
succeeded: source.status === "succeeded",
|
||||
});
|
||||
if (children.length === 0) continue;
|
||||
settledTurns.push({
|
||||
turn: {
|
||||
@@ -330,14 +307,8 @@ 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 merged) {
|
||||
for (const entry of orderedEntries) {
|
||||
out.push(entry.item);
|
||||
const following = turnsByAnchor.get(entry.id);
|
||||
if (following) out.push(...following);
|
||||
@@ -351,19 +322,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
agentName: liveRun.agentName,
|
||||
running: true,
|
||||
});
|
||||
// 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 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);
|
||||
}
|
||||
// Parent-row model (PAP-354/356): the run's status line IS the live turn
|
||||
// — one expandable row owning ALL activity. Finished self-talk nests
|
||||
// inside alongside tool/thinking rows; the interstitial still streaming
|
||||
// takes over the parent row's own line (liveStatus.selfTalk) instead of
|
||||
// rendering in the thread. The parent row sits last — the slot its
|
||||
// settled summary takes over in place.
|
||||
const children = parsed.filter(isNestableLiveChild);
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const queued = liveRun.status === "queued";
|
||||
const status = queued
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined }
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined }
|
||||
: deriveRunStatusLabel(entries);
|
||||
out.push({
|
||||
id: `${liveRun.id}:turn`,
|
||||
@@ -378,6 +347,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
toolName: status.toolName,
|
||||
selfTalk: status.selfTalk,
|
||||
startedAtMs: startedAt ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, MessageSquareReply } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { flattenSelfTalk } from "./transcript-adapter";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import {
|
||||
@@ -32,25 +35,46 @@ function initialsForName(name: string) {
|
||||
* bubble with an avatar author header (the agent's assigned icon + name · mode
|
||||
* chip); system notices are centered and recede.
|
||||
*/
|
||||
/**
|
||||
* Finished self-talk as a nested activity row (PAP-356): one muted flattened
|
||||
* line in the exact tool-row interaction grammar — click expands the full
|
||||
* markdown in a left-rail inset, identical to a tool row's detail block.
|
||||
* (While still streaming, the same text lives on the parent row's live line.)
|
||||
*/
|
||||
function InterstitialRow({ item }: { item: TaskChatMessageItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="tc-enter-tool flex flex-col text-xs" data-testid="task-chat-interstitial">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="group/selftalk -mx-1.5 flex min-w-0 cursor-pointer items-center gap-2 rounded-sm px-1.5 py-0.5 text-left text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<MessageSquareReply className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{flattenSelfTalk(item.text)}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"ml-auto h-3 w-3 shrink-0 opacity-0 transition-[opacity,transform] group-hover/selftalk:opacity-80",
|
||||
open ? "rotate-90" : null,
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="tc-interstitial ml-2.5 mt-0.5 border-l-2 border-border py-1 pl-2.5 text-muted-foreground">
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{item.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
"tc-interstitial w-full text-xs text-muted-foreground",
|
||||
item.streaming && "tc-typewriter-stream",
|
||||
)}
|
||||
data-testid="task-chat-interstitial"
|
||||
>
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{item.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
return <InterstitialRow item={item} />;
|
||||
}
|
||||
|
||||
if (item.author === "system") {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
import { lineScrollOffset, TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
import { whimsyWord, WHIMSY_ROTATE_MS } from "./status-whimsy";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
|
||||
@@ -89,4 +89,42 @@ describe("TaskChatStatusPill whimsy", () => {
|
||||
expect(container.textContent).toContain("1.2s");
|
||||
expect(container.textContent).toContain("18,240/200,000 ctx");
|
||||
});
|
||||
|
||||
it("hands the line to streaming self-talk, keeping elapsed · tokens meta (PAP-356)", () => {
|
||||
const item = liveStatus({
|
||||
label: "Responding",
|
||||
selfTalk: "I'll extend the ipRateLimit helper with a per-account bucket.",
|
||||
tokens: { used: 18240, size: 200000 },
|
||||
});
|
||||
render(item);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
|
||||
expect(line).not.toBeNull();
|
||||
expect(line?.textContent).toBe("I'll extend the ipRateLimit helper with a per-account bucket.");
|
||||
// The gerund/whimsy label yields the line; the right-side meta stays put.
|
||||
expect(container.textContent).not.toContain("Responding…");
|
||||
expect(container.textContent).not.toContain(`${whimsyWord(item.id, 1200)}…`);
|
||||
expect(container.textContent).toContain("1.2s");
|
||||
expect(container.textContent).toContain("18,240/200,000 ctx");
|
||||
// Viewport + transition classes: 1lh clip outside, transform-only inner.
|
||||
expect(line?.className).toContain("tc-line-scroll");
|
||||
expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner");
|
||||
expect(line?.firstElementChild?.className).toContain("tc-typewriter-line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineScrollOffset", () => {
|
||||
it("translates up one line-height per wrapped line", () => {
|
||||
expect(lineScrollOffset(16, 16)).toBe(0); // one line — no shift
|
||||
expect(lineScrollOffset(32, 16)).toBe(1);
|
||||
expect(lineScrollOffset(48.5, 16)).toBe(2); // sub-pixel scrollHeight rounds
|
||||
});
|
||||
|
||||
it("never goes negative and guards bad measurements", () => {
|
||||
expect(lineScrollOffset(0, 16)).toBe(0);
|
||||
expect(lineScrollOffset(32, 0)).toBe(0);
|
||||
expect(lineScrollOffset(Number.NaN, 16)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight, Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
@@ -31,6 +31,55 @@ function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): numbe
|
||||
return startedAtMs == null ? undefined : Math.max(0, now - startedAtMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many line-heights the streaming self-talk block is shifted up so only its
|
||||
* LAST line shows in the 1lh viewport: lines − 1 (never negative).
|
||||
*/
|
||||
export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): number {
|
||||
if (!Number.isFinite(scrollHeight) || !Number.isFinite(lineHeightPx) || lineHeightPx <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.round(scrollHeight / lineHeightPx) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming self-talk on the parent row's line: the text types in (typewriter
|
||||
* treatment) inside a one-line-height clipped viewport; when it wraps, the
|
||||
* inner block translates up by whole line-heights (--motion-line-scroll,
|
||||
* transform-only, snaps under reduced motion) so the completed line slides out
|
||||
* the top while typing continues on the fresh line — the same motion grammar
|
||||
* as tool rows animating in. Line count is measured from scrollHeight /
|
||||
* line-height, re-checked on resize.
|
||||
*/
|
||||
function LiveSelfTalkLine({ text }: { text: string }) {
|
||||
const innerRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight);
|
||||
setOffset(lineScrollOffset(el.scrollHeight, lineHeight));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [text]);
|
||||
return (
|
||||
<span className="tc-line-scroll min-w-0 flex-1" data-testid="task-chat-live-self-talk">
|
||||
<span
|
||||
ref={innerRef}
|
||||
className="tc-line-scroll-inner tc-typewriter-line block"
|
||||
style={offset > 0 ? { transform: `translateY(calc(${-offset} * 1lh))` } : undefined}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
@@ -65,6 +114,40 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
|
||||
const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live);
|
||||
const elapsed = elapsedLabel(item.elapsedMs);
|
||||
|
||||
if (live && item.selfTalk) {
|
||||
// Streaming self-talk takes the line (replacing the gerund) in the muted
|
||||
// tool-row style; right-side meta (elapsed · tokens) stays put. The row
|
||||
// alternates: whimsy gerund ↔ in-flight tool ↔ streaming self-talk.
|
||||
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
|
||||
const SelfTalkIcon = statusLabelIcon("Responding");
|
||||
return (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
<LiveSelfTalkLine text={item.selfTalk} />
|
||||
{liveElapsed ? (
|
||||
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
|
||||
{liveElapsed}
|
||||
</span>
|
||||
) : null}
|
||||
{item.tokens ? (
|
||||
<span className="shrink-0 font-mono text-(length:--text-micro)">
|
||||
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
|
||||
</span>
|
||||
) : null}
|
||||
{chevronOpen !== undefined ? (
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (live) {
|
||||
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
|
||||
const ToolIcon = item.toolName
|
||||
|
||||
@@ -110,7 +110,9 @@ export function TaskChatThreadView({
|
||||
// The live parent row's header changes (gerund ↔ tool-state flashes)
|
||||
// count too, so the collapsed single-line turn still advances the key.
|
||||
const headerSig = it.liveStatus
|
||||
? it.liveStatus.label.length + (it.liveStatus.detail?.length ?? 0)
|
||||
? it.liveStatus.label.length +
|
||||
(it.liveStatus.detail?.length ?? 0) +
|
||||
(it.liveStatus.selfTalk?.length ?? 0)
|
||||
: 0;
|
||||
return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
{ 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-line-scroll", 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 },
|
||||
|
||||
@@ -62,13 +62,28 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
],
|
||||
};
|
||||
case "responding":
|
||||
// Streaming self-talk takes the parent row's line (PAP-356): it types in
|
||||
// where the gerund sat, wraps into the 1lh viewport line-scroll, and the
|
||||
// already-finished interstitial nests below like a completed tool row.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
// Mid-run agent text is interstitial self-talk (PAP-355): muted,
|
||||
// bubble-less, typing itself in — matching the live adapter.
|
||||
{ id: "m-agent-stream", kind: "message", author: "agent", authorName: AGENT, streaming: true, interstitial: true, text: "I found an existing ipRateLimit helper, so I'll extend it with a per-account bucket and" },
|
||||
{
|
||||
id: "turn-responding",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: {
|
||||
id: "st-responding", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 9300,
|
||||
selfTalk:
|
||||
"I found an existing ipRateLimit helper, so I'll extend it with a per-account token bucket keyed on the email address instead of adding a second limiter. The bucket refills at six requests a minute, matching the lockout policy the auth spec documents, and failed attempts drain it twice as fast so brute-force runs hit the ceiling quickly while a fat-fingered password barely registers.",
|
||||
},
|
||||
items: [
|
||||
{ id: "resp-note", kind: "message", author: "agent", authorName: AGENT, interstitial: true, text: "The login route already imports `ipRateLimit`, so I can reuse its sliding-window store instead of adding a dependency." },
|
||||
{ id: "resp-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
case "tool-call":
|
||||
@@ -149,6 +164,8 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
items: [
|
||||
{ id: "th-done", kind: "thinking", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
|
||||
{ id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
// Settled self-talk nests with the tool rows (accepted §5.4).
|
||||
{ id: "m-done-note", kind: "message", author: "agent", authorName: AGENT, interstitial: true, text: "Reusing the `ipRateLimit` store — adding a per-account bucket beside it." },
|
||||
{ id: "tool-done-2", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -83,10 +83,7 @@ export interface TaskChatMessageItem {
|
||||
* 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.
|
||||
*/
|
||||
/** Epoch ms of the message's first streamed chunk. */
|
||||
atMs?: number;
|
||||
}
|
||||
|
||||
@@ -142,6 +139,13 @@ export interface TaskChatStatusItem {
|
||||
detail?: string;
|
||||
/** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */
|
||||
toolName?: string;
|
||||
/**
|
||||
* Flattened plain text of the interstitial message currently streaming: the
|
||||
* live parent row's line shows it typing in (replacing the gerund) inside a
|
||||
* one-line viewport that scrolls completed lines out the top. Full markdown
|
||||
* renders in the nested row once the message completes.
|
||||
*/
|
||||
selfTalk?: string;
|
||||
elapsedMs?: number;
|
||||
/** Run start epoch ms; live states tick their own elapsed from this. */
|
||||
startedAtMs?: number;
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { TranscriptEntry } from "@/adapters";
|
||||
import {
|
||||
buildTurnSummary,
|
||||
deriveRunStatusLabel,
|
||||
flattenSelfTalk,
|
||||
isNestableLiveChild,
|
||||
normalizeCommentBody,
|
||||
settledRunChildren,
|
||||
toolDisplayName,
|
||||
transcriptToTaskChatItems,
|
||||
} from "./transcript-adapter";
|
||||
@@ -216,26 +219,46 @@ describe("isNestableLiveChild", () => {
|
||||
{ id: "t", kind: "tool", name: "Read", status: "completed" },
|
||||
{ id: "th", kind: "thinking", lines: ["hm"] },
|
||||
{ id: "u", kind: "usage", usage: { used: 1, size: 2 } },
|
||||
{ id: "m", kind: "message", author: "agent", text: "self-talk" },
|
||||
{ id: "m", kind: "message", author: "agent", text: "finished self-talk", interstitial: true },
|
||||
{ id: "mk", kind: "marker", variant: "session_start", label: "Session started" },
|
||||
{ id: "s", kind: "status", status: "running", label: "Running" },
|
||||
{ id: "i", kind: "interaction", interaction: {} as never },
|
||||
];
|
||||
|
||||
it("nests only tool, thinking and usage rows inside the live parent row", () => {
|
||||
it("nests tool, thinking, usage and finished interstitial rows inside the live parent row", () => {
|
||||
expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([
|
||||
"tool",
|
||||
"thinking",
|
||||
"usage",
|
||||
"message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps messages, markers, statuses and interactions in the thread", () => {
|
||||
for (const kind of ["message", "marker", "status", "interaction"]) {
|
||||
it("keeps markers, statuses and interactions in the thread", () => {
|
||||
for (const kind of ["marker", "status", "interaction"]) {
|
||||
const item = items.find((it) => it.kind === kind)!;
|
||||
expect(isNestableLiveChild(item)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the still-streaming interstitial out — it lives on the parent row's line", () => {
|
||||
expect(
|
||||
isNestableLiveChild({
|
||||
id: "m2",
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
text: "typing…",
|
||||
interstitial: true,
|
||||
streaming: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never nests a non-interstitial message (the final reply keeps its bubble)", () => {
|
||||
expect(
|
||||
isNestableLiveChild({ id: "m3", kind: "message", author: "agent", text: "Final reply." }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interstitial self-talk classification (PAP-355)", () => {
|
||||
@@ -253,7 +276,26 @@ describe("interstitial self-talk classification (PAP-355)", () => {
|
||||
for (const m of messages) {
|
||||
if (m.kind !== "message") continue;
|
||||
expect(m.interstitial).toBe(true);
|
||||
expect(m.streaming).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks only the message still open at the tail as streaming", () => {
|
||||
const items = transcriptToTaskChatItems(stream, { runId: "run-1", running: true });
|
||||
const messages = items.filter((it) => it.kind === "message");
|
||||
if (messages[0].kind !== "message" || messages[1].kind !== "message") return;
|
||||
// The first message was closed by the tool call — finished self-talk that
|
||||
// nests as a settled row even while the run continues.
|
||||
expect(messages[0].streaming).toBe(false);
|
||||
expect(messages[1].streaming).toBe(true);
|
||||
});
|
||||
|
||||
it("marks no message streaming when the tail is a tool call", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[...stream, toolCall("Bash", { command: "pnpm test" })],
|
||||
{ runId: "run-1", running: true },
|
||||
);
|
||||
for (const m of items) {
|
||||
if (m.kind === "message") expect(m.streaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -278,3 +320,83 @@ describe("interstitial self-talk classification (PAP-355)", () => {
|
||||
expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("flattenSelfTalk", () => {
|
||||
it("strips markdown markers to one plain line", () => {
|
||||
expect(
|
||||
flattenSelfTalk("## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it"),
|
||||
).toBe("Plan I'll extend the ipRateLimit helper: read it patch it");
|
||||
});
|
||||
|
||||
it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => {
|
||||
expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe("see the docs for bol");
|
||||
expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRunStatusLabel streaming self-talk (PAP-356)", () => {
|
||||
it("returns the trailing message's flattened text with the Responding label", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "assistant", ts: TS, text: "I'll **extend** " } as TranscriptEntry,
|
||||
{ kind: "assistant", ts: TS, text: "the `ipRateLimit` helper." } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.label).toBe("Responding");
|
||||
expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper.");
|
||||
});
|
||||
|
||||
it("only accumulates the trailing message, not one closed by a tool call", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry,
|
||||
toolCall("Read", { file_path: "a.ts" }),
|
||||
{ kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.selfTalk).toBe("Fresh line.");
|
||||
});
|
||||
|
||||
it("carries no selfTalk for tool or thinking tails", () => {
|
||||
expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined();
|
||||
expect(
|
||||
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).selfTalk,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settledRunChildren (PAP-356, accepted §5.4)", () => {
|
||||
const parsed = transcriptToTaskChatItems(
|
||||
[
|
||||
{ kind: "assistant", ts: TS, text: "Checking the adapter first." } as TranscriptEntry,
|
||||
toolCall("Read", { file_path: "a.ts" }),
|
||||
{ kind: "assistant", ts: TS, text: "Done — the limiter is wired in." } as TranscriptEntry,
|
||||
],
|
||||
{ runId: "run-1", running: false },
|
||||
);
|
||||
|
||||
it("nests self-talk between the tool rows in transcript order", () => {
|
||||
const children = settledRunChildren(parsed, { succeeded: true });
|
||||
expect(children.map((c) => c.kind)).toEqual(["message", "tool", "message"]);
|
||||
});
|
||||
|
||||
it("drops messages matching a posted comment body (dedup preserved)", () => {
|
||||
const children = settledRunChildren(parsed, {
|
||||
commentBodies: new Set([normalizeCommentBody("Done — the limiter is wired\nin.")]),
|
||||
succeeded: true,
|
||||
});
|
||||
expect(children.map((c) => c.kind)).toEqual(["message", "tool"]);
|
||||
});
|
||||
|
||||
it("drops a succeeded run's trailing message when the run posted any comment", () => {
|
||||
const children = settledRunChildren(parsed, {
|
||||
commentBodies: new Set([normalizeCommentBody("A transformed version of the reply.")]),
|
||||
succeeded: true,
|
||||
});
|
||||
expect(children.map((c) => c.kind)).toEqual(["message", "tool"]);
|
||||
});
|
||||
|
||||
it("keeps the trailing message for failed runs", () => {
|
||||
const children = settledRunChildren(parsed, {
|
||||
commentBodies: new Set(["unrelated"]),
|
||||
succeeded: false,
|
||||
});
|
||||
expect(children.map((c) => c.kind)).toEqual(["message", "tool", "message"]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { TranscriptEntry } from "@/adapters";
|
||||
import type {
|
||||
TaskChatDiff,
|
||||
TaskChatItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatToolItem,
|
||||
TaskChatTurnChildItem,
|
||||
TaskChatTurnItem,
|
||||
@@ -28,15 +29,37 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The live parent row's nesting rule (PAP-354): only activity rows — tool
|
||||
* calls, thinking blocks, usage readouts — nest inside the expandable live
|
||||
* turn. Messages (mid-run self-talk, the streaming reply), markers, statuses
|
||||
* and interaction cards stay in the thread outside it.
|
||||
* The live parent row's nesting rule (PAP-354, extended by PAP-356): activity
|
||||
* rows — tool calls, thinking blocks, usage readouts — and FINISHED
|
||||
* interstitial self-talk nest inside the expandable live turn, all sharing the
|
||||
* tool-row interaction grammar. The interstitial still streaming lives on the
|
||||
* parent row's own line (TaskChatStatusItem.selfTalk), not in the nested list;
|
||||
* markers, statuses and interaction cards stay in the thread outside.
|
||||
*/
|
||||
export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem {
|
||||
if (item.kind === "message") return item.interstitial === true && !item.streaming;
|
||||
return item.kind === "tool" || item.kind === "thinking" || item.kind === "usage";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten markdown-ish self-talk to one plain line — for the live parent row
|
||||
* and the collapsed nested row. Full markdown renders only in the expanded
|
||||
* nested row. Stream-safe: markers are stripped without requiring pairs.
|
||||
*/
|
||||
export function flattenSelfTalk(text: string): string {
|
||||
return text
|
||||
.replace(/```[a-z]*\n?/gi, " ")
|
||||
.replace(/`/g, "")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*>\s?/gm, "")
|
||||
.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1")
|
||||
.replace(/\*+/g, "")
|
||||
.replace(/__/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function diffKind(changeType: string): "add" | "remove" | "context" {
|
||||
if (changeType === "add") return "add";
|
||||
if (changeType === "remove") return "remove";
|
||||
@@ -294,9 +317,52 @@ export function transcriptToTaskChatItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Only the message still open at the transcript tail is streaming; earlier
|
||||
// self-talk is finished and nests as a settled row even mid-run.
|
||||
if (running) {
|
||||
for (const [idx, it] of items.entries()) {
|
||||
if (it.kind === "message" && idx !== messageIndex) it.streaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Normalization used to match transcript self-talk against posted comment bodies. */
|
||||
export function normalizeCommentBody(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled run's nested children (PAP-356 / accepted §5.4): activity rows plus
|
||||
* interstitial self-talk, in transcript order, under the "✓ Worked ·" summary.
|
||||
* The reply that landed as a posted comment must not render twice, so messages
|
||||
* matching a run comment's body are dropped — and, since the harness may post
|
||||
* the reply lightly transformed, so is the trailing message of a succeeded run
|
||||
* that has comments at all.
|
||||
*/
|
||||
export function settledRunChildren(
|
||||
parsed: readonly TaskChatItem[],
|
||||
opts: { commentBodies?: ReadonlySet<string>; succeeded: boolean },
|
||||
): TaskChatTurnChildItem[] {
|
||||
const messages = parsed.filter((it): it is TaskChatMessageItem => it.kind === "message");
|
||||
let selfTalk = opts.commentBodies
|
||||
? messages.filter((m) => !opts.commentBodies!.has(normalizeCommentBody(m.text)))
|
||||
: messages;
|
||||
if (
|
||||
opts.commentBodies &&
|
||||
opts.succeeded &&
|
||||
selfTalk.length > 0 &&
|
||||
selfTalk[selfTalk.length - 1] === messages[messages.length - 1]
|
||||
) {
|
||||
selfTalk = selfTalk.slice(0, -1);
|
||||
}
|
||||
const keep = new Set<TaskChatItem>(selfTalk);
|
||||
return parsed.filter((it): it is TaskChatTurnChildItem =>
|
||||
it.kind === "message" ? keep.has(it) : it.kind !== "turn",
|
||||
);
|
||||
}
|
||||
|
||||
function formatDurationLabel(ms: number): string | undefined {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return undefined;
|
||||
const totalSec = Math.round(ms / 1000);
|
||||
@@ -358,11 +424,14 @@ export function buildTurnSummary(
|
||||
* Human-readable label for the live status pill from the tail of a transcript.
|
||||
* A tail tool_call yields the taxonomy verb ("Searching", "Running a command")
|
||||
* with tool + target as detail; `toolName` lets the pill show the family icon.
|
||||
* A tail assistant message yields "Responding" plus `selfTalk` — the flattened
|
||||
* text of the message streamed so far, which takes over the parent row's line.
|
||||
*/
|
||||
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||||
label: string;
|
||||
detail?: string;
|
||||
toolName?: string;
|
||||
selfTalk?: string;
|
||||
} {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
@@ -398,7 +467,29 @@ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||||
};
|
||||
}
|
||||
if (entry.kind === "tool_result") break;
|
||||
if (entry.kind === "assistant") return { label: "Responding" };
|
||||
if (entry.kind === "assistant") {
|
||||
// Accumulate the trailing message's deltas (the same coalescing run the
|
||||
// parser groups into one item: broken by tool/thinking/diff, not by
|
||||
// status-only entries like stdout).
|
||||
const parts: string[] = [];
|
||||
for (let j = i; j >= 0; j--) {
|
||||
const prev = entries[j];
|
||||
if (prev.kind === "assistant") {
|
||||
if (prev.text) parts.unshift(prev.text);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
prev.kind === "tool_call" ||
|
||||
prev.kind === "tool_result" ||
|
||||
prev.kind === "thinking" ||
|
||||
prev.kind === "diff"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const selfTalk = flattenSelfTalk(parts.join(""));
|
||||
return { label: "Responding", selfTalk: selfTalk || undefined };
|
||||
}
|
||||
if (entry.kind === "thinking") return { label: "Thinking" };
|
||||
}
|
||||
return { label: "Running" };
|
||||
|
||||
+14
-6
@@ -238,6 +238,7 @@
|
||||
--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-line-scroll: 240ms; /* completed self-talk line sliding out of the 1lh live-line viewport */
|
||||
--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);
|
||||
@@ -538,6 +539,7 @@
|
||||
--motion-streaming-cursor-blink: 0ms;
|
||||
--motion-turn-fold: 0ms;
|
||||
--motion-typewriter-reveal: 0ms;
|
||||
--motion-line-scroll: 0ms;
|
||||
--motion-scroll-pill-enter: 0ms;
|
||||
--motion-scroll-pill-exit: 0ms;
|
||||
--motion-pane-glide: 0ms;
|
||||
@@ -587,11 +589,16 @@
|
||||
.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; }
|
||||
/* Streaming self-talk on the live parent row: the text block wipes in
|
||||
left-to-right in discrete steps when it takes over the line — CSS-only
|
||||
typewriter, no per-char timers; appended stream text lands instantly. */
|
||||
.tc-typewriter-line { animation: tc-typewriter var(--motion-typewriter-reveal) var(--motion-typewriter-steps) both; }
|
||||
/* Live-line viewport: exactly one line-height tall, clipping the wrapped
|
||||
block. On wrap, the inner block translates up whole line-heights (set inline
|
||||
as translateY(-(n−1)·1lh)) so the completed line slides out the top —
|
||||
transform-only, same motion grammar as tool rows animating in. */
|
||||
.tc-line-scroll { display: block; height: 1lh; overflow: hidden; }
|
||||
.tc-line-scroll-inner { transition: transform var(--motion-line-scroll) var(--motion-ease-standard); will-change: transform; }
|
||||
/* Interstitial narration matches the tool rows (text-xs): prose-sm sets its
|
||||
own font-size on .paperclip-markdown, so the wrapper's text-xs alone can't
|
||||
shrink it — this scoped override wins on specificity. */
|
||||
@@ -636,8 +643,9 @@
|
||||
.tc-scroll-pill-in,
|
||||
.tc-scroll-pill-out,
|
||||
.tc-enter-plan-entry { animation: none; }
|
||||
.tc-typewriter-stream .paperclip-markdown > * { animation: none; }
|
||||
.tc-typewriter-line { animation: none; }
|
||||
.tc-turn-fold,
|
||||
.tc-line-scroll-inner,
|
||||
.tc-pane-glide { transition: none; }
|
||||
/* The pill keyframes carry the X-centering; with animation:none restore it. */
|
||||
.tc-scroll-pill-in,
|
||||
|
||||
@@ -19,13 +19,17 @@ function useStreamingReplay(
|
||||
): TaskChatItem[] {
|
||||
const [chars, setChars] = useState<number>(Number.MAX_SAFE_INTEGER);
|
||||
const streamingIndex = baseItems.findIndex(
|
||||
(i) => (i.kind === "message" && i.streaming) || (i.kind === "thinking" && i.streaming),
|
||||
(i) =>
|
||||
(i.kind === "message" && i.streaming) ||
|
||||
(i.kind === "thinking" && i.streaming) ||
|
||||
(i.kind === "turn" && !i.settled && i.liveStatus?.selfTalk != null),
|
||||
);
|
||||
const fullText = useMemo(() => {
|
||||
const it = baseItems[streamingIndex];
|
||||
if (!it) return "";
|
||||
if (it.kind === "message") return it.text;
|
||||
if (it.kind === "thinking") return it.lines.join("\n");
|
||||
if (it.kind === "turn") return it.liveStatus?.selfTalk ?? "";
|
||||
return "";
|
||||
}, [baseItems, streamingIndex]);
|
||||
|
||||
@@ -52,6 +56,11 @@ function useStreamingReplay(
|
||||
const done = chars >= fullText.length;
|
||||
if (it.kind === "message") return { ...it, text: shown, streaming: !done };
|
||||
if (it.kind === "thinking") return { ...it, lines: shown.split("\n"), streaming: !done };
|
||||
// Parent-row self-talk (PAP-356): reveal the live line's text; the y
|
||||
// line-scroll kicks in as the revealed text wraps past one line.
|
||||
if (it.kind === "turn" && it.liveStatus) {
|
||||
return { ...it, liveStatus: { ...it.liveStatus, selfTalk: shown } };
|
||||
}
|
||||
return it;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user