Skip to content

Commit 51bc310

Browse files
committed
🤖 refactor: deflake ChatInput focus for Storybook
1 parent 6595f11 commit 51bc310

File tree

4 files changed

+92
-12
lines changed

4 files changed

+92
-12
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,21 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
325325
}
326326
}, [variant, defaultModel, storageKeys.modelKey]);
327327

328+
// ChatInputSection is always present and is a convenient place to surface focus scheduling
329+
// state for Storybook/tests without adding story-only props.
330+
const chatInputSectionRef = useRef<HTMLDivElement | null>(null);
331+
const handleChatInputSectionRef = useCallback((el: HTMLDivElement | null) => {
332+
chatInputSectionRef.current = el;
333+
// Set an initial value once (avoid overriding later transitions).
334+
if (el && !el.hasAttribute("data-autofocus-state")) {
335+
el.setAttribute("data-autofocus-state", "pending");
336+
}
337+
}, []);
338+
339+
const setChatInputAutoFocusState = useCallback((state: "pending" | "done") => {
340+
chatInputSectionRef.current?.setAttribute("data-autofocus-state", state);
341+
}, []);
342+
328343
const focusMessageInput = useCallback(() => {
329344
const element = inputRef.current;
330345
if (!element || element.disabled) {
@@ -610,16 +625,50 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
610625
}, [voiceInput, setToast]);
611626

612627
// Auto-focus chat input when workspace changes (workspace only)
628+
//
629+
// This is intentionally NOT a setTimeout-based delay. Fixed sleeps are prone to races
630+
// (especially in Storybook) and can still fire after other UI interactions.
613631
const workspaceIdForFocus = variant === "workspace" ? props.workspaceId : null;
614632
useEffect(() => {
615633
if (variant !== "workspace") return;
616634

617-
// Small delay to ensure DOM is ready and other components have settled
618-
const timer = setTimeout(() => {
635+
const maxFrames = 10;
636+
setChatInputAutoFocusState("pending");
637+
638+
let cancelled = false;
639+
let rafId: number | null = null;
640+
let attempts = 0;
641+
642+
const step = () => {
643+
if (cancelled) return;
644+
645+
attempts += 1;
619646
focusMessageInput();
620-
}, 100);
621-
return () => clearTimeout(timer);
622-
}, [variant, workspaceIdForFocus, focusMessageInput]);
647+
648+
const input = inputRef.current;
649+
const isFocused = !!input && document.activeElement === input;
650+
const isDone = isFocused || attempts >= maxFrames;
651+
652+
if (isDone) {
653+
setChatInputAutoFocusState("done");
654+
return;
655+
}
656+
657+
rafId = requestAnimationFrame(step);
658+
};
659+
660+
// Start on the next frame so the textarea is mounted and ready.
661+
rafId = requestAnimationFrame(step);
662+
663+
return () => {
664+
cancelled = true;
665+
if (rafId !== null) {
666+
cancelAnimationFrame(rafId);
667+
}
668+
// Ensure we never leave the attribute stuck in "pending".
669+
setChatInputAutoFocusState("done");
670+
};
671+
}, [variant, workspaceIdForFocus, focusMessageInput, setChatInputAutoFocusState]);
623672

624673
// Handle paste events to extract images
625674
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
@@ -1371,6 +1420,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
13711420

13721421
{/* Input section - centered card for creation, bottom bar for workspace */}
13731422
<div
1423+
ref={handleChatInputSectionRef}
13741424
className={cn(
13751425
"relative flex flex-col gap-1",
13761426
variant === "creation"

src/browser/stories/App.bash.stories.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,19 @@ async function expandAllBashTools(canvasElement: HTMLElement) {
6464
}
6565

6666
// Avoid leaving focus on a tool header.
67-
// ChatInput also auto-focuses on a 100ms timer on mount/workspace changes; wait for
68-
// that to fire before blurring so Storybook screenshots are deterministic.
69-
await new Promise((resolve) => setTimeout(resolve, 150));
67+
// Wait for ChatInput's auto-focus attempt to finish (no timing-based sleeps).
68+
await waitFor(
69+
() => {
70+
const state = canvasElement
71+
.querySelector('[data-component="ChatInputSection"]')
72+
?.getAttribute("data-autofocus-state");
73+
if (state !== "done") {
74+
throw new Error("ChatInput auto-focus not finished");
75+
}
76+
},
77+
{ timeout: 5000 }
78+
);
79+
7080
(document.activeElement as HTMLElement | null)?.blur?.();
7181
}
7282

src/browser/stories/App.chat.stories.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,8 +330,18 @@ export const GenericTool: AppStory = {
330330
},
331331
{ timeout: 5000 }
332332
);
333-
// Wait for any auto-focus timers (ChatInput has 100ms delay), then blur
334-
await new Promise((resolve) => setTimeout(resolve, 150));
333+
// Wait for ChatInput's auto-focus attempt to finish (no timing-based sleeps), then blur
334+
await waitFor(
335+
() => {
336+
const state = canvasElement
337+
.querySelector('[data-component="ChatInputSection"]')
338+
?.getAttribute("data-autofocus-state");
339+
if (state !== "done") {
340+
throw new Error("ChatInput auto-focus not finished");
341+
}
342+
},
343+
{ timeout: 5000 }
344+
);
335345
(document.activeElement as HTMLElement)?.blur();
336346
},
337347
};

src/browser/stories/App.reviews.stories.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,18 @@ export const BulkReviewActions: AppStory = {
273273
await userEvent.click(bannerButton);
274274
});
275275

276-
// Wait for any auto-focus timers, then blur
277-
await new Promise((resolve) => setTimeout(resolve, 150));
276+
// Wait for ChatInput's auto-focus attempt to finish (no timing-based sleeps), then blur
277+
await waitFor(
278+
() => {
279+
const state = canvasElement
280+
.querySelector('[data-component="ChatInputSection"]')
281+
?.getAttribute("data-autofocus-state");
282+
if (state !== "done") {
283+
throw new Error("ChatInput auto-focus not finished");
284+
}
285+
},
286+
{ timeout: 5000 }
287+
);
278288
(document.activeElement as HTMLElement)?.blur();
279289
},
280290
};

0 commit comments

Comments
 (0)