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:
co-authored by
Claude Opus 5
parent
9790d28922
commit
1c344bf9ec
@@ -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
|
||||
|
||||
|
||||
@@ -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, 5–10 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/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user