feat: make chat-style tasks the default experience (#11101)

This commit is contained in:
scotttong authored and GitHub committed 2026-08-11 09:06:21 -07:00
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