Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/cli/ui/layout/CardStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ export function splitCardStream(
cards: readonly Card[],
suppressLive = false,
): { committed: Card[]; live: Card[] } {
const lastIdx = cards.length - 1;
const lastCard = lastIdx >= 0 ? (cards[lastIdx] as Card) : null;
const lastIsLive = !!lastCard && !isSettled(lastCard);
if (suppressLive && lastIsLive) {
return { committed: cards.slice(0, lastIdx), live: [] };
// Static appends in order and never re-renders prior items, so a card
// can only commit once it's settled AND every earlier card is too —
// otherwise reasoning(streaming=true) freezes mid-stream when a later
// streaming/tool card appears (issue: spinner survives reasoning.end).
const firstUnsettledIdx = cards.findIndex((c) => !isSettled(c));
if (firstUnsettledIdx === -1) {
return { committed: cards.slice(), live: [] };
}
const committed: Card[] = cards.slice(0, firstUnsettledIdx);
const live: Card[] = cards.slice(firstUnsettledIdx);
if (suppressLive && live.length > 0 && !isSettled(live[live.length - 1]!)) {
return { committed, live: live.slice(0, -1) };
}
const committed: Card[] = lastIsLive ? cards.slice(0, lastIdx) : cards.slice();
const live: Card[] = lastIsLive && lastCard ? [lastCard] : [];
return { committed, live };
}

Expand Down
51 changes: 50 additions & 1 deletion tests/card-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { splitCardStream } from "../src/cli/ui/layout/CardStream.js";
import type { Card, ToolCard, UserCard } from "../src/cli/ui/state/cards.js";
import type {
Card,
ReasoningCard,
StreamingCard,
ToolCard,
UserCard,
} from "../src/cli/ui/state/cards.js";

function userCard(id: string): UserCard {
return { id, ts: 0, kind: "user", text: `user ${id}` };
Expand All @@ -19,6 +25,22 @@ function liveToolCard(id: string): ToolCard {
};
}

function liveReasoningCard(id: string): ReasoningCard {
return {
id,
ts: 0,
kind: "reasoning",
text: "thinking",
paragraphs: 0,
tokens: 0,
streaming: true,
};
}

function liveStreamingCard(id: string): StreamingCard {
return { id, ts: 0, kind: "streaming", text: "", done: false };
}

describe("splitCardStream", () => {
it("keeps the last unsettled card live by default", () => {
const cards: Card[] = [userCard("u1"), liveToolCard("t1")];
Expand All @@ -41,4 +63,31 @@ describe("splitCardStream", () => {
expect(result.committed.map((c) => c.id)).toEqual(["u1", "t1"]);
expect(result.live).toEqual([]);
});

it("keeps a still-streaming reasoning card live even when a later card appears", () => {
// Reasoning is mid-stream; a streaming-content card lands behind it.
// Reasoning must NOT be committed to Static while streaming=true, or
// the spinner survives reasoning.end (Static doesn't re-render frozen items).
const cards: Card[] = [userCard("u1"), liveReasoningCard("r1"), liveStreamingCard("s1")];
const result = splitCardStream(cards);
expect(result.committed.map((c) => c.id)).toEqual(["u1"]);
expect(result.live.map((c) => c.id)).toEqual(["r1", "s1"]);
});

it("commits a settled reasoning card once every later card is also settled", () => {
const settledReasoning: ReasoningCard = {
...liveReasoningCard("r1"),
streaming: false,
endedAt: 1,
};
const settledStreaming: StreamingCard = {
...liveStreamingCard("s1"),
done: true,
endedAt: 2,
};
const cards: Card[] = [userCard("u1"), settledReasoning, settledStreaming];
const result = splitCardStream(cards);
expect(result.committed.map((c) => c.id)).toEqual(["u1", "r1", "s1"]);
expect(result.live).toEqual([]);
});
});
Loading