mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
feat: make chat-style tasks the default experience (#11101)
This commit is contained in:
1 parent
2da6a248c3
commit
815e49bb7c
80 files changed
+3858
-1497
No files matched your search
@@ -48,12 +48,18 @@ type TranscriptBlock =
|
||||
type: "message";
|
||||
role: "assistant" | "user";
|
||||
ts: string;
|
||||
// Timestamp of the first entry that opened this block. `ts` tracks the
|
||||
// latest merged delta and mutates every chunk; `startTs` stays fixed so
|
||||
// the React key is stable and the streaming block does not remount (and
|
||||
// restart its fade) on each delta.
|
||||
startTs: string;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
}
|
||||
| {
|
||||
type: "thinking";
|
||||
ts: string;
|
||||
startTs: string;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
}
|
||||
@@ -73,6 +79,7 @@ type TranscriptBlock =
|
||||
| {
|
||||
type: "activity";
|
||||
ts: string;
|
||||
startTs: string;
|
||||
activityId?: string;
|
||||
name: string;
|
||||
status: "running" | "completed";
|
||||
@@ -125,6 +132,7 @@ type TranscriptBlock =
|
||||
| {
|
||||
type: "stdout";
|
||||
ts: string;
|
||||
startTs: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
@@ -550,6 +558,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
type: "message",
|
||||
role: entry.kind,
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
text: entry.text,
|
||||
streaming: isStreaming,
|
||||
});
|
||||
@@ -567,6 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
blocks.push({
|
||||
type: "thinking",
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
text: entry.text,
|
||||
streaming: isStreaming,
|
||||
});
|
||||
@@ -691,6 +701,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
const block: Extract<TranscriptBlock, { type: "activity" }> = {
|
||||
type: "activity",
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
activityId: activity.activityId,
|
||||
name: activity.name,
|
||||
status: activity.status,
|
||||
@@ -758,6 +769,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
blocks.push({
|
||||
type: "stdout",
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
text: entry.text,
|
||||
});
|
||||
}
|
||||
@@ -766,6 +778,55 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
return groupToolBlocks(groupCommandBlocks(blocks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a block's React key. Anchored to the block's opening
|
||||
* timestamp (`startTs`) or a durable id (`toolUseId`, `activityId`) rather than
|
||||
* its latest `ts`, which mutates on every streamed delta. A mutating key
|
||||
* remounts the block, restarting its 300ms fade-in so the text visibly blinks
|
||||
* out and back each chunk; a stable one keeps the streaming tail mounted.
|
||||
*/
|
||||
function transcriptBlockIdentity(block: TranscriptBlock): string {
|
||||
switch (block.type) {
|
||||
case "message":
|
||||
return `message:${block.role}:${block.startTs}`;
|
||||
case "thinking":
|
||||
return `thinking:${block.startTs}`;
|
||||
case "stdout":
|
||||
return `stdout:${block.startTs}`;
|
||||
case "activity":
|
||||
return `activity:${block.activityId ?? block.startTs}`;
|
||||
case "tool":
|
||||
return `tool:${block.toolUseId ?? block.ts}`;
|
||||
case "command_group":
|
||||
return `command_group:${block.ts}`;
|
||||
case "tool_group":
|
||||
return `tool_group:${block.ts}`;
|
||||
case "stderr_group":
|
||||
return `stderr_group:${block.ts}`;
|
||||
case "system_group":
|
||||
return `system_group:${block.ts}`;
|
||||
case "diff_group":
|
||||
return `diff_group:${block.ts}`;
|
||||
case "event":
|
||||
return `event:${block.label}:${block.ts}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each block a stable, unique React key. Identity is position-independent
|
||||
* so earlier blocks collapsing (truncation) does not remount the survivors; a
|
||||
* per-render occurrence counter disambiguates the rare identity collision.
|
||||
*/
|
||||
export function keyTranscriptBlocks(blocks: TranscriptBlock[]): Array<{ block: TranscriptBlock; key: string }> {
|
||||
const seen = new Map<string, number>();
|
||||
return blocks.map((block) => {
|
||||
const identity = transcriptBlockIdentity(block);
|
||||
const occurrence = seen.get(identity) ?? 0;
|
||||
seen.set(identity, occurrence + 1);
|
||||
return { block, key: occurrence === 0 ? identity : `${identity}#${occurrence}` };
|
||||
});
|
||||
}
|
||||
|
||||
function TranscriptMessageBlock({
|
||||
block,
|
||||
density,
|
||||
@@ -789,7 +850,9 @@ function TranscriptMessageBlock({
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
|
||||
// Match the default view's chat message body (IssueChatThread:
|
||||
// `text-sm leading-6`) so streamed text reads identically (PAP-461, A2).
|
||||
compact ? "text-xs leading-5 text-foreground/85" : "text-sm leading-6",
|
||||
)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
@@ -798,7 +861,7 @@ function TranscriptMessageBlock({
|
||||
{block.streaming && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-(length:--text-nano) font-medium italic text-muted-foreground">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-70" />
|
||||
<span className="tc-live-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-70" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
Streaming
|
||||
@@ -822,8 +885,11 @@ function TranscriptThinkingBlock({
|
||||
return (
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
density === "compact" ? "text-(length:--text-micro) leading-5" : "text-sm leading-6",
|
||||
// Match the default view's chain-of-thought text (IssueChatThread:
|
||||
// `text-(length:--text-compact) italic leading-5 text-muted-foreground/70`)
|
||||
// so streamed thinking reads identically across both views (PAP-461, A2).
|
||||
"italic text-muted-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
density === "compact" ? "text-(length:--text-micro) leading-5" : "text-(length:--text-compact) leading-5",
|
||||
className,
|
||||
)}
|
||||
externalReferences={externalReferences}
|
||||
@@ -1304,7 +1370,7 @@ function TranscriptActivityRow({
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
|
||||
) : (
|
||||
<span className="relative mt-1 flex h-2.5 w-2.5 shrink-0">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-70" />
|
||||
<span className="tc-live-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-70" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
@@ -1716,6 +1782,7 @@ export function RunTranscriptView({
|
||||
[entries, mode, streaming],
|
||||
);
|
||||
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
|
||||
const keyedBlocks = useMemo(() => keyTranscriptBlocks(visibleBlocks), [visibleBlocks]);
|
||||
const visibleEntries = limit ? entries.slice(-limit) : entries;
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -1736,10 +1803,10 @@ export function RunTranscriptView({
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{visibleBlocks.map((block, index) => (
|
||||
{keyedBlocks.map(({ block, key }, index) => (
|
||||
<div
|
||||
key={`${block.type}-${block.ts}-${index}`}
|
||||
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
|
||||
key={key}
|
||||
className={cn(index === keyedBlocks.length - 1 && streaming && "tc-stream-block-enter")}
|
||||
>
|
||||
{block.type === "message" && (
|
||||
<TranscriptMessageBlock
|
||||
|
||||
Reference in new issue
Block a user