fix(bootstrap): stop showing first-run install steps on warm starts

BootstrapSplash derived step "done" state purely from STEPS.indexOf(stage),
so a warm start (bootstrap.rs finds the venv healthy and jumps straight from
Checking to StartingBackend) rendered downloading_uv/creating_venv/
installing_deps as fabricated green DONE ticks, including the "first run,
5-10 min." label. A repair sync (venv exists, only InstallingDeps runs) hit
the same fabrication, and JourneyRail hardcoded Setup=done/Installing=active
regardless of stage.

Track which stages are actually observed (sticky, via the ~1s
bootstrap_status poll) and derive doneness and journey-chrome visibility from
that instead of list position. Journey rail, the "Installing" heading, the
step list, and the resume note stay hidden until a genuine install stage
(downloading_uv/creating_venv/installing_deps/awaiting_setup) is observed;
the masthead, live stage label, progress meter, and activity log are
unaffected. No new user-facing strings.

Fixes #1894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
psiberfunk
2026-09-07 13:48:18 -04:00
co-authored by Claude Opus 5
parent 9790d28922
commit 1c344bf9ec
3 changed files with 214 additions and 56 deletions
+1
View File
@@ -91,6 +91,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
- The bootstrap splash no longer shows fabricated first-run install steps on a warm start or repair sync — a step now renders done only once it was actually observed (#1894)
## [0.5.2] — 2026-09-02
+100 -56
View File
@@ -120,6 +120,13 @@ const STEPS = [
'starting_backend',
];
// Stages that only occur when there is actual first-run/repair work to do.
// `awaiting_setup` renders its own screen (FirstRunSetup) rather than the
// step list below, but it still counts as "install work observed" (#1894):
// reaching it means Rust found no venv and is about to do real work, so the
// journey chrome should already be armed by the time the step list appears.
const INSTALL_STAGES = ['downloading_uv', 'creating_venv', 'installing_deps', 'awaiting_setup'];
const MAX_LOG_LINES = 200;
/** Scan logs + error message for known failure patterns and return i18n keys
@@ -445,6 +452,10 @@ export function BootstrapSplash({ stage, message }) {
const [progress, setProgress] = useState(null);
const [region, setRegionState] = useState('auto');
const [retrying, setRetrying] = useState(false);
// Stages actually seen this session (sticky/monotonic — see the tracking
// effect below). Drives "done" ticks and journey visibility off observed
// reality instead of list position (#1894).
const [observedStages, setObservedStages] = useState(() => new Set([stage]));
const logRef = useRef(null);
const prevProgRef = useRef(null); // {bytes, t} — last progress event
const rateRef = useRef(0); // EMA bytes/sec across events
@@ -454,6 +465,12 @@ export function BootstrapSplash({ stage, message }) {
const isFailed = stage === 'failed';
// Retrying an Intel-Mac install can never succeed — don't offer the dead end.
const isUnrecoverable = isFailed && isUnrecoverableFailure(message, logs);
// True once any genuine install stage has been observed this session. On a
// warm start the Rust stage jumps straight from `checking` to
// `starting_backend` — nothing here ever fires — so the first-run install
// chrome (journey rail, "Installing" heading, step list) stays suppressed
// instead of fabricating completed work (#1894).
const installWorkSeen = INSTALL_STAGES.some((s) => observedStages.has(s));
const handleRetry = async () => {
if (retrying) return;
@@ -484,6 +501,15 @@ export function BootstrapSplash({ stage, message }) {
}
};
// Record `stage` as observed the moment it's seen. Sticky/monotonic: the
// functional updater bails out (same Set reference) once a stage is
// already recorded, so this never un-observes anything and never loops.
// `bootstrap_status` is polled ~1/s by useBootstrapStage, so a stage that
// actually ran is guaranteed to land here at least once (#1894).
useEffect(() => {
setObservedStages((prev) => (prev.has(stage) ? prev : new Set(prev).add(stage)));
}, [stage]);
// Load persisted region on mount.
useEffect(() => {
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
@@ -638,7 +664,10 @@ export function BootstrapSplash({ stage, message }) {
data-tauri-drag-region
>
<Waveform />
<JourneyRail t={t} />
{/* Suppressed until real install work is observed — otherwise a
warm start (or a repair sync) shows "Setup done / Installing
active" for work that never happened (#1894). */}
{installWorkSeen && <JourneyRail t={t} />}
<div className="mt-2 flex flex-wrap items-end justify-between gap-6">
<div className="min-w-0">
{/* Version rides beside the app name — same masthead across all
@@ -760,9 +789,16 @@ export function BootstrapSplash({ stage, message }) {
</section>
) : (
<section className="fr-rise flex flex-col gap-2.5" style={{ '--rise': 1 }}>
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{t('firstrun.installing_title', 'Installing')}
</h2>
{/* Heading, step list and resume note only make sense once real
install work has actually been observed — otherwise a warm
start or a repair sync narrates a first-run install that
never happened (#1894). The live stage label in the masthead
and the progress meter below stay visible either way. */}
{installWorkSeen && (
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{t('firstrun.installing_title', 'Installing')}
</h2>
)}
{/* Overall journey meter. */}
<Progress
value={overallPct}
@@ -770,61 +806,69 @@ export function BootstrapSplash({ stage, message }) {
size="md"
aria-valuenow={Math.round(overallPct)}
/>
<ol className="m-0 mt-1 flex list-none flex-col gap-2 p-0">
{STEPS.map((s, i) => {
const done = i < stepIndex;
const activeStep = i === stepIndex;
return (
<li
key={s}
className={cn(
'flex min-w-0 items-center gap-2 text-sm',
!done && !activeStep && 'opacity-45',
)}
>
<span
{installWorkSeen && (
<ol className="m-0 mt-1 flex list-none flex-col gap-2 p-0">
{STEPS.map((s, i) => {
const activeStep = i === stepIndex;
// Done only if this stage was actually observed AND it isn't
// the one currently in progress — list POSITION alone lies on
// a warm start or a repair sync, where earlier stages in the
// fixed STEPS order are skipped by Rust entirely (#1894).
const done = !activeStep && observedStages.has(s);
return (
<li
key={s}
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
done
? 'bg-success shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-success)_50%,transparent)]'
: activeStep
? 'bg-primary shadow-[0_0_6px_1px_var(--color-brand-glow)] fr-pulse'
: 'bg-fg-subtle/40',
'flex min-w-0 items-center gap-2 text-sm',
!done && !activeStep && 'opacity-45',
)}
aria-hidden="true"
/>
<span className={cn(activeStep && 'font-semibold', done && 'text-fg-muted')}>
{t(`bootstrap.${s}`, STAGE_LABEL[s])}
</span>
{activeStep && stageProgress && (
<span className="ml-auto whitespace-nowrap font-mono text-[0.64rem] tabular-nums text-fg-muted">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0
? ` / ${formatBytes(stageProgress.bytes_total)}`
: ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
{stageProgress.bytes_total > 0 &&
rateRef.current > 0 &&
stageProgress.bytes_done < stageProgress.bytes_total &&
` · ${t('firstrun.eta_left', {
eta: formatEta(
(stageProgress.bytes_total - stageProgress.bytes_done) /
rateRef.current,
),
defaultValue: '~{{eta}} left',
})}`}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
done
? 'bg-success shadow-[0_0_5px_1px_color-mix(in_srgb,var(--color-success)_50%,transparent)]'
: activeStep
? 'bg-primary shadow-[0_0_6px_1px_var(--color-brand-glow)] fr-pulse'
: 'bg-fg-subtle/40',
)}
aria-hidden="true"
/>
<span className={cn(activeStep && 'font-semibold', done && 'text-fg-muted')}>
{t(`bootstrap.${s}`, STAGE_LABEL[s])}
</span>
)}
</li>
);
})}
</ol>
<p className="m-0 text-xs text-fg-subtle">
{t(
'firstrun.resume_note',
'Interrupted downloads resume automatically — closing the app is safe.',
)}
</p>
{activeStep && stageProgress && (
<span className="ml-auto whitespace-nowrap font-mono text-[0.64rem] tabular-nums text-fg-muted">
{formatBytes(stageProgress.bytes_done)}
{stageProgress.bytes_total > 0
? ` / ${formatBytes(stageProgress.bytes_total)}`
: ''}
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
{stageProgress.bytes_total > 0 &&
rateRef.current > 0 &&
stageProgress.bytes_done < stageProgress.bytes_total &&
` · ${t('firstrun.eta_left', {
eta: formatEta(
(stageProgress.bytes_total - stageProgress.bytes_done) /
rateRef.current,
),
defaultValue: '~{{eta}} left',
})}`}
</span>
)}
</li>
);
})}
</ol>
)}
{installWorkSeen && (
<p className="m-0 text-xs text-fg-subtle">
{t(
'firstrun.resume_note',
'Interrupted downloads resume automatically — closing the app is safe.',
)}
</p>
)}
</section>
)}
@@ -0,0 +1,113 @@
/**
* Regression tests for #1894 — the first-run INSTALLING journey was shown on
* every launch, with green ticks for work that never ran.
*
* `BootstrapSplash` used to derive "done" purely from `STEPS.indexOf(stage)`
* (BootstrapSplash.jsx:453/774 pre-fix): on a warm start Rust jumps straight
* from `checking` to `starting_backend` (bootstrap.rs finds the venv healthy
* and returns early), so `downloading_uv`, `creating_venv` and
* `installing_deps` — including the "first run, 510 min." label — all
* rendered with a green DONE tick for work that never happened. The same
* fabrication hit a repair sync (venv exists, only `installing_deps` runs,
* but `downloading_uv`/`creating_venv` still rendered done), and `JourneyRail`
* hardcoded Setup=done/Installing=active regardless of `stage`.
*
* The fix tracks which stages were actually observed (sticky, via the
* `bootstrap_status` poll) and derives doneness + journey-chrome visibility
* from that instead of list position.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BootstrapSplash } from '../components/BootstrapSplash';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(async () => null),
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock('@tauri-apps/plugin-opener', () => ({
revealItemInDir: vi.fn(),
}));
beforeEach(() => {
// Not in a Tauri context: the log/progress subscription effects no-op.
delete window.__TAURI_INTERNALS__;
});
describe('BootstrapSplash — observed-stage tracking (#1894)', () => {
it('warm start (checking -> starting_backend): no fabricated done ticks, no "first run" chrome', () => {
const { rerender } = render(<BootstrapSplash stage="checking" message={null} />);
// The journey rail and "Installing" heading must not appear before any
// real install stage has ever been observed.
expect(screen.queryByText('Installing')).toBeNull();
expect(screen.queryByText('Setup')).toBeNull();
expect(screen.queryByText('Models & engines')).toBeNull();
rerender(<BootstrapSplash stage="starting_backend" message={null} />);
// Live stage label still shows — this is not a blank screen.
expect(screen.getByText('Starting backend…')).toBeInTheDocument();
// The install journey never appeared: bootstrap.rs never entered any of
// downloading_uv/creating_venv/installing_deps on this run.
expect(screen.queryByText('Installing')).toBeNull();
expect(screen.queryByText('Downloading uv (Python package manager)…')).toBeNull();
expect(screen.queryByText('Creating Python virtual environment…')).toBeNull();
expect(screen.queryByText(/first run, 5.10 min/)).toBeNull();
});
it('repair sync (checking -> installing_deps): installing_deps active, earlier steps not fabricated done', () => {
const { rerender } = render(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
// Real install work observed: the journey chrome comes back (both the
// JourneyRail item and the section heading render the same "Installing"
// string, so there are two matches).
expect(screen.getAllByText('Installing').length).toBeGreaterThan(0);
// The live stage label (masthead) and the step-list item both render the
// "first run, 5-10 min" text; scope to the step-list one inside the <ol>.
const activeLabel = screen.getAllByText(/first run, 5.10 min/).find((el) => el.closest('ol'));
expect(activeLabel).toBeInTheDocument();
// The active step renders semibold, not the muted "done" styling.
expect(activeLabel.className).toMatch(/font-semibold/);
expect(activeLabel.className).not.toMatch(/text-fg-muted/);
// downloading_uv/creating_venv were never entered by Rust on a repair
// sync — they must render as pending, not done.
const uvStep = screen.getByText('Downloading uv (Python package manager)…');
const venvStep = screen.getByText('Creating Python virtual environment…');
expect(uvStep.className).not.toMatch(/text-fg-muted/);
expect(venvStep.className).not.toMatch(/text-fg-muted/);
});
it('genuine first run walks all five stages: every step is marked done as it passes (no regression)', () => {
const stages = [
'checking',
'downloading_uv',
'creating_venv',
'installing_deps',
'starting_backend',
];
const { rerender } = render(<BootstrapSplash stage={stages[0]} message={null} />);
for (let i = 1; i < stages.length; i += 1) {
rerender(<BootstrapSplash stage={stages[i]} message={null} />);
// Every stage strictly before the current one must show as done
// (muted styling), since this run genuinely walked through each one.
for (let j = 0; j < i; j += 1) {
const stepLabel = screen.getByText(
{
checking: 'Checking environment…',
downloading_uv: 'Downloading uv (Python package manager)…',
creating_venv: 'Creating Python virtual environment…',
installing_deps: /first run, 5.10 min/,
starting_backend: 'Starting backend…',
}[stages[j]],
);
expect(stepLabel.className).toMatch(/text-fg-muted/);
}
}
});
});