diff --git a/src/App.tsx b/src/App.tsx index 89671a00..7afbdb93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -461,7 +461,8 @@ function App() { // ConversationView (which only mounts once chat starts) so the warming // state is already current the moment a turn needs it - see the hook's // doc comment for why a late-mounting subscriber would risk missing it. - const { warming: builtinEngineWarming } = useEngineWarmupStatus(); + const { warming: builtinEngineWarming, engineState: builtinEngineState } = + useEngineWarmupStatus(); /** Capability flags for the currently active model, or undefined if not loaded yet. */ const activeModelCapabilities = activeModel @@ -3594,6 +3595,7 @@ function App() { config.inference.activeProviderKind } engineWarming={builtinEngineWarming} + engineState={builtinEngineState} /> ) : null} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 46a44158..4f0bb777 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -5949,7 +5949,8 @@ describe('App', () => { ); }); - it('shows a warming-up placeholder first, then swaps it to the thinking row when thinking tokens arrive', async () => { + it('shows the shared engine loading placeholder first, then swaps it to the thinking row when thinking tokens arrive', async () => { + vi.useFakeTimers(); enableChannelCapture(); render(); @@ -5965,9 +5966,13 @@ describe('App', () => { fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); }); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByTestId('reasoning-block')).toBeInTheDocument(); expect(screen.getByTestId('loading-label').textContent).toBe( - 'Warming up...', + 'Starting up the model…', ); expect( screen.queryByRole('button', { name: 'Toggle reasoning details' }), @@ -5980,13 +5985,15 @@ describe('App', () => { }); }); - expect(screen.queryByText('Warming up...')).toBeNull(); + expect(screen.queryByText('Starting up the model…')).toBeNull(); expect( screen.getByRole('button', { name: 'Toggle reasoning details' }), ).toBeInTheDocument(); expect(screen.getByTestId('loading-label').textContent).toBe( 'Reasoning...', ); + + vi.useRealTimers(); }); it('does nothing when /think has no query and no images', async () => { diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index cc778344..92654a1b 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -246,6 +246,13 @@ interface ChatBubbleProps { thinkingContent?: string; /** Whether a `/think` turn is waiting for the first thinking tokens. */ isThinkingPending?: boolean; + /** + * Cue shown in place of the reasoning block while `isThinkingPending` is + * true - the same engine-loading label shown next to a plain turn's + * typing dots (`null` before the loading threshold elapses, so a fast/warm + * turn shows no text either). + */ + pendingLabel?: string | null; /** Whether the model is currently in the thinking phase (streaming thinking tokens). */ isThinking?: boolean; /** Absolute file paths of images attached to this message, if any. */ @@ -323,6 +330,7 @@ export function ChatBubble({ onSwitchModel, thinkingContent, isThinkingPending, + pendingLabel, isThinking, searchSources, searchWarnings, @@ -484,6 +492,7 @@ export function ChatBubble({ )} diff --git a/src/components/LoadingStage.tsx b/src/components/LoadingStage.tsx index 44ff1b4b..236cd494 100644 --- a/src/components/LoadingStage.tsx +++ b/src/components/LoadingStage.tsx @@ -39,7 +39,7 @@ export function LoadingStage({ labelPrefix, }: LoadingStageProps) { return ( - + diff --git a/src/components/ReasoningBlock.tsx b/src/components/ReasoningBlock.tsx index 81ab4587..18480ae2 100644 --- a/src/components/ReasoningBlock.tsx +++ b/src/components/ReasoningBlock.tsx @@ -7,11 +7,24 @@ export interface ReasoningBlockProps { thinkingContent?: string; isThinking: boolean; isPending?: boolean; - pendingLabel?: string; + /** + * Cue shown next to the dots while `isPending` is true. `null`/`undefined` + * renders bare dots with no text - the caller (the engine-loading label, + * shared with plain turns) is the single source of truth for this copy; + * this component has no cue of its own. + */ + pendingLabel?: string | null; } const REASONING_LABEL = 'Reasoning...'; -const PENDING_LABEL = 'Warming up...'; + +/** + * Classes shared byte-for-byte between the pending row and the clickable + * summary row (both while `isThinking` and once done), so all three are + * pixel-identical by construction rather than by hand-matching independent + * class lists. Only the element tag and interactive attributes differ. + */ +const SUMMARY_ROW_CLASS = 'flex items-center gap-2 p-0 text-left w-full'; /** * Collapsible reasoning section rendered above an AI response. @@ -24,7 +37,7 @@ export function ReasoningBlock({ thinkingContent, isThinking, isPending = false, - pendingLabel = PENDING_LABEL, + pendingLabel = null, }: ReasoningBlockProps) { const [isExpanded, setIsExpanded] = useState(false); const hasThinkingContent = Boolean(thinkingContent?.trim()); @@ -32,10 +45,27 @@ export function ReasoningBlock({ if (!hasThinkingContent && !isPending) return null; if (isPending) { + // Invisible placeholder, not a real chevron: there's nothing to expand + // yet, so no click affordance should render or be announced. It exists + // purely to reserve the exact width the real chevron occupies once + // thinking starts, using the identical classes/markup that state uses + // below, so the label lands at the same x position in both. + const chevronSpacer = ( + + ); return (
-
- +
+ + +
); @@ -60,11 +90,12 @@ export function ReasoningBlock({ return (
- {/* Clickable summary row: chevron + label */} + {/* Clickable summary row: chevron + label. Same SUMMARY_ROW_CLASS the + pending row above uses, plus the interactive-only extras. */}