Skip to content

Commit 6112d06

Browse files
OpenSource03claude
andcommitted
fix: extract thinking animation state machine and add overlap-tolerant streaming merge
- Extract ThinkingBlock animation logic into pure thinking-animation.ts module, removing coalescing timer that caused replay/duplication under rapid updates - Add mergeStreamingChunk() to streaming-buffer.ts for overlap-tolerant chunk merging (handles cumulative snapshots, overlapping deltas, and exact replays) - Apply mergeStreamingChunk in background-claude-handler for consistent background session streaming - Add comprehensive tests for both modules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8705c2b commit 6112d06

7 files changed

Lines changed: 295 additions & 126 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "harnss",
3-
"version": "0.19.0",
3+
"version": "0.19.1",
44
"productName": "Harnss",
55
"description": "Harness your AI coding agents — one desktop app for Claude Code, Codex, and any ACP agent",
66
"author": {

src/components/ThinkingBlock.tsx

Lines changed: 14 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,29 @@ import {
66
} from "@/components/ui/collapsible";
77
import { useState, useRef, useEffect, useCallback } from "react";
88
import { TextShimmer } from "@/components/ui/text-shimmer";
9+
import {
10+
advanceThinkingAnimationState,
11+
createThinkingAnimationState,
12+
} from "@/lib/thinking-animation";
913

1014
interface ThinkingBlockProps {
1115
thinking: string;
1216
isStreaming?: boolean;
1317
thinkingComplete?: boolean;
1418
}
1519

16-
interface AnimatedChunk {
17-
id: number;
18-
text: string;
19-
}
20-
21-
function commonPrefixLength(a: string, b: string): number {
22-
const max = Math.min(a.length, b.length);
23-
let i = 0;
24-
while (i < max && a.charCodeAt(i) === b.charCodeAt(i)) i += 1;
25-
return i;
26-
}
27-
2820
export function ThinkingBlock({ thinking, isStreaming, thinkingComplete }: ThinkingBlockProps) {
2921
const [open, setOpen] = useState(false);
3022
const contentRef = useRef<HTMLDivElement>(null);
3123
// Tracks whether user manually scrolled up in the inner thinking div
3224
const userScrolledRef = useRef(false);
3325
const isThinking = isStreaming && !thinkingComplete && thinking.length > 0;
3426

35-
// Render thinking as stable prefix + appended animated chunks.
36-
// This keeps earlier chunk animations running even when new chunks arrive.
37-
const prevThinkingRef = useRef(thinking);
38-
const nextChunkIdRef = useRef(0);
39-
const [baseText, setBaseText] = useState(thinking);
40-
const [animatedChunks, setAnimatedChunks] = useState<AnimatedChunk[]>([]);
27+
// Keep the animation state simple and append-only. The v0.19.0 coalescing
28+
// timer introduced replay/duplication under rapid thinking updates.
29+
const [animationState, setAnimationState] = useState(() =>
30+
createThinkingAnimationState(thinking),
31+
);
4132

4233
const handleScroll = useCallback(() => {
4334
const el = contentRef.current;
@@ -57,106 +48,11 @@ export function ThinkingBlock({ thinking, isStreaming, thinkingComplete }: Think
5748
}, [thinking, open]);
5849

5950
useEffect(() => {
60-
const prev = prevThinkingRef.current;
61-
const curr = thinking;
62-
prevThinkingRef.current = curr;
63-
64-
if (!isThinking) {
65-
setBaseText(curr);
66-
setAnimatedChunks([]);
67-
return;
68-
}
69-
70-
if (!prev || !curr) {
71-
setBaseText(curr);
72-
setAnimatedChunks([]);
73-
return;
74-
}
75-
76-
const prefixLen = commonPrefixLength(prev, curr);
77-
const appendedLen = curr.length - prefixLen;
78-
if (appendedLen <= 0) {
79-
setBaseText(curr);
80-
setAnimatedChunks([]);
81-
return;
82-
}
83-
84-
// If upstream rewrites existing text, reset to avoid animating old regions.
85-
const changedInMiddle = prefixLen < prev.length;
86-
if (changedInMiddle) {
87-
setBaseText(curr);
88-
setAnimatedChunks([]);
89-
return;
90-
}
91-
92-
const appended = curr.slice(prev.length);
93-
if (!appended) return;
94-
95-
setAnimatedChunks((chunks) => [
96-
...chunks,
97-
{ id: nextChunkIdRef.current++, text: appended },
98-
]);
51+
setAnimationState((prev) =>
52+
advanceThinkingAnimationState(prev, thinking, isThinking),
53+
);
9954
}, [thinking, isThinking]);
10055

101-
// Coalesce completed animation chunks back into baseText every 500ms
102-
// to keep the animated <span> count bounded (~20-30 max)
103-
useEffect(() => {
104-
if (!isThinking) return;
105-
106-
const COALESCE_INTERVAL = 500;
107-
const ANIMATION_DURATION = 400; // chunks older than this are "done"
108-
109-
// Track when each chunk was added
110-
const chunkTimestamps = new Map<number, number>();
111-
112-
const interval = setInterval(() => {
113-
const now = Date.now();
114-
115-
setAnimatedChunks((chunks) => {
116-
if (chunks.length === 0) return chunks;
117-
118-
// Record timestamps for new chunks
119-
for (const chunk of chunks) {
120-
if (!chunkTimestamps.has(chunk.id)) {
121-
chunkTimestamps.set(chunk.id, now);
122-
}
123-
}
124-
125-
// Find the last completed chunk index
126-
let lastCompleted = -1;
127-
for (let i = 0; i < chunks.length; i++) {
128-
const ts = chunkTimestamps.get(chunks[i].id) ?? now;
129-
if (now - ts >= ANIMATION_DURATION) {
130-
lastCompleted = i;
131-
} else {
132-
break; // chunks are in order, so stop at first incomplete
133-
}
134-
}
135-
136-
if (lastCompleted < 0) return chunks;
137-
138-
// Merge completed chunks into baseText
139-
const completedText = chunks
140-
.slice(0, lastCompleted + 1)
141-
.map((c) => c.text)
142-
.join("");
143-
144-
// Clean up timestamps for merged chunks
145-
for (let i = 0; i <= lastCompleted; i++) {
146-
chunkTimestamps.delete(chunks[i].id);
147-
}
148-
149-
setBaseText((prev) => prev + completedText);
150-
return chunks.slice(lastCompleted + 1);
151-
});
152-
}, COALESCE_INTERVAL);
153-
154-
return () => {
155-
clearInterval(interval);
156-
chunkTimestamps.clear();
157-
};
158-
}, [isThinking]);
159-
16056
const handleOpenChange = useCallback((isOpen: boolean) => {
16157
setOpen(isOpen);
16258
if (isOpen) {
@@ -189,8 +85,8 @@ export function ThinkingBlock({ thinking, isStreaming, thinkingComplete }: Think
18985
onScroll={handleScroll}
19086
className="mt-1 mb-2 max-h-60 overflow-auto border-s-2 border-foreground/10 ps-3 py-1 text-xs text-foreground/40 whitespace-pre-wrap"
19187
>
192-
{baseText}
193-
{animatedChunks.map((chunk) => (
88+
{animationState.baseText}
89+
{animationState.animatedChunks.map((chunk) => (
19490
<span key={chunk.id} className="stream-chunk-enter">{chunk.text}</span>
19591
))}
19692
</div>

src/lib/background-claude-handler.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "./protocol";
2121
import { formatResultError } from "./message-factory";
2222
import { bgAgentStore } from "./background-agent-store";
23+
import { mergeStreamingChunk } from "./streaming-buffer";
2324
import { normalizeTodoToolInput } from "./todo-utils";
2425
import type { InternalState } from "./background-session-store";
2526

@@ -60,10 +61,12 @@ function handleStreamEvent(state: InternalState, event: StreamEvent): void {
6061
if (target.thinking && !target.thinkingComplete) {
6162
target.thinkingComplete = true;
6263
}
63-
target.content += streamEvt.delta.text;
64+
target.content = mergeStreamingChunk(target.content, streamEvt.delta.text);
6465
} else if (streamEvt.delta.type === "thinking_delta") {
65-
target.thinking =
66-
(target.thinking ?? "") + streamEvt.delta.thinking;
66+
target.thinking = mergeStreamingChunk(
67+
target.thinking ?? "",
68+
streamEvt.delta.thinking,
69+
);
6770
}
6871
break;
6972
}

src/lib/streaming-buffer.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { StreamingBuffer, mergeStreamingChunk } from "./streaming-buffer";
3+
4+
describe("mergeStreamingChunk", () => {
5+
it("appends ordinary delta chunks", () => {
6+
expect(mergeStreamingChunk("Hello", " world")).toBe("Hello world");
7+
});
8+
9+
it("tolerates cumulative snapshots without duplicating prior content", () => {
10+
expect(mergeStreamingChunk("Hello", "Hello world")).toBe("Hello world");
11+
});
12+
13+
it("tolerates overlapping chunks without repeating the overlap", () => {
14+
expect(mergeStreamingChunk("Hello wor", "world")).toBe("Hello world");
15+
});
16+
17+
it("ignores exact duplicate suffix replays", () => {
18+
expect(mergeStreamingChunk("Hello world", "world")).toBe("Hello world");
19+
});
20+
});
21+
22+
describe("StreamingBuffer", () => {
23+
it("keeps thinking content stable when the SDK resends the full thought so far", () => {
24+
const buffer = new StreamingBuffer();
25+
26+
buffer.startBlock(0, { type: "thinking", thinking: "" });
27+
buffer.appendDelta(0, {
28+
type: "thinking_delta",
29+
thinking: "The user wants me to think deeply again",
30+
});
31+
buffer.appendDelta(0, {
32+
type: "thinking_delta",
33+
thinking: "The user wants me to think deeply again so they can see the thinking block UI.",
34+
});
35+
36+
expect(buffer.getAllThinking()).toBe(
37+
"The user wants me to think deeply again so they can see the thinking block UI.",
38+
);
39+
});
40+
41+
it("merges overlapping text deltas without repeating the shared prefix", () => {
42+
const buffer = new StreamingBuffer();
43+
44+
buffer.startBlock(0, { type: "text", text: "" });
45+
buffer.appendDelta(0, { type: "text_delta", text: "Time is " });
46+
buffer.appendDelta(0, { type: "text_delta", text: "is strange." });
47+
48+
expect(buffer.getAllText()).toBe("Time is strange.");
49+
});
50+
});

src/lib/streaming-buffer.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import type { ContentBlockStartEvent, ContentBlockDeltaEvent } from "../types";
22

3+
/**
4+
* Merge a streamed chunk into the current buffer while tolerating
5+
* overlapping or cumulative snapshots from upstream.
6+
*/
7+
export function mergeStreamingChunk(current: string, incoming: string): string {
8+
if (!incoming) return current;
9+
if (!current) return incoming;
10+
11+
// Some SDK paths resend the full accumulated value instead of a pure delta.
12+
if (incoming.startsWith(current)) return incoming;
13+
if (current.endsWith(incoming)) return current;
14+
15+
const maxOverlap = Math.min(current.length, incoming.length);
16+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
17+
if (current.endsWith(incoming.slice(0, overlap))) {
18+
return current + incoming.slice(overlap);
19+
}
20+
}
21+
22+
return current + incoming;
23+
}
24+
325
/**
426
* Lightweight streaming buffer for engines that don't use SDK content block events
527
* (ACP and Codex). Accumulates text and thinking chunks with a simple append API.
@@ -10,8 +32,15 @@ export class SimpleStreamingBuffer {
1032
private thinkingChunks: string[] = [];
1133
thinkingComplete = false;
1234

13-
appendText(text: string): void { this.textChunks.push(text); }
14-
appendThinking(text: string): void { this.thinkingChunks.push(text); }
35+
appendText(text: string): void {
36+
const current = this.textChunks.join("");
37+
this.textChunks = [mergeStreamingChunk(current, text)];
38+
}
39+
40+
appendThinking(text: string): void {
41+
const current = this.thinkingChunks.join("");
42+
this.thinkingChunks = [mergeStreamingChunk(current, text)];
43+
}
1544

1645
getText(): string { return this.textChunks.join(""); }
1746
getThinking(): string { return this.thinkingChunks.join(""); }
@@ -68,15 +97,15 @@ export class StreamingBuffer {
6897
appendDelta(index: number, delta: ContentBlockDeltaEvent["delta"]): boolean {
6998
if (delta.type === "text_delta") {
7099
const current = this.text.get(index) ?? "";
71-
this.text.set(index, current + delta.text);
100+
this.text.set(index, mergeStreamingChunk(current, delta.text));
72101
return true;
73102
} else if (delta.type === "input_json_delta") {
74103
const current = this.toolInput.get(index) ?? "";
75104
this.toolInput.set(index, current + delta.partial_json);
76105
return false;
77106
} else if (delta.type === "thinking_delta") {
78107
const current = this.thinking.get(index) ?? "";
79-
this.thinking.set(index, current + delta.thinking);
108+
this.thinking.set(index, mergeStreamingChunk(current, delta.thinking));
80109
return true;
81110
}
82111
return false;

0 commit comments

Comments
 (0)