Skip to content

Add a guided first-run onboarding journey - #322

Open
gaelcado wants to merge 15 commits into
zeronsh:mainfrom
gaelcado:wip/onboarding
Open

Add a guided first-run onboarding journey#322
gaelcado wants to merge 15 commits into
zeronsh:mainfrom
gaelcado:wip/onboarding

Conversation

@gaelcado

@gaelcado gaelcado commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What this adds

Zeron now welcomes genuinely new profiles with a focused six-step setup journey:

  1. choose a local or synced workspace;
  2. choose appearance, theme, accent, and surface;
  3. detect and enable coding agents;
  4. choose the default agent, model, and reasoning level;
  5. configure automatic session titles;
  6. choose a project or intentionally continue without one.

The same journey can be reopened later from the user menu.

Why

The current first run drops people directly into the full application, leaving the most important decisions scattered across settings and composer menus. This gives those decisions a clear order, applies them through their existing owners, and ends in a ready-to-use composer.

Implementation notes

  • Detects a genuinely empty profile before showing onboarding automatically. Existing profiles that predate the new settings field continue straight into the app.
  • Persists lifecycle and navigation in UiSettings; appearance, agent preferences, composer defaults, title settings, and project selection remain in their existing stores.
  • Presents every step in one centered, height-bounded panel. Wide screens gain balanced breathing room; constrained width or height tightens margins and padding while keeping step content scrollable and the primary action and progress indicator pinned.
  • Removes the landing-page screenshot from the application bundle and removes the former split-pane, crop, and compact-artwork paths. No replacement marketing artwork is introduced.
  • Supports skip, continue-later, resume-after-relaunch, Back, keyboard activation, arrow-key choice navigation, and an explicit close confirmation.
  • Uses the existing sync/sign-in and sign-out transitions when the workspace choice changes.
  • Includes deterministic ZERON_OPEN_ROUTE=onboarding/... fixtures for visual review without mutating real profile data.
  • Adds accessible grouping, labels, progress state, alerts, focus indicators, and modal focus containment for the custom GPUI controls.
  • Keeps a legacy step variant solely to deserialize snapshots from earlier development builds safely.

Visual evidence

The previous screenshots were removed because they showed the retired artwork split pane and would misrepresent the current head. No replacement screenshots were captured during this follow-up because the requested revision explicitly excluded launching an application preview.

Verification

Current head d36d637:

  • cargo check -p zeron-ui
  • cargo test -p zeron-ui onboarding::tests --lib — 7 passed
  • cargo test -p zeron-ui --lib — 815 passed
  • cargo check --release --locked -p zeron
  • targeted Rust formatting and git diff --check

The repository-wide formatting check still reports pre-existing rustfmt drift in unrelated files; this change touches only crates/ui/src/onboarding.rs, which is formatted directly and passes diff hygiene.

CI context

On the previous head c6850db, session-sync-regressions, ui-tests, macos-frame-recovery, and linux-browser passed. The lone ios-tests failure was an intermediate-height timing assertion in TranscriptLayoutTests.testToolGroupsRevealAndCollapseThroughIntermediateHeights; this PR has no iOS diff, and the exact test passed three local iterations from that head. The full diagnosis and test provenance remain in the CI diagnosis comment. Fresh CI should evaluate the centered-layout head.

Deliberate boundaries

  • No engine, RPC, or synchronization protocol changes.
  • Existing local sessions are not uploaded when sync is enabled; the established transition behavior remains intact.
  • The onboarding state is device-local and does not become synced product data.

Integration order

This PR should land last in the current sequence: #216#320#322.

After #216 and #320 merge, rebase this branch onto the resulting main. The current heads produce an expected overlap in crates/ui/src/shell.rs; resolve it by preserving both Appshot staging and the onboarding/projectless new-session composer condition. Run fresh CI on the rewritten head before marking this PR ready.

This PR remains logically independent rather than stacked: the rebase is deferred until its predecessors have landed.

@gaelcado

Copy link
Copy Markdown
Contributor Author

CI diagnosis

The failing ios-tests job on head c6850db had 1 failure out of 103: TranscriptLayoutTests.testToolGroupsRevealAndCollapseThroughIntermediateHeights, at the intermediate-height timing assertion.

This PR changes no iOS files. I ran that exact test from the unchanged PR head locally with -test-iterations 3; all 3 iterations passed. The local machine has Xcode 26.3 while Actions used Xcode 26.6, so this does not prove identical runner behavior, but the zero iOS diff plus repeated local passes strongly identifies a pre-existing simulator timing flake rather than an onboarding regression.

Provenance: Wing introduced the test in f9fbca6 / #270 and later relaxed its timing assertions in c31b440 / #291. This is existing repository test reliability debt (maintainer/CI ownership), not an issue in this PR; the provenance is included to route follow-up, not to assign personal blame.

I attempted to rerun the failed GitHub job, but GitHub rejected it with Must have admin rights to Repository; no rerun occurred. A maintainer can rerun the failed job, or fresh CI can run after the documented rebase onto #320.

@gaelcado
gaelcado marked this pull request as ready for review September 11, 2026 14:57

@wingleeio wingleeio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a big, careful piece of work and the journey itself holds together well. I built the head (d36d637) and ran it on Linux (sway headless): all ZERON_OPEN_ROUTE=onboarding/* fixtures, plus two complete real first-run passes on empty profiles (Claude/Codex/Cursor/Devin/Grok/OpenCode CLIs installed), a relaunch on the finished profile, and Show onboarding from the user menu. cargo test -p zeron-ui --lib for the onboarding/settings/shell tests: 50 passed (incl. the 7 new onboarding tests). CI is green on the head.

Three things need fixing before this lands, all reproduced on a fresh profile (details inline):

  1. The default agent silently becomes the first harness that needs no sign-in (Devin here), not the first ready one. Agent list arrives before the account probe, so Claude/Codex/Cursor read "Checking sign-in…" and the auto-pick falls through to Devin; when accounts land, Devin is "ready" so it is kept, and Harnesses→Continue persists it into composer-defaults.json. Reproduced on both runs.
  2. "Continue without a project" is lost on Finish. onboarding_remember_target writes noProject: true to disk, but Pickers keeps its own in-memory ComposerDefaults and its next save_defaults() (catalog labels) rewrites the file with noProject: false, device: null. Relaunching the finished profile lands on "Add a project to get started" with no composer — exactly the state the step promised to avoid.
  3. Keyboard-only users cannot advance. Nothing binds Tab: GPUI only moves focus when the app calls focus_next/focus_prev, and the only Tab handling here is inside the close dialog. Arrows move within a choice group and Enter/Space activate, but focus never reaches Continue.

Smaller items (inline): Escape inside the theme dropdown opens the "Continue later?" dialog instead of closing the menu; the dialog unmounts the whole journey and titlebar (blank screen behind the scrim, step re-animates on cancel, and "Keep setting up" hands focus to Skip); reasoning chips are unreachable by keyboard when the model is Automatic; the theme menu is rebuilt every frame while closed; OAuth-only readiness locks API-key users out of Continue.

Not exercised here: the Sync sign-in transition (needs the edge) and the macOS titlebar.

Screenshots (Linux, 1280×800 @2x)

Workspace (fresh profile) Appearance after picking Dark
Workspace step Appearance step, dark
Theme menu Coding agents (real detection)
Theme menu open Agents step
Session defaults — Devin auto-picked (bug 1) Session titles
Defaults step with Devin selected Titles step
Project — "Continue without a project" Landing after Finish
Project step Composer after finish
Relaunch of the finished profile (bug 2) Escape inside theme menu (dialog replaces journey)
Relaunch lands on Add a project Continue later dialog
onboarding/agents-error fixture onboarding/narrow at 600×600
Agents error fixture Narrow fixture

Comment thread crates/ui/src/shell.rs Outdated
})
},
);
if !selected_is_ready {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default agent race. This callback runs when LIST_HARNESSES returns, which is before LIST_AGENT_ACCOUNTS. At that moment harness_is_ready is false for Claude/Codex/Cursor (accounts still Loading → "Checking sign-in…"), so selected_harness falls through to the first harness that needs no account — Devin on my box. When accounts arrive, the accounts callback keeps it because Devin is ready, and Harnesses→Continue then persists it via persist_onboarding_defaults.

Reproduced twice on a fresh profile with Claude Code detected as Ready and listed first; composer-defaults.json ends up with "harness": "devin" and the Defaults step opens on Devin.

Suggestion: only auto-pick once both harnesses and accounts are Ready (a small fn resolve_default_harness(&mut self) called from both callbacks), and prefer registry order at that point. Alternatively keep selected_harness = None until the user reaches Defaults and pick there.

Comment thread crates/ui/src/shell.rs
self.settings.ui_font_family = crate::typography::requested(cx);
self.settings.ui_font_size = crate::typography::font_size(cx);
settings::replace(self.settings.clone(), SavePolicy::Debounced, cx);
pub(super) fn onboarding_remember_target(&mut self, cx: &mut Context<Self>) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project choice is lost on Finish. This loads a fresh ComposerDefaults from disk and saves it, but Pickers holds its own in-memory copy (loaded at boot) and every Pickers::save_defaults() rewrites the whole file from that copy. Traced on a fresh run:

after "Continue without a project": noProject=True,  device=<local id>
after "Finish setup":               noProject=False, device=None   (modelLabels 413→437 = pickers catalog save)

Relaunching the finished profile then lands on "Add a project to get started" with no composer (screenshot in the review body), so the choice the step promised does not survive.

Suggestion: make Pickers::remember_target pub(crate) and call it through the pickers entity from here and from the spaces.rs hook, so there is a single owner of composer-defaults.json.

Comment thread crates/ui/src/shell.rs
}

fn on_key_down(&mut self, event: &gpui::KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
fn onboarding_key_down(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tab is never handled, so keyboard-only users cannot reach Continue. GPUI does not move focus on Tab by itself — it only exposes window.focus_next() / focus_prev(), and nothing in the shell binds them. The tab_stop(true) handles therefore only matter for the close dialog, which handles Tab explicitly below. Verified: pressing Tab repeatedly leaves the focus ring on Skip; arrows move within a group but never out of it.

Suggestion: at the top of this function, when event.keystroke.key == "tab" and no ctrl/alt/platform, call window.focus_prev(cx) for shift or window.focus_next(cx) otherwise and return true. Also worth focusing control(0) on the first mount: restore_mounted_focus picks unfocused when nothing is focused yet, so arrows/Enter do nothing until the user clicks.

Comment thread crates/ui/src/shell.rs
cx.notify();
return true;
}
if self.onboarding.active() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape with the theme dropdown open lands here (capture phase) and opens the "Continue setting up later?" dialog; the MenuKey::Escape branch in onboarding_key_down never runs. Suggest: if self.onboarding.theme_menu.is_open() { self.onboarding_close_theme_menu(cx); return true; } before the close-confirm logic.

Comment thread crates/ui/src/onboarding.rs Outdated
.font_family(theme.font_sans.clone())
// Keep the dialog's accessibility tree and tab order isolated. The
// underlying journey is remounted when the dialog closes.
.when(!ui.close_confirm, |root| root.child(panel).child(title_bar))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unmounting the panel and titlebar while the dialog is up makes the screen go blank behind the scrim (see screenshot), removes the window drag region, and re-runs the step's fade_quick on cancel. Overlaying the dialog on top of the journey (keep panel/title_bar mounted, gate only their focus/handlers) reads much better.

Related: "Keep setting up" hands focus to control 29 (Skip), so Esc, Space, Space skips the whole setup. Restoring focus to the control that had it, or to control(0), is safer.

Comment thread crates/ui/src/shell.rs
.map(|model| model.reasoning_levels.len())
})
.unwrap_or(0);
Some((16, 16 + count.min(9)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the model is Automatic (selected_model == None) the rendered reasoning chips come from the harness's reasoning_levels (render_defaults_step), but this count only looks at the selected model, so it is 0 and the group is just (16, 16); the same happens for Enter at the (17..26) branch below. Arrow keys can never reach those chips and Enter on them is a no-op. Suggest sharing one fn reasoning_levels(&self) -> Vec<ReasoningLevel> between render and key handling.

Comment thread crates/ui/src/onboarding.rs Outdated
)
}))
});
let theme_menu = popover::popover_card(theme)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: theme_menu (and the Theme::for_selection sample per variant) is built on every render even while the menu is closed; only when_some(ui.theme_menu.get(), …) uses it. Building it inside that closure keeps the Appearance step cheap.

.into_any_element()
}

fn harness_status(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Readiness for Claude/Codex/Cursor requires an active OAuth account. Users authenticating with ANTHROPIC_API_KEY / OPENAI_API_KEY (no oauthAccount in ~/.claude.json, no auth.json) never become Ready, Continue stays disabled, and the only way forward is Skip — which abandons Defaults, Titles and Project too. Consider treating installed + enabled as sufficient with the sign-in status shown as advisory, or make Skip step-local.

Comment thread crates/ui/src/onboarding.rs Outdated
.w_full()
.h_full()
.max_w(px(560.0))
.max_h(px(720.0))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: with h_full() + max_h(720) the card is always full height, so short steps (Workspace, Project) show a large empty band between the choices and Continue. Sizing to content with a min_h would look tighter on laptop viewports.

@wingleeio wingleeio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked head 6d32948 the same way: rebuilt, ran the onboarding/settings/pickers/shell unit tests (53 passed, incl. the 3 new ones), and drove two more fresh-profile passes on Linux (sway headless) plus a relaunch of the finished profile. CI is green on the head.

Fixed and verified:

  • Default agent: Defaults now opens on Claude Code and composer-defaults.json has "harness": "claude-code" (was Devin).
  • "Continue without a project" survives Finish and relaunch: noProject: true stays set through the later catalog save (labels 7→437) and the relaunched app lands on the projectless composer.
  • Tab/Shift+Tab move focus through the journey; Tab, Tab, Tab, Space from boot reaches Continue and advances, arrows move within a group, Space/Enter activate. No double activation from GPUI's synthesized keyboard click.
  • Escape inside the theme dropdown now closes only the dropdown; the leave dialog overlays the journey instead of replacing it; the Workspace/Project cards are compact.

Two keyboard items remain (inline):

  1. After Escape opens the leave dialog, focus does not end up on "Keep setting up". Pressing Space activates the control behind the scrim (here the "System" appearance chip) and the dialog stays open — GPUI synthesizes a click on the focused element for Space/Enter (div.rs is_activation_key). If Continue/Finish had focus when Escape was pressed, Space would advance the step under the modal. Reproduced twice.
  2. Nothing is focused at boot: Right/Space right after launch are no-ops and the first Tab lands on control 0, so the new focus_initialized path is not taking effect (window.focused() is already Some on that frame, so it falls through to restore_mounted_focus). Functionally fine since Tab recovers, but the code does not do what it says.

Screenshots (head 6d32948, Linux 1280×800 @2x)

Workspace (compact card) Escape closed only the theme menu
Workspace step Appearance dark, menu closed
Leave dialog overlaying the journey Space on the dialog picked "System" behind it (item 1)
Leave dialog Dialog open, System selected
Coding agents Session defaults — Claude Code (fixed)
Agents step Defaults step
Project — no project Relaunch: projectless composer restored (fixed)
Project step Relaunch

Comment thread crates/ui/src/shell.rs
self.onboarding_cancel_close(window, cx);
} else {
self.onboarding_request_close(window, cx);
self.onboarding_focus_control(26, window, cx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focus set here does not survive to the next key press. Sequence observed on a fresh profile (Appearance step, Dark picked): Escape → dialog opens; Space → the "System" chip (control 0) is activated and the dialog stays open; a second Escape closes it. Reproduced twice, screenshot in the review body.

Two things combine: the deferred restore_mounted_focus in render runs after this handler and moves focus back to control 0 (the dialog's handles were not in the tree when the check ran / preferred_focus still points at control 0 while close_confirm is set), and GPUI synthesizes a click for Space/Enter on whatever is focused (elements/div.rs, is_activation_key), so the chip's on_click fires through the scrim. With Continue/Finish focused when Escape is pressed, Space would advance the step under the modal.

Suggestion: while close_confirm is true make preferred_focus the dialog's control(26) (and skip restore_mounted_focus for the onboarding root), and in onboarding_key_down return true + stop_propagation for any activation key while the dialog is open, regardless of which control is focused.

Comment thread crates/ui/src/shell.rs Outdated
window.defer(cx, move |window, cx| {
restore_mounted_focus(&shortcut_focus, &preferred_focus, &unfocused, window, cx);
if onboarding_needs_initial_focus && window.focused(cx).is_none() {
window.focus(&preferred_focus, cx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is not reached in practice: right after launch, Right/Space are no-ops and the first Tab lands on control 0, i.e. nothing was focused (window.focused() is already Some on the first Ready frame — the gate/root handle — so the is_none() guard fails and focus_initialized is consumed). Either drop the guard when onboarding_needs_initial_focus is set, or check !root.contains_focused(...)/focused == unfocused instead of is_none(). Not blocking on its own since Tab recovers, but the comment above promises focus on the first frame.

@wingleeio wingleeio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3 on head 029e94e (ten new commits incl. the upstream merge): rebuilt, ran the onboarding/settings/pickers/shell tests (58 passed, incl. the new discovery, model-catalog and keyboard-isolation tests), and drove another fresh-profile pass plus a relaunch on Linux (sway headless).

Holding up on this head:

  • Defaults opens on Claude Code; picking "Fable 5.1" lands in composer-defaults.json under modelByHarness.
  • "Continue without a project" survives Finish (labels 7→437) and relaunch; the relaunched app opens on the projectless composer with Fable 5.1 selected.
  • Tab/Shift+Tab, arrows, Space/Enter all work; Escape closes only the theme dropdown; the leave dialog overlays the journey; Finish without a choice shows the inline error.
  • The new cardless layout, footer navigation, titlebar Skip, compact Workspace/Project/Titles heights, and the scrollable full model catalog all render correctly.

Still open from round 2 (neither site changed in these commits):

  1. Space on the freshly opened leave dialog still activates the control behind the scrim and leaves the dialog open — Escape → dialog; Space → "System" appearance chip selected (dark → system), dialog still up; Escape again closes it and focus lands on the theme trigger. Reproduced again, screenshot below.
  2. Nothing is focused at boot: Right/Space right after launch are no-ops; the first Tab lands on control 0.

Process: the branch now conflicts with main in crates/ui/src/shell.rs (#216 and #320 landed after your upstream merge), so GitHub did not run CI on 029e94e; the last CI-green head is b550b57f, which differs only by the one-line label commit. Please rebase onto current main and let CI run on the rewritten head — that plus item 1 is what I need before approving.

Small visual notes, no action required unless you agree: on Coding agents the list is capped at 6½ rows while the step keeps its 640px height, leaving an empty band above the actions; the "Choose a project or continue without one" error sits flush against the button row.

Screenshots (head 029e94e, Linux 1280×800 @2x)

Workspace (cardless, titlebar Skip) Appearance, Escape closed only the menu
Workspace Appearance dark
Leave dialog Space on the dialog picked "System" behind it (item 1)
Leave dialog Dialog open, System selected
Coding agents Session defaults — full catalog, Fable 5.1 picked
Agents Defaults
Session titles Project — Finish without a choice
Titles Project error
Project — "Continue without a project" Relaunch: projectless composer, Fable 5.1
Project none Relaunch

Comment thread crates/ui/src/shell.rs
self.onboarding_cancel_close(window, cx);
} else {
self.onboarding_request_close(window, cx);
self.onboarding_focus_control(26, window, cx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still reproducible on 029e94e: after this focuses control 26, the deferred restore_mounted_focus in render moves focus back to preferred_focus (control 0), and GPUI's synthesized keyboard click (elements/div.rs, is_activation_key) fires the focused chip's on_click through the scrim. Observed: Escape → dialog; Space → appearance flips dark → system, dialog stays open; Escape → dialog closes, focus lands on the theme trigger (the saved close_return_focus).

Suggested fix: while close_confirm is set, make preferred_focus the dialog's control(26) (and skip restore_mounted_focus for the journey), and in onboarding_key_down swallow any activation key while the dialog is open regardless of which control is focused.

Comment thread crates/ui/src/shell.rs Outdated
};
window.defer(cx, move |window, cx| {
restore_mounted_focus(&shortcut_focus, &preferred_focus, &unfocused, window, cx);
if onboarding_needs_initial_focus && window.focused(cx).is_none() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unchanged since round 2 and still not reached: Right/Space immediately after launch do nothing and the first Tab lands on control 0, so window.focused() is already Some on this frame and the branch is skipped. Dropping the is_none() guard when onboarding_needs_initial_focus is set (or comparing against unfocused/root instead) would make it do what the comment says.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants