Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3594,6 +3595,7 @@ function App() {
config.inference.activeProviderKind
}
engineWarming={builtinEngineWarming}
engineState={builtinEngineState}
/>
) : null}
</AnimatePresence>
Expand Down
13 changes: 10 additions & 3 deletions src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);
Expand All @@ -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' }),
Expand All @@ -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 () => {
Expand Down
9 changes: 9 additions & 0 deletions src/components/ChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -323,6 +330,7 @@ export function ChatBubble({
onSwitchModel,
thinkingContent,
isThinkingPending,
pendingLabel,
isThinking,
searchSources,
searchWarnings,
Expand Down Expand Up @@ -484,6 +492,7 @@ export function ChatBubble({
<ReasoningBlock
thinkingContent={thinkingContent}
isPending={isThinkingPending ?? false}
pendingLabel={pendingLabel}
isThinking={isThinking ?? false}
/>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/components/LoadingStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function LoadingStage({
labelPrefix,
}: LoadingStageProps) {
return (
<span className={`inline-flex items-center ${compact ? 'gap-2' : 'gap-3'}`}>
<span className="inline-flex items-center gap-2">
<span className="shrink-0">
<TypingIndicator />
</span>
Expand Down
45 changes: 38 additions & 7 deletions src/components/ReasoningBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,18 +37,35 @@ export function ReasoningBlock({
thinkingContent,
isThinking,
isPending = false,
pendingLabel = PENDING_LABEL,
pendingLabel = null,
}: ReasoningBlockProps) {
const [isExpanded, setIsExpanded] = useState(false);
const hasThinkingContent = Boolean(thinkingContent?.trim());

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 = (
<span
data-testid="reasoning-chevron"
aria-hidden="true"
className="loading-label inline-block shrink-0 text-[9px] transition-transform duration-150 opacity-0"
style={{ transform: 'rotate(90deg)' }}
>
&#9650;
</span>
);
return (
<div data-testid="reasoning-block" className="mb-2">
<div data-testid="reasoning-pending" className="inline-flex min-w-0">
<LoadingStage label={pendingLabel} />
<div data-testid="reasoning-pending" className={SUMMARY_ROW_CLASS}>
<span className="inline-flex min-w-0">
<LoadingStage label={pendingLabel} labelPrefix={chevronSpacer} />
</span>
</div>
</div>
);
Expand All @@ -60,11 +90,12 @@ export function ReasoningBlock({

return (
<div data-testid="reasoning-block" className="mb-2">
{/* Clickable summary row: chevron + label */}
{/* Clickable summary row: chevron + label. Same SUMMARY_ROW_CLASS the
pending row above uses, plus the interactive-only extras. */}
<button
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
className="flex items-center gap-2 cursor-pointer bg-transparent border-none p-0 text-left w-full"
className={`${SUMMARY_ROW_CLASS} cursor-pointer bg-transparent border-none`}
aria-expanded={isExpanded}
aria-label="Toggle reasoning details"
>
Expand Down
16 changes: 15 additions & 1 deletion src/components/__tests__/ChatBubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,14 +394,28 @@ describe('ChatBubble', () => {
content=""
index={0}
isThinkingPending={true}
pendingLabel="Starting up the model…"
/>,
);
expect(screen.getByTestId('reasoning-block')).toBeInTheDocument();
expect(screen.getByTestId('loading-label').textContent).toBe(
'Warming up...',
'Starting up the model…',
);
});

it('renders bare dots for the pending /think placeholder before the engine label threshold elapses', () => {
render(
<ChatBubble
role="assistant"
content=""
index={0}
isThinkingPending={true}
/>,
);
expect(screen.getByTestId('reasoning-block')).toBeInTheDocument();
expect(screen.queryByTestId('loading-label')).toBeNull();
});

it('does not render ReasoningBlock for user message even with thinkingContent', () => {
render(
<ChatBubble
Expand Down
66 changes: 64 additions & 2 deletions src/components/__tests__/ReasoningBlock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ describe('ReasoningBlock', () => {
expect(container.innerHTML).toBe('');
});

it('shows the pending placeholder label before thinking tokens arrive', () => {
it('renders bare dots with no label when pending and no pendingLabel is supplied', () => {
render(<ReasoningBlock isThinking={false} isPending />);
expect(screen.queryByTestId('loading-label')).toBeNull();
});

it('shows the caller-supplied pendingLabel while pending', () => {
render(
<ReasoningBlock
isThinking={false}
isPending
pendingLabel="Starting up the model…"
/>,
);
const label = screen.getByTestId('loading-label');
expect(label).toBeInTheDocument();
expect(label.textContent).toBe('Warming up...');
expect(label.textContent).toBe('Starting up the model…');
});

it('does not render a toggle button while pending', () => {
Expand All @@ -24,6 +35,57 @@ describe('ReasoningBlock', () => {
).toBeNull();
});

it('hides the chevron from assistive tech while pending (nothing to expand yet)', () => {
render(
<ReasoningBlock
isThinking={false}
isPending
pendingLabel="Starting up the model…"
/>,
);
const chevron = screen.getByTestId('reasoning-chevron');
expect(chevron).toHaveAttribute('aria-hidden', 'true');
expect(chevron.className).toContain('opacity-0');
});

it("reserves the chevron's width while pending via an invisible spacer, so the label does not shift once thinking starts", () => {
render(
<ReasoningBlock
isThinking={false}
isPending
pendingLabel="Starting up the model…"
/>,
);
const prefix = screen.getByTestId('loading-label-prefix');
expect(prefix).toBeInTheDocument();
expect(prefix.textContent).toBe('▲');
});

it('uses the exact same summary-row classes for the pending row and the clickable summary row', () => {
const { unmount } = render(
<ReasoningBlock
isThinking={false}
isPending
pendingLabel="Starting up the model…"
/>,
);
const pendingRow = screen.getByTestId('reasoning-pending');
const pendingClasses = pendingRow.className;
unmount();

render(
<ReasoningBlock thinkingContent="Working on it" isThinking={true} />,
);
const summaryButton = screen.getByRole('button', {
name: 'Toggle reasoning details',
});
// The button adds interactive-only extras on top of the same base
// classes the pending row uses - assert the base is a strict subset.
pendingClasses
.split(' ')
.forEach((cls) => expect(summaryButton.className).toContain(cls));
});

it('shows a clickable "Reasoning..." summary while isThinking', () => {
render(
<ReasoningBlock thinkingContent="Working on it" isThinking={true} />,
Expand Down
11 changes: 11 additions & 0 deletions src/config/engineLoadingLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,14 @@ export const ENGINE_PHASE2_PHRASES: readonly string[] = [

/** Spacing between phase 2's two phrases. */
export const ENGINE_PHASE2_INTERVAL_MS = 3000;

/**
* Shown when the engine was already `loaded` (not cold, not mid-prime) at
* the moment the turn began, yet the wait still crosses the threshold - the
* per-request prefill cost scales with how much conversation history there
* is, so a warm engine can still take real seconds on a long conversation.
* Neither phase-1 ("starting up") nor phase-2 ("warming up") language is
* true here: the engine never left `loaded`, so this gets its own single
* held phrase instead of borrowing either sequence's copy.
*/
export const ENGINE_SLOW_WARM_LABEL = 'Processing your message…';
Loading