Skip to content

Commit 2f0cc7e

Browse files
ILindsleyclaude
andcommitted
v1.1.2: Fix animation completing prematurely on stream end
The 500ms safety-valve timeout was force-finalizing the message swap before the character animation could finish, causing the same jarring scroll jump. Replaced with an isCompleting state flag so AnimatedText only triggers the swap when both the completion event has arrived AND the animation has fully caught up. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 35f69f0 commit 2f0cc7e

File tree

6 files changed

+47
-40
lines changed

6 files changed

+47
-40
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bt-servant-web-client",
3-
"version": "1.1.1",
3+
"version": "1.1.2",
44
"private": true,
55
"scripts": {
66
"dev": "next dev",

src/app/(auth)/login/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export default function LoginPage() {
135135

136136
{/* Footer */}
137137
<p className="mt-3 text-center font-sans text-[10px] text-[#8a8985] dark:text-[#6b6a68]">
138-
BT Servant Web v1.1.1
138+
BT Servant Web v1.1.2
139139
</p>
140140
</div>
141141
</div>

src/components/assistant-ui/thread.tsx

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const Thread: FC = () => {
113113
/>
114114
<Composer />
115115
<p className="mt-2 text-center font-sans text-xs text-[#9a9893]">
116-
BT Servant Web v1.1.1
116+
BT Servant Web v1.1.2
117117
</p>
118118
</div>
119119
</AssistantIf>
@@ -196,7 +196,7 @@ const ThreadWelcome: FC = () => {
196196
{/* Footer */}
197197
<div className="shrink-0 pb-4">
198198
<p className="text-center font-sans text-xs text-[#9a9893]">
199-
BT Servant Web v1.1.1
199+
BT Servant Web v1.1.2
200200
</p>
201201
</div>
202202
</div>
@@ -300,14 +300,10 @@ const UserMessage: FC = () => {
300300
};
301301

302302
// Animated text hook for streaming - handles character-by-character reveal
303-
function useAnimatedText(text: string, onComplete?: () => void): string {
303+
function useAnimatedText(text: string): [string, boolean] {
304304
const [displayedLength, setDisplayedLength] = useState(text.length);
305305
// Track previous text to detect resets
306306
const [prevText, setPrevText] = useState(text);
307-
const onCompleteRef = useRef(onComplete);
308-
useEffect(() => {
309-
onCompleteRef.current = onComplete;
310-
}, [onComplete]);
311307

312308
// Detect text reset and update state together
313309
if (text !== prevText) {
@@ -333,27 +329,42 @@ function useAnimatedText(text: string, onComplete?: () => void): string {
333329
}
334330
}, [text.length, displayedLength]);
335331

336-
// Fire onComplete when animation catches up to the full text
332+
const isAnimationDone = displayedLength >= text.length;
333+
return [
334+
text.slice(0, Math.min(displayedLength, text.length)),
335+
isAnimationDone,
336+
];
337+
}
338+
339+
// Animated text component for streaming
340+
const AnimatedText: FC<{
341+
text: string;
342+
isCompleting: boolean;
343+
onAnimationCaughtUp: () => void;
344+
}> = ({ text, isCompleting, onAnimationCaughtUp }) => {
345+
const [displayedText, isAnimationDone] = useAnimatedText(text);
346+
const calledRef = useRef(false);
347+
348+
// Reset the called flag when isCompleting transitions to true
337349
useEffect(() => {
338-
if (displayedLength >= text.length && text.length > 0) {
339-
onCompleteRef.current?.();
350+
if (isCompleting) {
351+
calledRef.current = false;
340352
}
341-
}, [displayedLength, text.length]);
353+
}, [isCompleting]);
342354

343-
return text.slice(0, Math.min(displayedLength, text.length));
344-
}
355+
// Fire callback when completing AND animation has caught up
356+
useEffect(() => {
357+
if (isCompleting && isAnimationDone && !calledRef.current) {
358+
calledRef.current = true;
359+
onAnimationCaughtUp();
360+
}
361+
}, [isCompleting, isAnimationDone, onAnimationCaughtUp]);
345362

346-
// Animated text component for streaming
347-
const AnimatedText: FC<{ text: string; onComplete?: () => void }> = ({
348-
text,
349-
onComplete,
350-
}) => {
351-
const displayedText = useAnimatedText(text, onComplete);
352363
return <span className="whitespace-pre-wrap">{displayedText}</span>;
353364
};
354365

355366
const AssistantMessage: FC = () => {
356-
const { finalizeComplete } = useChatContext();
367+
const { finalizeComplete, isCompleting } = useChatContext();
357368
const audioBase64 = useAssistantState(
358369
({ message }) => message.metadata?.custom?.audioBase64 as string | undefined
359370
);
@@ -366,7 +377,7 @@ const AssistantMessage: FC = () => {
366377
return firstPart?.type === "text" ? firstPart.text : "";
367378
});
368379

369-
const handleAnimationComplete = useCallback(() => {
380+
const handleAnimationCaughtUp = useCallback(() => {
370381
finalizeComplete();
371382
}, [finalizeComplete]);
372383

@@ -377,7 +388,8 @@ const AssistantMessage: FC = () => {
377388
{isStreaming ? (
378389
<AnimatedText
379390
text={messageText}
380-
onComplete={handleAnimationComplete}
391+
isCompleting={isCompleting}
392+
onAnimationCaughtUp={handleAnimationCaughtUp}
381393
/>
382394
) : (
383395
<MessagePrimitive.Parts components={{ Text: MarkdownText }} />

src/components/providers/assistant-provider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface ChatContextValue {
1414
statusMessage: string | null;
1515
streamingText: string;
1616
finalizeComplete: () => void;
17+
isCompleting: boolean;
1718
}
1819

1920
const ChatContext = createContext<ChatContextValue | null>(null);
@@ -33,6 +34,7 @@ export function AssistantProvider({ children }: { children: ReactNode }) {
3334
statusMessage,
3435
streamingText,
3536
finalizeComplete,
37+
isCompleting,
3638
} = useChatRuntime();
3739

3840
return (
@@ -43,6 +45,7 @@ export function AssistantProvider({ children }: { children: ReactNode }) {
4345
statusMessage,
4446
streamingText,
4547
finalizeComplete,
48+
isCompleting,
4649
}}
4750
>
4851
<AssistantRuntimeProvider runtime={runtime}>

src/hooks/use-chat-runtime.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function useChatRuntime() {
7171
const [streamingText, setStreamingText] = useState<string>("");
7272
const historyLoadedRef = useRef(false);
7373
const pendingCompleteRef = useRef<{ message: ChatMessage } | null>(null);
74+
const [isCompleting, setIsCompleting] = useState(false);
7475
const streamingTextRef = useRef(streamingText);
7576
useEffect(() => {
7677
streamingTextRef.current = streamingText;
@@ -131,26 +132,13 @@ export function useChatRuntime() {
131132
if (!pending) return;
132133

133134
pendingCompleteRef.current = null;
135+
setIsCompleting(false);
134136
setMessages((prev) => [...prev, pending.message]);
135137
setIsLoading(false);
136138
setStatusMessage(null);
137139
setStreamingText("");
138140
}, []);
139141

140-
// Safety valve: if animation callback never fires (e.g. component unmount),
141-
// force-finalize after a short delay to prevent stuck state
142-
useEffect(() => {
143-
if (!pendingCompleteRef.current) return;
144-
145-
const timeout = setTimeout(() => {
146-
if (pendingCompleteRef.current) {
147-
finalizeComplete();
148-
}
149-
}, 500);
150-
151-
return () => clearTimeout(timeout);
152-
}, [streamingText, finalizeComplete]);
153-
154142
// Define handlers before sendMessage so they can be in the dependency array
155143
const handleComplete = useCallback((data: ChatResponse) => {
156144
const joinedResponse = data.responses.join("\n\n");
@@ -175,12 +163,14 @@ export function useChatRuntime() {
175163

176164
// Defer swap: store pending data and set full text so animation finishes
177165
pendingCompleteRef.current = { message: assistantMessage };
166+
setIsCompleting(true);
178167
setStreamingText(joinedResponse);
179168
setStatusMessage(null);
180169
}, []);
181170

182171
const handleError = useCallback((errorMessage: string) => {
183172
pendingCompleteRef.current = null;
173+
setIsCompleting(false);
184174
setMessages((prev) => [
185175
...prev,
186176
createMessage(`error-${Date.now()}`, "assistant", errorMessage),
@@ -196,6 +186,7 @@ export function useChatRuntime() {
196186
if (pendingCompleteRef.current) {
197187
const pending = pendingCompleteRef.current;
198188
pendingCompleteRef.current = null;
189+
setIsCompleting(false);
199190
setMessages((prev) => [...prev, pending.message]);
200191
}
201192

@@ -312,5 +303,6 @@ export function useChatRuntime() {
312303
sendMessage,
313304
clearMessages: () => setMessages([]),
314305
finalizeComplete,
306+
isCompleting,
315307
};
316308
}

0 commit comments

Comments
 (0)