diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bd003e..f4791798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/frontend/src/components/BootstrapSplash.jsx b/frontend/src/components/BootstrapSplash.jsx index 391b132b..9d983e9d 100644 --- a/frontend/src/components/BootstrapSplash.jsx +++ b/frontend/src/components/BootstrapSplash.jsx @@ -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 > - + {/* 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 && }
{/* Version rides beside the app name — same masthead across all @@ -760,9 +789,16 @@ export function BootstrapSplash({ stage, message }) { ) : (
-

- {t('firstrun.installing_title', 'Installing')} -

+ {/* 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 && ( +

+ {t('firstrun.installing_title', 'Installing')} +

+ )} {/* Overall journey meter. */} -
    - {STEPS.map((s, i) => { - const done = i < stepIndex; - const activeStep = i === stepIndex; - return ( -
  1. - + {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 ( +
  2. - ); - })} -
-

- {t( - 'firstrun.resume_note', - 'Interrupted downloads resume automatically — closing the app is safe.', - )} -

+ {activeStep && stageProgress && ( + + {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', + })}`} + + )} + + ); + })} + + )} + {installWorkSeen && ( +

+ {t( + 'firstrun.resume_note', + 'Interrupted downloads resume automatically — closing the app is safe.', + )} +

+ )}
)} diff --git a/frontend/src/test/BootstrapSplashObservedStages.test.jsx b/frontend/src/test/BootstrapSplashObservedStages.test.jsx new file mode 100644 index 00000000..36b00103 --- /dev/null +++ b/frontend/src/test/BootstrapSplashObservedStages.test.jsx @@ -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(); + + // 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(); + + // 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(); + rerender(); + + // 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
    . + 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(); + + for (let i = 1; i < stages.length; i += 1) { + rerender(); + // 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/); + } + } + }); +});