From 881eb14528fdc928549eea2ea238029b102a6651 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 20:25:05 -0600 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20ChatInput?= =?UTF-8?q?=20focus=20in=20bash=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/stories/App.bash.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/browser/stories/App.bash.stories.tsx b/src/browser/stories/App.bash.stories.tsx index 4418837ff4..f8a3824db9 100644 --- a/src/browser/stories/App.bash.stories.tsx +++ b/src/browser/stories/App.bash.stories.tsx @@ -63,7 +63,10 @@ async function expandAllBashTools(canvasElement: HTMLElement) { } } - // Avoid leaving focus on a tool header (some components auto-focus inputs on timers) + // Avoid leaving focus on a tool header. + // ChatInput also auto-focuses on a 100ms timer on mount/workspace changes; wait for + // that to fire before blurring so Storybook screenshots are deterministic. + await new Promise((resolve) => setTimeout(resolve, 150)); (document.activeElement as HTMLElement | null)?.blur?.(); } From 701f303efaf4bc0ae3e3f94c4ce31364f67a1696 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 20:51:39 -0600 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20deflake=20ChatIn?= =?UTF-8?q?put=20focus=20for=20Storybook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/ChatInput/index.tsx | 60 +++++++++++++++++++-- src/browser/stories/App.bash.stories.tsx | 16 ++++-- src/browser/stories/App.chat.stories.tsx | 16 ++++++ src/browser/stories/App.reviews.stories.tsx | 14 ++++- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 67ae836050..e16a82eb92 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -325,6 +325,21 @@ const ChatInputInner: React.FC = (props) => { } }, [variant, defaultModel, storageKeys.modelKey]); + // ChatInputSection is always present and is a convenient place to surface focus scheduling + // state for Storybook/tests without adding story-only props. + const chatInputSectionRef = useRef(null); + const handleChatInputSectionRef = useCallback((el: HTMLDivElement | null) => { + chatInputSectionRef.current = el; + // Set an initial value once (avoid overriding later transitions). + if (el && !el.hasAttribute("data-autofocus-state")) { + el.setAttribute("data-autofocus-state", "pending"); + } + }, []); + + const setChatInputAutoFocusState = useCallback((state: "pending" | "done") => { + chatInputSectionRef.current?.setAttribute("data-autofocus-state", state); + }, []); + const focusMessageInput = useCallback(() => { const element = inputRef.current; if (!element || element.disabled) { @@ -610,16 +625,50 @@ const ChatInputInner: React.FC = (props) => { }, [voiceInput, setToast]); // Auto-focus chat input when workspace changes (workspace only) + // + // This is intentionally NOT a setTimeout-based delay. Fixed sleeps are prone to races + // (especially in Storybook) and can still fire after other UI interactions. const workspaceIdForFocus = variant === "workspace" ? props.workspaceId : null; useEffect(() => { if (variant !== "workspace") return; - // Small delay to ensure DOM is ready and other components have settled - const timer = setTimeout(() => { + const maxFrames = 10; + setChatInputAutoFocusState("pending"); + + let cancelled = false; + let rafId: number | null = null; + let attempts = 0; + + const step = () => { + if (cancelled) return; + + attempts += 1; focusMessageInput(); - }, 100); - return () => clearTimeout(timer); - }, [variant, workspaceIdForFocus, focusMessageInput]); + + const input = inputRef.current; + const isFocused = !!input && document.activeElement === input; + const isDone = isFocused || attempts >= maxFrames; + + if (isDone) { + setChatInputAutoFocusState("done"); + return; + } + + rafId = requestAnimationFrame(step); + }; + + // Start on the next frame so the textarea is mounted and ready. + rafId = requestAnimationFrame(step); + + return () => { + cancelled = true; + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + // Ensure we never leave the attribute stuck in "pending". + setChatInputAutoFocusState("done"); + }; + }, [variant, workspaceIdForFocus, focusMessageInput, setChatInputAutoFocusState]); // Handle paste events to extract images const handlePaste = useCallback((e: React.ClipboardEvent) => { @@ -1371,6 +1420,7 @@ const ChatInputInner: React.FC = (props) => { {/* Input section - centered card for creation, bottom bar for workspace */}
setTimeout(resolve, 150)); + // Wait for ChatInput's auto-focus attempt to finish (no timing-based sleeps). + await waitFor( + () => { + const state = canvasElement + .querySelector('[data-component="ChatInputSection"]') + ?.getAttribute("data-autofocus-state"); + if (state !== "done") { + throw new Error("ChatInput auto-focus not finished"); + } + }, + { timeout: 5000 } + ); + (document.activeElement as HTMLElement | null)?.blur?.(); } diff --git a/src/browser/stories/App.chat.stories.tsx b/src/browser/stories/App.chat.stories.tsx index b956d85a35..2755b2151b 100644 --- a/src/browser/stories/App.chat.stories.tsx +++ b/src/browser/stories/App.chat.stories.tsx @@ -16,6 +16,7 @@ import { } from "./mockFactory"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getModelKey } from "@/common/constants/storage"; +import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js"; import { setupSimpleChatStory, setupStreamingChatStory } from "./storyHelpers"; import { within, userEvent, waitFor } from "@storybook/test"; @@ -425,6 +426,21 @@ export const GenericTool: AppStory = { }, }, }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + // Wait for workspace metadata to load and main content to render + await waitFor( + async () => { + const toolHeader = canvas.getByText("fetch_data"); + await userEvent.click(toolHeader); + }, + { timeout: 5000 } + ); + + await waitForChatInputAutofocusDone(canvasElement); + blurActiveElement(); + }, }; /** Streaming compaction with shimmer effect - tests GPU-accelerated animation */ diff --git a/src/browser/stories/App.reviews.stories.tsx b/src/browser/stories/App.reviews.stories.tsx index 8fcba4c909..53e0fb9df2 100644 --- a/src/browser/stories/App.reviews.stories.tsx +++ b/src/browser/stories/App.reviews.stories.tsx @@ -273,8 +273,18 @@ export const BulkReviewActions: AppStory = { await userEvent.click(bannerButton); }); - // Wait for any auto-focus timers, then blur - await new Promise((resolve) => setTimeout(resolve, 150)); + // Wait for ChatInput's auto-focus attempt to finish (no timing-based sleeps), then blur + await waitFor( + () => { + const state = canvasElement + .querySelector('[data-component="ChatInputSection"]') + ?.getAttribute("data-autofocus-state"); + if (state !== "done") { + throw new Error("ChatInput auto-focus not finished"); + } + }, + { timeout: 5000 } + ); (document.activeElement as HTMLElement)?.blur(); }, }; From 92b1df15ee0df1b5b34a76a0d7ab34fc8a5a24d8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 20:56:29 -0600 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stop=20ChatInput=20au?= =?UTF-8?q?tofocus=20from=20fighting=20other=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/ChatInput/index.tsx | 28 ++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index e16a82eb92..90999b5037 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -331,8 +331,10 @@ const ChatInputInner: React.FC = (props) => { const handleChatInputSectionRef = useCallback((el: HTMLDivElement | null) => { chatInputSectionRef.current = el; // Set an initial value once (avoid overriding later transitions). + // Default to "done" so non-workspace variants (or stories that don't rely on this) + // never get stuck waiting for a focus attempt that won't run. if (el && !el.hasAttribute("data-autofocus-state")) { - el.setAttribute("data-autofocus-state", "pending"); + el.setAttribute("data-autofocus-state", "done"); } }, []); @@ -643,9 +645,31 @@ const ChatInputInner: React.FC = (props) => { if (cancelled) return; attempts += 1; - focusMessageInput(); const input = inputRef.current; + const active = document.activeElement; + + // If something else already took focus (e.g. a modal, command palette, or a user click), + // do not keep fighting it across frames. + if ( + active instanceof HTMLElement && + active !== document.body && + active !== document.documentElement + ) { + const isWithinChatInput = !!chatInputSectionRef.current?.contains(active); + const isInput = !!input && active === input; + + // If something else already took focus (e.g. a modal, command palette, or a user click), + // do not keep fighting it across frames. + if (!isWithinChatInput && !isInput) { + setChatInputAutoFocusState("done"); + return; + } + } + + // Try focusing; if the input isn't mounted yet, we'll retry on the next frame. + focusMessageInput(); + const isFocused = !!input && document.activeElement === input; const isDone = isFocused || attempts >= maxFrames; From c73c19423045c3f6c05995022e2a7dbfd01fd340 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 21:13:34 -0600 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20dedupe=20Storybo?= =?UTF-8?q?ok=20autofocus=20waits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/stories/App.bash.stories.tsx | 17 +++------------ src/browser/stories/App.chat.stories.tsx | 1 - src/browser/stories/App.reviews.stories.tsx | 17 ++++----------- src/browser/stories/storyPlayHelpers.ts | 24 +++++++++++++++++++++ 4 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 src/browser/stories/storyPlayHelpers.ts diff --git a/src/browser/stories/App.bash.stories.tsx b/src/browser/stories/App.bash.stories.tsx index 3c0ff334cc..d9c05c58bb 100644 --- a/src/browser/stories/App.bash.stories.tsx +++ b/src/browser/stories/App.bash.stories.tsx @@ -17,6 +17,7 @@ import { createBashBackgroundTerminateTool, } from "./mockFactory"; import { setupSimpleChatStory } from "./storyHelpers"; +import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js"; import { userEvent, waitFor } from "@storybook/test"; /** @@ -64,20 +65,8 @@ async function expandAllBashTools(canvasElement: HTMLElement) { } // Avoid leaving focus on a tool header. - // Wait for ChatInput's auto-focus attempt to finish (no timing-based sleeps). - await waitFor( - () => { - const state = canvasElement - .querySelector('[data-component="ChatInputSection"]') - ?.getAttribute("data-autofocus-state"); - if (state !== "done") { - throw new Error("ChatInput auto-focus not finished"); - } - }, - { timeout: 5000 } - ); - - (document.activeElement as HTMLElement | null)?.blur?.(); + await waitForChatInputAutofocusDone(canvasElement); + blurActiveElement(); } export default { diff --git a/src/browser/stories/App.chat.stories.tsx b/src/browser/stories/App.chat.stories.tsx index 2755b2151b..502566190a 100644 --- a/src/browser/stories/App.chat.stories.tsx +++ b/src/browser/stories/App.chat.stories.tsx @@ -437,7 +437,6 @@ export const GenericTool: AppStory = { }, { timeout: 5000 } ); - await waitForChatInputAutofocusDone(canvasElement); blurActiveElement(); }, diff --git a/src/browser/stories/App.reviews.stories.tsx b/src/browser/stories/App.reviews.stories.tsx index 53e0fb9df2..e4eb54f5d6 100644 --- a/src/browser/stories/App.reviews.stories.tsx +++ b/src/browser/stories/App.reviews.stories.tsx @@ -4,6 +4,7 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { setupSimpleChatStory, setReviews, createReview } from "./storyHelpers"; +import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js"; import { createUserMessage, createAssistantMessage } from "./mockFactory"; import { within, userEvent, waitFor } from "@storybook/test"; @@ -273,18 +274,8 @@ export const BulkReviewActions: AppStory = { await userEvent.click(bannerButton); }); - // Wait for ChatInput's auto-focus attempt to finish (no timing-based sleeps), then blur - await waitFor( - () => { - const state = canvasElement - .querySelector('[data-component="ChatInputSection"]') - ?.getAttribute("data-autofocus-state"); - if (state !== "done") { - throw new Error("ChatInput auto-focus not finished"); - } - }, - { timeout: 5000 } - ); - (document.activeElement as HTMLElement)?.blur(); + // Wait for ChatInput's auto-focus attempt to finish, then blur + await waitForChatInputAutofocusDone(canvasElement); + blurActiveElement(); }, }; diff --git a/src/browser/stories/storyPlayHelpers.ts b/src/browser/stories/storyPlayHelpers.ts new file mode 100644 index 0000000000..9c5c3dd5b5 --- /dev/null +++ b/src/browser/stories/storyPlayHelpers.ts @@ -0,0 +1,24 @@ +/** + * Helpers intended for Storybook play() functions only. + * Keeping these separate avoids pulling @storybook/test into render-time story setup helpers. + */ + +import { waitFor } from "@storybook/test"; + +export async function waitForChatInputAutofocusDone(canvasElement: HTMLElement): Promise { + await waitFor( + () => { + const state = canvasElement + .querySelector('[data-component="ChatInputSection"]') + ?.getAttribute("data-autofocus-state"); + if (state !== "done") { + throw new Error("ChatInput auto-focus not finished"); + } + }, + { timeout: 5000 } + ); +} + +export function blurActiveElement(): void { + (document.activeElement as HTMLElement | null)?.blur?.(); +} From 941ade4bf0d4c7d97040c44cbefd2026e63e1166 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 13 Dec 2025 21:39:56 -0600 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20reduce=20ChatInp?= =?UTF-8?q?ut=20and=20story=20play=20LoC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/components/ChatInput/index.tsx | 236 ++++++--------------- src/browser/stories/storyPlayHelpers.ts | 5 - 2 files changed, 67 insertions(+), 174 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 90999b5037..5f356f25cf 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -153,6 +153,13 @@ const ChatInputInner: React.FC = (props) => { const [imageAttachments, setImageAttachments] = useState([]); // Attached reviews come from parent via props (persisted in pendingReviews state) const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : []; + + const pushToast = useCallback( + (nextToast: Omit) => { + setToast({ id: Date.now().toString(), ...nextToast }); + }, + [setToast] + ); const handleToastDismiss = useCallback(() => { setToast(null); }, []); @@ -209,7 +216,7 @@ const ChatInputInner: React.FC = (props) => { }); }, onError: (error) => { - setToast({ id: Date.now().toString(), type: "error", message: error }); + pushToast({ type: "error", message: error }); }, onSend: () => void handleSend(), openAIKeySet, @@ -325,19 +332,8 @@ const ChatInputInner: React.FC = (props) => { } }, [variant, defaultModel, storageKeys.modelKey]); - // ChatInputSection is always present and is a convenient place to surface focus scheduling - // state for Storybook/tests without adding story-only props. + // Expose ChatInput auto-focus completion for Storybook/tests. const chatInputSectionRef = useRef(null); - const handleChatInputSectionRef = useCallback((el: HTMLDivElement | null) => { - chatInputSectionRef.current = el; - // Set an initial value once (avoid overriding later transitions). - // Default to "done" so non-workspace variants (or stories that don't rely on this) - // never get stuck waiting for a focus attempt that won't run. - if (el && !el.hasAttribute("data-autofocus-state")) { - el.setAttribute("data-autofocus-state", "done"); - } - }, []); - const setChatInputAutoFocusState = useCallback((state: "pending" | "done") => { chatInputSectionRef.current?.setAttribute("data-autofocus-state", state); }, []); @@ -592,8 +588,7 @@ const ChatInputInner: React.FC = (props) => { xhigh: "Extra High — extended deep thinking", }; - setToast({ - id: Date.now().toString(), + pushToast({ type: "success", message: `Thinking effort set to ${levelDescriptions[level]}`, }); @@ -602,7 +597,7 @@ const ChatInputInner: React.FC = (props) => { window.addEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); return () => window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); - }, [variant, props, setToast]); + }, [variant, props, pushToast]); // Voice input: command palette toggle + global recording keybinds useEffect(() => { @@ -610,8 +605,7 @@ const ChatInputInner: React.FC = (props) => { const handleToggle = () => { if (!voiceInput.isApiKeySet) { - setToast({ - id: Date.now().toString(), + pushToast({ type: "error", message: "Voice input requires OpenAI API key. Configure in Settings → Providers.", }); @@ -624,12 +618,9 @@ const ChatInputInner: React.FC = (props) => { return () => { window.removeEventListener(CUSTOM_EVENTS.TOGGLE_VOICE_INPUT, handleToggle as EventListener); }; - }, [voiceInput, setToast]); + }, [voiceInput, pushToast]); - // Auto-focus chat input when workspace changes (workspace only) - // - // This is intentionally NOT a setTimeout-based delay. Fixed sleeps are prone to races - // (especially in Storybook) and can still fire after other UI interactions. + // Auto-focus chat input when workspace changes (workspace only). const workspaceIdForFocus = variant === "workspace" ? props.workspaceId : null; useEffect(() => { if (variant !== "workspace") return; @@ -649,8 +640,6 @@ const ChatInputInner: React.FC = (props) => { const input = inputRef.current; const active = document.activeElement; - // If something else already took focus (e.g. a modal, command palette, or a user click), - // do not keep fighting it across frames. if ( active instanceof HTMLElement && active !== document.body && @@ -658,16 +647,12 @@ const ChatInputInner: React.FC = (props) => { ) { const isWithinChatInput = !!chatInputSectionRef.current?.contains(active); const isInput = !!input && active === input; - - // If something else already took focus (e.g. a modal, command palette, or a user click), - // do not keep fighting it across frames. if (!isWithinChatInput && !isInput) { setChatInputAutoFocusState("done"); return; } } - // Try focusing; if the input isn't mounted yet, we'll retry on the next frame. focusMessageInput(); const isFocused = !!input && document.activeElement === input; @@ -681,7 +666,6 @@ const ChatInputInner: React.FC = (props) => { rafId = requestAnimationFrame(step); }; - // Start on the next frame so the textarea is mounted and ready. rafId = requestAnimationFrame(step); return () => { @@ -689,7 +673,6 @@ const ChatInputInner: React.FC = (props) => { if (rafId !== null) { cancelAnimationFrame(rafId); } - // Ensure we never leave the attribute stuck in "pending". setChatInputAutoFocusState("done"); }; }, [variant, workspaceIdForFocus, focusMessageInput, setChatInputAutoFocusState]); @@ -787,11 +770,7 @@ const ChatInputInner: React.FC = (props) => { inputRef.current.style.height = ""; } await props.onTruncateHistory(1.0); - setToast({ - id: Date.now().toString(), - type: "success", - message: "Chat history cleared", - }); + pushToast({ type: "success", message: "Chat history cleared" }); return; } @@ -802,8 +781,7 @@ const ChatInputInner: React.FC = (props) => { inputRef.current.style.height = ""; } await props.onTruncateHistory(parsed.percentage); - setToast({ - id: Date.now().toString(), + pushToast({ type: "success", message: `Chat history truncated by ${Math.round(parsed.percentage * 100)}%`, }); @@ -818,15 +796,13 @@ const ChatInputInner: React.FC = (props) => { try { await props.onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); // Success - show toast - setToast({ - id: Date.now().toString(), + pushToast({ type: "success", message: `Provider ${parsed.provider} updated`, }); } catch (error) { console.error("Failed to update provider config:", error); - setToast({ - id: Date.now().toString(), + pushToast({ type: "error", message: error instanceof Error ? error.message : "Failed to update provider", }); @@ -842,40 +818,53 @@ const ChatInputInner: React.FC = (props) => { setInput(""); // Clear input immediately setPreferredModel(parsed.modelString); props.onModelChange?.(parsed.modelString); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Model changed to ${parsed.modelString}`, - }); + pushToast({ type: "success", message: `Model changed to ${parsed.modelString}` }); return; } - // Handle /vim command if (parsed.type === "mcp-open") { setInput(""); open("project"); return; } + if (parsed.type === "vim-toggle") { + setInput(""); // Clear input immediately + setVimEnabled((prev) => !prev); + return; + } + + // Handle /vim command + + // Handle other non-API commands (help, invalid args, etc) + const commandToast = createCommandToast(parsed); + if (commandToast) { + setToast(commandToast); + return; + } + + if (!api) { + pushToast({ type: "error", message: "Not connected to server" }); + return; + } + + const commandHandlerContextBase: CommandHandlerContext = { + api, + workspaceId: props.workspaceId, + sendMessageOptions, + setInput, + setImageAttachments, + setIsSending, + setToast, + }; + if ( parsed.type === "mcp-add" || parsed.type === "mcp-edit" || parsed.type === "mcp-remove" ) { - if (!api) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Not connected to server", - }); - return; - } if (!selectedWorkspace?.projectPath) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Select a workspace to manage MCP servers", - }); + pushToast({ type: "error", message: "Select a workspace to manage MCP servers" }); return; } @@ -893,8 +882,7 @@ const ChatInputInner: React.FC = (props) => { : await api.projects.mcp.remove({ projectPath, name: parsed.name }); if (!result.success) { - setToast({ - id: Date.now().toString(), + pushToast({ type: "error", message: result.error ?? "Failed to update MCP servers", }); @@ -906,16 +894,11 @@ const ChatInputInner: React.FC = (props) => { : parsed.type === "mcp-edit" ? `Updated MCP server ${parsed.name}` : `Removed MCP server ${parsed.name}`; - setToast({ - id: Date.now().toString(), - type: "success", - message: successMessage, - }); + pushToast({ type: "success", message: successMessage }); } } catch (error) { console.error("Failed to update MCP servers", error); - setToast({ - id: Date.now().toString(), + pushToast({ type: "error", message: error instanceof Error ? error.message : "Failed to update MCP servers", }); @@ -927,31 +910,11 @@ const ChatInputInner: React.FC = (props) => { return; } - if (parsed.type === "vim-toggle") { - setInput(""); // Clear input immediately - setVimEnabled((prev) => !prev); - return; - } - // Handle /compact command if (parsed.type === "compact") { - if (!api) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Not connected to server", - }); - return; - } const context: CommandHandlerContext = { - api: api, - workspaceId: props.workspaceId, - sendMessageOptions, + ...commandHandlerContextBase, editMessageId: editingMessage?.id, - setInput, - setImageAttachments, - setIsSending, - setToast, onCancelEdit: props.onCancelEdit, }; @@ -964,14 +927,6 @@ const ChatInputInner: React.FC = (props) => { // Handle /fork command if (parsed.type === "fork") { - if (!api) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Not connected to server", - }); - return; - } setInput(""); // Clear input immediately setIsSending(true); @@ -987,16 +942,10 @@ const ChatInputInner: React.FC = (props) => { if (!forkResult.success) { const errorMsg = forkResult.error ?? "Failed to fork workspace"; console.error("Failed to fork workspace:", errorMsg); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: errorMsg, - }); + pushToast({ type: "error", title: "Fork Failed", message: errorMsg }); setInput(messageText); // Restore input on error } else { - setToast({ - id: Date.now().toString(), + pushToast({ type: "success", message: `Forked to workspace "${parsed.newName}"`, }); @@ -1004,12 +953,7 @@ const ChatInputInner: React.FC = (props) => { } catch (error) { const errorMsg = error instanceof Error ? error.message : "Failed to fork workspace"; console.error("Fork error:", error); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: errorMsg, - }); + pushToast({ type: "error", title: "Fork Failed", message: errorMsg }); setInput(messageText); // Restore input on error } @@ -1019,23 +963,7 @@ const ChatInputInner: React.FC = (props) => { // Handle /new command if (parsed.type === "new") { - if (!api) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Not connected to server", - }); - return; - } - const context: CommandHandlerContext = { - api: api, - workspaceId: props.workspaceId, - sendMessageOptions, - setInput, - setImageAttachments, - setIsSending, - setToast, - }; + const context = commandHandlerContextBase; const result = await handleNewCommand(parsed, context); if (!result.clearInput) { @@ -1046,23 +974,7 @@ const ChatInputInner: React.FC = (props) => { // Handle /plan command if (parsed.type === "plan-show" || parsed.type === "plan-open") { - if (!api) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Not connected to server", - }); - return; - } - const context: CommandHandlerContext = { - api: api, - workspaceId: props.workspaceId, - sendMessageOptions, - setInput, - setImageAttachments, - setIsSending, - setToast, - }; + const context = commandHandlerContextBase; const handler = parsed.type === "plan-show" ? handlePlanShowCommand : handlePlanOpenCommand; @@ -1072,22 +984,11 @@ const ChatInputInner: React.FC = (props) => { } return; } - - // Handle all other commands - show display toast - const commandToast = createCommandToast(parsed); - if (commandToast) { - setToast(commandToast); - return; - } } // Regular message - send directly via API if (!api) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Not connected to server", - }); + pushToast({ type: "error", message: "Not connected to server" }); return; } setIsSending(true); @@ -1137,8 +1038,7 @@ const ChatInputInner: React.FC = (props) => { if (!result.success) { // Restore on error setDraft(preSendDraft); - setToast({ - id: Date.now().toString(), + pushToast({ type: "error", title: "Auto-Compaction Failed", message: result.error ?? "Failed to start auto-compaction", @@ -1148,18 +1048,16 @@ const ChatInputInner: React.FC = (props) => { if (sentReviewIds.length > 0) { props.onCheckReviews?.(sentReviewIds); } - setToast({ - id: Date.now().toString(), + pushToast({ type: "success", - message: `Context threshold reached - auto-compacting...`, + message: "Context threshold reached - auto-compacting...", }); props.onMessageSent?.(); } } catch (error) { // Restore on unexpected error setDraft(preSendDraft); - setToast({ - id: Date.now().toString(), + pushToast({ type: "error", title: "Auto-Compaction Failed", message: @@ -1307,8 +1205,7 @@ const ChatInputInner: React.FC = (props) => { if (matchesKeybind(e, KEYBINDS.TOGGLE_VOICE_INPUT) && voiceInput.shouldShowUI) { e.preventDefault(); if (!voiceInput.isApiKeySet) { - setToast({ - id: Date.now().toString(), + pushToast({ type: "error", message: "Voice input requires OpenAI API key. Configure in Settings → Providers.", }); @@ -1444,7 +1341,7 @@ const ChatInputInner: React.FC = (props) => { {/* Input section - centered card for creation, bottom bar for workspace */}
= (props) => { : "bg-separator border-border-light border-t px-[15px] pt-[5px] pb-[15px]" )} data-component="ChatInputSection" + data-autofocus-state="done" >
{/* Toast - show shared toast (slash commands) or variant-specific toast */} diff --git a/src/browser/stories/storyPlayHelpers.ts b/src/browser/stories/storyPlayHelpers.ts index 9c5c3dd5b5..123e374711 100644 --- a/src/browser/stories/storyPlayHelpers.ts +++ b/src/browser/stories/storyPlayHelpers.ts @@ -1,8 +1,3 @@ -/** - * Helpers intended for Storybook play() functions only. - * Keeping these separate avoids pulling @storybook/test into render-time story setup helpers. - */ - import { waitFor } from "@storybook/test"; export async function waitForChatInputAutofocusDone(canvasElement: HTMLElement): Promise { From 52a156d2996c30fa356e1bd3ce033b0d8acf1dc4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 14 Dec 2025 11:12:02 -0600 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20test-stor?= =?UTF-8?q?ybook=20after=20rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/main.ts | 5 +++++ src/browser/stories/App.chat.stories.tsx | 15 --------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index 332be6654a..2914732c4f 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -19,6 +19,11 @@ const config: StorybookConfig = { // src/version.ts in the Chromatic CI workflow, not via alias here }, }, + // Prevent Vite from discovering new deps mid-test and forcing a full reload (test-storybook + // interprets reloads as navigations and flakes). Keep this list minimal. + optimizeDeps: { + include: ["@radix-ui/react-checkbox"], + }, }); }, }; diff --git a/src/browser/stories/App.chat.stories.tsx b/src/browser/stories/App.chat.stories.tsx index 502566190a..b956d85a35 100644 --- a/src/browser/stories/App.chat.stories.tsx +++ b/src/browser/stories/App.chat.stories.tsx @@ -16,7 +16,6 @@ import { } from "./mockFactory"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getModelKey } from "@/common/constants/storage"; -import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js"; import { setupSimpleChatStory, setupStreamingChatStory } from "./storyHelpers"; import { within, userEvent, waitFor } from "@storybook/test"; @@ -426,20 +425,6 @@ export const GenericTool: AppStory = { }, }, }, - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - - // Wait for workspace metadata to load and main content to render - await waitFor( - async () => { - const toolHeader = canvas.getByText("fetch_data"); - await userEvent.click(toolHeader); - }, - { timeout: 5000 } - ); - await waitForChatInputAutofocusDone(canvasElement); - blurActiveElement(); - }, }; /** Streaming compaction with shimmer effect - tests GPU-accelerated animation */