mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
feat(ui): round-9 polish — reserved interstitial slot + Worked summary attached to the reply timestamp
The interstitial row above the gerund status line is now permanently reserved while a turn is live: text slides in/out of the always-mounted 1lh slot, so the layout above never jumps as messages appear/disappear. On settle, a turn directly following its own agent's reply bubble attaches to it (attachSettledTurns, after coalesceSettledTurns): the "✓ Worked · …" summary renders appended to the bubble's timestamp line — "2:34 PM · ✓ Worked · 38s · 2 tools" — still expandable to the tool history. Bubble timestamps are always visible (hover-only reveal removed). Turns without a reply bubble keep the standalone folded row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4951e2da0e
commit
eef40c0347
11 files changed
+265
-54
No files matched your search
@@ -3,6 +3,7 @@ import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import {
|
||||
attachSettledTurns,
|
||||
buildTurnSummary,
|
||||
coalesceSettledTurns,
|
||||
deriveRunStatusLabel,
|
||||
@@ -55,16 +56,16 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
* tool state + elapsed + tokens) is the turn's single visible line, with the
|
||||
* chronological tool activity nested behind an expand (PAP-354). Mid-run
|
||||
* agent text (interstitial updates between tool calls) is ephemeral
|
||||
* (PAP-361): while one streams it takes the parent row's line inside a
|
||||
* one-line line-scroll viewport, and when it finishes the line returns to the
|
||||
* gerund/tool rotation — nothing persists; the run log and the classic
|
||||
* transcript remain the archive. 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
|
||||
* comment (comment.runId linkage) — the agent's reply bubble above, the folded
|
||||
* activity summary below, in the slot the live parent row occupied. flag-OFF
|
||||
* remains byte-for-byte IssueChatThread.
|
||||
* (PAP-361, round 9): while one streams it occupies a dedicated one-line row
|
||||
* PERMANENTLY RESERVED above the status line (so the layout above never
|
||||
* jumps), and when it finishes the text slides out and the slot sits empty —
|
||||
* nothing persists; the run log and the classic transcript remain the
|
||||
* archive. When the run terminates, its settled turn anchors after the run's
|
||||
* last comment (comment.runId linkage) and — when it directly follows that
|
||||
* reply bubble — attaches to it: the "✓ Worked · …" summary renders appended
|
||||
* to the bubble's always-visible timestamp line (round 9), still expandable
|
||||
* to the tool history. Turns without a reply bubble keep the standalone
|
||||
* folded row. flag-OFF remains byte-for-byte IssueChatThread.
|
||||
*/
|
||||
export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const {
|
||||
@@ -325,9 +326,10 @@ 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/usage rows nest
|
||||
// inside it; a streaming interstitial update takes the header's own line
|
||||
// (liveStatus.selfTalk, PAP-361) and vanishes when it completes. The
|
||||
// parent row sits last — the slot its settled summary takes over in place.
|
||||
// inside it; a streaming interstitial update renders in the reserved row
|
||||
// above the status line (liveStatus.selfTalk, PAP-361/round 9) and
|
||||
// vanishes when it completes. The parent row sits last; on settle its
|
||||
// summary attaches to the reply bubble's timestamp line.
|
||||
const children = parsed.filter(isNestableLiveChild);
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const queued = liveRun.status === "queued";
|
||||
@@ -355,7 +357,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// PAP-362: two runs replying back-to-back (same agent, nothing but the
|
||||
// agent's own bubbles between) fold into ONE "Worked" row below the last
|
||||
// reply; a user message, interaction, or the live turn keeps them apart.
|
||||
return coalesceSettledTurns(out, turnMergeMetaById);
|
||||
// Round 9: a settled turn directly following its own agent's reply bubble
|
||||
// then attaches to that bubble — the "Worked · …" summary renders on the
|
||||
// bubble's always-visible timestamp line instead of as a standalone row.
|
||||
return attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById);
|
||||
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]);
|
||||
|
||||
const renderInteraction = useCallback(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -16,6 +17,13 @@ import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
interface TaskChatBubbleProps {
|
||||
item: TaskChatMessageItem;
|
||||
/**
|
||||
* The settled run turn rendered on this bubble's footer line (round 9):
|
||||
* replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools"
|
||||
* (the timestamp leads the summary), expandable to the nested tool history.
|
||||
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
|
||||
*/
|
||||
attachedTurn?: ReactNode;
|
||||
}
|
||||
|
||||
function initialsForName(name: string) {
|
||||
@@ -32,7 +40,7 @@ function initialsForName(name: string) {
|
||||
* bubble with an avatar author header (the agent's assigned icon + name · mode
|
||||
* chip); system notices are centered and recede.
|
||||
*/
|
||||
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
|
||||
export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) {
|
||||
if (item.interstitial) {
|
||||
// Interstitial updates are ephemeral (PAP-361): while streaming the text
|
||||
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
|
||||
@@ -54,7 +62,7 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
|
||||
// attachment chips under the bubble; link-only lines leave the body text.
|
||||
const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text);
|
||||
return (
|
||||
<div className={cn("tc-enter-bubble group flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
|
||||
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
|
||||
{!isHuman && item.authorName ? (
|
||||
<span className="flex items-center gap-2 px-1">
|
||||
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
|
||||
@@ -119,8 +127,15 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.optimistic === "queued" ? "Queued" : "Sending…"}
|
||||
</span>
|
||||
) : attachedTurn ? (
|
||||
// The settled turn takes over the footer line: timestamp + "✓ Worked"
|
||||
// summary, always visible; expanding stretches beneath the bubble.
|
||||
<div className="self-stretch" data-testid="task-chat-bubble-attached-turn">
|
||||
{attachedTurn}
|
||||
</div>
|
||||
) : item.timestamp ? (
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
||||
// Timestamps are always visible (round 9) — no longer hover-revealed.
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -90,6 +90,19 @@ describe("TaskChatStatusPill whimsy", () => {
|
||||
expect(container.textContent).toContain("18,240/200,000 ctx");
|
||||
});
|
||||
|
||||
it("permanently reserves the interstitial row while live so layout never jumps (round 9)", () => {
|
||||
const item = liveStatus({ tokens: { used: 18240, size: 200000 } });
|
||||
render(item);
|
||||
// No message streaming: the row is still mounted as an empty one-line
|
||||
// slot (same height as when text occupies it) — nothing above shifts when
|
||||
// a message later appears.
|
||||
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
|
||||
expect(row).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
|
||||
// The empty slot keeps the 1lh viewport height.
|
||||
expect(row?.querySelector(".tc-line-scroll")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("mounts a streaming interstitial as its own row above the status line (PAP-361 amended)", () => {
|
||||
const item = liveStatus({
|
||||
label: "Responding",
|
||||
@@ -118,20 +131,21 @@ describe("TaskChatStatusPill whimsy", () => {
|
||||
expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner");
|
||||
});
|
||||
|
||||
it("slides the row out and unmounts it once the interstitial completes", () => {
|
||||
it("slides the text out but keeps the reserved row once the interstitial completes", () => {
|
||||
const item = liveStatus({ tokens: { used: 18240, size: 200000 } });
|
||||
render(liveStatus({ label: "Responding", selfTalk: "Almost there." }));
|
||||
expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).not.toBeNull();
|
||||
render(item);
|
||||
// selfTalk gone → the row is briefly held for its slide-out…
|
||||
// selfTalk gone → the text is briefly held for its slide-out…
|
||||
const held = container.querySelector('[data-testid="task-chat-interstitial-row"]');
|
||||
expect(held?.textContent).toContain("Almost there.");
|
||||
// …then unmounts after the motion-token hold (0ms in jsdom — no computed
|
||||
// token value); gerund + dot carry on.
|
||||
// …then the TEXT unmounts after the motion-token hold (0ms in jsdom — no
|
||||
// computed token value) while the reserved row stays in layout (round 9:
|
||||
// no jump); gerund + dot carry on below.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
|
||||
expect(container.textContent).toContain(`${whimsyWord(item.id, 0)}…`);
|
||||
expect(container.querySelector(".animate-pulse")).not.toBeNull();
|
||||
|
||||
@@ -49,8 +49,9 @@ export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): nu
|
||||
* below the 1lh clip; when it wraps, the inner block translates up by whole
|
||||
* line-heights so the completed line slides out the top while the stream
|
||||
* continues on the fresh line; and when the message ends (`leaving`) it shifts
|
||||
* one further line so the last line slides out before the row unmounts. Line
|
||||
* count is measured from scrollHeight / line-height, re-checked on resize.
|
||||
* one further line so the last line slides out before the text unmounts (the
|
||||
* reserved row itself never leaves layout while the turn is live). Line count
|
||||
* is measured from scrollHeight / line-height, re-checked on resize.
|
||||
*/
|
||||
function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean }) {
|
||||
const innerRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -88,9 +89,10 @@ function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the streaming interstitial text briefly after it clears so the row can
|
||||
* slide out before unmounting. The hold duration is read from the same motion
|
||||
* token the slide transition uses (0 under reduced motion → instant unmount).
|
||||
* Holds the streaming interstitial text briefly after it clears so it can
|
||||
* slide out of the reserved row before unmounting. The hold duration is read
|
||||
* from the same motion token the slide transition uses (0 under reduced
|
||||
* motion → instant unmount).
|
||||
*/
|
||||
function useHeldSelfTalk(selfTalk: string | undefined): { text: string; leaving: boolean } | null {
|
||||
const [held, setHeld] = useState<string | null>(selfTalk ?? null);
|
||||
@@ -203,12 +205,13 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
if (!heldSelfTalk) return statusLine;
|
||||
// A streaming interstitial gets its own bubble-less single-line row
|
||||
// DIRECTLY ABOVE the status line — the two never share a line. The row
|
||||
// slides into / out of the 1lh viewport (LiveSelfTalkLine) when a message
|
||||
// starts/ends and unmounts after the slide-out; the empty lead slot keeps
|
||||
// its text column-aligned with the status label below.
|
||||
// The interstitial row is PERMANENTLY RESERVED while the turn is live
|
||||
// (round 9): the layout above never jumps to make room. A streaming
|
||||
// interstitial gets this bubble-less single-line row DIRECTLY ABOVE the
|
||||
// status line — the two never share a line. Text slides into / out of the
|
||||
// 1lh viewport (LiveSelfTalkLine) when a message starts/ends; between
|
||||
// messages the row stays as an empty one-line slot of identical height.
|
||||
// The empty lead slot keeps the text column-aligned with the status label.
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
@@ -216,8 +219,14 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
|
||||
data-testid="task-chat-interstitial-row"
|
||||
>
|
||||
<span aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
<LiveSelfTalkLine text={heldSelfTalk.text} leaving={heldSelfTalk.leaving} />
|
||||
{heldSelfTalk ? (
|
||||
<>
|
||||
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
<LiveSelfTalkLine text={heldSelfTalk.text} leaving={heldSelfTalk.leaving} />
|
||||
</>
|
||||
) : (
|
||||
<span aria-hidden className="tc-line-scroll min-w-0 flex-1" />
|
||||
)}
|
||||
</div>
|
||||
{statusLine}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,20 @@ function renderItem(
|
||||
) {
|
||||
switch (item.kind) {
|
||||
case "message":
|
||||
return <TaskChatBubble item={item} />;
|
||||
return (
|
||||
<TaskChatBubble
|
||||
item={item}
|
||||
attachedTurn={
|
||||
item.attachedTurn ? (
|
||||
<TaskChatTurn
|
||||
item={item.attachedTurn}
|
||||
timestampPrefix={item.timestamp}
|
||||
renderChild={(child) => renderItem(child, onApprovalDecision)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "marker":
|
||||
return <TaskChatMarker item={item} />;
|
||||
case "thinking":
|
||||
@@ -109,7 +122,7 @@ export function TaskChatThreadView({
|
||||
// item count changing) still advances the auto-follow key. Shared by the
|
||||
// desktop scroller above and the mobile window-scroll follow (TaskChatThread).
|
||||
function signatureOf(it: TaskChatItem): number {
|
||||
if (it.kind === "message") return it.text.length;
|
||||
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
|
||||
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
|
||||
if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
|
||||
if (it.kind === "turn") {
|
||||
|
||||
@@ -203,4 +203,17 @@ describe("TaskChatTurn", () => {
|
||||
expect(summaryBtn()?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
|
||||
it("leads the settled summary with the bubble timestamp when attached (round 9)", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TaskChatTurn item={SETTLED} timestampPrefix="2:34 PM" renderChild={(c) => <span>{c.id}</span>} />,
|
||||
);
|
||||
});
|
||||
// "2:34 PM · ✓ Worked · …" — timestamp first, always visible, and the
|
||||
// expand affordance still works from the same line.
|
||||
expect(summaryBtn()?.textContent).toMatch(/^2:34 PM·Worked/);
|
||||
flushSync(() => summaryBtn()!.click());
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,12 @@ import { TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
interface TaskChatTurnProps {
|
||||
item: TaskChatTurnItem;
|
||||
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
|
||||
/**
|
||||
* When the settled turn is attached to its reply bubble (round 9), the
|
||||
* bubble's timestamp leads the summary line — "2:34 PM · ✓ Worked · 38s" —
|
||||
* always visible, in the slot the hover-only timestamp used to occupy.
|
||||
*/
|
||||
timestampPrefix?: string;
|
||||
}
|
||||
|
||||
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
|
||||
@@ -44,7 +50,7 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
|
||||
* `liveStatus` (harness fixtures) renders its children expanded with no header
|
||||
* and folds when it settles.
|
||||
*/
|
||||
export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
|
||||
export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTurnProps) {
|
||||
const parentRow = !item.settled && item.liveStatus != null;
|
||||
// Parent-row live turns and settled turns start as their one-line header;
|
||||
// only the headerless legacy live turn starts expanded.
|
||||
@@ -77,6 +83,12 @@ export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
|
||||
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="task-chat-turn-summary"
|
||||
>
|
||||
{timestampPrefix ? (
|
||||
<>
|
||||
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
|
||||
<span aria-hidden className="text-(length:--text-micro)">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
|
||||
{turnSummaryMetrics(item.summary) ? (
|
||||
|
||||
@@ -168,19 +168,31 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
// Round 9: the settled turn attaches to the final reply bubble — the
|
||||
// "✓ Worked · …" summary renders on the bubble's always-visible
|
||||
// timestamp line ("2:34 PM · ✓ Worked · 38s · 2 tools").
|
||||
{
|
||||
id: "turn-done",
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
// "Worked · N tools" expands to exactly the tool rows (PAP-361):
|
||||
// toolCount matches the nested rows, no thinking row.
|
||||
summary: { durationLabel: "38s", toolCount: 2, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
|
||||
items: [
|
||||
{ id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
{ 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 } },
|
||||
],
|
||||
id: "m-done",
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
authorName: AGENT,
|
||||
agentIcon: "bot",
|
||||
modeLabel: "Agent mode",
|
||||
text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.",
|
||||
timestamp: "2:34 PM",
|
||||
attachedTurn: {
|
||||
id: "turn-done",
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
// "Worked · N tools" expands to exactly the tool rows (PAP-361):
|
||||
// toolCount matches the nested rows, no thinking row.
|
||||
summary: { durationLabel: "38s", toolCount: 2, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
|
||||
items: [
|
||||
{ id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
{ 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 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: "m-done", kind: "message", author: "agent", authorName: AGENT, agentIcon: "bot", modeLabel: "Agent mode", text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.", timestamp: "2:34 PM" },
|
||||
],
|
||||
};
|
||||
case "awaiting-approval":
|
||||
|
||||
@@ -86,6 +86,14 @@ export interface TaskChatMessageItem {
|
||||
interstitial?: boolean;
|
||||
/** Epoch ms of the message's first streamed chunk. */
|
||||
atMs?: number;
|
||||
/**
|
||||
* The settled run turn "attached" to this bubble (round 9): when the run's
|
||||
* final reply lands, the live parent row transforms into the "Worked · …"
|
||||
* summary rendered on this bubble's always-visible timestamp line —
|
||||
* "2:34 PM · ✓ Worked · 38s · 3 tools" — instead of a standalone row.
|
||||
* Expanding still nests the tool history beneath the bubble.
|
||||
*/
|
||||
attachedTurn?: TaskChatTurnItem;
|
||||
}
|
||||
|
||||
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
|
||||
@@ -142,10 +150,11 @@ export interface TaskChatStatusItem {
|
||||
toolName?: string;
|
||||
/**
|
||||
* Flattened plain text of the interstitial update currently streaming
|
||||
* (PAP-361, amended round 8): it renders as a dedicated single-line row
|
||||
* directly above the status line, inside a one-line viewport that scrolls
|
||||
* completed lines out the top; the gerund rotation below runs uninterrupted.
|
||||
* Ephemeral — when the message finishes the row slides out and unmounts,
|
||||
* (PAP-361, round 9): it renders in a dedicated single-line row directly
|
||||
* above the status line — a slot PERMANENTLY RESERVED while the turn is
|
||||
* live, so the layout above never jumps — inside a one-line viewport that
|
||||
* scrolls completed lines out the top; the gerund rotation below runs
|
||||
* uninterrupted. Ephemeral — when the message finishes the text slides out
|
||||
* and nothing persists in the thread.
|
||||
*/
|
||||
selfTalk?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import {
|
||||
attachSettledTurns,
|
||||
buildMergedTurnSummary,
|
||||
buildTurnSummary,
|
||||
coalesceSettledTurns,
|
||||
@@ -512,3 +513,73 @@ describe("coalesceSettledTurns (PAP-362)", () => {
|
||||
expect((merged[0] as Extract<TaskChatItem, { kind: "turn" }>).animateFold).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachSettledTurns (round 9)", () => {
|
||||
const runA: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||||
];
|
||||
|
||||
function agentBubble(id: string, authorName = "CEO"): TaskChatItem {
|
||||
return { id, kind: "message", author: "agent", authorName, text: "Done." };
|
||||
}
|
||||
function settledTurn(id: string): TaskChatItem {
|
||||
return {
|
||||
id,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
|
||||
summary: buildTurnSummary(runA, { durationMs: 30_000 }),
|
||||
};
|
||||
}
|
||||
const metaById = new Map<string, SettledTurnMergeMeta>([
|
||||
["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }],
|
||||
]);
|
||||
|
||||
it("folds a settled turn into the agent reply bubble directly above it", () => {
|
||||
const out = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-a:turn")], metaById);
|
||||
expect(out).toHaveLength(1);
|
||||
const bubble = out[0] as Extract<TaskChatItem, { kind: "message" }>;
|
||||
expect(bubble.id).toBe("msg-a");
|
||||
expect(bubble.attachedTurn?.id).toBe("run-a:turn");
|
||||
expect(bubble.attachedTurn?.summary.durationLabel).toBe("30s");
|
||||
});
|
||||
|
||||
it("keeps the standalone row when the preceding item is not that agent's bubble", () => {
|
||||
// Human message directly above.
|
||||
const afterHuman = attachSettledTurns(
|
||||
[{ id: "msg-u", kind: "message", author: "human", text: "hi" }, settledTurn("run-a:turn")],
|
||||
metaById,
|
||||
);
|
||||
expect(afterHuman.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
// Another agent's bubble above.
|
||||
const otherAgent = attachSettledTurns([agentBubble("msg-q", "QA"), settledTurn("run-a:turn")], metaById);
|
||||
expect(otherAgent.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
// No meta (unknown identity) → conservative standalone fallback.
|
||||
const noMeta = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-x:turn")], metaById);
|
||||
expect(noMeta.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
// Nothing above at all.
|
||||
const first = attachSettledTurns([settledTurn("run-a:turn")], metaById);
|
||||
expect(first.map((i) => i.kind)).toEqual(["turn"]);
|
||||
});
|
||||
|
||||
it("never attaches the live (unsettled) turn and attaches at most one turn per bubble", () => {
|
||||
const live: TaskChatItem = {
|
||||
id: "run-live:turn",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
items: [],
|
||||
summary: buildTurnSummary([]),
|
||||
};
|
||||
const out = attachSettledTurns([agentBubble("msg-a"), live], metaById);
|
||||
expect(out.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
|
||||
// A second settled turn behind an already-attached bubble stays standalone
|
||||
// (coalesceSettledTurns runs first, so same-agent turns arrive pre-merged).
|
||||
const twice = attachSettledTurns(
|
||||
[agentBubble("msg-a"), settledTurn("run-a:turn"), settledTurn("run-a:turn")],
|
||||
metaById,
|
||||
);
|
||||
expect(twice.map((i) => i.kind)).toEqual(["message", "turn"]);
|
||||
expect((twice[0] as Extract<TaskChatItem, { kind: "message" }>).attachedTurn?.id).toBe("run-a:turn");
|
||||
});
|
||||
});
|
||||
@@ -501,6 +501,44 @@ export function coalesceSettledTurns(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final assembly pass (round 9, after coalesceSettledTurns): a settled turn
|
||||
* that directly follows its own agent's reply bubble folds INTO that bubble as
|
||||
* `attachedTurn` — the "Worked · …" summary renders appended to the bubble's
|
||||
* always-visible timestamp line instead of as a standalone row. The turn must
|
||||
* belong to the same agent as the bubble (metaById identity); turns without
|
||||
* meta, or preceded by anything other than that agent's non-interstitial
|
||||
* bubble, keep the standalone-row fallback.
|
||||
*/
|
||||
export function attachSettledTurns(
|
||||
items: readonly TaskChatItem[],
|
||||
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
|
||||
): TaskChatItem[] {
|
||||
const out: TaskChatItem[] = [];
|
||||
for (const item of items) {
|
||||
const prev = out[out.length - 1];
|
||||
if (
|
||||
item.kind === "turn" &&
|
||||
item.settled &&
|
||||
prev?.kind === "message" &&
|
||||
prev.author === "agent" &&
|
||||
!prev.interstitial &&
|
||||
!prev.streaming &&
|
||||
prev.attachedTurn == null
|
||||
) {
|
||||
const meta = metaById.get(item.id);
|
||||
const sameAgent =
|
||||
meta != null && (meta.agentName == null || prev.authorName == null || meta.agentName === prev.authorName);
|
||||
if (sameAgent) {
|
||||
out[out.length - 1] = { ...prev, attachedTurn: item };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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")
|
||||
|
||||
Reference in new issue
Block a user