Add a guided first-run onboarding journey - #322
Conversation
CI diagnosisThe failing This PR changes no iOS files. I ran that exact test from the unchanged PR head locally with Provenance: Wing introduced the test in I attempted to rerun the failed GitHub job, but GitHub rejected it with |
wingleeio
left a comment
There was a problem hiding this comment.
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):
- 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. - "Continue without a project" is lost on Finish.
onboarding_remember_targetwritesnoProject: trueto disk, butPickerskeeps its own in-memoryComposerDefaultsand its nextsave_defaults()(catalog labels) rewrites the file withnoProject: 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. - 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 |
|---|---|
![]() |
![]() |
| Theme menu | Coding agents (real detection) |
|---|---|
![]() |
![]() |
| Session defaults — Devin auto-picked (bug 1) | Session titles |
|---|---|
![]() |
![]() |
| Project — "Continue without a project" | Landing after Finish |
|---|---|
![]() |
![]() |
| Relaunch of the finished profile (bug 2) | Escape inside theme menu (dialog replaces journey) |
|---|---|
![]() |
![]() |
onboarding/agents-error fixture |
onboarding/narrow at 600×600 |
|---|---|
![]() |
![]() |
| }) | ||
| }, | ||
| ); | ||
| if !selected_is_ready { |
There was a problem hiding this comment.
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.
| 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>) { |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| fn on_key_down(&mut self, event: &gpui::KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) { | ||
| fn onboarding_key_down( |
There was a problem hiding this comment.
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.
| cx.notify(); | ||
| return true; | ||
| } | ||
| if self.onboarding.active() { |
There was a problem hiding this comment.
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.
| .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)) |
There was a problem hiding this comment.
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.
| .map(|model| model.reasoning_levels.len()) | ||
| }) | ||
| .unwrap_or(0); | ||
| Some((16, 16 + count.min(9))) |
There was a problem hiding this comment.
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.
| ) | ||
| })) | ||
| }); | ||
| let theme_menu = popover::popover_card(theme) |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
| .w_full() | ||
| .h_full() | ||
| .max_w(px(560.0)) | ||
| .max_h(px(720.0)) |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.jsonhas"harness": "claude-code"(was Devin). - "Continue without a project" survives Finish and relaunch:
noProject: truestays 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):
- 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.rsis_activation_key). If Continue/Finish had focus when Escape was pressed, Space would advance the step under the modal. Reproduced twice. - 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_initializedpath is not taking effect (window.focused()is alreadySomeon that frame, so it falls through torestore_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 |
|---|---|
![]() |
![]() |
| Leave dialog overlaying the journey | Space on the dialog picked "System" behind it (item 1) |
|---|---|
![]() |
![]() |
| Coding agents | Session defaults — Claude Code (fixed) |
|---|---|
![]() |
![]() |
| Project — no project | Relaunch: projectless composer restored (fixed) |
|---|---|
![]() |
![]() |
| self.onboarding_cancel_close(window, cx); | ||
| } else { | ||
| self.onboarding_request_close(window, cx); | ||
| self.onboarding_focus_control(26, window, cx); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.jsonundermodelByHarness. - "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):
- 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.
- 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 |
|---|---|
![]() |
![]() |
| Leave dialog | Space on the dialog picked "System" behind it (item 1) |
|---|---|
![]() |
![]() |
| Coding agents | Session defaults — full catalog, Fable 5.1 picked |
|---|---|
![]() |
![]() |
| Session titles | Project — Finish without a choice |
|---|---|
![]() |
![]() |
| Project — "Continue without a project" | Relaunch: projectless composer, Fable 5.1 |
|---|---|
![]() |
![]() |
| self.onboarding_cancel_close(window, cx); | ||
| } else { | ||
| self.onboarding_request_close(window, cx); | ||
| self.onboarding_focus_control(26, window, cx); |
There was a problem hiding this comment.
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.
| }; | ||
| 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() { |
There was a problem hiding this comment.
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.
029e94e to
7d58d00
Compare






























What this adds
Zeron now welcomes genuinely new profiles with a focused six-step setup journey:
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
UiSettings; appearance, agent preferences, composer defaults, title settings, and project selection remain in their existing stores.ZERON_OPEN_ROUTE=onboarding/...fixtures for visual review without mutating real profile data.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-uicargo test -p zeron-ui onboarding::tests --lib— 7 passedcargo test -p zeron-ui --lib— 815 passedcargo check --release --locked -p zerongit diff --checkThe 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, andlinux-browserpassed. The loneios-testsfailure was an intermediate-height timing assertion inTranscriptLayoutTests.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
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 incrates/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.