mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
121 lines
4.8 KiB
TypeScript
121 lines
4.8 KiB
TypeScript
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 { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
|
|
import { TaskChatLiveTail } from "./TaskChatLiveTail";
|
|
import { paperclipRunnerHistoryItems } from "./transcript-adapter";
|
|
import { toolTaxonomy } from "./tool-taxonomy";
|
|
|
|
function lastOf<T extends TaskChatItem>(items: readonly TaskChatItem[], predicate: (item: TaskChatItem) => item is T): T | undefined {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (predicate(item)) return item;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
|
|
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
|
|
items,
|
|
(item): item is TaskChatThinkingItem | TaskChatToolItem => item.kind === "thinking" || item.kind === "tool",
|
|
);
|
|
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 />
|
|
<span className="shimmer-text shimmer-text-muted">Thinking</span>
|
|
</div>
|
|
);
|
|
}
|
|
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
|
|
const Icon = taxonomy.icon;
|
|
const active = activity.status === "pending" || activity.status === "in_progress";
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
|
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>{taxonomy.verbLabel}</span>
|
|
{activity.target ? <span className="min-w-0 truncate font-mono text-xs">{activity.target}</span> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TaskChatRunnerTurn({
|
|
runId,
|
|
items,
|
|
status,
|
|
startedAtMs,
|
|
finishedAtMs,
|
|
toolSummary,
|
|
}: {
|
|
/** Stable identity used to clear replay-latched final text for the next turn. */
|
|
runId?: string | null;
|
|
items: readonly TaskChatItem[];
|
|
status: string;
|
|
startedAtMs: number | null;
|
|
finishedAtMs?: number | null;
|
|
toolSummary: string | null;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const historyItems = paperclipRunnerHistoryItems(items);
|
|
const progress = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial));
|
|
const observedFinal = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
|
|
// A reconnect/replay can briefly rebuild the transcript without the final
|
|
// item (or with an earlier, shorter prefix). Once durable answer text is on
|
|
// screen it must be monotonic for the lifetime of this mounted turn.
|
|
const finalRef = useRef<{ runId?: string | null; item?: TaskChatMessageItem }>({ runId });
|
|
if (finalRef.current.runId !== runId) {
|
|
finalRef.current = { runId };
|
|
}
|
|
if (observedFinal && (!finalRef.current.item || observedFinal.text.length >= finalRef.current.item.text.length)) {
|
|
finalRef.current.item = observedFinal;
|
|
}
|
|
const final = finalRef.current.item;
|
|
|
|
if (status === "queued") {
|
|
return (
|
|
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
|
|
<CurrentActivity items={items} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
|
|
<TaskChatLiveRunPill
|
|
status={status}
|
|
startedAtMs={startedAtMs}
|
|
finishedAtMs={finishedAtMs}
|
|
toolSummary={toolSummary}
|
|
runnerStyle
|
|
expanded={open}
|
|
onToggle={() => setOpen((value) => !value)}
|
|
/>
|
|
<div className="tc-turn-fold" data-folded={open ? "false" : "true"} aria-hidden={!open}>
|
|
<div>
|
|
<div className="flex flex-col gap-2 py-2">
|
|
<TaskChatLiveTail items={historyItems} excludeFinal />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!open ? (
|
|
<div className="flex min-w-0 flex-col py-1">
|
|
{progress ? (
|
|
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
|
|
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
|
|
</div>
|
|
) : null}
|
|
{!final ? <CurrentActivity items={items} /> : null}
|
|
</div>
|
|
) : null}
|
|
{final ? (
|
|
<div data-testid="task-chat-final-response">
|
|
<TaskChatBubble item={final} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|