mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
fix(runner): keep live turn layout stable
This commit is contained in:
1 parent
c31a345a3d
commit
d2c851523b
9 files changed
+144
-34
No files matched your search
@@ -580,7 +580,7 @@ describe("TaskChatThread mobile composer dock (PAP-495)", () => {
|
||||
});
|
||||
|
||||
describe("TaskChatThread live transcript", () => {
|
||||
it("keeps a persisted runner reply hidden while the live turn still owns that response", () => {
|
||||
it("keeps an unlinked persisted runner reply hidden while the live turn still owns that response", () => {
|
||||
transcriptState.transcriptByRun.set("run-runner", [
|
||||
{
|
||||
kind: "assistant",
|
||||
@@ -600,7 +600,7 @@ describe("TaskChatThread live transcript", () => {
|
||||
body: "Completed the requested streaming test.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
runId: "run-runner",
|
||||
runId: null,
|
||||
createdAt: new Date("2026-08-21T15:44:22.000Z"),
|
||||
updatedAt: new Date("2026-08-21T15:44:22.000Z"),
|
||||
};
|
||||
|
||||
@@ -60,6 +60,25 @@ function toMs(value: Date | string | null | undefined): number {
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
function normalizedMessageText(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function isRunnerResponseComment(params: {
|
||||
comment: TaskChatThreadProps["comments"][number];
|
||||
runId: string;
|
||||
runStartedAtMs: number | null;
|
||||
finalText: string | null;
|
||||
}): boolean {
|
||||
const { comment, runId, runStartedAtMs, finalText } = params;
|
||||
if (comment.deletedAt || comment.authorType === "user") return false;
|
||||
if (comment.runId === runId) return true;
|
||||
if (comment.runId || !finalText) return false;
|
||||
const commentAtMs = toMs(comment.createdAt);
|
||||
if (runStartedAtMs != null && commentAtMs < runStartedAtMs - 5_000) return false;
|
||||
return normalizedMessageText(comment.body) === normalizedMessageText(finalText);
|
||||
}
|
||||
|
||||
// PAP-462 B4: backstop for how long the just-settled run's transcript stays
|
||||
// mounted when neither a settled turn nor a reply comment ever arrives to hand
|
||||
// off to (e.g. a stopped run with no tool activity). Normal completions hand off
|
||||
@@ -276,6 +295,20 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: settlingRun && runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner"
|
||||
? settlingRun.id
|
||||
: null;
|
||||
const heldPaperclipRunnerStartedAtMs = heldPaperclipRunnerRunId === liveRun?.id
|
||||
? (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)
|
||||
: settlingRun?.startedAtMs ?? null;
|
||||
const heldPaperclipRunnerFinalText = useMemo(() => {
|
||||
if (!heldPaperclipRunnerRunId) return null;
|
||||
const entries = transcriptByRun.get(heldPaperclipRunnerRunId) ?? [];
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (entry.kind === "assistant" && entry.channel === "final" && entry.text.trim()) {
|
||||
return entry.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [heldPaperclipRunnerRunId, transcriptByRun]);
|
||||
|
||||
// Runs observed non-terminal while mounted: their turns ANIMATE the fold when
|
||||
// they settle. Runs already terminal at mount collapse instantly.
|
||||
@@ -313,7 +346,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// The live/settling runner lane already renders this run's final answer.
|
||||
// Keep a just-persisted duplicate out of the backbone until the complete
|
||||
// canonical bubble + Worked header can replace that lane atomically.
|
||||
if (comment.runId === heldPaperclipRunnerRunId) return;
|
||||
if (
|
||||
heldPaperclipRunnerRunId &&
|
||||
isRunnerResponseComment({
|
||||
comment,
|
||||
runId: heldPaperclipRunnerRunId,
|
||||
runStartedAtMs: heldPaperclipRunnerStartedAtMs,
|
||||
finalText: heldPaperclipRunnerFinalText,
|
||||
})
|
||||
) return;
|
||||
const item = commentItems[index];
|
||||
if (!item) return;
|
||||
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
|
||||
@@ -370,7 +411,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return entries.sort(
|
||||
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
|
||||
);
|
||||
}, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument, heldPaperclipRunnerRunId]);
|
||||
}, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument, heldPaperclipRunnerRunId, heldPaperclipRunnerStartedAtMs, heldPaperclipRunnerFinalText]);
|
||||
|
||||
// Boolean gate (stable across the host's per-render brief objects) so the
|
||||
// heavy assembly memo doesn't recompute on every parent render.
|
||||
@@ -509,10 +550,18 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// Hand off once the settled turn or its reply comment is in the thread; a
|
||||
// stopped run that yields neither is released by the backstop timeout so the
|
||||
// tail never lingers indefinitely.
|
||||
const settlingHasComment = settlingRun != null &&
|
||||
comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt);
|
||||
const settlingIsPaperclipRunner = settlingRun != null &&
|
||||
runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner";
|
||||
const settlingHasComment = settlingRun != null && comments.some((comment) =>
|
||||
settlingIsPaperclipRunner
|
||||
? isRunnerResponseComment({
|
||||
comment,
|
||||
runId: settlingRun.id,
|
||||
runStartedAtMs: settlingRun.startedAtMs,
|
||||
finalText: heldPaperclipRunnerFinalText,
|
||||
})
|
||||
: comment.runId === settlingRun.id && !comment.deletedAt,
|
||||
);
|
||||
const settledRunRendered = settlingRun != null &&
|
||||
(settlingIsPaperclipRunner
|
||||
? settlingHasComment && settledRunIds.has(settlingRun.id)
|
||||
@@ -587,6 +636,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// tailStreaming: true while live, false through the settle gap — so the tail
|
||||
// renders identically either side of the run finishing.
|
||||
const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName;
|
||||
const tailAgentId = liveRun?.agentId
|
||||
?? linkedRunMetaById.get(tailRunId ?? "")?.agentId
|
||||
?? issueAssigneeAgentId;
|
||||
const tailAgent = tailAgentId ? agentMap?.get(tailAgentId) : undefined;
|
||||
const visibleTailAgentName = tailAgentName ?? tailAgent?.name ?? null;
|
||||
const visibleTailAgentIcon = tailAgent?.icon ?? null;
|
||||
const tailItems = useMemo(
|
||||
() =>
|
||||
tailRunId
|
||||
@@ -764,6 +819,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
{paperclipRunnerTail || optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
runId={tailRunId}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
items={tailItems}
|
||||
status={optimisticRunnerStartup ? "queued" : tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
|
||||
@@ -50,6 +50,37 @@ function initialsForName(name: string) {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function TaskChatAgentIdentity({
|
||||
agentName,
|
||||
agentIcon,
|
||||
onBehalfOfUserName,
|
||||
}: {
|
||||
agentName: string;
|
||||
agentIcon?: string | null;
|
||||
onBehalfOfUserName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 px-1" data-testid="task-chat-agent-identity">
|
||||
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
|
||||
{agentIcon ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={agentIcon} className="h-3.5 w-3.5" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<AvatarFallback>{initialsForName(agentName)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="text-sm font-semibold text-foreground">{agentName}</span>
|
||||
{onBehalfOfUserName ? (
|
||||
<CommentAttributionChip
|
||||
agentName={agentName}
|
||||
userName={onBehalfOfUserName}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Author-typed message row — the primary legibility signal. Human messages sit
|
||||
* right in a solid accent bubble; agent messages sit directly on the page
|
||||
@@ -109,24 +140,11 @@ export function TaskChatBubble({
|
||||
<div className={cn("flex w-full flex-col gap-1", animateEntry && "tc-enter-bubble", isHuman ? "items-end" : "items-start")}>
|
||||
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
|
||||
{!isHuman && item.authorName ? (
|
||||
<span className="flex items-center gap-2 px-1">
|
||||
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
|
||||
{item.agentIcon ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={item.agentIcon} className="h-3.5 w-3.5" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<AvatarFallback>{initialsForName(item.authorName)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="text-sm font-semibold text-foreground">{item.authorName}</span>
|
||||
{item.onBehalfOfUserName ? (
|
||||
<CommentAttributionChip
|
||||
agentName={item.authorName}
|
||||
userName={item.onBehalfOfUserName}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<TaskChatAgentIdentity
|
||||
agentName={item.authorName}
|
||||
agentIcon={item.agentIcon}
|
||||
onBehalfOfUserName={item.onBehalfOfUserName}
|
||||
/>
|
||||
) : null}
|
||||
{bodyText.length > 0 ? (
|
||||
<div
|
||||
|
||||
@@ -89,4 +89,20 @@ describe("TaskChatLiveRunPill", () => {
|
||||
expect(pill?.textContent).toContain("for 42 seconds");
|
||||
expect(pill?.textContent).toContain("ran 1 command");
|
||||
});
|
||||
|
||||
it("keeps the paperclip runner Working header static", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TaskChatLiveRunPill
|
||||
status="running"
|
||||
startedAtMs={Date.now() - 2_000}
|
||||
toolSummary={null}
|
||||
runnerStyle
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Workingfor 2s");
|
||||
expect(container.querySelector(".shimmer-text")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export function TaskChatLiveRunPill({
|
||||
if (runnerStyle) {
|
||||
const content = (
|
||||
<>
|
||||
<span className={cn("text-sm font-medium text-foreground/80", active && "shimmer-text")}>{verb}</span>
|
||||
<span className="text-sm font-medium text-foreground/80">{verb}</span>
|
||||
{suffix ? <span className="text-sm text-muted-foreground">{suffix}</span> : null}
|
||||
<ChevronRight
|
||||
className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform", expanded && "rotate-90")}
|
||||
|
||||
@@ -24,13 +24,15 @@ describe("TaskChatRunnerTurn", () => {
|
||||
|
||||
const render = (items: TaskChatItem[], status = "running", runId = "run-1") => act(() => root.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatRunnerTurn runId={runId} items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
|
||||
<TaskChatRunnerTurn runId={runId} agentName="Runner" items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
|
||||
</ThemeProvider>,
|
||||
));
|
||||
|
||||
it("shows immediate Thinking before the first runner event", () => {
|
||||
render([], "queued");
|
||||
expect(container.textContent).toContain("Thinking");
|
||||
expect(container.textContent).toContain("Runner");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"] svg')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Working");
|
||||
expect(container.textContent).not.toContain("Waiting for transcript");
|
||||
});
|
||||
@@ -51,7 +53,7 @@ describe("TaskChatRunnerTurn", () => {
|
||||
{ id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "Completed successfully.", channel: "final", streaming: true },
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully.");
|
||||
expect(container.querySelector('[data-testid="task-chat-agent-avatar"]')).not.toBeNull();
|
||||
expect(container.querySelectorAll('[data-testid="task-chat-agent-avatar"]')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Brain } from "lucide-react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskChatItem, TaskChatMessageItem, TaskChatThinkingItem, TaskChatToolItem } from "./task-chat-model";
|
||||
import { TaskChatBubble } from "./TaskChatBubble";
|
||||
import { TaskChatAgentIdentity } from "./TaskChatBubble";
|
||||
import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
|
||||
import { TaskChatLiveTail } from "./TaskChatLiveTail";
|
||||
import { paperclipRunnerHistoryItems } from "./transcript-adapter";
|
||||
@@ -24,8 +23,7 @@ function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
|
||||
);
|
||||
if (!activity || activity.kind === "thinking") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<Brain className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<div className="px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<span className="shimmer-text shimmer-text-muted">Thinking</span>
|
||||
</div>
|
||||
);
|
||||
@@ -44,6 +42,8 @@ function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
|
||||
|
||||
export function TaskChatRunnerTurn({
|
||||
runId,
|
||||
agentName,
|
||||
agentIcon,
|
||||
items,
|
||||
status,
|
||||
startedAtMs,
|
||||
@@ -52,6 +52,8 @@ export function TaskChatRunnerTurn({
|
||||
}: {
|
||||
/** Stable identity used to clear replay-latched final text for the next turn. */
|
||||
runId?: string | null;
|
||||
agentName?: string | null;
|
||||
agentIcon?: string | null;
|
||||
items: readonly TaskChatItem[];
|
||||
status: string;
|
||||
startedAtMs: number | null;
|
||||
@@ -76,8 +78,9 @@ export function TaskChatRunnerTurn({
|
||||
|
||||
if (status === "queued") {
|
||||
return (
|
||||
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
|
||||
<div className="flex flex-col gap-1 py-1" data-testid="task-chat-runner-turn" data-phase="startup">
|
||||
<CurrentActivity items={items} />
|
||||
{agentName ? <TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,9 +113,12 @@ export function TaskChatRunnerTurn({
|
||||
{!final ? <CurrentActivity items={items} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{agentName ? <TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} /> : null}
|
||||
{final ? (
|
||||
<div data-testid="task-chat-final-response">
|
||||
<TaskChatBubble item={final} />
|
||||
<div className="tc-enter-bubble w-full" data-testid="task-chat-final-response">
|
||||
<div className="break-words px-1 py-2 text-sm text-foreground" data-testid="task-chat-agent-bubble">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{final.text}</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -84,6 +84,14 @@ describe("toolTaxonomy", () => {
|
||||
expect(toolTaxonomy(undefined).icon).toBe(Wrench);
|
||||
expect(toolTaxonomy(null).icon).toBe(Wrench);
|
||||
});
|
||||
|
||||
it("labels the semantic completion tool as Finishing", () => {
|
||||
expect(toolTaxonomy("paperclip_finish")).toEqual({
|
||||
family: "other",
|
||||
icon: Wrench,
|
||||
verbLabel: "Finishing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolTaxonomy multi-word ACP titles", () => {
|
||||
|
||||
@@ -104,6 +104,9 @@ function classify(n: string): Exclude<ToolFamily, "mcp"> {
|
||||
export function toolTaxonomy(name: string | undefined | null): ToolTaxonomyEntry {
|
||||
const raw = (name ?? "").trim();
|
||||
if (!raw) return { family: "other", ...FAMILY_META.other };
|
||||
if (raw.toLowerCase() === "paperclip_finish") {
|
||||
return { family: "other", icon: Wrench, verbLabel: "Finishing" };
|
||||
}
|
||||
const mcpTool = mcpToolSegment(raw);
|
||||
if (mcpTool) return { family: "mcp", icon: McpIcon, verbLabel: `Using ${mcpTool}` };
|
||||
const family = classify(raw.toLowerCase());
|
||||
|
||||
Reference in new issue
Block a user