-
);
@@ -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. */}
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"
>
diff --git a/src/components/__tests__/ChatBubble.test.tsx b/src/components/__tests__/ChatBubble.test.tsx
index cbd76ef2..ce482ff0 100644
--- a/src/components/__tests__/ChatBubble.test.tsx
+++ b/src/components/__tests__/ChatBubble.test.tsx
@@ -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(
+ ,
+ );
+ expect(screen.getByTestId('reasoning-block')).toBeInTheDocument();
+ expect(screen.queryByTestId('loading-label')).toBeNull();
+ });
+
it('does not render ReasoningBlock for user message even with thinkingContent', () => {
render(
{
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( );
+ expect(screen.queryByTestId('loading-label')).toBeNull();
+ });
+
+ it('shows the caller-supplied pendingLabel while pending', () => {
+ render(
+ ,
+ );
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', () => {
@@ -24,6 +35,57 @@ describe('ReasoningBlock', () => {
).toBeNull();
});
+ it('hides the chevron from assistive tech while pending (nothing to expand yet)', () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ const pendingRow = screen.getByTestId('reasoning-pending');
+ const pendingClasses = pendingRow.className;
+ unmount();
+
+ render(
+ ,
+ );
+ 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(
,
diff --git a/src/config/engineLoadingLabels.ts b/src/config/engineLoadingLabels.ts
index aa70f198..191953e9 100644
--- a/src/config/engineLoadingLabels.ts
+++ b/src/config/engineLoadingLabels.ts
@@ -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…';
diff --git a/src/hooks/__tests__/useEngineLoadingLabel.test.tsx b/src/hooks/__tests__/useEngineLoadingLabel.test.tsx
index 9627ae66..89cec95c 100644
--- a/src/hooks/__tests__/useEngineLoadingLabel.test.tsx
+++ b/src/hooks/__tests__/useEngineLoadingLabel.test.tsx
@@ -7,6 +7,7 @@ import {
ENGINE_PHASE1_INTERVAL_MS,
ENGINE_PHASE2_PHRASES,
ENGINE_PHASE2_INTERVAL_MS,
+ ENGINE_SLOW_WARM_LABEL,
} from '../../config/engineLoadingLabels';
describe('useEngineLoadingLabel', () => {
@@ -20,14 +21,14 @@ describe('useEngineLoadingLabel', () => {
it('renders no label when inactive', () => {
const { result } = renderHook(() =>
- useEngineLoadingLabel(false, 'builtin', false),
+ useEngineLoadingLabel(false, 'builtin', false, 'stopped'),
);
expect(result.current).toBeNull();
});
it('renders no label for a remote provider', () => {
const { result } = renderHook(() =>
- useEngineLoadingLabel(true, 'openai', false),
+ useEngineLoadingLabel(true, 'openai', false, 'stopped'),
);
act(() => {
vi.advanceTimersByTime(10000);
@@ -37,7 +38,7 @@ describe('useEngineLoadingLabel', () => {
it('stays null before the threshold elapses (fast/warm turn)', () => {
const { result } = renderHook(() =>
- useEngineLoadingLabel(true, 'builtin', false),
+ useEngineLoadingLabel(true, 'builtin', false, 'starting'),
);
act(() => {
vi.advanceTimersByTime(ENGINE_LOADING_THRESHOLD_MS - 1);
@@ -47,7 +48,7 @@ describe('useEngineLoadingLabel', () => {
it('shows the first phase-1 phrase once the threshold elapses', () => {
const { result } = renderHook(() =>
- useEngineLoadingLabel(true, 'builtin', false),
+ useEngineLoadingLabel(true, 'builtin', false, 'starting'),
);
act(() => {
vi.advanceTimersByTime(ENGINE_LOADING_THRESHOLD_MS);
@@ -57,7 +58,7 @@ describe('useEngineLoadingLabel', () => {
it('steps to the second phase-1 phrase at the configured interval', () => {
const { result } = renderHook(() =>
- useEngineLoadingLabel(true, 'ollama', false),
+ useEngineLoadingLabel(true, 'ollama', false, 'starting'),
);
act(() => {
vi.advanceTimersByTime(
@@ -69,7 +70,7 @@ describe('useEngineLoadingLabel', () => {
it('holds on the last phase-1 phrase for Ollama (no phase-2 signal exists)', () => {
const { result } = renderHook(() =>
- useEngineLoadingLabel(true, 'ollama', false),
+ useEngineLoadingLabel(true, 'ollama', false, 'starting'),
);
act(() => {
vi.advanceTimersByTime(
@@ -81,7 +82,8 @@ describe('useEngineLoadingLabel', () => {
it('jumps to the first phase-2 phrase immediately when warming fires', () => {
const { result, rerender } = renderHook(
- ({ warming }) => useEngineLoadingLabel(true, 'builtin', warming),
+ ({ warming }) =>
+ useEngineLoadingLabel(true, 'builtin', warming, 'starting'),
{ initialProps: { warming: false } },
);
act(() => {
@@ -95,7 +97,8 @@ describe('useEngineLoadingLabel', () => {
it('cuts phase 1 short when warming fires before its second phrase', () => {
const { result, rerender } = renderHook(
- ({ warming }) => useEngineLoadingLabel(true, 'builtin', warming),
+ ({ warming }) =>
+ useEngineLoadingLabel(true, 'builtin', warming, 'starting'),
{ initialProps: { warming: false } },
);
act(() => {
@@ -115,7 +118,8 @@ describe('useEngineLoadingLabel', () => {
it('steps to the second phase-2 phrase once warming has run long enough', () => {
const { result, rerender } = renderHook(
- ({ warming }) => useEngineLoadingLabel(true, 'builtin', warming),
+ ({ warming }) =>
+ useEngineLoadingLabel(true, 'builtin', warming, 'starting'),
{ initialProps: { warming: true } },
);
expect(result.current).toBe(ENGINE_PHASE2_PHRASES[0]);
@@ -137,7 +141,8 @@ describe('useEngineLoadingLabel', () => {
// The label must not fall back to a phase-1 phrase - that would
// misreport a loaded model as still spinning up.
const { result, rerender } = renderHook(
- ({ warming }) => useEngineLoadingLabel(true, 'builtin', warming),
+ ({ warming }) =>
+ useEngineLoadingLabel(true, 'builtin', warming, 'starting'),
{ initialProps: { warming: false } },
);
act(() => {
@@ -159,7 +164,8 @@ describe('useEngineLoadingLabel', () => {
it('is a no-op when warming flips true again while already in phase 2', () => {
const { result, rerender } = renderHook(
- ({ warming }) => useEngineLoadingLabel(true, 'builtin', warming),
+ ({ warming }) =>
+ useEngineLoadingLabel(true, 'builtin', warming, 'starting'),
{ initialProps: { warming: true } },
);
expect(result.current).toBe(ENGINE_PHASE2_PHRASES[0]);
@@ -178,7 +184,7 @@ describe('useEngineLoadingLabel', () => {
it('never enters phase 2 for Ollama (no real signal exists)', () => {
const { result } = renderHook(() =>
- useEngineLoadingLabel(true, 'ollama', false),
+ useEngineLoadingLabel(true, 'ollama', false, 'starting'),
);
act(() => {
vi.advanceTimersByTime(
@@ -190,7 +196,8 @@ describe('useEngineLoadingLabel', () => {
it('clears the label once the turn becomes inactive', () => {
const { result, rerender } = renderHook(
- ({ active }) => useEngineLoadingLabel(active, 'builtin', false),
+ ({ active }) =>
+ useEngineLoadingLabel(active, 'builtin', false, 'starting'),
{ initialProps: { active: true } },
);
act(() => {
@@ -204,7 +211,8 @@ describe('useEngineLoadingLabel', () => {
it('restarts the threshold from zero on a fresh active turn', () => {
const { result, rerender } = renderHook(
- ({ active }) => useEngineLoadingLabel(active, 'builtin', false),
+ ({ active }) =>
+ useEngineLoadingLabel(active, 'builtin', false, 'starting'),
{ initialProps: { active: true } },
);
act(() => {
@@ -225,7 +233,7 @@ describe('useEngineLoadingLabel', () => {
it('does not carry the phase-2 latch over into a fresh turn', () => {
const { result, rerender } = renderHook(
({ active, warming }) =>
- useEngineLoadingLabel(active, 'builtin', warming),
+ useEngineLoadingLabel(active, 'builtin', warming, 'starting'),
{ initialProps: { active: true, warming: false } },
);
rerender({ active: true, warming: true });
@@ -243,4 +251,80 @@ describe('useEngineLoadingLabel', () => {
});
expect(result.current).toBe(ENGINE_PHASE1_PHRASES[0]);
});
+
+ describe('engine already loaded (slow-warm path)', () => {
+ it('never shows a phase-1 phrase when the engine is already loaded', () => {
+ const { result } = renderHook(() =>
+ useEngineLoadingLabel(true, 'builtin', false, 'loaded'),
+ );
+ act(() => {
+ vi.advanceTimersByTime(
+ ENGINE_LOADING_THRESHOLD_MS + ENGINE_PHASE1_INTERVAL_MS * 5,
+ );
+ });
+ expect(ENGINE_PHASE1_PHRASES).not.toContain(result.current);
+ });
+
+ it('shows the slow-warm label once the threshold elapses on an already-loaded engine', () => {
+ const { result } = renderHook(() =>
+ useEngineLoadingLabel(true, 'builtin', false, 'loaded'),
+ );
+ act(() => {
+ vi.advanceTimersByTime(ENGINE_LOADING_THRESHOLD_MS);
+ });
+ expect(result.current).toBe(ENGINE_SLOW_WARM_LABEL);
+ });
+
+ it('stays null before the threshold elapses on an already-loaded engine', () => {
+ const { result } = renderHook(() =>
+ useEngineLoadingLabel(true, 'builtin', false, 'loaded'),
+ );
+ act(() => {
+ vi.advanceTimersByTime(ENGINE_LOADING_THRESHOLD_MS - 1);
+ });
+ expect(result.current).toBeNull();
+ });
+
+ it('holds on the slow-warm label indefinitely (no further rotation)', () => {
+ const { result } = renderHook(() =>
+ useEngineLoadingLabel(true, 'builtin', false, 'loaded'),
+ );
+ act(() => {
+ vi.advanceTimersByTime(ENGINE_LOADING_THRESHOLD_MS + 30000);
+ });
+ expect(result.current).toBe(ENGINE_SLOW_WARM_LABEL);
+ });
+
+ it('still jumps to phase 2 if warming fires on an already-loaded engine', () => {
+ // Real world: the engine is loaded, and a fresh proactive prime kicks
+ // off for this exact turn (e.g. a model switch just completed). The
+ // real signal always wins over the slow-warm guess.
+ const { result, rerender } = renderHook(
+ ({ warming }) =>
+ useEngineLoadingLabel(true, 'builtin', warming, 'loaded'),
+ { initialProps: { warming: false } },
+ );
+ act(() => {
+ vi.advanceTimersByTime(ENGINE_LOADING_THRESHOLD_MS);
+ });
+ expect(result.current).toBe(ENGINE_SLOW_WARM_LABEL);
+
+ rerender({ warming: true });
+ expect(result.current).toBe(ENGINE_PHASE2_PHRASES[0]);
+ });
+
+ it('ignores a stray engineState=loaded for Ollama (engineState only ever describes the built-in engine)', () => {
+ // A quirk of Ollama being the active provider: `engineState` still
+ // reflects the built-in engine's own runner, so a value of "loaded"
+ // here says nothing about Ollama's residency. Ollama must always use
+ // the phase-1 cold-start filler, never the slow-warm skip.
+ const { result } = renderHook(() =>
+ useEngineLoadingLabel(true, 'ollama', false, 'loaded'),
+ );
+ act(() => {
+ vi.advanceTimersByTime(ENGINE_LOADING_THRESHOLD_MS);
+ });
+ expect(result.current).toBe(ENGINE_PHASE1_PHRASES[0]);
+ });
+ });
});
diff --git a/src/hooks/__tests__/useEngineWarmupStatus.test.tsx b/src/hooks/__tests__/useEngineWarmupStatus.test.tsx
index 90518f4d..d66cfe96 100644
--- a/src/hooks/__tests__/useEngineWarmupStatus.test.tsx
+++ b/src/hooks/__tests__/useEngineWarmupStatus.test.tsx
@@ -13,10 +13,26 @@ describe('useEngineWarmupStatus', () => {
clearEventHandlers();
});
- it('starts not warming', async () => {
+ it('starts not warming and stopped', async () => {
const { result } = renderHook(() => useEngineWarmupStatus());
await act(async () => {});
expect(result.current.warming).toBe(false);
+ expect(result.current.engineState).toBe('stopped');
+ });
+
+ it('updates engineState on engine:status', async () => {
+ const { result } = renderHook(() => useEngineWarmupStatus());
+ await act(async () => {});
+
+ await act(async () => {
+ emitTauriEvent('engine:status', {
+ state: 'loaded',
+ model_path: '/tmp/m.gguf',
+ port: 8080,
+ error: null,
+ });
+ });
+ expect(result.current.engineState).toBe('loaded');
});
it('flips to warming on warmup:builtin-warming', async () => {
diff --git a/src/hooks/useEngineLoadingLabel.ts b/src/hooks/useEngineLoadingLabel.ts
index 2d9e12f0..26e692f7 100644
--- a/src/hooks/useEngineLoadingLabel.ts
+++ b/src/hooks/useEngineLoadingLabel.ts
@@ -7,14 +7,24 @@
* Waits under `ENGINE_LOADING_THRESHOLD_MS` never show a label at all (a
* warm/fast turn looks identical to today: bare dots) - matching the
* standard "don't show a loading indicator for sub-second waits" guidance.
- * Past that threshold, phase 1's filler plays on a fixed schedule
- * (`ENGINE_PHASE1_PHRASES`). The built-in engine's real `warming` signal can
- * cut phase 1 short at any point and moves straight to phase 2
- * (`ENGINE_PHASE2_PHRASES`), which has its own independent schedule timed
- * from the moment warming started - not from when the turn began.
+ * Past that threshold, which schedule plays depends on the built-in engine's
+ * real state at the moment the turn began (`engineState` is meaningless for
+ * Ollama - see its own doc below - so Ollama always takes the first branch):
+ * - Not yet `loaded` (genuine cold start): phase 1's filler plays on a fixed
+ * schedule (`ENGINE_PHASE1_PHRASES`).
+ * - Already `loaded`: phase 1 is skipped entirely - "starting up"/"reading
+ * weights" would be false, since the engine never left `loaded`. Instead
+ * `ENGINE_SLOW_WARM_LABEL` holds once the threshold elapses (per-request
+ * prefill cost scales with conversation length, so a warm engine can still
+ * be genuinely slow on a long conversation).
+ *
+ * Either path can be cut short by the built-in engine's real `warming`
+ * signal, which moves straight to phase 2 (`ENGINE_PHASE2_PHRASES`) with its
+ * own independent schedule timed from the moment warming started - not from
+ * when the turn began.
*
* Progress is monotonic within one active turn: once phase 2 has been
- * entered, the label never falls back to a phase-1 phrase, even if
+ * entered, the label never falls back to an earlier phrase, even if
* `warming` later flips back to `false` while `active` is still `true`. In
* practice the built-in engine's `warmup:builtin-warmed` event (its prime
* finishing) can arrive before this specific request's first token does,
@@ -28,6 +38,11 @@
* @param warming Live `warmup:builtin-warming` state from
* {@link import('./useEngineWarmupStatus').useEngineWarmupStatus}. Only
* ever `true` for the built-in engine; Ollama never reaches phase 2.
+ * @param engineState Live `engine:status` state from the same hook. Read
+ * once at the moment `active` becomes true (like `warming`, deliberately
+ * not a live dependency - see the turn-lifecycle effect below). Only ever
+ * meaningful for the built-in engine; Ollama's engine state is irrelevant
+ * to it and always falls through to the phase-1 cold-start schedule.
*/
import { useEffect, useRef, useState } from 'react';
@@ -37,16 +52,18 @@ import {
ENGINE_PHASE1_PHRASES,
ENGINE_PHASE2_INTERVAL_MS,
ENGINE_PHASE2_PHRASES,
+ ENGINE_SLOW_WARM_LABEL,
} from '../config/engineLoadingLabels';
const LOCAL_PROVIDER_KINDS = new Set(['builtin', 'ollama']);
-type Phase = 'idle' | 'phase1' | 'phase2';
+type Phase = 'idle' | 'waiting' | 'phase2';
export function useEngineLoadingLabel(
active: boolean,
providerKind: string,
warming: boolean,
+ engineState: string,
): string | null {
const [label, setLabel] = useState(null);
const phaseRef = useRef('idle');
@@ -57,14 +74,14 @@ export function useEngineLoadingLabel(
timersRef.current = [];
};
- // Enters (or no-ops if already in) phase 2: cancels any pending phase-1
- // timers and schedules phase 2's own independent sequence, timed from
- // now rather than from when the turn began.
+ // Enters (or no-ops if already in) phase 2: cancels any pending timers
+ // from the "waiting" phase and schedules phase 2's own independent
+ // sequence, timed from now rather than from when the turn began.
const enterPhase2 = () => {
if (phaseRef.current === 'phase2') return;
phaseRef.current = 'phase2';
clearTimers();
- // eslint-disable-next-line @eslint-react/set-state-in-effect -- intended: the real warming signal must override whatever phase-1 filler is showing the instant it fires
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- intended: the real warming signal must override whatever filler is showing the instant it fires
setLabel(ENGINE_PHASE2_PHRASES[0]);
timersRef.current.push(
setTimeout(
@@ -74,10 +91,11 @@ export function useEngineLoadingLabel(
);
};
- // Turn lifecycle: (re)starts phase 1 when the turn becomes active for a
- // local provider, tears everything down when it ends. Deliberately does
- // NOT depend on `warming` - a warming flip must never restart this effect,
- // or an in-progress phase 2 would be clobbered back to phase 1's schedule.
+ // Turn lifecycle: (re)starts the "waiting" schedule when the turn becomes
+ // active for a local provider, tears everything down when it ends.
+ // Deliberately does NOT depend on `warming` or `engineState` - either
+ // changing later must never restart this effect, or an in-progress phase
+ // 2 (or an already-decided cold-vs-warm schedule) would be clobbered.
useEffect(() => {
if (!active || !LOCAL_PROVIDER_KINDS.has(providerKind)) {
phaseRef.current = 'idle';
@@ -87,22 +105,34 @@ export function useEngineLoadingLabel(
return;
}
- phaseRef.current = 'phase1';
+ phaseRef.current = 'waiting';
// eslint-disable-next-line @eslint-react/set-state-in-effect -- intended: a fresh turn always starts dots-only until the threshold elapses
setLabel(null);
- timersRef.current = ENGINE_PHASE1_PHRASES.map((phrase, i) =>
- // eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout -- cleared via clearTimers() on the next transition
- setTimeout(
- () => setLabel(phrase),
- ENGINE_LOADING_THRESHOLD_MS + i * ENGINE_PHASE1_INTERVAL_MS,
- ),
- );
+
+ if (providerKind === 'builtin' && engineState === 'loaded') {
+ timersRef.current = [
+ // eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout -- cleared via clearTimers() on the next transition
+ setTimeout(
+ () => setLabel(ENGINE_SLOW_WARM_LABEL),
+ ENGINE_LOADING_THRESHOLD_MS,
+ ),
+ ];
+ } else {
+ timersRef.current = ENGINE_PHASE1_PHRASES.map((phrase, i) =>
+ // eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout -- cleared via clearTimers() on the next transition
+ setTimeout(
+ () => setLabel(phrase),
+ ENGINE_LOADING_THRESHOLD_MS + i * ENGINE_PHASE1_INTERVAL_MS,
+ ),
+ );
+ }
return clearTimers;
+ // eslint-disable-next-line @eslint-react/exhaustive-deps -- intended: warming/engineState are deliberately excluded, see the comment above
}, [active, providerKind]);
// Reacts to the real warming signal the instant it fires, independent of
- // how far phase 1's timer has progressed.
+ // how far the "waiting" schedule has progressed.
useEffect(() => {
if (!active || !LOCAL_PROVIDER_KINDS.has(providerKind)) return;
if (warming) enterPhase2();
diff --git a/src/hooks/useEngineWarmupStatus.ts b/src/hooks/useEngineWarmupStatus.ts
index e98cb5d0..97a1b488 100644
--- a/src/hooks/useEngineWarmupStatus.ts
+++ b/src/hooks/useEngineWarmupStatus.ts
@@ -1,10 +1,11 @@
/**
- * Live snapshot of the built-in engine's prefill-priming state, sourced from
- * the same global Tauri events the Settings Providers pane uses for its
- * "Warming up…" status line (`warmup:builtin-warming` / `warmup:builtin-warmed`,
+ * Live snapshot of the built-in engine's lifecycle, sourced from the same
+ * global Tauri events the Settings Providers pane uses for its status line
+ * (`engine:status`, `warmup:builtin-warming` / `warmup:builtin-warmed`, all
* emitted app-wide so any window can subscribe). Ollama has no equivalent
- * signal — Thuki does not manage its process — so `warming` never flips true
- * while Ollama is the active provider.
+ * signal for either, since Thuki does not manage its process, so `warming`
+ * never flips true and `engineState` stays at its initial value while
+ * Ollama is the active provider.
*
* Meant to be mounted once near the app root (the main window's React tree
* stays alive for the app's lifetime, even while hidden), so the value is
@@ -15,21 +16,26 @@
import { useEffect, useState } from 'react';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
+import type { EngineStatus } from '../types/starter';
export interface EngineWarmupStatus {
/** True while the built-in engine is priming the system-prompt prefix. */
warming: boolean;
+ /** The built-in engine's last known lifecycle state. */
+ engineState: EngineStatus['state'];
}
export function useEngineWarmupStatus(): EngineWarmupStatus {
const [warming, setWarming] = useState(false);
+ const [engineState, setEngineState] =
+ useState('stopped');
useEffect(() => {
let cancelled = false;
const unlisteners: UnlistenFn[] = [];
- const subscribe = (event: string, handler: () => void) => {
- void listen(event, handler)
+ const subscribe = (event: string, handler: (payload: T) => void) => {
+ void listen(event, (e) => handler(e.payload))
.then((stop) => {
if (cancelled) {
stop();
@@ -44,6 +50,9 @@ export function useEngineWarmupStatus(): EngineWarmupStatus {
subscribe('warmup:builtin-warming', () => setWarming(true));
subscribe('warmup:builtin-warmed', () => setWarming(false));
+ subscribe('engine:status', (status) =>
+ setEngineState(status.state),
+ );
return () => {
cancelled = true;
@@ -51,5 +60,5 @@ export function useEngineWarmupStatus(): EngineWarmupStatus {
};
}, []);
- return { warming };
+ return { warming, engineState };
}
diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx
index 2f4b6f5f..a72ef4b8 100644
--- a/src/view/ConversationView.tsx
+++ b/src/view/ConversationView.tsx
@@ -120,6 +120,13 @@ interface ConversationViewProps {
* mounted once near the app root. Only ever true for the built-in engine.
*/
engineWarming?: boolean;
+ /**
+ * Live `engine:status` state from the same hook, describing only the
+ * built-in engine (meaningless for Ollama). Lets the loading label skip
+ * "starting up" language when the engine was already resident at the
+ * moment a turn began.
+ */
+ engineState?: string;
}
/**
@@ -155,27 +162,30 @@ export function ConversationView({
isExportOpen,
providerKind = '',
engineWarming = false,
+ engineState = 'stopped',
}: ConversationViewProps) {
const scrollContainerRef = useRef(null);
// True while the trailing assistant message is waiting on its first token
- // from a plain (non-search, non-think) chat turn - the exact condition
- // that renders the bare-dots loading row below. Drives the cold-start
- // label timer; think/search turns render their own loading state inside
- // ChatBubble and never reach this row.
+ // (or, for a /think turn, its first thinking token) from a non-search chat
+ // turn. Search turns render their own loading state inside ChatBubble's
+ // SearchTraceBlock and never reach this. Drives the cold-start label timer
+ // for BOTH surfaces: the bare-dots row below (plain turns) and, for a
+ // /think turn, the pending state inside ReasoningBlock (see `pendingLabel`
+ // below) - one shared source of truth for the cue instead of two.
const lastMessage = messages[messages.length - 1];
const isAwaitingFirstToken = Boolean(
isGenerating &&
lastMessage?.role === 'assistant' &&
!lastMessage?.content &&
!lastMessage?.thinkingContent &&
- !lastMessage?.fromSearch &&
- !lastMessage?.fromThink,
+ !lastMessage?.fromSearch,
);
const engineLoadingLabel = useEngineLoadingLabel(
isAwaitingFirstToken,
providerKind,
engineWarming,
+ engineState,
);
/** Threshold in pixels - if within this distance of the bottom, consider "near bottom". */
@@ -329,6 +339,7 @@ export function ConversationView({
onSwitchModel={onSwitchModel}
thinkingContent={msg.thinkingContent}
isThinkingPending={isThinkingPending}
+ pendingLabel={engineLoadingLabel}
// "Still thinking" reflects the real stream state, not whether
// /think was used: thinking tokens have arrived, the answer has
// not started, and the turn is still generating (isLastAssistant
@@ -355,8 +366,10 @@ export function ConversationView({
{/* Loading row: always show 9-dot indicator when waiting for first
content. For search turns, show the stage label inline as plain
text next to the dots; for a plain turn stuck behind a cold
- provider spin-up, show the engine loading label instead. */}
- {isAwaitingFirstToken ? (
+ provider spin-up, show the engine loading label instead. A
+ /think turn gets the same engine label, but rendered inside its
+ own ChatBubble (ReasoningBlock's pending state) rather than here. */}
+ {isAwaitingFirstToken && !lastMessage?.fromThink ? (
diff --git a/src/view/__tests__/ConversationView.test.tsx b/src/view/__tests__/ConversationView.test.tsx
index 911a108c..203208c2 100644
--- a/src/view/__tests__/ConversationView.test.tsx
+++ b/src/view/__tests__/ConversationView.test.tsx
@@ -496,7 +496,7 @@ describe('ConversationView', () => {
});
describe('Thinking props forwarding', () => {
- it('renders the /think pending placeholder before thinking tokens arrive', () => {
+ it('renders the /think pending placeholder with bare dots before the engine loading threshold elapses', () => {
render(
{
/>,
);
expect(screen.getByTestId('reasoning-block')).toBeInTheDocument();
+ expect(screen.queryByTestId('loading-label')).toBeNull();
+ });
+
+ it('shows the shared engine loading label inside the /think pending placeholder for a builtin cold start', () => {
+ vi.useFakeTimers();
+ render(
+ ,
+ );
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByTestId('reasoning-block')).toBeInTheDocument();
expect(screen.getByTestId('loading-label').textContent).toBe(
- 'Warming up...',
+ 'Starting up the model…',
+ );
+ vi.useRealTimers();
+ });
+
+ it('does not render a duplicate external loading row for a /think turn', () => {
+ vi.useFakeTimers();
+ render(
+ ,
);
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ // Only ReasoningBlock's own pending row should render the label; the
+ // bare-dots external row is suppressed for /think turns.
+ expect(screen.getAllByTestId('loading-label')).toHaveLength(1);
+ vi.useRealTimers();
});
it('renders ReasoningBlock when assistant message has thinkingContent', () => {
@@ -942,5 +993,23 @@ describe('ConversationView', () => {
'Analyzing query',
);
});
+
+ it('shows the slow-warm cue instead of "starting up" when the engine is already loaded', () => {
+ render(
+ ,
+ );
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.getByTestId('loading-label').textContent).toBe(
+ 'Processing your message…',
+ );
+ });
});
});